ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

PHP在线客服接入AI知识库:从向量检索到人工兜底的工程实践

PHP在线客服接入AI知识库:从向量检索到人工兜底的工程实践

简介:基于ThinkPHP框架的运营级在线客服系统源码包,已接入AI知识库问答能力,并附带完整的安装部署教程,面向需要为网站或业务系统快速搭建智能客服能力的PHP开发者、中小企业及运维人员,重点解决传统人工客服响应慢、重复咨询多、知识沉淀难的问题。压缩包共2010个文件、体积约68.72MB,主要包含PHP后端逻辑、JavaScript前端交互、HTML/CSS页面模板、JSON配置数据、Markdown与TXT说明文档及SQL数据库脚本,从文件构成看工程结构完整,便于按模块学习调用。教程重点讲解了fileinfo与redis扩展安装,以及安全禁用pcntl系列函数的具体步骤,能有效减少环境搭建中的常见弯路,目前已有137人学习下载。资源不仅提供可直接运行的客服系统源码,还呈现了ThinkPHP框架的MVC分层结构、AI知识库接入流程与自然语言处理应用思路,适合作为二次开发和教学研究的参考。

1. PHP在线客服接入AI知识库:把重复问答的成本先打下来

如果你维护过一套 PHP 在线客服系统,一定见过这种场面:客服下班后留言刷屏,用户反复问“发货多久”“怎么改地址”“退款多久到账”,第二天客服上班光是回这些消息就耗掉一上午。把 AI 知识库接进在线客服,本质上是把这批高频、重复、有标准答案的问题,交给机器人回答;客服只处理真正需要判断的复杂会话。这里面的核心不是“接个大模型 API”这么简单,而是把现有客服系统的会话结构、消息模型、知识库分块和检索链路重新梳理一遍,让 AI 在合适的时机接管,在回答不了的时候果断转人工。

这篇笔记以一个我用 ThinkPHP 搭建的某客服工作台为例,完整拆解接入 AI 知识库的流程:从表结构设计、向量化检索,到接入大模型和人工兜底,以及我踩过的五个坑。新手可以照步骤把整套链路搭起来,熟手可以直接看第三章和第四章的参数与边界。

2. 会话与消息模型:三张表扛住客服系统的状态流转

AI 接入客服系统,第一件事不是写调用大模型的代码,而是先把“谁来回答、答给谁、答完怎么收场”用数据结构定下来。很多项目在演示阶段跑得通,一上真实会话就乱,就是因为消息来源、会话状态没有建模,AI 回答和人工回复搅在一起。

2.1 会话表与消息表:先分清“人”“机器人”“系统”三种消息来源

我一般把会话表cs_session和消息表cs_message分开设计。会话表只管状态,消息表只管内容流。这样后续要做统计、转人工、弹历史都很方便。消息来源至少要有三种类型:用户(customer)、客服(agent)、机器人和系统(system)。系统消息用来记录“会话已转人工”“客服已接入”这类状态变更,这样 AI 接入后,用户和客服都能看到会话被谁接管了。

CREATE TABLE `cs_session` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `user_id` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '用户ID', `agent_id` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '当前客服ID,0表示未分配', `channel` VARCHAR(20) NOT NULL DEFAULT 'web' COMMENT '来源渠道', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0待分配 1进行中 2已结束 3AI接管', `source` TINYINT NOT NULL DEFAULT 0 COMMENT '0用户主动 1AI自动建会话', `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_status` (`status`, `updated_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='客服会话表'; CREATE TABLE `cs_message` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `session_id` INT UNSIGNED NOT NULL COMMENT '所属会话ID', `sender_type` VARCHAR(10) NOT NULL COMMENT 'customer/agent/bot/system', `msg_type` VARCHAR(10) NOT NULL DEFAULT 'text' COMMENT '消息类型', `content` TEXT NOT NULL COMMENT '消息内容', `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_session` (`session_id`, `id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='客服消息表';

cs_session.status是我后来加上的关键字段。原来只有待分配、进行中、已结束三种状态,接入 AI 后又加了一个“AI接管”状态。用户发起会话时,如果没有空闲客服,系统自动把会话置为 AI 接管;用户点“转人工”按钮时,状态改回待分配,同时插入一条 system 类型的消息记录“用户申请转人工”。消息表里 sender_type 的区分至关重要,前端展示头像样式、后台统计 AI 解决率都依赖这个字段。

2.2 知识库表设计:分块存储是检索质量的地基

知识库这部分,我在早期版本走过弯路:把整篇文档存成一个字段,然后直接把全文拼接进 prompt,让大模型自己理解。结果文档一长,上下文窗口爆掉,检索效果也完全不可控。后来改成“文档 + 分块”两张表的结构,效果立刻稳了。

CREATE TABLE `kb_document` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `title` VARCHAR(255) NOT NULL, `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1有效 0停用', `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='知识库文档表'; CREATE TABLE `kb_chunk` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `doc_id` INT UNSIGNED NOT NULL, `chunk_index` INT NOT NULL COMMENT '分块序号', `chunk_text` TEXT NOT NULL COMMENT '分块文本', `embedding` JSON DEFAULT NULL COMMENT '向量,JSON数组', `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_doc` (`doc_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='知识库分块表';

分块不是简单按字数切。我常用的做法是:先把文档按换行拆成段落,再把段落合并到 300~500 个中文字符左右,相邻块之间保留 50~80 字重叠。这样做的原因是,向量化检索对语义完整性非常敏感,一个句子被截成两半,检索出来的结果往往答非所问。分块函数里用mb_substr按字符切,避免按字节切导致中文乱码。

2.3 会话状态机的四种状态与人工接管时机

AI 接入后,整个会话流转变成这样:用户进线后,先判断是否有空闲客服,有则正常进入人工会话;没有则自动创建或复用当前会话,状态置为 AI 接管。AI 接管状态下,每条用户消息先走知识库检索,检索相似度高于阈值就直接生成回答;低于阈值则回复“这个问题我暂时无法准确回答,已为你转接人工”,并自动把会话状态改回待分配。

这里有一个值得注意的点:不要把 AI 回答和人工回答混在同一个逻辑里。我在cs_message里强制区分 sender_type,生成 AI 文案的代码统一走一个BotService,人工回复走原有客服逻辑。这样后面要统计 AI 解决率、要停用 AI、要排查异常,都有干净的数据可查。

3. 向量化与相似检索:在 PHP 里把知识检索跑起来的两种做法

客服系统接入 AI 知识库,检索环节决定了回答质量的上限。大模型本身不做记忆,它只是把检索到的内容整理成通顺的回答。所以,“用户问题 → 向量 → 相似度 → 命中知识片段”这条链路由自己实现,每一步都影响最终效果。

3.1 先选 Embedding 模型:维度、费用和超时是三个硬指标

Embedding 是把一段文本转换成一串浮点数(向量)的接口。选模型时我主要看三个指标:输出维度、单次调用时长、每千条 token 的费用。维度决定存储和计算开销,768 维是常见选择,1024 维以上更精细但计算量更大;调用时长决定入库和实时检索的体验,超过 3 秒的模型我会直接放弃;费用则要看每日会话量,如果每天要向量化上千条新消息,费用差异是实打实的成本。

无论选哪家服务商的接口,都有一个共性问题:PHP 里调用必须设置超时时间。我一般把 curl 超时设为 15 秒,因为接口偶尔会在高峰期慢到 8 秒以上。下面是 ThinkPHP 里的封装,这段代码在入库和用户提问检索时都会用到。

public function getEmbedding(string $text): array { $resp = Http::timeout(15) ->withHeaders([ 'Authorization' => 'Bearer ' . $this->apiKey, 'Content-Type' => 'application/json; charset=utf-8', ]) ->post($this->baseUrl . '/embeddings', [ 'model' => $this->embedModel, // 例如: text-embedding-xxx 'input' => $text, ]); $body = json_decode($resp->body(), true); if (isset($body['error']) || !isset($body['data'][0]['embedding'])) { Log::error('embedding 调用失败: ' . json_encode($body, JSON_UNESCAPED_UNICODE)); throw new \RuntimeException('Embedding 接口异常'); } return $body['data'][0]['embedding']; }

这段代码的关键参数有三个:timeout(15)防止接口慢导致消息全卡住;请求头里的charset=utf-8是中文乱码的高频原因之一;model参数要和向量库记录里的模型版本保持一致,否则维度不匹配会引发第 4 章的坑。返回结果是浮点数组,入库前我建议顺手校验一下维度是否符合预期。

3.2 方案 A:千条级直接载入内存算余弦

知识库分块数量在 5000 条以内时,最省事的检索方式就是把所有向量载入内存,逐个算余弦相似度。PHP 做纯计算不慢,768 维 × 5000 条大概在 200~300 毫秒内能算完,完全够客服场景使用。优势是零额外组件、逻辑透明、好排查。

function cosineSimilarity(array $a, array $b): float { $dot = 0.0; $normA = 0.0; $normB = 0.0; $n = count($a); for ($i = 0; $i < $n; $i++) { $dot += $a[$i] * $b[$i]; $normA += $a[$i] * $a[$i]; $normB += $b[$i] * $b[$i]; } if ($normA == 0.0 || $normB == 0.0) { return 0.0; } return $dot / (sqrt($normA) * sqrt($normB)); } public function search(string $query, float $threshold = 0.72, int $topN = 3): array { $queryVec = $this->getEmbedding($query); $chunks = Db::name('kb_chunk')->field('id, chunk_text, embedding')->select(); $hits = []; foreach ($chunks as $chunk) { $vec = json_decode($chunk['embedding'], true); $sim = cosineSimilarity($queryVec, $vec); if ($sim >= $threshold) { $hits[] = [ 'id' => $chunk['id'], 'text' => $chunk['chunk_text'], 'sim' => round($sim, 4), ]; } } usort($hits, fn($a, $b) => $b['sim'] <=> $a['sim']); return array_slice($hits, 0, $topN); }

两个参数要重点理解:threshold表示相似度下限,低于这个值的知识片段会被丢弃,避免把无关内容硬塞给大模型;topN表示最多取几个片段拼进 prompt,取太多会稀释核心信息,我一般取 3。入库时把 embedding 存成 JSON 字符串,这里json_decode还原成数组,如果库里混入其他模型生成的向量,会出现维度不一致,需要在循环开头加count($vec) !== count($queryVec)的校验。

3.3 方案 B:万条级用 MySQL VECTOR 或引入独立检索引擎

分块数量超过一万条后,内存计算会逼近一秒,用户端体感明显变差。这时候我建议把检索下沉到数据库或独立引擎。如果你的 MySQL 版本较新、支持 VECTOR 列类型,可以直接用向量列配合距离函数做排序,省掉 PHP 逐条计算的循环。

CREATE TABLE kb_chunk ( id BIGINT PRIMARY KEY AUTO_INCREMENT, doc_id INT UNSIGNED NOT NULL, chunk_index INT NOT NULL, chunk_text TEXT NOT NULL, embedding VECTOR(768) NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_doc_id (doc_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

查询时使用距离函数,按距离升序取前 N 条。如果数据库版本或函数名不一致,先查官方文档里向量类型的标准写法。另一种选择是引入专项向量检索服务,通过 HTTP 接口读写,PHP 侧只是拼参数、解析结果。术语和具体接入方式可能随版本更新,但思路不变:向量计算不该跟业务代码挤在同一台 PHP 进程里。

3.4 检索阈值与 topN:宁可漏答也不要答非所问

阈值和 topN 是检索链路里唯一两个需要手动调优的参数。阈值设得太低,比如 0.6,会把大量语义关系很弱的片段放进来,AI 会基于错误内容一本正经地胡说八道;设得太高,比如 0.85,又会让大部分问题漏答,用户体验是“机器人啥也不会”。我的调优起点是 0.72,然后拿 20 个真实客服问题做一轮测试,挑出回答离谱的,看它们的相似度分数集中在哪个区间,再上下微调 0.02。

topN 我固定为 3。给大模型的知识片段数量太多,它反而抓不住重点。在测试中我发现 topN=5 时回答内容明显变散,topN=3 时回答质量最稳定。这套参数并非万能,如果你的知识库行业术语密度高,建议自己花一下午跑一轮测试再定。

4. 接入与排查避坑:五个高频翻车点及处理

接入过程里我翻过不少车,下面五条全是真实踩过的坑,按“现象 → 原因 → 解决”的方式列出来。每一条都对应一个具体的代码或配置改动,照着检查能省不少排查时间。

4.1 中文 Embedding 返回空数组:九成是字符编码问题

现象:英文测试一切正常,换成中文就返回空向量或者报错;客服消息里的中文变成乱码入库。

原因:最常见的是 PHP 字符串编码不是 UTF-8,尤其是从旧系统迁移过来的历史数据。其次,请求头里的Content-Type没带charset=utf-8,导致服务端按非 UTF-8 解析请求体。

解决:在调用 Embedding 接口前统一做一次编码转换,再入库。排查时先打印mb_detect_encoding($text)看看是不是 UTF-8;如果不是,用mb_convert_encoding($text, 'UTF-8')转换。历史数据可以通过脚本批量重算,一次性把整张表洗一遍。

4.2 AI 回答超时把消息队列堵死

现象:用户发消息后前端一直转圈,查询日志发现大模型接口平均要 5~8 秒才返回;高峰期 PHP-FPM 的进程全部被长时间占用,其他请求也跟着变慢。

原因:AI 回答是同步调用,而大模型接口的响应时间天然比普通 HTTP API 长。客服系统是长连接交互场景,同步等待会让整个流程非常脆弱。

解决:把 AI 生成回答的流程丢进异步队列,前端通过 WebSocket 接收结果,而不是同步等待接口返回。用 ThinkPHP 的 Queue 配合 redis 驱动,发送消息后立即返回“正在帮你查询”,异步任务生成回答再推送到前端页面。同步模式也不是不能用,但必须把超时时间设置得足够宽,同时控制并发上限,防止接口被局部故障拖垮。

4.3 知识库命中错乱:chunk 大小和阈值没配对

现象:用户问“退货怎么处理”,AI 回答里拼了“物流配送时间”的片段,完全跑偏;把日志打出来后,发现命中的片段相似度很低,却还是进了 prompt。

原因:两个因素叠加。一是 chunk 切得太大,一段文本里混了好几个主题,向量化后语义被平均化;二是阈值设得太低,0.6 左右的噪声片段被放了进来。

解决:分块逻辑改为按段落合并,每块控制在 300~500 字,相邻块保留 50 字重叠,确保语义边界完整。阈值从 0.72 起调,如果业务场景必须覆盖更多问法,可以降到 0.70,但不要再低。改完重新向量化整张kb_chunk表,再做一轮回归。

4.4 维度不匹配:换模型后旧向量没重算

现象:开发环境一切正常,发布到生产环境后,检索接口偶尔报错,错误信息指向数组长度不一致。

原因:embedding 模型升过级,比如从 768 维换到了更高维度,但生产库里的旧数据还是老模型生成的向量。PHP 里count($oldVec)和count($queryVec)不相等,余弦计算直接崩。

解决:在kb_chunk表加两个字段:model_version和vec_dim,写入向量时把模型版本和维度记录成行数据。换模型时写一个批量脚本,只重算WHERE model_version = '旧版本'的数据,而不是全表重来。检索入口处加维度校验,发现不一致立刻打日志,不要等到相似度计算时才报错。

4.5 重复问题烧 token:把命中结果做成缓存

现象:上线一周后看了账单,token 消耗远超预期;日志里发现同一句话被重复问了几十次,每次都重新调用了向量化和大模型接口。

原因:用户反复提交同样的短路,或者几个用户几乎同时问同一件事,而系统没有任何缓存层,每次都重新走检索和生成链路。

解决:给检索结果加缓存,以规范化后的用户问题为 key,缓存时间 5 分钟。命中缓存直接复用知识片段和 AI 回答,不回源调用。如果问题信息不足,用问题的 MD5 做 key 也能达到效果。

5. 组装 AI 回复与效果验证:上下文窗口、缓存与压准确率

检索和生成是两回事。检索到知识片段之后,最重要的是如何把这些片段组织成 prompt 里的上下文。上下文太长会稀释关键信息,太短又会丢失对话背景。我限定只带最近 6 轮会话历史,超过就截断;知识片段最多 3 段,拼接时按相似度从高到低排在最前面。

$systemPrompt = '你是该客服系统的AI助手,基于知识库内容如实回答用户问题。' . '如果知识库中没有相关信息,直接说明无法回答,不要编造。'; $userContent = "知识库相关片段:\n" . $hitTexts . "\n\n会话历史:\n" . $historyText . "\n\n用户问题:" . $question; $resp = Http::timeout(30) ->withHeaders(['Authorization' => 'Bearer ' . $this->apiKey]) ->post($this->chatUrl, [ 'model' => $this->chatModel, 'messages' => [ ['role' => 'system', 'content' => $systemPrompt], ['role' => 'user', 'content' => $userContent], ], 'temperature' => 0.2, ]);

两个参数值得说明:temperature设为 0.2,让回答内容尽量忠实于知识库原文,减少编造空间;timeout(30)是给生成接口留足缓冲。如果回答超时或返回异常,prompt 里我还会要求模型在无法回答时输出固定的格式,方便前端识别并触发转人工。

效果验证上,我习惯用一个约 50 条真实问题的回归集来压准确率。每一条问题预先标记对应的知识 chunk 编号,跑一遍检索,看知识片段是否能命中标记;同时人工抽查 AI 生成的回答,从“正确、部分正确、错误”三个档位打分。修改 chunk 切法、调整阈值、换模型之后,都先跑回归集,对比准确率变化。

我后来养成的习惯是:每次改完检索参数,先跑一遍这 50 条回归,再上线去真实会话里测试,而不是直接拿用户当小白鼠。AI 客服这层逻辑最好有数据支撑再放量,这套表结构和检索代码在项目包里可以直接跑,从接入到验证一条线走完少踩一半以上的坑。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进