简介:面向PHP开发者与客服系统搭建者的在线客服AI知识库接入源码包,基于ThinkPHP框架实现,可用于构建具备智能问答与人工客服协同能力的运营级客服平台,帮助解决传统客服响应慢、知识检索分散等问题。压缩包共2010个文件、约68.72MB,包含131个PHP核心程序、613个JavaScript脚本、368个HTML页面与258个CSS样式文件,并配有XML、JSON、Markdown、TXT等配置说明与文档,便于按模块查阅前端界面、后端逻辑及部署资料。包内附有安装配置说明,涉及fileinfo、redis插件及PHP运行环境调整,适合需要二次开发或学习客服系统架构的中高级PHP开发者。目前已有138人学习,可作为客服系统搭建、AI知识库接入与ThinkPHP项目实践的参考样本。
1. 需求与边界:一套 PHP 在线客服为什么要接 AI 知识库
很多团队做在线客服的 AI 改造,第一反应是「接个大模型接口就完事」,结果上线三天就被业务打回:答得挺流畅,就是不对。问题从来不在模型,而在知识从哪来、答案怎么控、什么时候必须转人工。这套方案面向已经在用 PHP + ThinkPHP 维护在线客服系统的团队——人工坐席工作台、会话记录、渠道接入都已经有了,现在要加一层 AI 前置应答:常见问题由机器人先接,答不准就干脆转人工,知识库运营人员能自己更新文档而不用改代码,每条回答都能追溯到具体知识片段。它适合做课程设计、毕业设计,也适合已经上班、要交一份能落地的客服智能化方案的人。下面从架构一路写到验收,中间给的是可直接抄改的建表语句、切片逻辑和转人工判定。
2. 总体架构设计:消息接入、会话、检索、生成、坐席五层职责划分
一条用户消息从网页挂件发出到收到回答,中间要经过鉴权、去重、落库、意图判定、知识检索、模型生成、敏感词过滤、超时兜底这一串动作。全塞进一个控制器里也能跑,但两周后加一个渠道、换一个模型,整段代码就得重写。合理的做法是按职责切层,层与层之间只用约定的数据结构通信,任何一层替换都不影响其他层。这一章先把这个骨架立起来,后面几章往骨架里填肉。
2.1 从一条用户消息说起:链路上的六个处理节点
第一个节点是接入层,网页、H5、小程序、APP 的 SDK 都往同一个入口打,入口只做三件事:校验渠道签名、按用户维度限流、把原始报文规整成统一结构。第二个节点是消息网关,负责幂等——同一个 client_msg_id 重复上报只落一条,客服系统里重复消息引发的串话是最常见的低级事故。第三个节点是会话服务,判断这条消息该落到哪个会话、当前会话是机器人接待还是人工接待、人工接待时坐席是否在线。
第四个节点是意图与路由,这是整条链路的分水岭:先用规则和 FAQ 精确匹配拦掉一部分;剩下的进知识检索;检索有高分片段才交给模型生成;分不够就直接转人工。第五个节点是知识库服务,负责切片、向量化、召回和重排。第六个节点是运营后台,客服主管在这里看未命中的问题、补知识、调阈值。六个节点里,只有第四个会调外部模型,其余全在 PHP 进程内或本地存储里完成,这样成本和排障范围都可控。
提示:把模型调用收敛到单一节点,是后期换模型、做灰度、限流的前提。散落在多个控制器里的模型调用,改一次要动十几处。
2.2 技术选型:为什么让 ThinkPHP 做编排层,而不是把逻辑塞给模型
ThinkPHP 在这套方案里的定位很明确:HTTP 接口、中间件鉴权、数据库访问、缓存、队列、命令行任务,这些它都成熟。模型能力则通过 HTTP 接口调用,不把 PHP 变成算法工程。有人会问,为什么不干脆用 Python 写一整套?因为客服系统的主体——坐席管理、工单、权限、报表——已经在 PHP 里了,为了 AI 部分重写一遍后台,投入产出比不划算。
知识检索这块要给两种落地路径。数据量在几万条片段以内,向量直接存进 MySQL 的 JSON 列或二进制列,召回时在 PHP 里算余弦相似度,配合关键词倒排就够用,部署简单、无额外组件。数据量再往上,或者要做多路召回、按租户隔离,就接一个独立的向量检索服务,PHP 侧只负责拼查询和拿结果。两条路的上层代码结构完全一致,只是检索驱动换实现,用一个接口类隔离掉。
| 组件 | 承担职责 | 选型理由 |
|---|---|---|
| ThinkPHP | 接口编排、鉴权、任务调度 | 已有业务基础,团队熟悉 |
| MySQL | 会话、消息、知识元数据 | 事务可靠,运营后台直接查 |
| 缓存 | 热点答案、会话状态 | 抗住重复提问的瞬时并发 |
| 队列 | 文档切片、向量化、日志落盘 | 把重活从请求链路里挪走 |
| 模型接口 | 答案生成、向量化 | 走 HTTP,可替换可灰度 |
注意:向量化和答案生成是两类不同接口,别混在同一个 SDK 里封装,前者要批量、要重试,后者要流式、要超时控制,调用特征完全不一样。
2.3 数据表设计:会话、消息、知识、召回日志四组核心表
表结构决定后面能做什么。会话表管状态,消息表管过程,知识表和片段表管内容,召回日志表管效果。少了召回日志,线上出现答非所问时,你只能靠猜,这是最难受的黑匣子。
-- 会话:一条会话从机器人接待到转人工,状态在这张表上流转 CREATE TABLE `cc_session` ( `id` bigint unsigned NOT NULL AUTO_INCREMENT, `session_no` varchar(32) NOT NULL COMMENT '对外会话号', `channel` varchar(20) NOT NULL COMMENT 'web/h5/app/mini', `user_key` varchar(64) NOT NULL COMMENT '渠道内用户标识', `agent_id` int unsigned NOT NULL DEFAULT 0 COMMENT '人工坐席ID,0为未分配', `status` tinyint NOT NULL DEFAULT 1 COMMENT '1机器人 2排队 3人工 4已结束', `last_msg_at` datetime DEFAULT NULL, `created_at` datetime NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_no` (`session_no`), KEY `idx_status` (`status`, `last_msg_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='客服会话'; -- 消息:每条回答都带上引用的知识片段与置信分,用于回溯 CREATE TABLE `cc_message` ( `id` bigint unsigned NOT NULL AUTO_INCREMENT, `session_id` bigint unsigned NOT NULL, `role` varchar(10) NOT NULL COMMENT 'user/assistant/agent', `content` text NOT NULL, `client_msg_id` varchar(64) DEFAULT NULL COMMENT '幂等键', `ref_chunks` varchar(255) DEFAULT NULL COMMENT '命中的片段ID,逗号分隔', `confidence` decimal(5,4) DEFAULT NULL, `latency_ms` int unsigned DEFAULT NULL, `created_at` datetime NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_client` (`session_id`, `client_msg_id`), KEY `idx_session` (`session_id`, `id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='会话消息';知识侧拆成文档表和片段表两级。文档表记录来源、分类、版本、状态,片段表存切片正文、关键词、向量。两级设计的好处是重新切片时只动片段,文档的运营属性(分类、生效范围、负责人)不受影响。
-- 知识片段:embedding 以 JSON 数组存储,规模小的时候直接在 PHP 侧算相似度 CREATE TABLE `kb_chunk` ( `id` bigint unsigned NOT NULL AUTO_INCREMENT, `doc_id` int unsigned NOT NULL, `chunk_no` int unsigned NOT NULL DEFAULT 0, `content` text NOT NULL, `keywords` varchar(255) DEFAULT NULL COMMENT '抽取的关键词,用于倒排召回', `embedding` json DEFAULT NULL COMMENT '向量数组', `token_len` int unsigned DEFAULT 0, `status` tinyint NOT NULL DEFAULT 1 COMMENT '1生效 0失效', PRIMARY KEY (`id`), KEY `idx_doc` (`doc_id`, `chunk_no`), FULLTEXT KEY `ft_content` (`content`) WITH PARSER ngram ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='知识片段';client_msg_id上的唯一索引是幂等的基础,前端重发、网络抖动重试都会被数据库挡掉。ref_chunks用逗号分隔存片段 ID,虽然不符合范式,但查起来快,运营后台点开一条回答就能看到依据。FULLTEXT ... WITH PARSER ngram让中文能走 MySQL 原生全文索引,作为关键词召回的第一层,省掉一次外部调用。
2.4 接口契约:前端、PHP 与 AI 服务之间的三个约定
前后端之间走 SSE,PHP 与模型服务之间走流式 HTTP。约定三条:请求体统一带 session_no 和 client_msg_id;响应统一是「事件类型 + 数据」两段式;任何异常都必须在 200 的响应体里以 error 事件表达,不要让前端去解析 500。
{ "session_no": "S20240613001", "client_msg_id": "c-8f2a91", "channel": "web", "content": "订单还没发货能改地址吗", "context": {"last_intent": "logistics", "turns": 2} }请求体里带context.last_intent是为了让路由层知道用户上一轮在聊什么,避免多轮里话题一跳就重新检索。turns用来控制上下文长度,超过预设轮次就丢弃最早的历史。
{"event": "delta", "data": {"text": "可以,"}} {"event": "delta", "data": {"text": "未发货订单支持修改收货地址。"}} {"event": "done", "data": {"msg_id": 90211, "confidence": 0.83, "refs": [1201, 1207]}} {"event": "handoff", "data": {"reason": "low_confidence", "queue": "after_sale"}}delta负责吐字,done携带最终置信分和引用片段,handoff表示转人工并给出排队队列。前端收到handoff就把聊天窗切到排队态,不需要自己判断该不该转。这套契约定死之后,后面换模型、加渠道、做灰度,前端一行不用改。
3. 知识库侧设计:文档切片、向量化与混合检索的落地细节
AI 客服答得准不准,八成取决于知识库这一侧,模型只占两成。我见过太多项目把精力全花在调提示词上,知识库里躺着三年前的产品说明、格式混乱的工单记录、一整篇五万字的制度文件,切片切得七零八落,再怎么调提示词也救不回来。这一章讲怎么把知识整理成模型能用的形状。
3.1 知识从哪来:FAQ、文档、历史工单三种来源的清洗规则
FAQ 是最优质的知识,一问一答,直接就是天然的片段,几乎不用处理,只要把同义问法合并、把答案里的过时信息剔掉。文档类知识(产品手册、售后政策)质量参差,需要先做结构化:去掉页眉页脚、目录、修订记录,保留标题层级,因为标题层级决定了后面切片时的语义边界。历史工单是最容易被忽略的一块,里面藏着大量真实用户问法,但噪声也最大,只保留「坐席明确给出结论且用户未再追问」的对话对,其余的丢掉。
清洗要有统一的入口,不能让运营直接往库里贴。我一般会做一个导入流程:上传文件 → 解析成纯文本 → 按标题切成章节 → 人工确认章节标题 → 进入切片队列。人工确认这一步看着笨,但能挡掉大量格式灾难。导入时给每篇文档打上分类、生效渠道、生效时间、负责人四个运营属性,后面检索时可以按渠道过滤,避免把 A 渠道的政策答给 B 渠道的用户。
3.2 切片策略:按语义切还是按字数切
纯按字数切是最省事的,也是最容易翻车的:一句话被从中间劈开,前半句在片段一、后半句在片段二,两片召回都不完整,模型拼出来的答案就飘。可行的做法是三级切分:先按标题层级切大块,再按段落切中块,最后对超长段落按句子边界合并到目标长度。目标长度控制在 300 到 500 字之间,太短丢上下文,太长稀释向量语义。
/** * 按段落与句子边界切片,避免把一句话劈成两半 * @param string $text 已清洗的纯文本 * @param int $maxLen 单片段目标长度(字符) * @param int $overlap 相邻片段重叠字符数,防止边界信息丢失 */ public function split(string $text, int $maxLen = 400, int $overlap = 60): array { // 先按空行切成段落,保留原有语义块 $paras = preg_split('/\n{2,}/u', trim($text)); $chunks = []; $buf = ''; foreach ($paras as $p) { $p = trim($p); if ($p === '') { continue; } // 单段超长时按中文句末标点再切 $sentences = preg_split('/(?<=[。!?;])/u', $p); foreach ($sentences as $s) { if (mb_strlen($buf . $s) > $maxLen && $buf !== '') { $chunks[] = $buf; // 重叠:保留尾部若干字符,接上下一段 $buf = mb_substr($buf, -$overlap) . $s; } else { $buf .= $s; } } $buf .= "\n"; } if (trim($buf) !== '') { $chunks[] = trim($buf); } return array_values(array_filter($chunks, fn($c) => mb_strlen($c) > 20)); }maxLen不是拍脑袋定的,它和模型的上下文窗口、召回精度共同决定。片段太长,一次召回五条就撑爆提示词;太短,单条片段信息不完整。400 字左右是中文客服知识的常见甜点区。overlap的作用是给边界上保险,比如「退货期限为 7 天」和「超过期限不予受理」被切到两片,重叠 60 字能让两片都带上关键限定。切完必须落一个片段序号chunk_no,召回时按序号可以把相邻片段一起取出来补全上下文。
3.3 向量化与存储:MySQL 存元数据,向量单独管理
向量化的时机放在文档审核通过之后,走队列异步做,不要卡在导入请求里。一批文档几十个片段,逐条调接口慢且贵,按批提交,每批 16 到 32 条,失败重试三次后标记为失败,运营后台可以手动重跑。向量维度按所选向量模型定,不要自己在代码里写死数字,配置化,换模型时只改配置。
存储上有两条路。片段量在几万以内,把向量以 JSON 数组存进kb_chunk.embedding,召回时用 SQL 拉出候选集(先按分类、状态、关键词过滤,把候选压到几百条),再在 PHP 里算余弦相似度,响应时间完全够用。片段量上去了,就把向量同步到独立的检索服务,MySQL 只留元数据,召回时先去检索服务拿 ID 列表,再回 MySQL 补正文。两条路用一个Retriever接口隔离,方法签名固定为「查询文本 + 过滤条件 + topK → 片段列表」。
提示:向量入库一定要带版本号。换了向量模型,旧向量和新查询不在同一个空间,混着用结果会很难看,版本号能让新旧两批向量各自独立,切换时整体重建。
3.4 混合检索:关键词召回加向量召回,再做一个轻量重排
纯向量召回对同义表达友好,但对产品型号、订单号、专有名词这类精确匹配很弱;纯关键词召回反过来。客服场景里两类查询都大量存在,所以必须混合。做法是两路各取 topK(比如各 20 条),按片段 ID 去重合并,再用加权分排序。
/** * 混合召回打分:向量相似度 + 关键词命中 + 业务权重 * 权重可按业务调整,客服场景通常向量略高 */ public function score(array $candidates, array $queryVec): array { $w = ['vec' => 0.6, 'kw' => 0.3, 'biz' => 0.1]; foreach ($candidates as &$c) { $vecSim = $this->cosine($queryVec, json_decode($c['embedding'], true)); // 关键词得分:命中词数 / 查询词数,简单但有效 $kwHit = 0; foreach ($c['query_terms'] as $t) { if (mb_strpos($c['content'], $t) !== false) { $kwHit++; } } $kwScore = $c['query_terms'] ? $kwHit / count($c['query_terms']) : 0; // 业务权重:置顶知识、近期更新的文档给一点加成 $biz = ($c['is_top'] ? 1 : 0) * 0.5 + $c['fresh_score']; $c['score'] = $w['vec'] * $vecSim + $w['kw'] * $kwScore + $w['biz'] * $biz; } usort($candidates, fn($a, $b) => $b['score'] <=> $a['score']); return $candidates; }三个权重里,vec决定语义泛化能力,kw保证型号和编号不被漏掉,biz给运营留了一个手动干预的口子——某条政策刚更新,运营可以在后台置顶,is_top直接体现在排序上。fresh_score用文档更新时间的衰减函数算,越新越高,上限 1。这套打分算完之后,取前 3 到 5 条作为模型输入,分数低于阈值的直接丢弃,不要抱着「多给几条总有一条对的」的心态,噪声片段是幻觉的主要来源之一。
3.5 知识更新与失效:增量重建和版本切换
知识库不是建完就不动的。产品改价、政策调整、活动上下线,都需要知识跟着变。做法是文档级版本:文档每次编辑生成一个新版本,旧版本的片段状态置为失效但不删除,新版本重新切片、重新向量化,全部完成后原子切换文档的当前版本号。这样任何一次更新都可回滚,出问题能退回上一版。
失效还有个隐含需求:定时失效。活动类知识应该带生效起止时间,到期自动下线,别指望运营记得手动删。检索时把时间条件加进过滤,过期片段根本不进候选集,比事后清理干净得多。
4. AI 应答链路:意图判定、上下文拼装与转人工阈值
知识库准备好了,接下来是把用户的一句话变成一条靠谱的回答。这条链路上最容易被低估的是「什么时候不该答」——一个高质量客服机器人的标志不是答得多,而是知道什么时候闭嘴转人工。这一章把链路的每一段拆开讲,包括提示词怎么拼、流式怎么输出、置信度怎么算。
4.1 意图分层:先用规则和缓存把成本压下来
用户消息进来,不要一上来就检索加调模型。先做三层过滤。第一层是精确匹配,把用户问题做归一化(去标点、转小写、繁简统一、去语气词),和 FAQ 的标准问做哈希比对,命中就直接返回缓存答案,这条路径耗时在毫秒级。第二层是高频问缓存,把最近一段时间内出现频次高且反馈良好的问题答案缓存起来,key 用归一化后的问题文本,重复提问直接命中。
第三层才进检索和生成。经过前两层,通常能拦掉四成到六成的请求,模型调用量直接减半,成本和时间都省下来了。
public function route(string $question, string $channel): array { $norm = $this->normalize($question); // 去标点、统一大小写、繁转简 // 第一层:FAQ 精确命中 if ($faq = $this->faqHit($norm, $channel)) { return ['type' => 'faq', 'answer' => $faq['answer'], 'confidence' => 0.99]; } // 第二层:热点答案缓存,带渠道前缀防止串渠道 $cacheKey = "cc:ans:{$channel}:" . md5($norm); if ($cached = Cache::get($cacheKey)) { return ['type' => 'cache', 'answer' => $cached, 'confidence' => 0.9]; } // 第三层:检索 + 生成 return ['type' => 'rag', 'chunks' => $this->retrieve($question, $channel)]; }normalize里最容易漏的是繁简和全半角,用户从不同端复制粘贴过来的文本差异很大。缓存 key 必须带渠道前缀,A 渠道的促销政策答给 B 渠道用户,是客服系统里典型的翻车点。confidence在这里是预估值,最终值在生成后再算。
4.2 提示词拼装:系统约束、知识片段、历史对话三段式
提示词的结构要固定,不要让每次请求的模板都不一样,否则出问题没法复盘。三段式:系统段写死角色和红线(只依据给定资料回答、不猜测、资料不足时明确说不知道并引导转人工、不透露内部文档编号);知识段按打分顺序编号贴入片段;对话段只保留最近若干轮,且做长度截断。
public function buildPrompt(string $question, array $chunks, array $history): array { $sys = "你是某产品的在线客服助手。规则:\n" . "1. 只能依据【参考资料】回答,资料未覆盖时回答“这个问题我需要请人工同事帮您确认”。\n" . "2. 不编造价格、时效、政策条款,不引用资料以外的编号。\n" . "3. 回答控制在 150 字内,分点不超过 3 条,语气简洁礼貌。\n" . "4. 涉及退款金额、账号安全、投诉时,直接建议转人工。"; $ref = ''; foreach ($chunks as $i => $c) { $ref .= "[" . ($i + 1) . "] " . $c['content'] . "\n"; } // 历史对话截断:只保留最近 6 条,且总字符不超过 800 $hist = array_slice($history, -6); $messages = [['role' => 'system', 'content' => $sys]]; foreach ($hist as $h) { $messages[] = ['role' => $h['role'], 'content' => mb_substr($h['content'], 0, 200)]; } $messages[] = ['role' => 'user', 'content' => "【参考资料】\n{$ref}\n【用户问题】\n{$question}"]; return $messages; }系统段里的第 4 条是硬性兜底,把高风险意图直接排除在模型决策之外,不让模型去判断「这笔退款该不该批」。知识段编号是为了让模型能引用,同时方便日志里对齐是哪条片段支撑了答案。历史截断看着粗暴,但比按 token 精确计算好维护,中文客服对话很少有超过 200 字的单条消息。
4.3 流式输出与超时控制:PHP 侧怎么不卡死
用户等不了五秒才看到第一个字。流式输出的关键是关掉所有缓冲,让每个数据块立刻出去。PHP 里做 SSE,要设text/event-stream、关掉输出缓冲、关掉压缩、每条消息后 flush,同时要把执行时间拉长,否则默认 30 秒就断了。
public function stream(string $sessionNo, array $messages): void { header('Content-Type: text/event-stream'); header('Cache-Control: no-cache'); header('X-Accel-Buffering: no'); // 关掉反向代理缓冲,否则会攒着一起发 while (ob_get_level() > 0) { ob_end_flush(); } set_time_limit(120); @ini_set('zlib.output_compression', '0'); $start = microtime(true); $full = ''; foreach ($this->callModelStream($messages) as $piece) { // 首字超时保护:3 秒没吐字就中止,走兜底话术 if ($full === '' && microtime(true) - $start > 3) { $this->emit('handoff', ['reason' => 'first_token_timeout']); return; } $full .= $piece; $this->emit('delta', ['text' => $piece]); } $this->emit('done', [ 'msg_id' => $this->saveMessage($sessionNo, $full, $start), 'confidence' => $this->confidence($full, $messages), ]); } private function emit(string $event, array $data): void { echo "event: {$event}\ndata: " . json_encode($data, JSON_UNESCAPED_UNICODE) . "\n\n"; flush(); }X-Accel-Buffering: no这行是血泪经验,不加的话反向代理会把整个响应攒完再发,前端看起来和一次性返回没区别,白折腾。首字超时保护设在 3 秒,超时就发handoff,比让用户对着转圈强。set_time_limit要和网关的超时对齐,网关 60 秒、PHP 120 秒,让 PHP 后死,才能把错误事件发出去。
4.4 置信度与转人工:三个信号决定交不交给人
转人工不是「模型说不知道就转」,要有量化信号。第一个信号是检索分:最高片段得分低于阈值,说明知识库压根没覆盖这个问题,生成出来的必然是幻觉。第二个信号是模型自评:在提示词末尾要求模型输出一个 0 到 1 的把握程度,或者用一个轻量的判定接口对答案做一次「是否被资料支持」的二分类。第三个信号是行为信号:用户连续两轮追问同一个问题、出现负面情绪词、消息里带订单号但检索没命中。
public function shouldHandoff(array $ctx): bool { if (($ctx['top_score'] ?? 0) < 0.35) { return true; // 知识未覆盖 } if (($ctx['self_score'] ?? 1) < 0.5) { return true; // 模型自评不自信 } if (($ctx['repeat_count'] ?? 0) >= 2) { return true; // 连续追问视为未解决 } // 高风险意图一律转人工,不交给模型判断 return in_array($ctx['intent'] ?? '', ['refund', 'account', 'complaint'], true); }三个阈值都要用线上数据校准,0.35只是起点。上线前两周把每次转人工的记录连同当时的分数存下来,看分布,把阈值调到「转人工率可接受且投诉率不上升」的位置。
4.5 上下文与成本:历史裁剪、答案缓存与配额
会话状态放在缓存里,key 用 session_no,value 存最近若干轮对话加当前意图,设置合理的过期时间。不要每次请求都从数据库全量拉历史消息,会话长了数据库压力顶不住。缓存失效时从数据库回填,回填只取最近二十条。
答案缓存要分两类:一类是精确命中问题的缓存,key 是归一化问题文本,命中率高但覆盖面窄;另一类是「问题簇」缓存,把语义相近的问题归到同一个簇,簇内共享答案,覆盖面广但要定期清理。两类都设过期时间,政策类知识一改,缓存必须主动清掉,否则用户拿到的是旧答案,这种事故排查起来极其费劲。模型调用加配额,按租户或渠道设日限额,超了自动降级到 FAQ 匹配,保证系统不整体挂掉。
5. 避坑与常见问题排查:上线后最容易翻车的五个点
方案跑通和系统能用是两回事。下面五条是这类项目里出现频率最高的问题,每条都按「现象、原因、解决」写,照着排查能省不少时间。
5.1 答非所问:召回片段是对的,答案还是偏
现象是运营抽查时发现,明明库里有一条完全对应的知识,机器人答的却是另一回事。原因通常有两个:一是召回返回了多条片段,按分数排序后把真正相关的那条排到了第四第五,模型在拼装时被前几条带偏;二是片段本身内容太杂,一条片段里塞了退款、换货、发票三件事,模型抓到哪句算哪句。
解决办法是提高召回精度而不是加大召回数量。把 topK 从 10 降到 5,加一个分数差判断:第一条和第二条分差过大时只给第一条,避免干扰。切片那一侧回头检查,一条片段只讲一件事,一件事超过 400 字就拆成两片。再加一步「答案归因」校验——生成完成后拿答案和引用片段做一次相似度比对,相似度太低就判定为偏离,直接走转人工。
5.2 首字延迟高:不是模型慢,是链路在等
现象是用户发完消息转了两三秒才看到第一个字,但模型侧的耗时日志显示首字只要 800 毫秒。原因往往在 PHP 侧:输出缓冲没关干净、反向代理开了压缩缓冲、或者检索那一步串行调了三次接口。
排查顺序是从外往里。先看网关日志里的 TTFB,再在 PHP 里打点,把「收到请求」「检索完成」「模型首字」「输出完成」四个时间戳记下来,哪一段占大头一目了然。检索接口能并行的并行,向量召回和关键词召回同时发,别串着等。缓冲区那部分,ob_end_flush之外还要确认框架自身的中间件没有接管输出,有些框架的响应对象会把内容攒到最后统一发送,流式接口要绕开它。
注意:本地开发环境常常看不出缓冲问题,因为本地没有反向代理。流式接口必须在有代理的环境里测,否则上线才发现。
5.3 重复回答与串话:会话状态存错了地方
现象有两个:同一个用户连着发两条消息,机器人把第一条的答案又答了一遍;或者两个用户的对话串到一起。原因是会话状态用了错误的 key,比如用用户 IP 或者渠道 ID 做 key,同一网络下多个用户就串了;也可能是幂等没做,前端重发导致重复处理。
解决办法是把 session_no 作为唯一状态键,由服务端在会话创建时生成并下发,前端每次带回来。消息表上(session_id, client_msg_id)的唯一索引必须存在,插入冲突时直接返回上一次的结果,不要重新走一遍链路。会话状态的缓存 key 加前缀区分环境,测试环境的数据不要污染生产。
5.4 知识更新不生效:缓存和索引没对齐
现象是运营在后台改了知识,前端提问还是旧答案,重启服务才好。原因是三层缓存各活各的:应用层缓存了答案、检索层缓存了向量、模型侧可能还有提示词缓存。改知识的时候只清了数据库。
解决思路是把「知识变更」做成一个事件,文档版本切换成功后发一条消息,订阅方各自清各自的缓存。应用层按文档 ID 打标签,清缓存时按标签批量清,不要用全量 flush,那会把无关的热点答案一起干掉。检索层重建时用新版本号写入,旧版本保留一段时间再删,切换瞬间不会出现查不到的情况。
5.5 敏感内容与越权:内部文档被答出去了
现象是用户问了一个刁钻问题,机器人把内部定价规则、成本说明、内部工单编号答了出来。原因是知识入库时没有做可见范围标记,检索时也没按用户身份过滤。
解决要两道闸。入库时给每条片段打上可见范围(公开、仅内部、指定渠道),内部文档默认不进 AI 检索池,要用也得走单独的库。检索时把用户身份和渠道作为过滤条件传下去,SQL 层面就过滤掉,不要靠提示词里写「不要透露」来约束模型,那是靠不住的。输出侧再加一道敏感词和编号格式的检测,命中就替换成兜底话术并记一条告警。
6. 验收与答辩:用一套 60 条评测集把效果讲清楚
这套东西做完,最难回答的问题是「效果到底怎么样」。说「感觉挺准的」过不了答辩,也过不了业务评审。可行的做法是建一套自己的评测集:从历史工单和真实提问里抽 60 条,覆盖常见问题、长尾问题、多轮追问、超纲问题四类,每条标注标准答案要点和期望动作(直接回答 / 转人工)。每次改动检索策略或提示词,跑一遍,看四个数。
| 指标 | 计算方式 | 参考目标 |
|---|---|---|
| 首条命中率 | 标准答案要点被回答覆盖的比例 | 75% 以上 |
| 误答率 | 答了但要点错误的比例 | 5% 以下 |
| 转人工准确率 | 应转人工的问题中实际转了的比例 | 85% 以上 |
| 首字延迟 | 从请求到第一个字输出的 P90 | 1.5 秒以内 |
评测脚本不用搞复杂,一个命令行任务读 CSV,逐条调内部接口,把回答和期望动作一起写回结果表,人工复核一遍。跑完输出一份按类别分组的报告,答辩时直接展示这张表比讲十分钟架构有说服力。
# 逐条跑评测集,输出每条的回答与判定,人工复核后统计 php think eval:run --file=eval_set.csv --out=result_$(date +%m%d).csv答辩演示控制在五分钟:先演示一次标准问题直接命中,再演示一次知识库没覆盖时的转人工,最后打开召回日志,指着某条回答说清楚它引用了哪几个片段、分数多少、为什么没转人工。这条链路讲得通,说明你真做过,而不是拼了个界面。
上线节奏上,先灰度一个渠道、一个知识分类,观察一周的转人工率和投诉量,再逐步放开。转人工率突然升高通常是知识更新导致的检索失效,先查最近改过的文档,别急着调模型。
从那以后我每次交付这类系统,都会在灰度前强制走一遍评测集,哪怕只改了一个阈值——阈值这种东西,改一个数字看着无害,实际影响的是整条召回链路的分布,不跑一遍心里没底。希望这套结构能帮到你,少走几步弯路。
本文还有配套的精品资源,点击获取