简介:这是一套面向PHP开发者与企业运维人员的运营级在线客服系统源码,基于ThinkPHP框架开发,核心亮点是接入AI知识库,借助自然语言处理能力辅助客服快速匹配问题与答案,适用于需要搭建智能客服平台、提升响应效率与用户满意度的中小型团队。压缩包共2010个文件,约68.72MB,以613个js、368个html、258个css等前端资源为主,配合131个php业务代码、3个sql数据库脚本及151个md说明文档,整体结构完整,便于二次开发与部署调试。资源附带安装教程,涵盖fileinfo、redis插件安装,以及pcntl_signal、pcntl_fork等进程控制函数的禁用配置,帮助读者规避常见环境报错。目前已有137人学习下载,适合具备一定PHP与ThinkPHP基础、希望快速落地AI客服项目的开发者参考借鉴。
1. 从一次客服响应超时说起:这套 PHP 在线客服怎么把 AI 知识库接进来
去年帮一个做 SaaS 工具的朋友看他们的客服后台,用户提问“发票怎么开”平均要等 47 秒才有回复,客服妹子一边翻帮助文档一边复制粘贴,高峰期直接崩。后来他们把知识库文档丢给 AI,用 PHP 写了个中间层,响应压到 3 秒内,客服只处理 AI 拿不准的边角问题。这套「php在线客服接入AI知识库」就是干这个的——基于 ThinkPHP 框架,把企业已有的 FAQ、产品手册、工单记录做成向量索引,用户在前台聊天窗口提问,后端检索知识库片段拼进 Prompt,调大模型接口生成回答,同时保留人工接管入口。适合谁?手里有 PHP 环境、想给现有客服系统加 AI 能力但不想重写整套前端的团队,或者想学 RAG 落地但不想碰 Python 服务栈的 PHP 开发者。下面按我拆源码的顺序,从目录结构一路讲到二次开发怎么改检索策略。
2. 技术选型与目录结构:为什么是 ThinkPHP 而不是裸写原生 PHP
2.1 框架与依赖的取舍逻辑
拿到源码先看composer.json,核心依赖就三块:topthink/framework提供 MVC 和 ORM,topthink/think-queue处理异步消息(用户提问后先入队再消费,避免 HTTP 请求卡在模型调用上),guzzlehttp/guzzle发 HTTP 请求调外部 AI 接口。没有引入向量数据库的 PHP 客户端,因为大多数向量库官方 SDK 是 Python/Node 的,PHP 这边常见做法是直接走 REST API 或者用文件缓存做轻量检索。
为什么不用原生 PHP 写?客服系统天然有路由、中间件、数据库迁移、模板渲染的需求,ThinkPHP 的多应用模式能把admin(客服工作台)、api(前端聊天接口)、console(队列消费和知识库索引命令)拆成独立目录,互不干扰。裸写原生 PHP 到后期加一个“转人工”状态机就要手动管理 session 和路由,维护成本翻倍。
2.2 目录结构逐层拆解
app/ ├── admin/ # 客服后台应用 │ ├── controller/ │ │ ├── Chat.php # 会话管理、消息记录 │ │ └── Knowledge.php # 知识库文档增删改查 │ └── view/ ├── api/ # 前端聊天接口应用 │ ├── controller/ │ │ ├── Message.php # 接收用户提问、返回 AI 回复 │ │ └── Session.php # 会话创建与轮询 │ └── middleware/ │ └── ChatAuth.php # 访客 token 校验 ├── console/ # 命令行工具 │ └── command/ │ ├── IndexKnowledge.php # 把文档切片并生成向量 │ └── ConsumeQueue.php # 消费提问队列 ├── common/ # 公共层 │ ├── service/ │ │ ├── AiClient.php # 封装大模型 API 调用 │ │ ├── Retriever.php # 向量检索与重排序 │ │ └── PromptBuilder.php # 拼装系统提示词和上下文 │ └── model/ │ ├── KnowledgeDoc.php │ └── ChatMessage.php config/ ├── ai.php # 模型接口地址、密钥、超时 ├── queue.php # 队列驱动配置 └── database.php extend/ └── vector/ # 轻量向量运算工具类 └── Cosine.php这个结构里最值得关注的是common/service层,它把 AI 调用、检索、Prompt 拼装三个职责拆开。很多新手会把所有逻辑塞进api/controller/Message.php,结果换一个模型供应商就要改控制器,测试也没法单独跑检索逻辑。拆成 Service 后,AiClient只负责发请求和解析响应,Retriever只负责从知识库里找相关片段,PromptBuilder只负责按模板拼字符串,每个类都能单独写单元测试。
2.3 数据表设计的关键字段
知识库文档表knowledge_doc除了常规的id、title、content、create_time,有两个字段是 RAG 场景特有的:chunk_index记录该片段在原文中的顺序,embedding存向量数组的 JSON 序列化结果。会话消息表chat_message里role字段区分user、assistant、system,source_doc_ids记录这条 AI 回复引用了哪些知识片段,方便后续排查“AI 为什么这么答”。
提示:如果知识库文档量超过 5000 条,MySQL 存 embedding 并全表扫描算余弦相似度会明显变慢,常见做法是加一个
vector_cache表只存 id 和向量,或者接入外部向量检索服务。
3. 核心链路拆解:一条用户消息从进入到 AI 回复返回
3.1 消息接收与队列投递
前端聊天窗口发 POST 到/api/message/send,控制器不直接调 AI,而是先落库再投队列。这样做的好处是用户端可以立刻拿到“消息已收到”的响应,前端显示“正在输入…”动画,实际 AI 生成在后台完成,前端通过轮询或 SSE 拿结果。
// app/api/controller/Message.php public function send() { $userId = $this->request->post('user_id'); $content = $this->request->post('content'); // 1. 先落库,保证消息不丢 $message = ChatMessage::create([ 'session_id' => $this->request->post('session_id'), 'role' => 'user', 'content' => $content, 'status' => 'pending', // 待处理 ]); // 2. 投递到队列,异步处理 Queue::push(AskJob::class, [ 'message_id' => $message->id, 'question' => $content, ], 'ai_chat'); return json(['code' => 0, 'msg' => 'ok', 'data' => ['message_id' => $message->id]]); }Queue::push的第三个参数ai_chat是队列名,在config/queue.php里可以给这个队列单独配 Redis 连接,避免和邮件、短信队列抢资源。AskJob类里写fire方法,消费时先查消息状态,如果已经被人工接管就跳过 AI 生成。
3.2 知识检索:从问题到相关片段
消费队列后第一步是检索。Retriever类先把用户问题转成向量,然后和知识库里的向量算余弦相似度,取 Top-K 片段。这里有个工程细节:问题向量和文档向量必须用同一个模型生成,否则维度对不上或者语义空间不一致,检索结果会非常玄学。
// app/common/service/Retriever.php public function search(string $question, int $topK = 5): array { // 1. 问题转向量 $queryVec = $this->aiClient->embed($question); // 2. 从缓存或数据库取所有文档向量 $docs = KnowledgeDoc::field('id,title,content,embedding') ->where('status', 1) ->select(); // 3. 算相似度并排序 $scored = []; foreach ($docs as $doc) { $docVec = json_decode($doc['embedding'], true); $score = Cosine::similarity($queryVec, $docVec); $scored[] = ['doc' => $doc, 'score' => $score]; } // 4. 按分数降序取前 K 条 usort($scored, fn($a, $b) => $b['score'] <=> $a['score']); return array_slice($scored, 0, $topK); }Cosine::similarity就是标准的点积除以模长乘积,extend/vector/Cosine.php里不到 20 行。topK默认 5 是经验值,太少可能漏掉关键信息,太多会撑大 Prompt 导致模型注意力分散。如果知识库文档很长,切片时每片控制在 300~500 字,重叠 50 字,避免答案被切断。
3.3 Prompt 拼装与模型调用
检索到片段后,PromptBuilder把系统指令、知识片段、历史对话、当前问题按顺序拼成消息数组。系统指令里要明确告诉模型“只根据提供的知识回答,不知道就说不知道”,否则模型容易自由发挥。
// app/common/service/PromptBuilder.php public function build(string $question, array $contexts, array $history = []): array { $systemPrompt = "你是一个客服助手。请严格根据以下知识片段回答用户问题。\n" . "如果知识片段中没有相关信息,请回答“这个问题我需要转人工处理”。\n\n" . "知识片段:\n"; foreach ($contexts as $i => $item) { $systemPrompt .= "【片段" . ($i + 1) . "】" . $item['doc']['content'] . "\n"; } $messages = [['role' => 'system', 'content' => $systemPrompt]]; // 拼接最近 3 轮历史对话,控制 token 消耗 foreach (array_slice($history, -6) as $msg) { $messages[] = ['role' => $msg['role'], 'content' => $msg['content']]; } $messages[] = ['role' => 'user', 'content' => $question]; return $messages; }AiClient::chat收到消息数组后,按config/ai.php里的api_url、api_key、model发请求。超时时间建议设 15 秒,超过就降级返回“正在为您转接人工”。返回的文本写回chat_message表,role设为assistant,source_doc_ids存引用的片段 id,前端轮询到状态变为done就渲染出来。
3.4 人工接管的状态流转
AI 不是万能的,用户连续两次对同一问题表示不满,或者直接输入“转人工”,系统要把会话标记为human_takeover。实现方式是在chat_session表加mode字段,值为ai或human。消费队列时先检查这个字段,如果是human就只落库不调 AI。客服工作台通过 WebSocket 或轮询拿到新消息,回复时role设为assistant但source标记为agent,前端展示不同的头像和名称。
4. 避坑与排查:知识库接入 AI 最常见的 5 个翻车现场
4.1 检索结果答非所问,相似度分数普遍偏低
现象:用户问“怎么修改绑定手机号”,检索出来的却是“如何注销账号”。原因通常是 embedding 模型对中文短文本的语义区分度不够,或者文档切片太碎导致上下文丢失。解决:换一个在中文语义相似度任务上表现更好的 embedding 模型;切片时按段落而不是按固定字数切,保证每个片段有完整语义;在检索后加一层关键词过滤,把包含“手机号”“绑定”等实体词的片段加权。
4.2 模型回复“根据知识库无法回答”但知识库里明明有
现象:知识库文档里写了答案,但 AI 坚持说不知道。原因多半是检索阶段没把相关片段捞回来,或者捞回来了但 Prompt 里片段顺序太靠后,模型注意力被稀释。解决:把topK从 5 调到 8 试试;在 Prompt 里把最相关的片段放在最前面;检查文档的status字段是否为 1,很多新手忘了发布文档。
4.3 队列消费卡死,用户消息一直显示“正在输入”
现象:前端轮询永远拿不到 AI 回复,消息状态停在pending。原因通常是队列进程没启动,或者消费时抛异常没被捕获导致任务不断重试。解决:用php think queue:work --queue ai_chat手动跑一次看报错;在AskJob的fire方法里用 try-catch 包住 AI 调用,失败时把消息状态改为failed并记录日志,不要让异常冒泡导致队列阻塞。
4.4 向量维度对不上,余弦相似度算出 NaN
现象:检索时所有片段分数都是NaN或者-1。原因:问题向量和文档向量用了不同的 embedding 模型,维度不一致;或者文档向量存进数据库时 JSON 编码出错,json_decode返回 null。解决:统一 embedding 模型,在config/ai.php里把embed_model固定;存向量前用json_encode并检查返回值,取出来时加JSON_UNESCAPED_UNICODE参数。
4.5 并发一高,AI 接口限流导致大量失败
现象:高峰期用户提问集中,AI 接口返回 429 或超时。原因:没有做请求队列的削峰填谷,所有任务同时打向模型接口。解决:在队列消费端加限流,比如每秒最多消费 3 个任务,用think-queue的--sleep参数控制;或者把 AI 调用改成批量接口,一次请求处理多个问题。另外在AiClient里加指数退避重试,第一次失败等 1 秒,第二次等 2 秒,第三次直接降级转人工。
5. 二次开发:换检索策略、接不同模型、加多轮记忆的改造思路
5.1 把余弦相似度换成关键词加权混合检索
纯向量检索在专有名词多的场景下容易翻车,比如产品型号“X200-Pro”和“X200 Pro”向量距离可能很远。改造思路是在Retriever::search里加一路 BM25 或简单关键词匹配,把两路分数加权融合。具体做法:先用jieba分词(PHP 有fukuball/jieba-php包)把问题和文档都切词,算 TF-IDF 分数,然后final_score = 0.7 * vector_score + 0.3 * keyword_score。权重可以配在config/ai.php里,方便调参。
// 改造后的混合检索片段 $vectorScore = Cosine::similarity($queryVec, $docVec); $keywordScore = $this->keywordMatcher->score($question, $doc['content']); $finalScore = 0.7 * $vectorScore + 0.3 * $keywordScore;5.2 接入不同大模型供应商的适配层
AiClient目前只写了一种请求格式,如果要从一家模型换到另一家,请求体和响应解析都不一样。改造思路是定义AiProviderInterface,每个供应商写一个实现类,config/ai.php里用provider字段决定加载哪个。比如OpenAiProvider的响应取choices[0].message.content,另一个供应商可能取output.text。这样换模型只改配置不改业务代码。
| 配置项 | 说明 | 常见值 |
|---|---|---|
| provider | 供应商标识 | openai / qwen / zhipu |
| api_url | 接口地址 | 各供应商不同 |
| api_key | 密钥 | 从环境变量读 |
| model | 对话模型名 | 按供应商文档填 |
| embed_model | 向量模型名 | 必须与索引时一致 |
| timeout | 超时秒数 | 15 |
| top_k | 检索片段数 | 5 |
5.3 加多轮记忆和会话摘要
目前只拼了最近 3 轮历史,超过就丢掉。如果用户咨询的是复杂问题,前面提到的关键信息会被截断。改造思路是加一个SessionSummary服务,当历史消息超过 10 条时,调模型生成一段 100 字以内的摘要,存到chat_session.summary字段,下次拼 Prompt 时用摘要替代早期对话。这样既保留上下文又控制 token 消耗。
5.4 验证改造效果的方法
改完检索策略或 Prompt 后,不要凭感觉判断好坏。建一个eval_questions.json,里面放 50 条真实用户问题和期望答案的关键词,写一个命令行脚本php think eval:run批量跑,统计“检索命中率”(Top-5 片段里包含期望关键词的比例)和“回答准确率”(AI 回复包含期望关键词的比例)。每次改动前后各跑一次,用数据说话。我一般会在console/command下建EvalRun.php,输出一个简单的对比表格。
注意:评估集里的问题要覆盖“知识库有答案”“知识库没答案”“需要转人工”三类场景,否则准确率虚高。
5.5 一个容易忽略的细节:知识库更新后的索引重建
知识库文档改了内容,但embedding字段还是旧的,检索结果自然不对。常见做法是在Knowledge控制器的save和delete方法里投递一个ReindexJob,异步重新生成向量。如果文档量大,全量重建耗时长,可以只重建变更的那几条。我习惯在knowledge_doc表加embedding_version字段,每次 embedding 模型升级时递增版本号,检索时只取当前版本的向量,避免新旧混用。
从那以后我每次改检索逻辑或换 embedding 模型,都强制先跑一遍评估脚本再上线,不然线上翻车连后悔药都没得吃。希望帮到你。
本文还有配套的精品资源,点击获取