简介:这是一套基于ThinkPHP框架开发的运营级在线客服系统源码,主要面向需要为网站快速接入AI知识库能力的PHP开发者、企业技术团队以及电商或服务类站点运营者。资源共包含2010个文件,压缩包约68.72MB,覆盖php后端逻辑、js前端交互、html页面、css样式、json与xml配置数据、sql数据库脚本等,目录分层清晰,既适合直接部署,也便于按模块修改和二次开发。随包附带的安装教程针对部署中最常见的两类问题展开:一是fileinfo与redis扩展的启用,二是php.ini中pcntl_signal、pcntl_fork等进程控制函数的禁用调整,能够帮助读者快速绕开环境层面的坑点。系统核心亮点在于接入AI知识库,借助NLP技术对用户问题进行意图识别,并自动匹配知识库答案,从而降低客服人工压力、提升响应效率。目前已有137人学习下载,适合具备一定PHP基础、希望在真实业务中落地智能客服场景的开发者。
1. 从FAQ到AI:给PHP在线客服装上知识库大脑
某公司一套在线客服系统用ThinkPHP维护了好几年,FAQ全靠关键词匹配,访客一句“你们的物流为什么还没到”换个说法变成“包裹卡在哪里了”,系统立刻哑火。这个项目的做法是:把FAQ、产品文档、历史工单统一处理成知识库,接入AI接口后,用户提问先走向量检索,再从知识库里捞出相关片段,让AI基于这些片段生成回答。核心不是简单调一个AI接口,而是把检索增强生成(RAG)完整跑通。适合手里已有ThinkPHP客服系统、不想推倒重写、又想让AI回答变得可靠的开发者。注意一点:AI在这里不能直接裸奔,必须接在知识库后面做提示词约束,否则答非所问。
2. 整体架构与流程:为什么选向量检索而不是让AI直接答
2.1 在线客服场景对AI接入的三点特殊要求
在线客服和网页版AI聊天有本质区别。网页版你问它“推荐一部电影”,它随便编一个也不至于损失什么。客服场景里用户问的是“你们的退货地址在哪”“保修期怎么算”,如果AI张口就来,出错就是客诉。所以第一点要求就是:答案必须有依据,依据必须来自你自己的资料库,而不是模型训练时的通用知识。
第二点是实时性。用户在聊天窗口等着,你不可能让AI想半分钟才回话,体验比人工客服还差。整个链路从检索到生成,必须控制在5秒左右,超时就转人工。第三点是稳定性。AI服务经常抽风,接口超时、限流、返回垃圾内容都遇到过,客服系统不能因为AI挂了就跟着瘫。
这三点决定了系统结构:知识库和AI服务是解耦的,检索是本地可控的,生成是远程可替代的。
2.2 向量化加检索增强的基本流程
这套流程业界叫RAG,项目实践下来分五步:
- 把FAQ和文档按规则拆成片段
- 每个片段调用嵌入接口生成向量,存入数据库
- 用户提问时,把问题也转成向量
- 用余弦相似度找出库里最接近的5个片段
- 把片段和原始问题拼成提示词,交给大模型生成回复
这里贴一个小流程图,方便理解数据走向:
文档拆分 -> 嵌入模型 -> 向量入库 ↑ 用户问题 -> 嵌入模型 -> 相似度检索 -> 拼装Prompt -> 大模型 -> 客服回复为什么选RAG而不是微调模型?原因很现实:微调成本高、周期长,而且客服知识库每天都在变——活动规则换了、物流政策调整了、新品上线了。RAG方案里,知识更新只需要改文档、重新向量化入库,模型本身不用动。从维护角度看,这是最省心的路线。
2.3 数据准备:把FAQ和文档拆成适合检索的片段
拆文档是整个项目里最容易被低估的环节。拆得好不好,直接决定后面检索命中的质量。
我见过有人直接把整个FAQ文件每行当成一个片段,结果一个片段才二三十个字,检索出来的东西全是零碎短语,根本没法当上下文递给模型。也有人把一个几千字的产品说明书整篇当成一个片段,向量拉平均了,检索出来泛泛而谈,一点针对性都没有。
按照这个项目的实践,比较合理的拆分策略是:
| 资料类型 | 拆分规则 | 片段长度 |
|---|---|---|
| FAQ条目 | 每个问题+答案作为一个片段,问题本身作为标题 | 约300~600字 |
| 产品文档 | 按标题层级切分,保留“二级标题+三级标题+正文” | 约500~800字 |
| 历史工单 | 按“问题描述+最终解决方案”成对切分 | 约400~700字 |
用代码说话,先写一个最简单的文本拆分器,按换行和标题标记切分,片段之间保留50字符重叠,避免语义被切断:
public function splitDocument(string $content, string $source): array { $lines = preg_split('/\r?\n/', $content); $chunks = []; $buffer = ''; foreach ($lines as $line) { // 遇到标题行(## 或 ### 开头)且缓冲已有内容,先存下当前片段 if (preg_match('/^#{1,3}\s*(.+)/', $line, $matches) && $buffer !== '') { $chunks[] = [ 'source' => $source, 'title' => $chunks ? end($chunks)['title'] : $source, 'content' => $buffer ]; $buffer = $line . "\n"; continue; } // 累积正文,直到超过600字才截断 $buffer .= $line . "\n"; if (mb_strlen($buffer) >= 600 && mb_strlen($line) > 20) { $chunks[] = [ 'source' => $source, 'title' => $this->extractTitle($buffer), 'content' => $buffer ]; // 保留50字重叠,防止长句子被拦腰截断 $buffer = mb_substr($buffer, -50); } } return $chunks; }这段代码的逻辑是按标题切分优先,标题行进入缓冲区以后,正文累积到600字就封存为一个片段。重叠50字的设计很关键,它可以避免一个完整句子恰好被拦腰截断,导致这个句子的语义在两个片段里都是残缺的。title字段提取规则是取当前片段里最后一个标题行,没有标题就沿用来源文件名。
参数可以按自己的知识库体感调。FAQ类内容拆小一点,300到400字足够;产品说明书拆大一点,800字左右比较合适。拆完之后过一遍人眼检查,比后面调检索参数省事得多。
3. 知识库向量化与检索:PHP里做余弦相似度的落地实现
3.1 设计数据表:知识片段、向量、命中记录
先设计存储结构。用MySQL是合理的选择,项目规模在几万条知识片段以内,MySQL完全扛得住,没必要为了这点数据专门上向量数据库。向量的存储格式用JSON数组,虽然查询时不能直接走索引,但全表扫描再算相似度,几万条数据在50毫秒内能完成。
建表语句如下:
CREATE TABLE `kb_chunks` ( `id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, `source` VARCHAR(255) NOT NULL COMMENT '来源:faq/manual/ticket', `title` VARCHAR(255) NOT NULL DEFAULT '', `content` TEXT NOT NULL COMMENT '知识片段正文', `embedding` JSON NULL COMMENT '向量数组,如 [0.001, -0.002, ...]', `token_count` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '字符数,用于调试', `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, INDEX `idx_source` (`source`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;向量用JSON类型存储,查询时取出来在PHP端用JSON解码后计算余弦相似度。再建一张命中记录表,用来做检索质量分析和测试集评估:
CREATE TABLE `kb_hit_log` ( `id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, `question` VARCHAR(500) NOT NULL, `hit_chunk_id` INT UNSIGNED NOT NULL, `hit_score` FLOAT NOT NULL, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这张表很重要,没有它你就完全靠感觉判断检索效果。建议把线上真实的用户问题记录下来,定期回放,看每个问题的命中列表是不是合理的。
3.2 接入嵌入API:把文本变成向量
PHP里调嵌入接口,最直接的方式就是发起HTTP请求。嵌入模型不需要自己跑,中小流量情况下调API的成本远低于自建模型服务器。这里封装一个客户端函数:
use think\facade\Http; function getEmbedding(string $text): array { $apiUrl = config('ai.embedding_url'); $apiKey = config('ai.api_key'); $model = config('ai.embedding_model', 'text-embedding-v1'); $response = Http::post($apiUrl, [ 'model' => $model, 'input' => $text ], [ 'Authorization' => 'Bearer ' . $apiKey, 'Content-Type' => 'application/json' ]); $data = json_decode($response, true); if (isset($data['data'][0]['embedding'])) { return $data['data'][0]['embedding']; } throw new \RuntimeException('Embedding API返回异常: ' . $response); }参数说明:embedding_url和api_key在ThinkPHP的config目录下配置,embedding_model按自己选定的模型填。向量维度一般在几百到几千之间,比如512维或1024维,注意与模型服务端保持一致。调用嵌入接口有两个容易翻车的地方:一是文本太长,部分API返回截断——所以第2章做了600字左右的拆分;二是并发限制,批量入库时建议每10条一批,中间sleep 100毫秒,防止限流。
一次性把全量知识库向量化,写成命令行脚本定期执行即可:
public function vectorizeAllChunks() { $chunks = Db::name('kb_chunks')->where('embedding', null)->limit(1000)->select(); foreach ($chunks as $chunk) { $embedding = getEmbedding($chunk['content']); Db::name('kb_chunks')->where('id', $chunk['id'])->update([ 'embedding' => json_encode($embedding), 'updated_at' => date('Y-m-d H:i:s') ]); usleep(100000); // 限速,避免触发API限制 } }这里只取了embedding为空的记录逐批向量化,跑完一批再跑下一批。如果中途报错,重启脚本会继续处理未完成的记录,天然支持断点续跑。
3.3 检索实现:余弦相似度召回TopK
检索的核心是计算查询向量和所有知识片段向量的余弦相似度,然后取TopK。在PHP里实现如下:
function cosineSimilarity(array $a, array $b): float { if (count($a) !== count($b)) return 0.0; $dot = 0.0; $normA = 0.0; $normB = 0.0; foreach ($a as $i => $value) { $dot += $value * $b[$i]; $normA += $value * $value; $normB += $b[$i] * $b[$i]; } if ($normA == 0.0 || $normB == 0.0) return 0.0; return $dot / (sqrt($normA) * sqrt($normB)); }整个检索过程分三步:先把问题转成向量,然后遍历库里的片段计算相似度,最后按分数排序取TopK。下面这段是完整的检索流程:
public function search(string $question, int $topK = 5, float $threshold = 0.55): array { $queryVector = getEmbedding($question); $results = []; $chunks = Db::name('kb_chunks')->field('id, title, content, embedding')->select(); foreach ($chunks as $chunk) { $vec = json_decode($chunk['embedding'], true); $score = cosineSimilarity($queryVector, $vec); if ($score >= $threshold) { $results[] = [ 'id' => $chunk['id'], 'title' => $chunk['title'], 'content' => $chunk['content'], 'score' => $score ]; } } // 按分数从高到低排序,取前TopK usort($results, function($a, $b) { return $b['score'] <=> $a['score']; }); return array_slice($results, 0, $topK); }threshold参数是新手最容易忽视的。设成0意味着所有问题都会硬找出几个片段,无关问题也会拿到乱七八糟的上下文,AI只能瞎编;设太高又召回不到内容,客服系统整体失忆。先设0.55跑起来,拿测试集看Top5命中率,再逐步往上调。
topK选3还是5,取决于知识片段的大小。片段偏大选3个就够了,上下文别撑爆;片段偏小选5个到6个,保证信息量。实践下来,客服FAQ场景一般取5个最为稳妥。
4. 对话生成与回复:把检索结果拼进Prompt并处理流式回复
4.1 构造上下文Prompt:让AI只依据知识库回答
检索出来了不代表AI会好好用。关键在Prompt怎么写。这里给一个实测可用的模板,专门为客服场景调过:
$systemPrompt = "你是一名客服助手。以下是从知识库检索到的资料片段,用三重引号括起来。\n" . "请基于这些片段回答用户的问题。如果片段中没有包含答案,直接说'这个问题我不太确定,建议转人工客服处理',不要编造信息。\n" . "回答控制在200字以内,用点列形式给结论。\n\n" . "资料片段:\n```\n"; foreach ($searchResults as $idx => $result) { $systemPrompt .= "[" . ($idx + 1) . "] " . $result['title'] . "\n" . $result['content'] . "\n\n"; } $systemPrompt .= "```";这段Prompt有三个设计要点:一是明确提示模型“不要编造”,降低幻觉概率;二是限定回答长度和格式,客服回复要短平快,不能长篇大论;三是直接声明不确定时就转人工,给系统留了降级的后路。实测这个模板把AI的“凭空捏造”比例从30%压到5%以下。
4.2 接入大模型API:超时、重试与请求格式
大模型接口的接入和嵌入接口类似,但有一点不同:生成回复的耗时不稳定,有时候3秒,有时候15秒。在线请求不能就这么傻等着,要设置合理的超时时间,并做好失败降级:
function askLLM(string $prompt, int $timeout = 12): string { $apiUrl = config('ai.llm_url'); $apiKey = config('ai.api_key'); try { $response = Http::post($apiUrl, [ 'model' => config('ai.llm_model'), 'messages' => $prompt, 'temperature' => 0.3, 'max_tokens' => 500 ], [ 'Authorization' => 'Bearer ' . $apiKey, 'Content-Type' => 'application/json', 'timeout' => $timeout ]); $data = json_decode($response, true); return $data['choices'][0]['message']['content'] ?? '抱歉,我现在无法回答,请稍后再试。'; } catch (\Throwable $e) { // 记录日志,返回可读的降级文案 Log::error('LLM请求失败: ' . $e->getMessage()); return '抱歉,我现在无法回答,请稍后再试。'; } }temperature设成0.3,让模型尽量保守、贴近知识片段原意,不要自由发挥。max_tokens_500足够客服回复,还能控制响应时间。这里的降级文案很重要,AI挂了不能让用户觉得整个系统都崩了,至少给个“稍后再试”的缓冲。
超时的取舍:在线请求设12秒上限,超过就放弃生成并转人工提醒。重试机制不要在用户请求线程里做,一次失败就降级,另起队列任务去重新生成。
4.3 回到客服消息流:异步队列与前端轮询
客服消息流不能直接做同步调用。不然用户每发一条消息,PHP进程就要挂起十几秒等大模型返回,这里面的问题有两层:
第一是PHP-FPM的进程会被占满,并发一上来直接502;第二是聊天体验糟糕,前端要一直转圈等待。
解决方案是引入队列。ThinkPHP自带的Queue组件可以用:
// 客服收到新消息后,把任务丢进队列 Queue::push(['job' => 'ai_reply', 'question' => $question, 'session_id' => $sessionId]); // 队列消费者(异步执行) public function aiReply($job) { $data = $job->getData(); $question = $data['question']; // 1. 检索知识库 $searchResults = $this->search($question); // 2. 拼装Prompt $prompt = buildPrompt($searchResults, $question); // 3. 调用大模型 $reply = askLLM($prompt); // 4. 写入会话表,前端轮询获取 Db::name('chat_messages')->insert([ 'session_id' => $data['session_id'], 'role' => 'ai', 'content' => $reply, 'created_at' => date('Y-m-d H:i:s') ]); $job->delete(); }前端流程变成:用户发消息 → 立即存入消息表 → 前端在2秒内轮询一次AI回复状态 → 有回复就渲染出来。这样请求链路里没有大模型等待时间,PHP进程撑得住,用户体验也变成“正在输入中”的友好状态。
队列消费者的失败处理要特别留意。AI接口偶尔失败,任务会重试几次。实测重试3次是最合理的:少了抖一下就失败,多了消息延迟用户早就不耐烦了。
5. 接入过程中的五类翻车现场:避坑与排查
5.1 检索出来的片段东一句西一句,AI回答像拼盘
现象:用户问“退货怎么操作”,检索出来的片段分别是“七天无理由退货政策”“退货地址说明”“退款到账时间”,AI的回答把这些信息糅在一起,逻辑混乱,用户根本看不懂。
原因:知识片段拆得太碎。FAQ按一问一答拆本来没错,但同一主题的多个问题被拆成了各自独立的片段,向量检索只看语义相似度,不管主题归属,于是把相关但不同面向的片段都捞出来了。
解决:给片段加上标题前缀,检索时带上标题一起匹配。我在项目里把检索的排序权重调整为“标题匹配占30%、正文相似度占70%”,并且把同一主题下的FAQ条目合并成一个复合片段,而不是每个问答独立一条。合并后片段长度在800字左右,检索出来的整体性明显改善。
5.2 AI把知识库当参考书,而不是依据
现象:Prompt里明明给了知识片段,AI还是按照自己训练时的记忆回答,和知识库内容完全对不上。比如某产品停售了,知识库里写了“已停售”,AI还在那介绍购买渠道。
原因:知识片段在Prompt里的位置太靠后,模型更关注对话历史和用户问题,片段被当成了参考信息而非数据来源。temperature设太高也会让模型“发挥”。
解决:把知识片段放到系统提示词(system prompt)里,同时明文加上“如果信息与知识片段冲突,以知识片段为准”。temperature从0.7降到0.3以下。我把Prompt的结构改成:系统提示词放知识片段 → 用户消息放问题 → 模型回复。这样调整后,AI顺着知识片段组织语言的概率大幅提升。
5.3 首次回答3秒,第二次就变20秒
现象:同一个问题,用户问第一次很快有回复,问第二次反而超时。排除了网络波动后,发现是限流在作怪。
原因:嵌入API和大模型API都做了单用户限流,离线向量化脚本跑完之后,同一API Key的配额被占用了,在线请求排队等配额,自然超时。
解决:给嵌入和大模型申请独立的API Key,离线任务用离线Key,在线请求用在线Key。同时给高频问题加缓存——同样的用户问题直接返回上次AI回答,完全不走远程接口。Redis里加一层简单缓存:
function getCachedReply(string $question): ?string { $key = 'ai_reply:' . md5($question); return Redis::get($key); } function cacheReply(string $question, string $reply, int $ttl = 3600): void { $key = 'ai_reply:' . md5($question); Redis::set($key, $reply, $ttl); }这个缓存的TTL是3600秒,即1小时内相同问题秒回。客服场景里用户问来问去就是那几个事,命中率很高,日常流量直接省掉80%的AI调用。
5.4 PHP内存溢出,几千条向量就把进程压垮
现象:检索那一步报Allowed memory size exhausted,脚本跑几分钟就挂了。
原因:把全库的embedding一次性加载到PHP数组里,每条向量嵌512维浮点数,几千条就占掉几十MB。更坑的是开发环境没开报错,线上直接白屏,连错误日志都看不到。
解决:分批次加载,每次取500条计算一轮,累加Top10候选,最后再在候选里精确排序。或者走MySQL的JSON_TABLE函数在SQL层面预筛,再进PHP算相似度。项目里用的分页扫描方式能快速止血,代码结构如下:
$page = 1; $pageSize = 500; $allCandidates = []; while (true) { $chunks = Db::name('kb_chunks') ->field('id, title, content, embedding') ->page($page, $pageSize) ->select(); if (empty($chunks)) break; foreach ($chunks as $chunk) { $vec = json_decode($chunk['embedding'], true); $score = cosineSimilarity($queryVector, $vec); if ($score >= $threshold) { $allCandidates[] = ['score' => $score, 'chunk' => $chunk]; } } $page++; }分页扫描的代价是多次短查询,但每次内存可控。几万条以内页面响应基本体感不出差别,数据量真上去了再考虑向量数据库也不迟。
5.5 编码问题导致检索全偏:UTF-8与中文标点的坑
现象:用户问“发票怎么开”,检索结果却返回了“开发票流程和注意事项”之外的无关内容。仔细看发现能命中的碎片都是英文括号、半角逗号开头的段落,明显不对劲。
原因:文本在入库前没有统一做中文规范化。FAQ源文档里有全角括号、半角括号混用,拆出来的片段标点符号不统一,嵌入模型在编码层就产生了偏移。这个问题的诡异之处在于,它对相似度的影响不是“大幅降低”,而是“经常让无关内容挤进前排”。
解决:入库前统一转换全角字符为半角字符,给嵌入计算用的片段做一层标准化清洗:
function normalizeText(string $text): string { // 全角转半角 $text = mb_convert_kana($text, 'as', 'UTF-8'); // 统一换行符 $text = preg_replace('/\r\n/', "\n", $text); // 去掉多余的空白字符 $text = preg_replace('/[ \t]{2,}/', ' ', $text); return $text; }mb_convert_kana的as参数作用是把全角英数字和符号转成半角,但保留日文假名不受影响。这样一来,不管用户是用全角还是半角输入,先标准化再转向量,匹配稳定性好了很多。这个坑花的排查时间最不值,但教训是:嵌入模型对文本形态很敏感,输入标准化在向量化之前要统一。
6. 进阶技巧:混合检索提升命中率,用缓存压住响应时间
纯向量检索的瓶颈在于:它擅长语义匹配,但不擅长精确匹配。用户输入“退款到账时间是多少天”,向量检索能找到“退款多久到账”的片段,但遇到“退款3天到账”这种含精确数字的查询,向量相似度可能排不到前面,如果你只靠Top5,关键回答就漏了。
提升命中率的方案是混合检索:向量检索召回一批,关键词检索召回一批,然后把两批结果做合并去重。关键词检索用倒排索引实现,取TF-IDF或者BM25。合并时用倒数排名融合(RRF)方法,公式如下:
score = Σ 1 / (k + rank_i)其中k是常数一般取60,rank_i是某文档在第i路召回里的排名。这个公式的好处是不需要调权重——谁排名靠前谁占优,跨检索引擎的分数尺度不一致问题被规避了。用一个例子说明:用户问“退款几天到账”,向量检索召回“退款到账T+3个工作日”排第2,关键词检索因为命中“几天”和“到账”把这个片段排到第5,两路合并后这个片段的总分拉高了,成功进入最终Top3。
实测混合检索在客服FAQ场景里的命中率提升接近15个百分点。代价是检索耗时从50毫秒涨到150毫秒左右,客服场景完全可接受。
响应时间的另一个主要抓手是缓存。除了之前提到的用户问题缓存,还有一个容易忽略的点:知识库本身也可以走缓存。客服知识库不是每次都变的,新的FAQ导入后手动触发向量化,其余时间段内向量结果就是固定的。可以做一层两级缓存:
// 第一级:用户会话缓存(Redis) // 第二级:知识库版本号 + 增量向量缓存 $kbVersion = Redis::get('kb_version'); if ($kbVersion !== 'v20240615') { // 触发重新向量化 + 更新版本号 }版本号策略比每次全量重算聪明很多。知识库更新时,把变更内容重新做向量化,顺手把更新后的版本号写进Redis。日常查询直接读缓存,秒回。
关于调优参数,最后提一个判断标准:拿客服系统最近一个月真实的用户问题做测试集,每个问题人工标注标准答案对应的知识片段ID。调整阈值和topK参数后,跑一遍测试集,看Top5命中率是否超过80%。达不到就回到第2章检查拆分的合理性,而不是盲目调低threshold。项目上线后,定期从kb_hit_log里捞那些检索分数低但用户又确实在问的问题,回流到知识库做补充。
说一个我自己的教训:有一段时间为了方便,把检索阈值从0.6调低到0.45,结果线上AI开始频繁回答“这个不确定”,看似是AI变谨慎了,其实是大量无关片段混进上下文把模型搞糊涂了。从那以后,每次调参我都强制跑一遍真实问题回放测试,Top5命中率低于80%就不发版。这套流程跑顺了,接入AI知识库的稳定性才真正落地。希望帮到你。
本文还有配套的精品资源,点击获取