简介:这份资源是一套基于PHP与ThinkPHP框架的运营级在线客服系统源码,重点解决客服系统接入AI知识库的问题,适合有一定PHP基础、希望搭建企业级智能客服的开发者与运维人员。压缩包共2010个文件,约68.72MB,以613个js、368个html、258个css等前端资源为主,配合131个php业务代码、3个sql数据库脚本及大量xml、json、md配置与说明文档,整体结构完整,便于二次开发与部署。资源附带安装教程,涵盖fileinfo、redis插件安装,以及pcntl_signal、pcntl_fork等禁用函数的配置说明,帮助读者快速完成环境搭建。目前已有137人学习下载。通过该源码,读者可掌握AI知识库与在线客服的对接思路,理解NLP问答匹配、缓存加速与ThinkPHP模块化开发,并获得一套可直接运行、便于扩展的客服系统实践参考。
1. 从工单到知识库:PHP 在线客服接入 AI 的落地路径
很多团队做在线客服系统,第一版都是「人工坐席 + 常见问题静态页」,用户问一句、客服查半天,响应慢、口径还不统一。这套基于 PHP 的在线客服接入 AI 知识库方案,解决的就是这个断层:把历史工单、产品文档、FAQ 沉淀成可检索的知识库,用户提问时先由 AI 召回并生成回答,答不上来再转人工。它适合用 ThinkPHP 做后台、已经有一套客服工单表结构、想低成本加一层智能问答的团队。整套逻辑不依赖重型框架,核心是「知识入库 → 向量或关键词召回 → 拼 Prompt → 调模型 → 回写会话」这条链路,PHP 侧负责调度和兜底,模型侧只做生成。下面按落地顺序拆开讲,从环境到代码到踩坑,能直接照着改。
2. 知识库接入前的环境与数据准备:ThinkPHP 侧要先把地基打平
2.1 为什么选 ThinkPHP 做调度层而不是纯脚本
在线客服系统本身有会话、坐席、工单、消息几张核心表,ThinkPHP 的模型和查询构造器能直接复用现有连接,不用为了接 AI 再搭一套服务。常见做法是把 AI 问答封装成一个 Service 类,在消息入库的钩子里调用,而不是单独起一个进程。这样会话上下文、用户身份、历史消息都能顺手拿到,省掉一次跨服务查询。选型上不建议一上来就上向量数据库,中小体量用 MySQL 全文索引或关键词匹配先跑通,等知识条目过万再考虑 embedding 方案,否则运维成本会吃掉收益。
环境上,PHP 建议 8.0 以上,ThinkPHP 6.x,扩展需要curl、mbstring、json、pdo_mysql。如果本地用 phpstudy 或小皮面板,注意 PHP 版本和 CLI 版本要一致,否则命令行跑迁移脚本时会报扩展缺失。下面先建知识库表。
-- 知识条目表:存 FAQ、文档片段、历史优质回答 CREATE TABLE `kb_article` ( `id` int unsigned NOT NULL AUTO_INCREMENT, `title` varchar(255) NOT NULL DEFAULT '' COMMENT '条目标题', `content` text NOT NULL COMMENT '正文,用于召回和拼 Prompt', `keywords` varchar(500) NOT NULL DEFAULT '' COMMENT '逗号分隔关键词,提升召回', `category_id` int unsigned NOT NULL DEFAULT 0 COMMENT '分类,对应产品线', `status` tinyint NOT NULL DEFAULT 1 COMMENT '1启用 0停用', `updated_at` int unsigned NOT NULL DEFAULT 0, PRIMARY KEY (`id`), KEY `idx_category` (`category_id`), FULLTEXT KEY `ft_content` (`title`,`content`) WITH PARSER ngram ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI知识库条目';逻辑说明:content是召回主体,keywords用来补全文索引对短词不敏感的问题,category_id让不同产品线互不串味。WITH PARSER ngram是 MySQL 5.7+ 对中文全文检索的支持,没加这个中文分词基本失效。参数上ngram_token_size默认 2,意味着至少两个汉字才能命中,单字查询会漏,这是后面避坑章要展开的点。
2.2 把历史工单清洗成知识条目
直接拿原始工单喂给模型效果很差,因为工单里有大量「你好」「在吗」「转人工」这类噪声。清洗思路是:只保留客服最终回复且用户未再追问的会话,把用户首问作为title,客服回复作为content,再从首问里抽关键词。下面是一个批量导入脚本。
<?php // 命令行执行:php think kb:import namespace app\command; use think\console\Command; use think\console\Input; use think\console\Output; use think\facade\Db; class KbImport extends Command { protected function configure() { $this->setName('kb:import')->setDescription('清洗工单并导入知识库'); } protected function execute(Input $input, Output $output) { // 只取已关闭且客服有最终回复的会话 $rows = Db::name('chat_session') ->alias('s') ->join('chat_message m', 'm.session_id = s.id') ->where('s.status', 2) ->where('m.role', 'agent') ->field('s.id, s.first_question, m.content, m.create_time') ->order('m.create_time desc') ->limit(2000) ->select(); $batch = []; foreach ($rows as $r) { $q = trim($r['first_question']); $a = trim(strip_tags($r['content'])); // 过滤过短和纯寒暄内容 if (mb_strlen($q) < 4 || mb_strlen($a) < 10) { continue; } if (preg_match('/^(你好|在吗|转人工|有人吗)/u', $q)) { continue; } $batch[] = [ 'title' => mb_substr($q, 0, 100), 'content' => $a, 'keywords' => $this->extractKeywords($q), 'category_id'=> 1, 'status' => 1, 'updated_at' => time(), ]; } if ($batch) { Db::name('kb_article')->insertAll($batch); } $output->writeln('导入条数:' . count($batch)); } // 简易关键词抽取:按标点和停用词切分 private function extractKeywords(string $q): string { $stop = ['的','了','吗','呢','怎么','如何','请问']; $q = str_replace($stop, ' ', $q); $parts = preg_split('/[\s,,。??!!]+/u', $q, -1, PREG_SPLIT_NO_EMPTY); return implode(',', array_slice(array_unique($parts), 0, 5)); } }逻辑说明:status=2表示会话已结束,role=agent取客服消息,避免把用户追问也当成答案。mb_strlen过滤掉过短内容,正则过滤寒暄。extractKeywords是兜底方案,停用词表按自己业务补,比如电商要加「发货」「退款」。参数上limit(2000)是防止一次性拉爆内存,实际可以分批跑。跑完用SELECT COUNT(*) FROM kb_article确认条数,再抽查几条看content有没有混入 HTML 标签。
提示:导入前先备份
kb_article,清洗规则调一次可能要重跑,有备份就不用后悔药。
3. 召回与拼 Prompt:让 AI 答得像你们自己的客服
3.1 关键词召回与全文索引的取舍
召回质量直接决定回答质量。中小体量下,MySQL 全文索引够用,但中文场景有两个坑:一是ngram_token_size最小 2,单字词查不到;二是全文索引对keywords字段不生效,因为上面只给title和content建了索引。常见做法是先用keywords做一次 LIKE 匹配,再用全文索引兜底,两路结果合并去重。下面是对外暴露的召回方法。
<?php namespace app\service; use think\facade\Db; class KbRecall { /** * @param string $question 用户问题 * @param int $categoryId 产品线 * @param int $topN 返回条数 */ public function recall(string $question, int $categoryId = 0, int $topN = 3): array { $question = trim($question); if ($question === '') { return []; } // 第一路:关键词 LIKE,命中快,适合短查询 $like = Db::name('kb_article') ->where('status', 1) ->where(function ($q) use ($question) { $q->whereLike('keywords', '%' . $question . '%') ->whereOr('title', 'like', '%' . $question . '%'); }) ->when($categoryId > 0, function ($q) use ($categoryId) { $q->where('category_id', $categoryId); }) ->limit($topN) ->select()->toArray(); // 第二路:全文索引,适合长句 $fulltext = []; if (mb_strlen($question) >= 2) { $fulltext = Db::name('kb_article') ->where('status', 1) ->whereRaw( 'MATCH(title, content) AGAINST(:q IN NATURAL LANGUAGE MODE)', ['q' => $question] ) ->when($categoryId > 0, function ($q) use ($categoryId) { $q->where('category_id', $categoryId); }) ->limit($topN) ->select()->toArray(); } // 合并去重,保留顺序 $merged = []; foreach (array_merge($like, $fulltext) as $item) { $merged[$item['id']] = $item; } return array_slice(array_values($merged), 0, $topN); } }逻辑说明:whereLike那一路对keywords和title同时匹配,解决全文索引对短词不敏感的问题;MATCH ... AGAINST那一路处理长句。when是 ThinkPHP 的条件构造,categoryId为 0 时不加分类过滤。参数topN建议 3 到 5,太多会撑大 Prompt 导致模型跑偏,太少召回不全。注意whereRaw里用了绑定参数,别直接拼字符串,否则有注入风险。
3.2 拼 Prompt 的模板与上下文控制
召回结果不能直接丢给模型,要拼成结构化 Prompt,明确告诉模型「只根据以下资料回答,资料没有就说不知道」。这样能大幅降低胡编。下面是一个可复用的 Prompt 构造方法。
<?php namespace app\service; class PromptBuilder { public function build(string $question, array $articles): array { $context = ''; foreach ($articles as $i => $a) { $no = $i + 1; $context .= "【资料{$no}】{$a['title']}\n{$a['content']}\n\n"; } $system = "你是一名在线客服助手。只能依据下面提供的资料回答用户问题。" . "资料中没有的内容,直接回复「这个问题我需要转人工帮您确认」。" . "回答要简洁,不超过 150 字,不要编造资料外的信息。"; $user = "资料如下:\n{$context}用户问题:{$question}"; return [ ['role' => 'system', 'content' => $system], ['role' => 'user', 'content' => $user], ]; } }逻辑说明:system里三条约束是关键——限定来源、限定兜底话术、限定长度。context拼接时给每条资料编号,方便模型引用。参数上如果content很长,要做截断,单条建议不超过 800 字,总上下文控制在模型窗口的三分之一以内,留出余量给用户问题和模型输出。常见做法是在build前先按updated_at排序,优先用新资料。
注意:Prompt 里的兜底话术要和你们转人工的关键词对齐,否则用户看到「转人工」但系统没触发转接,体验会断。
4. 调用模型与回写会话:把 AI 回答接进现有消息流
4.1 封装模型调用与超时重试
模型调用是整条链路最容易翻车的地方,网络抖动、限流、返回格式异常都会遇到。封装时要设超时、重试和降级。下面用 curl 封装一个通用调用方法,兼容常见的对话补全接口。
<?php namespace app\service; class AiClient { private string $endpoint; private string $apiKey; private int $timeout = 8; public function __construct(string $endpoint, string $apiKey) { $this->endpoint = $endpoint; $this->apiKey = $apiKey; } /** * @param array $messages PromptBuilder 产出的消息数组 * @return string 模型回答,失败返回空串 */ public function chat(array $messages): string { $payload = json_encode([ 'model' => 'your-model-name', 'messages' => $messages, 'temperature' => 0.3, 'max_tokens' => 300, ], JSON_UNESCAPED_UNICODE); for ($i = 0; $i < 2; $i++) { // 最多重试一次 $ch = curl_init($this->endpoint); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_POSTFIELDS => $payload, CURLOPT_TIMEOUT => $this->timeout, CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', 'Authorization: Bearer ' . $this->apiKey, ], ]); $resp = curl_exec($ch); $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); $err = curl_error($ch); curl_close($ch); if ($code === 200 && $resp) { $data = json_decode($resp, true); $text = $data['choices'][0]['message']['content'] ?? ''; if ($text !== '') { return trim($text); } } // 记录日志便于排查,别把 key 写进去 trace("ai_call_fail code={$code} err={$err}", 'error'); usleep(300000); // 等 300ms 再试 } return ''; } }逻辑说明:temperature设 0.3 是为了让客服回答稳定,不要发挥;max_tokens300 对应 150 字左右。重试只做一次,避免雪崩。trace是 ThinkPHP 的日志助手,记录时不要带apiKey。参数上timeout8 秒是经验值,超过这个时间用户已经等不及了,不如直接走兜底。
4.2 在消息入库钩子里接入并回写
AI 回答生成后要写回chat_message表,并标记来源,方便后续统计命中率。下面是在消息服务里的接入片段。
<?php namespace app\service; use think\facade\Db; class ChatService { public function handleUserMessage(int $sessionId, string $question): array { // 1. 先入库用户消息 Db::name('chat_message')->insert([ 'session_id' => $sessionId, 'role' => 'user', 'content' => $question, 'create_time'=> time(), ]); // 2. 召回 + 拼 Prompt $recall = new KbRecall(); $articles = $recall->recall($question, 1, 3); // 3. 没召回到直接转人工,不浪费一次模型调用 if (empty($articles)) { return ['type' => 'transfer', 'msg' => '这个问题我需要转人工帮您确认']; } $builder = new PromptBuilder(); $messages = $builder->build($question, $articles); // 4. 调模型 $client = new AiClient(config('ai.endpoint'), config('ai.key')); $answer = $client->chat($messages); // 5. 模型失败也转人工 if ($answer === '') { return ['type' => 'transfer', 'msg' => '这个问题我需要转人工帮您确认']; } // 6. 回写 AI 消息,source 标记来源 Db::name('chat_message')->insert([ 'session_id' => $sessionId, 'role' => 'ai', 'content' => $answer, 'source' => 'kb', 'create_time'=> time(), ]); return ['type' => 'ai', 'msg' => $answer]; } }逻辑说明:先入库用户消息保证会话完整,召回为空直接转人工省调用成本,模型失败也转人工保证不冷场。source='kb'方便后面统计 AI 命中率。参数上recall的categoryId传 1 是示例,实际按用户当前咨询的产品线传。注意config('ai.key')要放在.env或独立配置文件里,别硬编码进代码。
提示:上线前先用几条真实问题跑一遍,看召回条数和回答是否对得上,别直接放全量流量。
5. 避坑与排查:接入 AI 知识库最常见的五个翻车点
5.1 中文全文索引查不到短词
现象:用户问「退款」,召回结果为空,但知识库明明有退款条目。原因是ngram_token_size默认 2,单字和部分双字词在全文索引里匹配不到。解决:短查询走keywords的 LIKE 那一路,或者把ngram_token_size调到 1 并重建索引。调小会增加索引体积,权衡后我一般保留 2,靠 LIKE 兜底。
5.2 模型回答里出现资料外的内容
现象:知识库没有的内容,AI 也编了一段。原因是 Prompt 约束不够强,或者temperature太高。解决:system里明确「只能依据资料」,temperature压到 0.3 以下,并在代码里做一次校验——如果回答里出现资料中没有的关键实体,直接替换成兜底话术。
5.3 curl 调用偶发超时导致用户干等
现象:高峰期部分用户消息发出后十几秒没反应。原因是模型接口超时没设或设太长。解决:CURLOPT_TIMEOUT设 8 秒,超时后走转人工,别让用户等。同时加日志统计超时率,超过 5% 要考虑换接口或加缓存。
5.4 知识库条目重复导致召回冗余
现象:同一个问题召回三条内容几乎一样的资料,Prompt 被撑大。原因是导入时没去重。解决:导入前按title做一次GROUP BY去重,或者用keywords做相似度过滤。我一般会在导入脚本里加一步SELECT title, COUNT(*) FROM kb_article GROUP BY title HAVING COUNT(*) > 1检查。
5.5 会话上下文丢失导致多轮对话答非所问
现象:用户追问「那这个要多久」,AI 答的是上一个不相关的问题。原因是每次只拿当前问题去召回,没带历史。解决:召回时把最近两轮用户消息拼进去一起查,或者在 Prompt 里带上最近一轮问答。注意别把整个会话历史都塞进去,会超上下文。
6. 进阶:用缓存和命中率统计把成本压下来
跑通之后最该做的是两件事:缓存高频问题和统计命中率。高频问题比如「怎么退款」「发货时间」,每天被问几百次,每次都调模型是浪费。常见做法是用 Redis 缓存「问题 → 回答」,key 用问题归一化后的哈希,过期时间设 1 小时。归一化就是去掉标点、转小写、去空格,这样「怎么退款?」和「怎么退款」能命中同一条。
<?php // 在 ChatService 调模型前加一层缓存 $cacheKey = 'kb_ans:' . md5(mb_strtolower(preg_replace('/[\s,,。??!!]+/u', '', $question))); $cached = cache($cacheKey); if ($cached) { return ['type' => 'ai', 'msg' => $cached, 'from' => 'cache']; } // ... 调模型拿到 $answer 后 cache($cacheKey, $answer, 3600);逻辑说明:preg_replace做归一化,md5生成定长 key,cache是 ThinkPHP 的缓存助手,底层配 Redis。过期时间 3600 秒是平衡新鲜度和命中率的值,知识库更新频繁就调短。注意缓存要区分产品线,否则 A 产品的答案会串到 B 产品,可以在 key 里拼categoryId。
命中率统计则是给chat_message加一个source字段(前面已经加了),定期跑 SQL 看 AI 回答占比和转人工占比。如果转人工率超过 40%,说明知识库覆盖不够,该补条目了;如果 AI 回答里用户紧接着又追问同一问题,说明回答质量不行,该优化 Prompt 或换模型。我一般每周跑一次这个统计,把转人工率高的问题捞出来人工补进知识库,形成闭环。
从那以后我每次接新的知识库,都强制先跑一周的召回日志再调 Prompt,不然根本不知道模型在哪些问题上翻车。希望帮到你。
本文还有配套的精品资源,点击获取