简介:基于ThinkPHP框架开发的运营级在线客服系统源码,已接入AI知识库能力,面向需要为网站快速部署智能客服的PHP开发者。资源包为zip格式,总计2010个文件、约68.72MB,其中js/html/css前端资源共1239个,用于搭建客服界面与交互;php文件131个,实现后端业务逻辑;sql脚本可初始化数据库,md/txt文档约292个,包含安装配置说明,解压后即可按教程部署。系统重点解决了两个常见环境问题:安装fileinfo与redis扩展,以及在php.ini中禁用pcntl_signal、pcntl_fork等进程控制函数,配套教程对此有详细拆解;基于ThinkPHP MVC框架,代码分层清晰,便于二次开发,AI知识库利用NLP技术匹配客户问题,可显著提升响应效率。资源内置Bootstrap、AmazeUI等前端组件,界面组件丰富,适合快速搭建面向业务运营的智能客服平台,已有136人学习下载,适合具备PHP基础、希望快速落地AI客服场景的开发者。
1. PHP在线客服接入AI知识库:先给机器人一块“只读记忆”
访客深夜问“你们支持对公转账吗”,坐席不在,自动回复只会甩链接——这是大多数PHP在线客服系统的真实状态。把 PHP 在线客服接入 AI 知识库,本质不是给聊天窗口换一套话术,而是让机器人先读一遍你沉淀过的 FAQ、产品文档、工单结论,再结合大模型组织语言回答。它能解决“机器人瞎编答案”和“知识散落各处没人维护”两个老问题,适合已经有自研客服系统、手上有几十上百条真实问答数据、但不想把数据全交给外部平台的团队。先提醒一句:不要一上来就上向量库,先用最朴素的关键词检索跑通闭环,后面再逐步加语义能力。
2. 怎么接入才划算:先定知识库形态,再定 RAG 流水线
通用大模型不知道你产品的具体参数、售后政策和工单处理口径,直接让它回答等于打开一个黑匣子。所以要做的是“召回增强生成”(RAG):每次用户提问时,先从知识库里检索相关片段,再把片段作为参考资料塞给大模型,让它基于资料作答。这条流水线才是接入方案的核心,模型换谁都能跑,知识库和召回策略才决定客服机器人答得准不准。
2.1 三种知识库方案的取舍
常见做法有三种,我按部署成本和召回效果排序。
- SQLite FTS5 全文检索或 MySQL 全文索引:零部署、零额外运维,适合 FAQ 标准化程度高、问题描述相对固定的场景。缺点是语义泛化弱,“对公转账”和“企业汇款”这种同义表达匹配不上。
- 向量召回:把文档和用户问题都转成向量,用余弦相似度找相近语义。效果好,但需要有 embedding 模型来源。数据量几百条时,直接用 PHP 全量扫一遍算余弦完全可行;数据量上万条后再考虑专用向量库。
- 开源知识库平台:企业里如果已经部署了 FastGPT、Dify 这类开源知识库,PHP 侧就不需要自己处理分段、向量化、日志,只需要把客服会话流转成对方 API 的输入输出。省事,但引入一个重平台,后续多租户权限、审计日志仍然要自己在外面包一层。
| 方案 | 部署成本 | 召回效果 | 维护量 | 适合场景 |
|---|---|---|---|---|
| SQLite FTS5 / MySQL 全文 | 低 | 中,关键词命中 | 低 | FAQ 规范、问题固定 |
| 自建向量召回 | 中 | 中上,可同义改写 | 中 | 文档量大、口语化问题多 |
| 开源知识库平台 | 高 | 高 | 高 | 已有平台、多团队共用 |
我一般会建议从“关键词召回 + 向量召回并行、分数相加”开始。关键词负责精确命中,向量负责兜底同义表达,两个结果取并集后按融合分数排序,比单独用任何一种都稳。数据不出内网的团队,embedding 可以本地跑 Ollama 上的开源模型,比如 Qwen,显存和并发量要提前估算,不要等到上线才测。
2.2 一次完整请求要经过的五步
不管选哪种知识库形态,PHP 侧每次收到访客消息后的处理链路是固定的,我通常拆成五个步骤:
- 拉取最近对话上下文,做意图分流:闲聊直接走通用回复,售后问题才进知识库问答。
- 对用户问题做轻量改写:把口语转成检索句,例如“怎么给你们打钱”改写成“对公转账 方式”。
- 从知识库召回 Top K 片段:这一步必须在 PHP 里做,因为多租户过滤、权限校验、审计日志都在这一层控制。
- 拼装 System + 知识片段 + 最近对话 + 当前问题,控制在模型可接受的 token 预算内。
- 请求大模型并用流式方式返回给客服界面,同时把本次问答写入日志表。
为什么强调“召回放在 PHP 里”?因为直接让大模型平台接管知识库,等于把权限模型外包了。在线客服里常有“不同客户只能看不同产品线文档”的需求,这个过滤条件只能在召回 SQL 里加,模型侧管不了。
这里顺带说一个 PHP 接口对接的常见坑:json_decode 第二个参数不传 true 时拿到的是对象,传了才是数组,很多人数组和对象混用导致参数拼错。拼 prompt 之前先把返回结构统一成数组,否则后面每一步都在和“stdClass 属性访问”较劲。
3. 建库与召回:用 PHP 把“能答的问题”变成可检索片段
知识库的数据来源通常是历史 FAQ 表格、产品文档、工单解决记录。第一步是清洗,把 HTML 标签、换行符、重复的“您好”问候语去掉,再把一条完整知识拆成可检索的片段。切块原则我踩过几次坑后固定下来:按自然段落切,每段 200 到 500 字,块与块之间重叠 50 字左右,并且每块开头保留所属标题。因为召回到的片段是要直接拼进 prompt 的,片段里没有标题,大模型就会丢失“这段在回答哪个主题”的信息。
3.1 建表与 FTS5 索引:外部内容表加触发器
FTS5 是 SQLite 自带的全文检索扩展,PHP 的 SQLite3/PDO 直接可用,不用装额外服务。为了让检索表和业务表解耦,我用外部内容表的方式建索引。
CREATE TABLE faq_documents ( id INTEGER PRIMARY KEY AUTOINCREMENT, category TEXT NOT NULL DEFAULT 'faq', title TEXT, question TEXT, answer TEXT NOT NULL, full_text TEXT NOT NULL, embedding_json TEXT, tenant_id INTEGER NOT NULL DEFAULT 1, created_at INTEGER ); CREATE VIRTUAL TABLE faq_documents_fts USING fts5( full_text, title, content='faq_documents', content_rowid='id', tokenize='trigram' ); CREATE TRIGGER faq_documents_ai AFTER INSERT ON faq_documents BEGIN INSERT INTO faq_documents_fts(rowid, full_text, title) VALUES (new.id, new.full_text, new.title); END; CREATE TRIGGER faq_documents_ad AFTER DELETE ON faq_documents BEGIN INSERT INTO faq_documents_fts(faq_documents_fts, rowid, full_text, title) VALUES('delete', old.id, old.full_text, old.title); END;原因分两点:第一,trigram 分词器对中英文混合场景比 unicode61 更友好,它按连续三个字符做索引,“对公转账”这种词拆起来不会散架,缺点是索引体积偏大,几千条文档场景完全可接受;第二,外部内容表让 FTS 表不重复存正文,触发器负责同步,避免业务表和索引表内容不一致。SQLite 3.34 以上才支持 trigram,老版本建议直接升 SQLite 或改用 unicode61,不要在生产环境纠结兼容旧版本。
插入文档时,full_text 我一般拼成“标题 + 问题 + 答案”三段,中间用换行分隔。目的是让检索时标题命中的文档排在前面,因为 FTS5 的 bm25 排序会综合各列权重。
3.2 关键词召回:FTS5 MATCH 的转义与排序
召回函数是整条链路里最容易翻车的地方,翻车点不在 SQL,而在用户输入。用户消息里随便带个双引号或括号,直接拼进 MATCH 子句就会报语法错误。
public function keywordSearch(PDO $pdo, string $query, int $topK = 5): array { $match = $this->buildFtsMatch($query); $sql = "SELECT f.rowid AS id, d.full_text, d.title, bm25(faq_documents_fts) AS score FROM faq_documents_fts f JOIN faq_documents d ON d.id = f.rowid WHERE faq_documents_fts MATCH :q AND d.tenant_id = :tenant_id ORDER BY score LIMIT :topK"; $stmt = $pdo->prepare($sql); $stmt->bindValue(':q', $match, PDO::PARAM_STR); $stmt->bindValue(':tenant_id', $this->tenantId, PDO::PARAM_INT); $stmt->bindValue(':topK', $topK, PDO::PARAM_INT); $stmt->execute(); return $stmt->fetchAll(PDO::FETCH_ASSOC); } private function buildFtsMatch(string $query): string { $clean = preg_replace('/[^\p{L}\p{N}\s]+/u', ' ', $query); $terms = array_values(array_filter(explode(' ', $clean))); $terms = array_map(function (string $term): string { return '"' . str_replace('"', '""', trim($term)) . '"'; }, $terms); return implode(' AND ', $terms); } private function cosineSim(array $a, array $b): float { $dot = 0.0; $normA = 0.0; $normB = 0.0; foreach ($a as $i => $val) { $dot += $val * $b[$i]; $normA += $val * $val; $normB += $b[$i] * $b[$i]; } if ($normA == 0.0 || $normB == 0.0) { return 0.0; } return $dot / (sqrt($normA) * sqrt($normB)); }buildFtsMatch 做了两件事:把所有标点替换成空格,再把每个词用双引号包起来形成词组匹配。这样“支持对公转账吗”会变成 “支持” AND “对公转账” ,“对公转账”中间的空格不会破坏整体。bm25 在 FTS5 中返回的是负数,数值越大相关性越高,所以 ORDER BY score,不要倒序。LIMIT 的绑定值在 PDO 里必须用 bindValue 而不是 bindParam,否则类型不匹配会导致 SQLite 报错。
召回后还要设一个最低阈值,低于阈值就认为知识库没有命中,直接转人工。这个阈值不能拍脑袋,我的做法是:把知识库里所有文档跑一遍自问自答,看得分分布,取 10% 分位数作为兜底线,并将这个阈值做成后台配置项。
3.3 向量召回:几百条数据用 PHP 全量算余弦就够了
向量召回的常见做法是:离线脚本把每篇文档转成向量存进 embedding_json 字段,在线请求时只编码用户问题,然后用 PHP 全量扫一遍算余弦相似度。这个方案在 5000 条以内完全够用,毫秒级响应,不要过早引入向量数据库。
public function semanticSearch(PDO $pdo, array $queryVector, int $topK = 5): array { $rows = $pdo ->query('SELECT id, full_text, title, embedding_json FROM faq_documents WHERE tenant_id = ' . (int)$this->tenantId) ->fetchAll(PDO::FETCH_ASSOC); $results = []; foreach ($rows as $row) { if (empty($row['embedding_json'])) { continue; } $docVector = json_decode($row['embedding_json'], true); if (!is_array($docVector)) { continue; } $sim = $this->cosineSim($queryVector, $docVector); if ($sim < $this->semanticThreshold) { continue; } $results[] = [ 'id' => $row['id'], 'title' => $row['title'], 'full_text' => $row['full_text'], 'score' => round($sim, 4), ]; } usort($results, fn($a, $b) => $b['score'] <=> $a['score']); return array_slice($results, 0, $topK); }cosineSim 里默认两个向量维度相同,如果 embedding 模型换了版本导致维度不一致,json_decode 后要先校验 count,否则返回的相似度没有任何意义。embedding 结果的生成要放在定时脚本里跑,不要在客服请求路径里在线生成,否则每次用户提问都要等一次模型推理,体验直接崩盘。
4. 把召回结果拼成提示词:PHP 流式输出 AI 回答的完整链路
召回只是上半场,下半场是把结果组装成一次大模型调用,并且用流式方式推送回前端。这里最核心的认知是:PHP 不是不能做流式,而是你必须关掉所有缓冲机制,让数据边到边出。
4.1 组装上下文与控制 token 预算
提示词我固定用四段结构:System 设定人设和约束、参考资料、最近对话、当前问题。控制预算比调模型参数更影响体验,参数表我给一个参考值:
| 段落 | 预算上限 | 说明 |
|---|---|---|
| System | 400 token | 人设、禁止编造、转人工条件 |
| 知识片段 | 2000 token | 召回 Top 3 到 5 条,每条截断 400 字 |
| 最近对话 | 1000 token | 只保留最近 6 轮,超出丢弃 |
| 当前问题 | 200 token | 原文即可 |
public function buildPrompt(array $docs, array $recentMessages, string $question): array { $context = ''; foreach ($docs as $i => $doc) { $content = mb_substr($doc['full_text'], 0, 400, 'UTF-8'); $context .= "[资料{$i}] {$content}\n\n"; } $history = []; $lastMessages = array_slice($recentMessages, -6); foreach ($lastMessages as $msg) { $history[] = [ 'role' => $msg['role'], 'content' => mb_substr($msg['content'], 0, 300, 'UTF-8'), ]; } $system = "你是商城在线客服。只依据参考资料回答;" . "资料不足时直接说明并引导转人工;" . "不要编造商品参数和售后政策。"; return [ 'model' => 'your-model-name', 'messages' => array_merge( [['role' => 'system', 'content' => $system]], $history, [['role' => 'user', 'content' => $question . "\n\n参考资料:\n" . $context]] ), 'stream' => true, 'temperature' => 0.3, ]; }mb_substr 截断是为了防止用户消息里粘贴了大段日志导致发送失败。temperature 设在 0.3 左右,客服场景要的是稳定回答,不是创意写作。把参考资料放在 user 消息末尾而不是塞进 system,是兼容大多数模型的习惯,也方便调试时肉眼检查到底传了哪些内容。
4.2 用 cURL 的 CURLOPT_WRITEFUNCTION 做流式转发
OpenAI 兼容接口和 Ollama 本地接口我都用同一套写法:CURLOPT_RETURNTRANSFER 设为 false,配合 CURLOPT_WRITEFUNCTION 回调,每收到一块数据就立刻转发给前端。这样访客看到的是打字机输出,而不是转圈十秒后一次性弹出来。
public function streamChat(array $payload): void { header('Content-Type: text/event-stream; charset=utf-8'); header('Cache-Control: no-cache'); header('X-Accel-Buffering: no'); @ob_end_flush(); @ob_implicit_flush(true); $ch = curl_init($this->llmEndpoint); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_HTTPHEADER => ['Content-Type: application/json'], CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE), CURLOPT_RETURNTRANSFER => false, CURLOPT_TIMEOUT => 60, CURLOPT_WRITEFUNCTION => function ($ch, $chunk): int { $lines = explode("\n", $chunk); foreach ($lines as $line) { $line = trim($line); if ($line === '' || !str_starts_with($line, 'data:')) { continue; } $json = json_decode(substr($line, 5), true); $content = $json['choices'][0]['delta']['content'] ?? $json['message']['content'] ?? ''; if ($content !== '') { echo $content; @ob_flush(); flush(); } } return strlen($chunk); }, ]); $ok = curl_exec($ch); if ($ok === false) { error_log('stream curl error: ' . curl_error($ch)); echo "系统繁忙,请稍后重试或转人工。"; } curl_close($ch); }这段代码的关键是返回 strlen($chunk),告诉 cURL 这次回调完整处理了缓冲数据;返回其他值会导致 cURL 中止传输。X-Accel-Buffering: no 是给 Nginx 看的,让 Nginx 不要在后端之前缓冲响应,这是 PHP 流式输出在 Nginx 后面能即时到达浏览器的关键。Ollama 的响应格式略有差异,但兜底解析了 message.content 字段,两边都能兼容。
不想把那么长的 HTTP 连接挂在 PHP-FPM worker 上,另一个常见做法是:PHP 把任务推进 Redis 队列,后台常驻进程负责请求大模型,结果通过 WebSocket 推给客服界面。这个方案能彻底解决 PHP-FPM 占用问题,但要额外维护一个长驻进程,适合并发量上来之后再演进。
4.3 机器人答不上来的转人工设计
LLM 不是每次都能答对,转人工要设计成明线而不是暗线。我的做法是:召回阶段分数低于阈值就直接给前端返回“转人工”指令,根本不需要请求大模型;如果召回到资料但生成后坐席端觉得回答不可信,界面提供“一键转人工并携带上下文”按钮,把最近十轮对话和召回片段一并转给坐席。
public function reportUnmatched(int $tenantId, string $question, string $category): void { $stmt = $this->pdo->prepare( 'INSERT INTO unmatched_questions (tenant_id, question, category, created_at) VALUES (:tenant_id, :question, :category, :created_at)' ); $stmt->execute([ ':tenant_id' => $tenantId, ':question' => mb_substr($question, 0, 200, 'UTF-8'), ':category' => $category, ':created_at' => time(), ]); }reportUnmatched 在召回无命中或用户主动点“没解决”时调用。这张表是知识库闭环的入口:坐席在后台补充答案后,审核通过就可以直接入库成为新的知识片段,下一次类似问题就能命中。没有这个闭环,知识库永远靠人工维护,跑一个月就过期了。
5. 接入中的 5 个坑:从 SSE 卡死到知识泄漏
这章全是我在真实接入里踩过的坑,基本覆盖了从联调到上线的排查路径,每条都按现象、原因、解决三步写。
5.1 现象:SSE 半天不出字,访客以为机器人死了
原因出在两层缓冲:PHP 的 output_buffering 开着,内容积满 4096 字节才发送;或者 Nginx 开了 proxy_buffering,后端响应被 Nginx 攒住。日志里 curl 正常、但浏览器端就是没输出。
解决:PHP 侧在 streamChat 开头调用 ob_end_flush 和 ob_implicit_flush(true),同时确认 php.ini 里 output_buffering 是 Off;Nginx 站点配置里加 proxy_buffering off,以及刚才代码里的 X-Accel-Buffering: no 响应头。三个条件缺一个,流式效果都出不来。联调时建议先用 curl -N 直连 PHP 端口,排除 Nginx 影响再往上查。
5.2 现象:上下文一长就超时或直接报 400
原因:把全部历史消息和所有召回片段都拼进 prompt,超过模型单次输入上限。API 返回 400 还算好,有些平台会静默丢弃超长内容,导致回答驴唇不对马嘴。
解决:做一次 prompt 长度自检,发送前统计字符数,超过预算就按优先级丢弃:先丢最旧的对话,再丢资料片段中分数最低的。我通常在 buildPrompt 最后加一行日志,记录本次发送的消息数组大小,上线后拉日志看分布,按 P95 值调整上限。
5.3 现象:检索结果答非所问,术语变一个说法就搜不到
原因:FTS5 按字面匹配,用户说“怎么给你们打钱”,知识库写的是“对公转账”,两个词不重合。这是关键词召回的天花板,不是 bug。
解决:建一张同义词表,把高频口语词映射到标准词。例如“打钱、汇款、公对公、转账”统一映射到“对公转账”,查询前先做一次词组替换。这一步在 PHP 里实现很简单,一张 map 就够了,等映射表超过几百条再考虑接入向量召回做语义兜底。
5.4 现象:并发一高,PHP-FPM 全被占满,普通页面也跟着卡
原因:一次流式请求最长可能持续几十秒,期间 worker 一直被占用。10 个并发请求就能吃掉默认的 10 个 worker,其他接口全部排队。
解决:最直接的是用 Nginx 做并发限制,limit_req 单独给流式接口限速;更彻底的是把大模型请求改造成异步任务,PHP 接收请求后推 Redis,消费端是常驻 CLI 进程,前端用 WebSocket 或轮询拿结果。第二套方案工程量多一天,但对在线客服这种长连接场景是正解。
5.5 现象:知识库串租户,客户 A 问到了客户 B 的信息
原因:召回 SQL 漏了 tenant_id 过滤,或者知识库里混入了未脱敏的聊天记录、工单内容,大模型把这些内容当资料输出了。
解决:召回 SQL 强制带租户条件,参数绑定不要拼字符串;知识库入库前只允许通过审核的 FAQ 和文档,聊天记录必须脱敏后才能进知识库;System 提示词里写明“只依据资料回答,资料中不含相关内容时转人工”。这三层缺一层,后面出了事都是安全事故,不是普通 bug。
6. 上线前用 30 条问题做回归,比调参更有用
调 temperature、调 topK、调阈值,都不如先建一个小型回归集见效。我做知识库更新时必跑一遍 30 条真实客服问题的回归,流程固定:把问题逐条发给机器人接口,记录每条问题的召回分数、机器人回答、是否建议转人工,再用表格比对期望结果。表格字段就四个:问题、期望答案是否在知识库中、实际回答质量评分、转人工判断是否合理。
跑一轮后看两类数据:一类是“知识库明明有答案但没召回”,说明切块或索引有问题;另一类是“召回正常但大模型答偏了”,说明 prompt 结构或参考资料排序需要调整。一次回归大概花半小时,比上线后翻聊天记录排查高效得多。
进阶技巧是把第 4.3 节的 unmatched_questions 表做成自动任务:每周统计未命中问题里出现频次最高的前二十条,自动生成一条待办给坐席主管。坐席补充答案后,知识库就有了新的弹药。我自己的教训是:第一次接 AI 客服时跳过了回归集,上线第一周就被访客截图“机器人答非所问”,后来老老实实把 30 条问题跑成固定脚本,每次更新知识库先重跑一遍,再也没出过系统性翻车。这套做法不挑模型、不挑知识库形态,值得在你自己的 PHP 客服系统里先落地一轮。希望帮到你。
本文还有配套的精品资源,点击获取