资讯动态

AI智能客服系统源码实战:架构、部署与二次开发

发布时间:2026/10/8 4:53:12 来源:尧图企业网站定制
简介这是一套基于PHP开发的AI智能客服系统完整源码包面向需要快速搭建在线客服平台的开发者、企业技术人员及PHP学习者主打智能问答、全渠道统一管理、客户信息管理、常见问题知识库、违禁词过滤等功能可有效降低人工客服压力。压缩包共8017个文件约41.8MB以PHP源码文件为主辅以PNG等前端图片、JavaScript交互脚本、HTML页面、CSS样式及JSON/XML配置数据整体结构清晰自带Composer依赖定义、shell启动脚本与PHPUnit测试配置便于部署和二次开发。资源另含客服源码安装配置说明文档可指导完成环境配置与系统上线。当前已有3255人学习下载适合希望深挖NLP与机器学习在客服场景落地的PHP开发者参考借鉴。1. Ai智能客服系统源码先搞清楚它解决什么问题“客服系统”听起来简单真选型时坑不少——SaaS按坐席收费开源项目要么停更要么改不动。这套Ai智能客服系统在线客服源码给我第一印象是完整且可改访客聊天窗口、坐席工作台、会话分配、AI自动回复全部摊开后端用PHP实现消息路由与会话状态AI通过HTTP接口接任意大模型前端是纯JavaScript组件目录里自带建表SQL和启动脚本。它能解决三件事把人工从重复咨询里释放让已有网站快速获得聊天能力给二次开发一套没有黑匣子的会话流转模板。适合谁想低成本验证AI客服效果的技术负责人需要把客服能力嵌进自有产品的开发者。这篇笔记按我拆解的顺序写先讲架构与状态机再讲部署参数中间是可直接抄走的代码最后一章是踩坑记录。2. 系统架构与核心模块会话状态机与AI接入点2.1 前端聊天窗口消息协议与渲染逻辑这套源码的前端聊天组件是没有框架依赖的原生JavaScript文件目录在chat-widget/下主要包含chat-widget.js、chat-widget.css和一个嵌入用的embed.js。embed.js 只在页面上插入一个悬浮按钮点击后弹出聊天窗口chat-widget.js 负责建立WebSocket连接、发送消息、渲染消息列表。我拆的第一步是看它约定的消息结构因为前后端通信是否稳定全靠这个协议长什么样。// chat-widget.js 中约定的消息对象结构 const message { session_id: s_20240512_001, // 会话ID由后端创建会话时返回 type: text, // 消息类型text / image / system content: 你好我想查一下订单, from: visitor, // 发送方visitor(访客) / ai / agent / system seq: 0 // 客户端自增序号从0开始 }; window.KefuWS.send(JSON.stringify(message));这里最值得注意的就是seq这个字段。访客在弱网环境连续快速发多条消息时WebSocket本身不保证到达顺序如果没有序号后端的回执和坐席看到的消息顺序很可能是乱的。源码里给每条消息带一个客户端自增序号后端按session_id seq做唯一索引重复消息直接忽略乱序消息在写入前根据序号排队。这个设计看着简单却是聊天类系统最容易漏掉的地方。前端拿到回复后的渲染逻辑也很直白把消息对象 push 到本地数组再按seq升序重排然后一次性更新DOM。没有用虚拟滚动因为单条会话窗口的消息量不大重排后整体替换列表的方式在这个场景下反而更稳不会出现增量更新时的插入位置错乱。如果你要改造成React或Vue组件核心要抄的也是这套“先排序再渲染”的思路而不是逐条append。2.2 后端消息路由WebSocket与异步队列后端有两个入口HTTP入口负责创建会话、拉取历史消息、坐席上下线等一次性操作WebSocket入口负责实时消息流转。WebSocket服务基于Swoole的WebSocket Server实现核心逻辑在bin/server.php里。所有进来的消息先经过一个统一的MessageRouter由它判断这条消息该进AI队列、发给坐席还是直接丢弃。// bin/server.php 中消息路由的关键分支 $server-on(Message, function ($server, $frame) { $msg json_decode($frame-data, true); if (!check_session($msg[session_id])) { return; // 会话不存在或已关闭直接忽略 } // 会话处于人工接管状态推送给坐席端同时写库 if (session_is_agent_taken($msg[session_id])) { save_message($msg); push_to_agent($msg[session_id], $msg); return; } // 未接管先写库再把消息塞进Redis队列等AI消费 save_message($msg); redis()-rpush(queue:ai_reply, json_encode([ session_id $msg[session_id], last_seq $msg[seq], ])); });这个分支逻辑是整个系统的“交通警察”人工接管状态下消息不经过AI减少一次无效的大模型调用未接管时消息落库后马上进Redis队列AI消费端从队列里取出会话ID再主动去库里捞上下文。这样设计的好处是AI回复与消息写入解耦即使大模型接口超时访客的消息也已经落库不会丢失。参数说明queue:ai_reply是Redis列表键名消费端用BLPOP阻塞读取避免空转消耗CPU。如果消息量预期很高可以把键名改成带业务线前缀的多个队列比如queue:ai_reply:order用不同的提示词处理售前和售后场景。这个改动只涉及入队和消费两处的键名其他代码不用动。注意入队时我带了last_seq而不是完整消息消费端需要再次查库拿上下文这是刻意为之——队列里不放大对象Redis内存占用低消息内容是动态的坐席如果刚好在同一秒接管AI消费时也能通过状态检查放弃回复。2.3 会话状态机从待分配到人工接管的迁移状态机是整个系统里我认为最值得细看的部分。源码中会话状态用四个整型常量表示定义在app/Service/SessionService.php里。很多在线客服系统把状态判断散落在各个if里改一处漏三处这套源码把所有合法迁移收敛到一张表里统一校验。// 会话状态常量 const SESSION_WAIT 0; // 刚创建等待系统分配接待模式 const SESSION_AI 1; // AI接待中 const SESSION_AGENT 2; // 人工坐席接管中 const SESSION_CLOSED 3; // 已关闭 // 状态迁移只允许以下四种合法路径 $transitions [ SESSION_WAIT [SESSION_AI, SESSION_AGENT], SESSION_AI [SESSION_AGENT, SESSION_CLOSED], SESSION_AGENT [SESSION_CLOSED], SESSION_CLOSED [], ];changeState()方法统一做合法性校验非法迁移直接抛异常并记录日志。比如访客在AI接待阶段点击“转人工”走的是SESSION_AI - SESSION_AGENT坐席手动关单走SESSION_AGENT - SESSION_CLOSED。有一个容易踩的细节SESSION_WAIT状态只出现在会话刚创建、系统还没决定由AI还是人工接待的瞬间如果创建后立即分配AI状态不会在WAIT停留保证页面刷新后能看到稳定状态。每次状态迁移都会写一条kf_session_log记录包含操作人ID、旧状态、新状态、原因和操作时间。排查问题时这条日志是首选证据比看消息记录管用得多——它直接告诉你会话在哪一刻被谁接管、为什么关闭。我在二次开发时加过一个“超时关单”的定时任务思路就是在last_msg_at超过30分钟后把状态从AI迁移到CLOSED走的就是这条现成的状态机通道不需要额外处理消息表。2.4 AI回复的触发条件与知识库检索AI不是每句话都回的。源码里的触发条件有三条会话处于SESSION_AI状态、消息来自访客、会话没有被标记为“人工优先”。满足条件后消费者进程才从队列里拉取任务。这是很多“伪AI客服”和真正可用的AI客服的核心区别没有状态约束时坐席已经回复了AI可能还在生成两条消息顺序完全乱掉。// app/Consumer/AiReplyConsumer.php 中的任务处理流程 public function handle(array $task): void { $session session_info($task[session_id]); if ($session[state] ! SessionService::SESSION_AI) { return; // 状态已变化放弃本次AI回复 } $history get_recent_messages($task[session_id], 10); // 最近10条作为上下文 $last get_last_message($task[session_id]); // 用户最后一句作为检索问句 $docs knowledge_search($last[content], 3); // 命中知识库前3条 $prompt build_prompt($history, $docs, $session[goods_id] ?? ); $reply call_llm($prompt); // 调用大模型 save_message($task[session_id], 1, $reply); // 1 来自AI }参数说明get_recent_messages的窗口大小建议10条覆盖大概5到6个来回的对话太短模型记不住用户前面说过的要求太长会浪费token且增加首字延迟。knowledge_search返回条数取3多了容易把不相关的内容塞进上下文导致AI答非所问。build_prompt还会把当前商品ID传进去如果你接入的是电商场景这个参数可以直接替换成订单号或用户昵称让回复更具体。知识库检索这层不是简单的关键词匹配源码把知识条目切分后存进MySQL的kf_knowledge表检索时用用户问句做分词按关键词重叠度和条目更新时间加权排序。对中小客服团队来说这个方案够用不依赖外部向量数据库部署成本低。我见过有人一上来就上向量库结果发现知识量只有几百条关键词检索效果已经不错复杂度却翻了几倍。所以选型逻辑应该是先在简单方案上跑通流程等知识库超过几千条、检索明显不准时再考虑升级。3. 本地部署与参数配置从源码包到能跑起来的完整步骤3.1 环境准备PHP扩展、目录结构与安装检查部署前先确认环境这套源码后端依赖PHP 8.0以上WebSocket依赖Swoole 4.8以上队列用Redis 6数据库用MySQL 8。如果你手头是PHP 7.4也可以跑HTTP部分但WebSocket服务起不来所以最稳妥的做法是先检查扩展。php -v php -m | grep -E swoole|pdo_mysql|redis # 若输出没有swoole安装方式 pecl install swoole # 然后确认扩展加载 php -m | grep swoole这里有个容易翻车的点Swoole 4.8和5.0的API有兼容性差异我先确认源码包里bin/server.php用的是哪种风格再决定版本。如果用的是new Swoole\WebSocket\Server()这种旧接口就锁定4.8如果用了Co\Http\Server等新风格装5.x没问题。拿到源码包的第一时间先跑一遍语法检查php -l 文件名能提前发现因PHP版本差导致的功能废掉。解压后的目录结构大致是这样的我按下载包里的实际布局还原过一遍kefu/ ├── app/ │ ├── Controller/ # HTTP控制器会话创建、历史消息、坐席接管 │ ├── Service/ # 会话服务、AI服务、知识库服务 │ ├── Consumer/ # Redis队列消费者 │ └── Model/ # PDO模型 ├── public/ │ └── index.php # HTTP入口 ├── bin/ │ └── server.php # WebSocket入口 ├── sql/ │ └── install.sql # 建表语句 ├── config.php # 全局配置 └── chat-widget/ # 前端聊天组件先确认public/和bin/两个入口都存在这是判断源码包是否完整的快捷方式。缺了任一个后面部署都要自己补入口工作量完全不同。3.2 数据库初始化与账号配置数据库部分源码里带了sql/install.sql直接导入即可不需要手工建表。需要注意字符集必须用utf8mb4因为消息内容里会出现表情符号用utf8会导致写入失败。-- install.sql 中核心表 kf_session 的建表语句节选 CREATE TABLE kf_session ( id bigint unsigned NOT NULL AUTO_INCREMENT, visit_key varchar(64) NOT NULL COMMENT 访客唯一标识由前端cookie生成, state tinyint NOT NULL DEFAULT 1 COMMENT 0等待 1AI 2人工 3关闭, agent_id int unsigned DEFAULT NULL COMMENT 当前接管坐席ID, goods_id varchar(32) DEFAULT NULL COMMENT 业务参数页面传入, last_msg_at datetime DEFAULT NULL COMMENT 最后一条消息时间超时关单用, created_at datetime NOT NULL, PRIMARY KEY (id), KEY idx_visit_state (visit_key, state) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;导入完成后修改config.php里的数据库连接信息。三个常用配置项分别是数据库名、账号、密码端口默认3306不需要改主机地址建议从默认的localhost改成127.0.0.1避免部分PHP版本上localhost走Unix socket导致连不上。// config.php 数据库部分 db [ host 127.0.0.1, port 3306, name kefu, user root, pass your_password, ],如果生产环境用的是云数据库建议单独创建一个只读账号给历史消息接口用写权限只留给WebSocket服务和消费者进程。这不是源码要求是我自己养成的习惯——历史消息接口暴露在公网万一被刷只读账号能限制损失范围。3.3 启动服务HTTP服务、WebSocket服务与队列消费源码的启动分为三条线WebSocket常驻进程、HTTP服务、Redis队列消费者。开发环境可以用PHP内置服务器跑HTTP部分生产环境建议用Nginx转发到public/index.php。WebSocket进程以守护方式启动消费者进程用nohup随业务一起拉起。# 启动WebSocket服务常驻端口9501 php bin/server.php start --daemon # 启动AI回复消费进程 nohup php bin/consumer.php logs/consumer.log 21 # 开发环境HTTP服务端口8080 php -S 0.0.0.0:8080 -t public参数说明--daemon让WebSocket进程后台运行日志默认写到logs/server.log。消费者进程没有--daemon选项我是用nohup托底加 logs/consumer.log把标准输出导到文件这样进程被意外杀掉时能看到最后一条日志。第一次部署时建议先不加--daemon跑前台能看到完整的启动提示确认三个服务都没有报错再切后台。启动后验证访问http://127.0.0.1:8080/返回聊天组件测试页然后开浏览器开发者工具切到 Network 面板看WebSocket连接是否建立再发一条消息看是否有AI回执。这个流程走通说明HTTP、WS、Redis、AI接口四条链路全部正常。注意生产环境WebSocket端口9501要在防火墙放行Nginx里配置proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade;否则外部访客连不上本地测试却一切正常这个坑我在第五节会展开。3.4 AI接口配置模型、温度、超时与知识库挂载AI接口在config.php中对应的配置块是ai。源码遵循OpenAI兼容协议也就是api_url指向/v1/chat/completions形式的地址因此国内主流大模型平台只要支持同样协议都能接不必强绑某一家。// config.php 中的 ai 配置块 ai [ api_url https://your-api.example.com/v1/chat/completions, api_key sk-xxxxxxxxxxxxxxxx, model qwen-plus, temperature 0.3, timeout 30, // 单位秒建议30-60 max_tokens 500, knowledge_top_k 3, // 知识库检索返回条数 ],参数说明temperature建议先设0.3客服场景需要稳定、可复现的答复温度太高会让同一问题每次回答措辞都不一样用户会怀疑对面是不是真人。timeout设30秒是平均线模型接口响应慢时超过30秒会被PHP端直接掐断此时队列任务进入重试不会导致进程卡死。max_tokens控制在500以内客服回答通常一句话就够给太多反而可能让模型长篇输出拖慢整体响应。knowledge_top_k对应知识库检索返回条数和消费者进程里的检索逻辑联动改了这里就等于改了每一次AI回答的上下文宽度。配置项示例值说明api_urlhttps://your-api.example.com/v1/chat/completionsOpenAI兼容协议地址modelqwen-plus模型名按平台实际支持的模型填temperature0.3回复随机性0-1客服建议0.3以下timeout30HTTP请求超时秒数建议30-60max_tokens500单次回复最大token数knowledge_top_k3知识库检索返回条数挂载知识库在管理后台的“知识库管理”页面操作每条知识包含标题、分类、正文和状态。注意正文不要超过500字超过的会被自动切分切分后两条内容语境割裂检索时容易命中半截内容。这个细节是很多知识库效果不佳的根源我放在第五章专门展开。我在第一次部署时就把售前、售后、物流三类问题分成了三个分类检索时按分类过滤命中率比全库混搜明显高。4. 三组核心代码实战消息收发、AI回复与坐席切换4.1 消息收发WebSocket事件与HTTP兜底消息收发分两条链路实时链路走WebSocket兜底链路走HTTP轮询。WebSocket只处理新增消息HTTP接口负责首次进入页面时拉取历史消息以及WebSocket意外断开后的补偿。源码里HTTP拉取历史消息的接口是GET /api/messages?session_idxxxafter_seq0。// public/index.php 中历史消息接口的简化实现 $sessionId $_GET[session_id] ?? ; $afterSeq (int)($_GET[after_seq] ?? 0); $rows query( SELECT * FROM kf_message WHERE session_id ? AND seq ? ORDER BY seq ASC LIMIT 50, [$sessionId, $afterSeq] ); echo json_encode([code 0, data $rows]);逻辑说明after_seq是增量拉取的关键参数WebSocket断线重连后前端把本地最后一条消息的seq传上来HTTP接口只返回缺失的部分避免全量重传。LIMIT 50是防止单条会话消息量过大把接口拖慢如果超过50条前端会再次带上更高的after_seq分批拉取。这个设计保证了消息同步不依赖网络是否稳定断线期间的消息全部能兜底补回来。参数说明after_seq传0表示从第一条开始拉适合新访客打开页面时的首次加载老访客打开页面时应该传本地存储的最后一条seq否则每次都全量拉历史会话多了接口会越来越慢。我在改造时给前端加了localStorage缓存刷新页面后直接读本地seq界面秒开。4.2 AI回复上下文组装与大模型调用AI回复的完整调用链在app/Service/AiService.php里拆开看就三步组装上下文、调用模型、写回消息。这里最值得抄的是上下文组装它直接决定AI回答质量的上限。// app/Service/AiService.php 中的关键方法 private function buildPrompt(array $history, array $docs, string $system): string { $lines []; $lines[] 你是本店的客服助手请基于以下知识库内容回答用户问题。; foreach ($docs as $i $doc) { $lines[] [知识 . ($i) . ] . $doc[content]; } $lines[] 对话历史; foreach ($history as $msg) { $role $msg[from_type] 0 ? 访客 : 客服; $lines[] $role . : . $msg[content]; } $lines[] 请用不超过50字回答不确定时请引导用户转人工。; return implode(\n, $lines); } public function call(string $sessionId, string $userText): string { $history get_recent_messages($sessionId, 10); $docs knowledge_search($userText, $conf[knowledge_top_k]); $prompt $this-buildPrompt($history, $docs, $conf[system_prompt] ?? ); $ch curl_init($conf[api_url]); curl_setopt_array($ch, [ CURLOPT_POST true, CURLOPT_HTTPHEADER [ Content-Type: application/json, Authorization: Bearer . $conf[api_key], ], CURLOPT_POSTFIELDS json_encode([ model $conf[model], messages [[role user, content $prompt]], temperature $conf[temperature], max_tokens $conf[max_tokens], ]), CURLOPT_TIMEOUT $conf[timeout], CURLOPT_RETURNTRANSFER true, ]); $resp curl_exec($ch); $json json_decode($resp, true); return $json[choices][0][message][content] ?? 抱歉我没有理解正在为您转接人工。; }逻辑说明buildPrompt把知识库内容放在对话历史前面让模型先看权威资料再看聊天记录这是RAG场景的常规做法。后面那句“请用不超过50字回答不确定时引导转人工”属于系统提示词它直接限制AI不回长篇大论、不乱承诺。参数说明knowledge_search第二个参数读的是配置里的knowledge_top_k如果你把AI接口的模型换成更强的大模型可以保持3不变如果换成轻量化模型建议减到2减少上下文噪音。CURLOPT_TIMEOUT和配置里的timeout是同一个值两处要保持一致否则配置改了实际还是旧值。这里有一个常见误用有人会把知识库内容直接全量塞进prompt每条都带结果上下文里有几千字和当前问题无关的内容。这套源码的做法是只带检索命中的前3条回答准确率反而更高。如果你要扩展成多轮对话记忆建议在history的组装逻辑里按时间倒序取而不是正序让模型优先看到最新一轮的对话。4.3 坐席切换转人工与自动接管转人工有两种入口访客主动点击“转人工”按钮或者坐席端直接点击“接管会话”。两种入口最终都调用SessionService::changeState()只是触发原因不同。// 访客请求转人工 public function visitorRequestAgent(string $sessionId): bool { if ($this-state($sessionId) ! self::SESSION_AI) { return false; // 只有AI接待阶段才允许访客主动转人工 } $this-changeState($sessionId, self::SESSION_AGENT, null, visitor_request); // 通知所有在线坐席有新会话待接管 push_to_agents($sessionId, session.waiting_agent); return true; } // 坐席接管 public function agentAccept(string $sessionId, int $agentId): bool { $this-changeState($sessionId, self::SESSION_AGENT, $agentId, agent_accept); $this-updateAgentLoad($agentId, 1); // 坐席负载计数 return true; }逻辑说明访客转人工时changeState的第四个参数传的是visitor_request坐席接管时传agent_accept这个原因字段会原样写进kf_session_log。排查问题时只需要SELECT * FROM kf_session_log WHERE session_id ? ORDER BY id就能还原整条会话的生命周期。坐席接管后AI回复分支自动失效因为状态已经离开SESSION_AI队列消费者即使还在处理该会话的旧任务也会在第一步检查状态后直接放弃。这里值得注意的一个设计坐席接管时没有把AI已经生成但还没发出去的回复清掉。我在实际运行中遇到过“坐席刚接管AI最后一句回复还是发出去了”的情况访客会困惑。后来我在agentAccept里加了一条清理逻辑把Redis队列中该会话的待处理任务全部删除同时把AI回复的写入改为“投递前再检查一次会话状态”双保险。源码本身没做这层防护但状态机留了足够的钩子加这个逻辑不需要改表结构。4.4 消息类型扩展图片消息与商品卡片聊天场景里纯文本往往不够源码的消息协议里预留了type: image和type: card两种扩展类型。后端处理这两种消息时多了一个字段content的解析逻辑图片消息的content存的是图片URL商品卡片的content是一段JSON。// 图片或卡片消息的入库解析 $typeMap [ text 0, image 1, card 2, ]; $data [ content $msg[type] card ? json_encode($msg[card_data], JSON_UNESCAPED_UNICODE) : $msg[content], ]; $data[content_type] $typeMap[$msg[type]] ?? 0; save_message_ext($msg[session_id], $msg[from], $data);逻辑说明content_type用0、1、2三个数字区分文本、图片、卡片这样数据库字段不用改只改解析层就能支持新消息类型。前端拿到content_type2时会把content按JSON解析成卡片渲染。我在做二次开发时扩展了一个“订单卡片”类型访客发订单号AI回复里直接带出订单状态卡片坐席端不需要额外开发就能看到结构化信息。这个扩展路径很顺难点不在后端而在前端渲染层要新增一个卡片组件。参数说明json_encode加JSON_UNESCAPED_UNICODE是因为卡片里可能带中文地址、商品名不转会存成\uXXXX形式前端还得解析一次徒增一步。图片消息的URL建议强制转成HTTPS否则嵌入到微信小程序等HTTPS环境时会被拦截。5. 上线避坑与常见问题排查五条踩坑记录5.1 WebSocket频繁断开现象、原因与解决现象线上环境访客聊到一半WebSocket连接经常掉线重连后历史消息和坐席回复偶发丢失。原因部署在Nginx后面时没有配置WebSocket升级头Nginx默认把101升级请求当普通HTTP处理连接只能维持几秒就被断开。另外源码默认心跳间隔是60秒如果中间代理的空闲超时小于60秒连接也会被静默回收。解决在Nginx站点配置里加上Upgrade和Connection头并把代理超时调到120秒以上。最关键的是把心跳间隔从60秒改小比如30秒保证在代理空闲回收前发出心跳包。改完后用ss -tnp | grep 9501看连接存活时间能稳定超过10分钟才算通过。原因里第二条很容易被忽略心跳间隔不止要小于代理超时还要给网络抖动留余量如果代理超时120秒心跳间隔60秒是安全的如果代理超时只有30秒心跳就必须调到20秒以内否则一次网络抖动就能让代理认为连接已死。5.2 AI回复超时导致对话卡死现象大模型接口偶尔响应超过30秒访客等不到回复连续追问多条后后端Redis队列堆积消费者进程一个任务卡住后续所有会话的AI回复全部停摆。原因调用大模型的curl是同步阻塞的一个消费者进程同时只能处理一个任务。虽然后端有超时设置但超时后的重试机制没有做最大次数限制失败任务会反复入队把队列堵死。解决我在AiReplyConsumer里给每个任务加了重试计数入队时带上retry字段超过3次直接丢弃并记日志另外把消费者进程数从1个加到3个用--process 3启动进程之间互不阻塞。从那以后我再没遇到因为单任务超时拖垮整个队列的情况。还有一个连带问题消费者进程挂掉时Redis队列里的任务不会被自动清理重启后旧任务会继续消费但会话状态可能已经变了。我在启动脚本里加了一步“清空队列再启动”的操作确保重启后消费的是新任务。测试环境可以省生产环境建议保留。5.3 消息乱序并发写库与seq冲突现象访客快速连发两条消息AI回复时把后一条问题的答案回给了前一条坐席看到的消息顺序和访客实际发送顺序不一致。原因前端是单线程发送但WebSocket消息到达后端后由多个Worker进程并行处理两条消息的seq在后端写入库时没有做严格串行校验。源码虽然有session_id seq唯一索引但插入时只处理“重复忽略”没有处理“乱序排队”。解决在消息写入前增加一道顺序校验SELECT MAX(seq) FROM kf_message WHERE session_id ?当待写入消息的seq不等于max_seq 1时暂存到Redis的等待队列等前一条落库后再写入。这个改动大约二十行代码却彻底解决了并发下的乱序问题。具体做法是在写入方法里加一个锁用Redis的SETNX给session_id加锁拿到锁才允许写写完释放。这样即使多个Worker同时处理同一条会话的消息后到的也会等先到的写完。注意锁要带超时时间防止进程崩溃导致死锁。5.4 访客IP全是127.0.0.1现象后台坐席工作台显示的访客IP全部是127.0.0.1无法按地区判断访客来源。原因Nginx反代没有把真实IP透传后端$_SERVER[REMOTE_ADDR]拿到的永远是Nginx本机地址。这不是源码缺陷是部署链路少了一个环节。解决在Nginx配置文件增加X-Real-IP和X-Forwarded-For头然后在PHP端增加一层IP解析优先读取X-Real-IP没有才用REMOTE_ADDR。注意必须确保IP解析逻辑只信任来自反向代理的请求否则客户端可以直接伪造请求头。我在config.php里加了一个trusted_proxy配置项只有来源IP在可信列表内时才读取X-Real-IP否则一律用REMOTE_ADDR。这个配置上线前就要设好否则访客伪造IP会直接影响坐席对访客来源地的判断。5.5 知识库命中不准现象访客询问“退款几天到账”AI经常回答与“退货”相关的内容措辞绕来绕去用户仍然不满意。原因知识库条目太长退出时没有按语义切分一条知识里同时包含退款、退货、换货三种场景检索时关键词重叠度高命中了条目但命中的段落不对。解决按“一个知识点只讲一件事”的原则重写知识条目正文不超过300字同时把knowledge_search的匹配逻辑增加一个开关优先匹配标题再匹配正文。我把知识库条目标题从“退款流程”改成“退款到账时间”后同一问句的命中准确率明显提升。提高知识库质量比换更大的模型更有效。参数说明标题匹配的权重建议设为标题命中得5分、正文命中得1分这样“退款到账时间”标题精确命中时不会让“退款流程”“退货退款”这些旁支条目抢走位置。这个权重在knowledge_search里一改就行不需要动数据库。6. 二次开发与验证技巧消息轨迹、压力测试与话术迭代6.1 用消息轨迹表验证完整链路上线前我习惯先跑一轮“消息轨迹”验证把kf_session_log和kf_message两张表按时间关联人工还原一条访客消息从进入到AI回复的完整链路。这不是看日志而是查库。SELECT * FROM kf_session_log WHERE session_id s_20240512_001 ORDER BY id; SELECT seq, from_type, content, created_at FROM kf_message WHERE session_id s_20240512_001 ORDER BY seq;逻辑说明kf_session_log里会依次出现SESSION_WAIT - SESSION_AI、AI - AGENT如果转了人工、AGENT - CLOSED几条记录时间戳应该连续。kf_message表里from_type为0的记录是访客消息为1是AI回复为2是坐席回复三条路的消息序号必须连续且递增。如果发现序号断档说明有消息写入后被回滚或漏写直接对应到5.3的乱序问题。这步验证我建议写成一个脚本每次发版后自动跑一遍而不是人工翻表。脚本逻辑不复杂查出所有状态不是CLOSED但超过24小时没有新消息的会话逐个检查它们的消息序号是否连续把异常的列进报告。6.2 压力测试先打HTTP接口再打WebSocket对客服系统做压测我一般不直接压WebSocket而是先压HTTP历史消息接口因为它带LIMIT 50和after_seq两类查询条件能暴露索引和慢查询问题。接口压测用wrk命令如下。wrk -t4 -c200 -d30s \ --headerCookie: visit_keytest_20240512 \ http://127.0.0.1:8080/api/messages?session_ids_20240512_001after_seq0参数说明-t4是4个线程-c200是200个并发连接-d30s持续30秒。这一轮压测主要看两个指标失败率为0QPS稳定。如果p99响应时间超过200毫秒优先检查idx_session_seq联合索引是否生效再看MySQL的slow_query_log里有没有对应的慢SQL。HTTP链路稳了WebSocket链路一般也不会出现阻塞性问题这个顺序能有效减少排障面。WebSocket的压测我用的脚本工具去模拟多个访客同时发消息重点看Redis队列堆积速度和消费者处理速度的差值。如果队列只进不出说明消费者进程处理不过来优先加进程数而不是加服务器配置。6.3 话术迭代用坐席标记反哺知识库运营一段时间后你手里最有价值的资产是坐席转人工前的聊天记录。我在源码里加了一个很小的功能坐席关闭会话时填一个“未解决原因”字段可选“知识库未命中”“回答错误”“用户不满意”三个选项。这个字段写进kf_session_log的remark每周导一次数据统计哪些问题坐席接手最多、这些问题对应的知识条目命中情况如何。SELECT remark, COUNT(*) AS cnt FROM kf_session_log WHERE to_state 2 AND remark ! GROUP BY remark ORDER BY cnt DESC;逻辑说明to_state 2表示会话迁移到人工状态remark是坐席填的未解决原因。如果“知识库未命中”占比最高说明当前知识库覆盖度不够优先补充新条目如果“用户不满意”占比高说明回复语气或内容有问题调整系统提示词比加知识条目更直接。这个迭代闭环把AI客服从“上线就完事”变成持续优化坐席填写的成本很低但每次填写都在为下一次AI回答质量投票。说一个我的习惯每次改完知识库或提示词我都会在测试环境用一个固定问题列表跑一遍回归把五个常见问题的回答截图存进话术对照表和前一版逐条比对。不是看哪个版本“感觉更好”而是确认改完之后原本回答准确的问题没有被改差。从那以后客服系统的每一次迭代都强制走一遍这条链路改配置、跑回归、看轨迹、比对话术。这套源码最值钱的地方不是AI调得快而是把会话状态和消息轨迹完整留下来让你有证据去优化每一轮对话。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑