
简介这是一套面向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 而不是裸写原生 PHP2.1 框架与依赖的取舍逻辑拿到源码先看composer.json核心依赖就三块topthink/framework提供 MVC 和 ORMtopthink/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、systemsource_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 导致模型注意力分散。如果知识库文档很长切片时每片控制在 300500 字重叠 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设为assistantsource_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 / zhipuapi_url接口地址各供应商不同api_key密钥从环境变量读model对话模型名按供应商文档填embed_model向量模型名必须与索引时一致timeout超时秒数15top_k检索片段数55.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 模型都强制先跑一遍评估脚本再上线不然线上翻车连后悔药都没得吃。希望帮到你。本文还有配套的精品资源点击获取