接到一个很有意思的项目需求:用原生 PHP 搭建一个 AI 智能客服管理系统的后台。这东西放在两三年前,大家第一反应肯定是上 Python,或者抱各种大模型平台的大腿。但实际做下来你会发现,原生 PHP 在特定场景下反而有它不可替代的优势——部署简单、成本低、跟现有业务系统同构。这篇文章我就把这套系统的设计思路、核心代码、避坑经验全部摊开讲,代码可以直接拿去改,部署步骤也给你写清楚。
我做过几版类似的客服系统,踩过不少坑,从最开始的单文件跑通,到后来拆出相对清晰的分层,整个过程里最深的体感是:AI 客服这个"AI"不一定是大模型,规则引擎加合理的知识库设计,在绝大多数中低并发业务场景里完全够用,而且可控性极强。如果你后续确实需要接入真正的 NLP 服务或大模型接口,这套系统的分层设计也能让你平滑切换,不至于推翻重来。
1. 项目整体设计与技术选型思路
1.1 为什么用 PHP 来实现 AI 客服后台
先回答一个很多人会问的问题:都 2025 年了,为什么还用 PHP 写 AI 客服?我的答案很现实:这套系统的核心价值不在于 AI 算法,而在于业务落地。客服后台要解决的绝大多数问题是:访客消息接入、知识库命中、会话管理、转人工、数据统计。这些恰好是 PHP 最擅长的应用层业务逻辑。
我见过不少团队把客服系统搞得极度复杂,微服务、消息队列、向量数据库,一套组合拳下来光运维就要养两个人。实际上,对于日活几千、并发几十的业务体量,一台 2 核 4G 的服务器跑原生 PHP 加 MySQL,绰绰有余。成本一个月几十块,部署只需要一个 Nginx 加 PHP-FPM,没有任何额外的运行时依赖。
另外还有一点很实际:PHP 的部署生态跟虚拟主机、宝塔面板这类环境极度兼容。很多企业已有的业务系统就是 PHP 写的,客服后台直接部署在同一台机器上,数据互通、会话关联都方便。你让非技术出身的运维去折腾 Python 虚拟环境和 Gunicorn,人家真的会崩溃。
注意:我这里说的是"原生 PHP",不依赖 Laravel、ThinkPHP 这类框架。原生 PHP 对于这个体量的项目完全够用,而且代码透明、可控性强,出问题直接改,不用去翻框架源码。如果你公司内部已有成熟的框架规范,那用框架改造也是顺手的事,核心逻辑不变。
1.2 系统功能规划与技术架构拆解
这套系统的核心功能我划分成四大模块:
| 模块 | 核心功能 | 技术要点 |
|---|---|---|
| 消息接入层 | 接收访客消息、发送回复 | 轮询替代 WebSocket,降低实现复杂度 |
| 机器人引擎 | 关键词匹配、知识库检索 | 规则引擎 + 加权评分,可平滑升级为大模型接口 |
| 管理后台 | 会话监控、转人工、知识库维护 | 前后端分离,PHP 提供 JSON API |
| 数据存储层 | 会话记录、消息记录、知识条目 | MySQL 三张核心表,索引优化到位 |
架构上我刻意不做消息队列,不做 Redis 缓存——除非后续量级真的大到撑不住。原因很简单:架构的复杂度应该跟着业务量走,而不是跟着技术潮流走。多引入一个组件,就是多一个故障点。
通信方案上,我选择了前端轮询而不是 WebSocket。主要是考虑到部署和运维成本。轮询 2 秒一次,对于客服这个场景完全够用。后面如果要做实时性更高的功能,再引入 Swoole 或者直接上 GatewayWorker。一开始就上长连接,各种断线重连、心跳保活的问题会把你折磨得怀疑人生。
2. 核心模型拆解:AI 回复的底层逻辑
2.1 意图识别:不靠大模型,靠特征匹配
很多人一听"AI 客服"就以为必须接大模型 API,这是最大的误解。这一版系统的核心是一个基于评分机制的规则引擎,它比传统的 if-else 硬编码要灵活得多。
实现的逻辑是这样的:知识库里的每一条问答,都配置多个触发关键词,每个关键词带一个权重值。用户提问时,系统把问题拆成词元,再去知识库中匹配,按命中的关键词权重求和排序,得分最高且超过设定阈值的条目作为回复。
举个例子,知识库里有这么一条:
问题:如何修改收货地址 关键词:改地址(权重 10)、修改收货(权重 8)、地址错了(权重 6)、换地址(权重 5)
用户问"我想换个收快递的地址",分词后得到"换""收快递""地址"。命中"换地址"(权重 5)和"地址""收快递"这些相关词,总分 11,超过阈值 8,命中这条问答。
这个方案最大的优点就是可控性和可解释性。每一条回复为什么触发,后台一查权重清清楚楚,不会出现大模型那种"看着合理但实际胡说八道"的情况。在客服这个场景里,答错一次可能就要丢一个客户,稳定性比花哨更重要。
我的建议是:规则引擎做底层保底,后期如果要接大模型,让规则引擎先做一次初筛。高置信度的直接返回,低置信度的再甩给大模型兜底。这样既享受了大模型的泛化能力,又保住了规则引擎的稳定性。
2.2 知识库设计:AI 客服的弹药库
如果说引擎是大脑,那知识库就是弹药库。这个系统的知识库设计成多级分类 + 标准问题 + 扩展问法的结构。
每条知识包含三块信息:
- 标准问题:比如"你们发货用哪家快递"
- 扩展问法:把"发什么快递""物流是谁送的""走什么物流"这类变体都挂在这条知识下
- 标准答案:一段完整的回复文本
这个结构参考了客服行业沉淀下来的 FAQ 管理方法论。扩展问法不是随便填的,得从真实客服聊天记录里提炼。我见过不少团队知识库上线后命中率低,问题就出在扩展问法都是开发自己猜的,跟用户真实表达方式严重脱节。
知识库的维护功能必须做进管理后台,让运营人员可以随时增删改。这条看着简单,实际上特别容易被开发忽略——代码写完了才发现知识只能写死在 PHP 数组里,运营每次改答案都得找开发,效率低到令人发指。
2.3 转人工的触发机制
规则引擎再好,也得有兜底方案。这套系统里我设计了三个转人工触发条件:
- 无答案:没有匹配到任何高置信度条目,自动转人工
- 用户明确要求:消息中命中"人工""客服""转人工"等关键词,立即转人工
- 连续未命中:连续两轮消息都未命中知识库,主动引导转人工
每个会话维护一个is_human状态字段。当这个字段为 1 时,后续所有访客消息都直接进入人工客服工作台,机器人不再参与回复。这个状态机是整个会话管理的核心,必须理清楚。
3. 数据库设计与核心表结构
3.1 会话与消息表:撑起整个客服流程
数据库设计上,我坚持核心表尽量少,字段尽量精简的原则。整张库就三张核心表:cs_sessions、cs_messages、cs_knowledge,外加一张管理员表。
cs_sessions会话表的设计如下:
CREATE TABLE `cs_sessions` ( `id` int(11) NOT NULL AUTO_INCREMENT, `visitor_id` varchar(64) NOT NULL COMMENT '访客唯一标识', `customer_name` varchar(50) DEFAULT NULL COMMENT '访客名称', `is_human` tinyint(1) NOT NULL DEFAULT 0 COMMENT '是否转人工 0机器人 1人工', `status` tinyint(1) NOT NULL DEFAULT 0 COMMENT '0待处理 1进行中 2已结束', `last_message` text COMMENT '最后一条消息内容', `create_time` datetime NOT NULL, `update_time` datetime NOT NULL, PRIMARY KEY (`id`), KEY `idx_visitor` (`visitor_id`), KEY `idx_status` (`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;is_human和status这两个字段是查询高频字段,必须建索引。visitor_id用于识别同一个访客的历史会话,这块要跟前端埋点配合:访客第一次进入页面时,由后端生成一个唯一 ID,写入前端 Cookie,后续所有请求都带上这个 ID。
cs_messages消息表我更关注查询效率:
CREATE TABLE `cs_messages` ( `id` int(11) NOT NULL AUTO_INCREMENT, `session_id` int(11) NOT NULL COMMENT '所属会话ID', `sender_type` tinyint(1) NOT NULL COMMENT '0访客 1机器人 2人工客服', `content` text NOT NULL COMMENT '消息内容', `create_time` datetime NOT NULL, PRIMARY KEY (`id`), KEY `idx_session` (`session_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;实际项目里有个要注意的细节:消息内容字段类型我用的是
text而不是varchar。原因很简单,访客可能会贴一段很长的报错信息,varchar(255)根本不够用。早期版本吃过这个亏,消息直接被 MySQL 截断,客服要看完整信息都看不到。
3.2 知识库表:支撑意图匹配的结构
cs_knowledge表的结构设计为多级分类与触发词关联:
CREATE TABLE `cs_knowledge` ( `id` int(11) NOT NULL AUTO_INCREMENT, `category_id` int(11) DEFAULT NULL COMMENT '分类ID', `standard_question` varchar(255) NOT NULL COMMENT '标准问题', `answer` text NOT NULL COMMENT '标准答案', `keywords` text NOT NULL COMMENT '触发关键词集(JSON数组)', `hit_count` int(11) NOT NULL DEFAULT 0 COMMENT '命中次数,用于后续优化', `status` tinyint(1) NOT NULL DEFAULT 1 COMMENT '0禁用 1启用', `create_time` datetime NOT NULL, `update_time` datetime NOT NULL, PRIMARY KEY (`id`), KEY `idx_category` (`category_id`), KEY `idx_status` (`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;keywords字段我存的是 JSON 格式的数组,每项包含关键词和权重。这比另外建一张关联表在实际操作中要灵活得多——后台直接一整块 JSON 提交,不涉及 JOIN 查询,性能更好。反正每次匹配就是把整条记录读出来,内存里做计算,数据库不需要智能。
特别的优化点在于hit_count字段。每命中一次就UPDATE cs_knowledge SET hit_count = hit_count + 1 WHERE id = ?。这个数据积累下来,配合对话记录,就是后续优化知识库最有价值的数据依据。哪些问答被问得最多,哪些答案命中率高但客户还是不满意,一目了然。
3.3 管理员表与会话关联
管理员表非常简单,字段只有id、username、password_hash、real_name、create_time。密码用password_hash()函数生成,明文绝对不进数据库。登录态用 PHP Session 维护。
这里有个我在项目里踩过的坑:管理员处理会话时,需要在cs_sessions表里加一个assignee_id字段,记录当前是哪位客服在接管。不加这个字段,多客服同时在线时,一个访客的消息会被多个客服看到,然后抢着回复,或者以为别人会回复结果都没人处理。
ALTER TABLE `cs_sessions` ADD COLUMN `assignee_id` int(11) DEFAULT NULL COMMENT '当前客服ID';4. 核心代码实现:机器人回复引擎
4.1 消息接入入口的封装
所有的外部请求(访客发消息、轮询新回复)统一走一个入口文件api.php,通过action参数区分是收消息还是拉消息。统一入口的好处是方便做鉴权、日志和参数校验,只暴露一个文件给外部访问。
<?php // api.php - 前端统一接口入口 require_once 'config.php'; require_once 'db.php'; require_once 'bot_engine.php'; require_once 'session.php'; $action = $_GET['action'] ?? ''; $allowed_actions = ['send_message', 'poll_messages', 'create_session']; if (!in_array($action, $allowed_actions)) { json_response(['code' => 400, 'msg' => '无效的action']); } // 会话鉴权:所有请求必须携带有效的visitor_id $visitor_id = $_POST['visitor_id'] ?? ''; if (empty($visitor_id) || !preg_match('/^[a-zA-Z0-9]{8,32}$/', $visitor_id)) { json_response(['code' => 401, 'msg' => '无效的访客ID']); } try { switch ($action) { case 'create_session': $result = create_session($visitor_id); break; case 'send_message': $result = handle_send_message($visitor_id); break; case 'poll_messages': $result = handle_poll_messages($visitor_id); break; } json_response(['code' => 0, 'data' => $result]); } catch (Exception $e) { error_log('[客服系统] ' . $e->getMessage()); json_response(['code' => 500, 'msg' => '服务器开小差了,请稍后再试']); }为什么 session 状态放在服务端而不是前端传过来?因为服务端必须掌握会话的最终控制权。如果前端拿着is_human = 0就能自己决定是机器人回还是人工回,那整个转人工机制就形同虚设了。访客端永远只传visitor_id,服务端根据 session 状态判断走哪条逻辑,这是安全底线。
4.2 机器人回复引擎的完整实现
接下来是整个系统的核心:bot_engine.php。这里实现了分词、知识匹配、权重评分和转人工判断的完整逻辑。
<?php // bot_engine.php - 机器人回复引擎 /** * 简单的分词函数 * 基于常用中文停用词表过滤,再做二元词组组合 * 真正的分词方案需要引入第三方库,这里用字符串切割 + 二元组合 */ function simple_tokenize($text) { $stopwords = ['的', '了', '吗', '呢', '啊', '在', '是', '我', '你', '他', '什么', '怎么', '如何', '哪些', '可以']; $clean = preg_replace('/[^\x{4e00}-\x{9fa5}a-zA-Z0-9]/u', '', $text); // 连续提取二字词、三字词作为候选特征 $tokens = []; $len = mb_strlen($clean, 'UTF-8'); for ($i = 0; $i < $len - 1; $i++) { $tokens[] = mb_substr($clean, $i, 2, 'UTF-8'); } for ($i = 0; $i < $len - 2; $i++) { $tokens[] = mb_substr($clean, $i, 3, 'UTF-8'); } // 过滤停用词,去重 return array_values(array_unique(array_filter($tokens, function($w) use ($stopwords) { return !in_array($w, $stopwords) && mb_strlen($w, 'UTF-8') >= 2; }))); } /** * 核心评分算法 * 遍历知识库,对每条知识的keywords做命中累加 */ function match_knowledge($tokens, $min_score = 8) { $pdo = get_db_connection(); $sql = "SELECT * FROM cs_knowledge WHERE status = 1 ORDER BY hit_count DESC LIMIT 200"; $stmt = $pdo->query($sql); $best_score = 0; $best_item = null; $match_details = []; while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { $keywords = json_decode($row['keywords'], true); if (!is_array($keywords)) continue; $score = 0; $hit_words = []; foreach ($keywords as $kw) { $word = $kw['word'] ?? ''; $weight = $kw['weight'] ?? 1; if (empty($word)) continue; // 判断关键词是否在原文或分词结果中出现 foreach ($tokens as $token) { if (mb_strpos($token, $word) !== false || mb_strpos($word, $token) !== false) { $score += $weight; $hit_words[] = $word; break; } } } $details = [ 'id' => $row['id'], 'standard_question' => $row['standard_question'], 'score' => $score, 'hit_words' => $hit_words ]; $match_details[] = $details; if ($score > $best_score) { $best_score = $score; $best_item = $row; } } // 记录匹配详情到日志,便于后续优化知识库 if ($best_score >= $min_score) { // 更新命中次数 $update = $pdo->prepare("UPDATE cs_knowledge SET hit_count = hit_count + 1 WHERE id = ?"); $update->execute([$best_item['id']]); return [ 'found' => true, 'score' => $best_score, 'answer' => $best_item['answer'], 'knowledge_id' => $best_item['id'] ]; } return ['found' => false, 'score' => $best_score, 'details' => $match_details]; } /** * 检测是否要求转人工 */ function is_human_request($text) { $patterns = ['人工', '转人工', '人工客服', '真人', '在线客服', '活人', '客服人员']; foreach ($patterns as $p) { if (mb_strpos($text, $p) !== false) { return true; } } return false; } /** * 主回复逻辑 */ function get_bot_reply($session_id, $user_message) { $pdo = get_db_connection(); // 1. 查询会话状态 $stmt = $pdo->prepare("SELECT * FROM cs_sessions WHERE id = ?"); $stmt->execute([$session_id]); $session = $stmt->fetch(PDO::FETCH_ASSOC); if (!$session) { throw new Exception('会话不存在'); } // 2. 如果已经是人工接待状态,直接返回提示,不走机器人逻辑 if ($session['is_human'] == 1) { return ['type' => 'human', 'reply' => '']; } // 3. 检查是否要求转人工 if (is_human_request($user_message)) { // 将会话标记为转人工 $update = $pdo->prepare("UPDATE cs_sessions SET is_human = 1, status = 1 WHERE id = ?"); $update->execute([$session_id]); return [ 'type' => 'transfer_human', 'reply' => '您好,正在为您转接人工客服,请稍候...' ]; } // 4. 分词并进行知识库匹配 $tokens = simple_tokenize($user_message); $result = match_knowledge($tokens); if ($result['found']) { return [ 'type' => 'answer', 'reply' => $result['answer'], 'score' => $result['score'] ]; } // 5. 未命中知识库:检查连续未命中次数,连续2次就转人工 $fail_count = $session['fail_count'] ?? 0; $new_fail_count = $fail_count + 1; if ($new_fail_count >= 2) { $update = $pdo->prepare("UPDATE cs_sessions SET is_human = 1, fail_count = ? WHERE id = ?"); $update->execute([$new_fail_count, $session_id]); return [ 'type' => 'transfer_human', 'reply' => '连续两次没有理解您的意思,正在为您转接人工客服...' ]; } $update = $pdo->prepare("UPDATE cs_sessions SET fail_count = ? WHERE id = ?"); $update->execute([$new_fail_count, $session_id]); return [ 'type' => 'fallback', 'reply' => '抱歉,我暂时没有理解您的问题。您可以换个方式描述,或者说"人工"转接客服为您服务。' ]; }这个引擎我有几点想特别说明:
关于简单分词方案:如果你只依赖 PHP 内置函数,对中文的支持确实是短板。上面的simple_tokenize用"连续提取二字词 + 三字词 + 停用词过滤"的方式做了一个轻量分词,原理其实就是枚举所有子串作为候选特征,再跟知识库关键词做子串包含判断。这个方法在关键词是专业名词或独特词汇时命中率不错(比如"发货""退款"),但遇到"你们"“他们”这种代词还是要靠停用词过滤。如果你需要更专业的分词,引入一个分词扩展比如scws或者直接调第三方分词 API,替换掉simple_tokenize就可以了。
注意:
match_knowledge里的关键词匹配我用了双向mb_strpos判断。为什么要双向?因为用户打的字可能跟知识库关键词有细微差异,比如"发贷"(错别字)和"发货"。单向包含能命中一部分,双向能提高容错率。但也别指望完全靠这个解决错别字问题——更彻底的方式是用编辑距离算法做模糊匹配,这块可以根据业务需要扩展。
4.3 消息收发完整流程串联
下面这条链路是整个系统的业务主线:
- 访客在网页端输入消息,前端通过
api.php?action=send_messagePOST 提交 - 后端保存访客消息到
cs_messages,然后调用get_bot_reply() - 机器人引擎返回回复内容,后端再把机器人回复保存到
cs_messages - 前端通过
api.php?action=poll_messages轮询拉取新消息(间隔 2 秒) - 管理员在后台看到会话列表,点击进入人工接管,发送的消息在
sender_type标记为 2
handle_send_message和handle_poll_messages是会话的收口,直接决定整个流程能否跑通:
<?php // session.php - 会话管理逻辑 function create_session($visitor_id) { $pdo = get_db_connection(); // 先查这个访客有没有进行中的会话 $stmt = $pdo->prepare("SELECT * FROM cs_sessions WHERE visitor_id = ? AND status IN (0, 1) ORDER BY id DESC LIMIT 1"); $stmt->execute([$visitor_id]); $session = $stmt->fetch(PDO::FETCH_ASSOC); if ($session) { return ['session_id' => (int)$session['id'], 'is_new' => false]; } // 创建新会话 $stmt = $pdo->prepare("INSERT INTO cs_sessions (visitor_id, create_time, update_time) VALUES (?, NOW(), NOW())"); $stmt->execute([$visitor_id]); $new_id = (int)$pdo->lastInsertId(); // 写入一条欢迎语 $stmt = $pdo->prepare("INSERT INTO cs_messages (session_id, sender_type, content, create_time) VALUES (?, 1, ?, NOW())"); $welcome_msg = '您好,我是智能客服小研。请问有什么可以帮您?您可以咨询物流、退换货、订单等问题。'; $stmt->execute([$new_id, $welcome_msg]); return ['session_id' => $new_id, 'is_new' => true]; } function handle_send_message($visitor_id) { $pdo = get_db_connection(); $content = trim($_POST['content'] ?? ''); if (empty($content)) { throw new Exception('消息内容不能为空'); } if (mb_strlen($content, 'UTF-8') > 500) { throw new Exception('消息长度不能超过500字'); } // 找到该访客的进行中会话,没有就创建一个 $session = get_active_session($visitor_id); if (!$session) { $session_info = create_session($visitor_id); $session = get_active_session($visitor_id); } $session_id = (int)$session['id']; // 1. 保存访客消息 $stmt = $pdo->prepare("INSERT INTO cs_messages (session_id, sender_type, content, create_time) VALUES (?, 0, ?, NOW())"); $stmt->execute([$session_id, $content]); // 2. 更新会话的last_message和update_time $stmt = $pdo->prepare("UPDATE cs_sessions SET last_message = ?, update_time = NOW() WHERE id = ?"); $stmt->execute([mb_substr($content, 0, 100), $session_id]); // 3. 判断是机器人回还是人工回 if ($session['is_human'] == 1) { // 人工接管中,机器人不回复,等客服来 return ['session_id' => $session_id, 'need_human' => true]; } // 4. 机器人回复 $bot_result = get_bot_reply($session_id, $content); if (!empty($bot_result['reply'])) { // 机器人回复存库 $stmt = $pdo->prepare("INSERT INTO cs_messages (session_id, sender_type, content, create_time) VALUES (?, 1, ?, NOW())"); $stmt->execute([$session_id, $bot_result['reply']]); } return [ 'session_id' => $session_id, 'reply' => $bot_result['reply'] ?? '', 'type' => $bot_result['type'] ?? 'answer' ]; } function handle_poll_messages($visitor_id) { $pdo = get_db_connection(); $session = get_active_session($visitor_id); if (!$session) { return ['messages' => [], 'is_human' => 0]; } // 获取从这个会话开始的所有消息,限制最近的50条 $stmt = $pdo->prepare("SELECT sender_type, content, create_time FROM cs_messages WHERE session_id = ? ORDER BY id ASC LIMIT 50"); $stmt->execute([(int)$session['id']]); $messages = $stmt->fetchAll(PDO::FETCH_ASSOC); return [ 'messages' => $messages, 'is_human' => (int)$session['is_human'] ]; }特别注意
handle_send_message里的一个业务判断:当机器人返回类型为transfer_human时,前端要立即切换到"人工客服在线"的 UI 状态,并提示访客"已转接人工,请稍候"。
5. 管理后台的搭建与关键功能
5.1 会话监控与人工接管流程
管理后台我用的是最简单的 PHP 原生页面加 AJAX 轮询。页面布局分三块:左侧会话列表、右侧对话窗口、顶部是当前客服信息和退出按钮。没有引入前端框架,直接原生 JavaScript 加fetchAPI 轮询接口,每 2 秒刷新一次会话列表和当前对话内容。
人工接管的核心逻辑:
<?php // admin_api.php - 管理后台接口(需要登录态) function admin_takeover_session() { check_admin_login(); // 未登录直接返回401 $session_id = intval($_POST['session_id'] ?? 0); $admin_id = $_SESSION['admin_id']; $pdo = get_db_connection(); // 原子更新:防止两个客服同时接管同一个会话 $sql = "UPDATE cs_sessions SET is_human = 1, status = 1, assignee_id = ? WHERE id = ? AND (assignee_id IS NULL OR assignee_id = ?)"; $stmt = $pdo->prepare($sql); $stmt->execute([$admin_id, $session_id, $admin_id]); $affected = $stmt->rowCount(); if ($affected === 0) { // 检查是否被其他客服接管 $check = $pdo->prepare("SELECT assignee_id FROM cs_sessions WHERE id = ?"); $check->execute([$session_id]); $row = $check->fetch(PDO::FETCH_ASSOC); if ($row && $row['assignee_id'] != $admin_id) { return ['code' => 1, 'msg' => '该会话已被其他客服接管']; } } return ['code' => 0, 'msg' => '接管成功']; } function admin_send_message() { check_admin_login(); $session_id = intval($_POST['session_id'] ?? 0); $content = trim($_POST['content'] ?? ''); $admin_id = $_SESSION['admin_id']; if (empty($content)) { return ['code' => 1, 'msg' => '消息不能为空']; } $pdo = get_db_connection(); // 验证这个会话确实由当前客服接管 $stmt = $pdo->prepare("SELECT id FROM cs_sessions WHERE id = ? AND assignee_id = ?"); $stmt->execute([$session_id, $admin_id]); if (!$stmt->fetch()) { return ['code' => 1, 'msg' => '您未接管该会话,无法发送']; } // 保存客服消息 $stmt = $pdo->prepare("INSERT INTO cs_messages (session_id, sender_type, content, create_time) VALUES (?, 2, ?, NOW())"); $stmt->execute([$session_id, $content]); $pdo->prepare("UPDATE cs_sessions SET last_message = ?, update_time = NOW() WHERE id = ?") ->execute([mb_substr($content, 0, 100), $session_id]); return ['code' => 0, 'msg' => '发送成功']; }这里用了原子 UPDATE 加行数判断来防止两个客服同时接管同一个会话。这种"乐观锁"的思路在并发场景下比先 SELECT 再 UPDATE 要可靠得多。
注意一个细节:客服接管时,会往
cs_sessions写入is_human = 1。这时如果访客刚好发来一条新消息,handle_send_message会发现已经是人工接管状态,不会让机器人回复,直接等客服来答。这个联动逻辑就是前面反复强调的"服务端控制会话状态"的意义。
5.2 知识库管理功能:运营人员的操作台
知识库管理页面尽量做得"傻瓜化",因为使用者大概率不是程序员。界面上就三个操作:新增问答、编辑问答、禁用问答。但是背后有几个小的隐藏功能点值得提一下。
批量导入是必须的。知识库冷启动的时候,几百条问答靠手工录入会录到怀疑人生。我选择支持 CSV 导入,列格式固定为"标准问题、标准答案、关键词1:权重1|关键词2:权重2|...",导入时按行解析,校验必填字段,给出导入报告。写了两个多小时,省了后续几十个小时的重复劳动。
命中调试功能,是我后来加上的。后台提供一个输入框,输入任意语句,系统会模拟整个匹配流程,返回命中了哪些知识、得分多少、命中了哪些关键词。这个功能极其有用——知识库维护者可以实时验证自己的关键词配得对不对,而不需要真的拿一个访客页面去测。
上线一段时间后强烈建议定期看hit_count排名。命中次数最多的问答,就是你的知识库 MVP。如果这些问答仍然产生大量转人工,不是客户难搞,是你的答案质量不行——需要人工客服打磨更好的回复,然后更新知识库答案。
5.3 统计报表:用数据驱动知识库迭代
报表功能别做太重,三个核心指标就够用:
| 指标 | 计算方式 | 价值 |
|---|---|---|
| 机器人解决率 | 未转人工的会话数 / 总会话数 | 衡量知识库整体质量 |
| 平均响应时长 | SUM(机器人回复时间间隔) / 回复次数 | 衡量体验,找出慢接口 |
| 知识命中 TOP10 | 按hit_count排序 | 指导优化重点 |
统计脚本放在 cron 里每天凌晨跑一次,汇总昨天的数据。报表页面不需要实时计算,性能没有压力。
6. 部署上线全流程
6.1 环境准备与目录结构
到部署环节了。这套系统对运行环境的要求极低:PHP 7.4 以上、MySQL 5.7 以上、Nginx 或 Apache。不需要 Composer 安装依赖,不需要 Redis,不需要任何扩展库,这也是为什么我说它适合低成本落地。
生产环境目录结构建议这样组织:
/var/www/ai-chat/ ├── api.php # 前端访客接口 ├── admin_api.php # 后台管理员接口 ├── config.php # 配置文件 ├── db.php # PDO 数据库连接 ├── bot_engine.php # 机器人引擎 ├── session.php # 会话管理逻辑 ├── admin/ │ ├── index.html # 管理后台页面 │ ├── login.html # 登录页面 │ └── assets/ │ ├── style.css │ └── admin.js └── sql/ └── init.sql # 建表脚本6.2 部署步骤详解
部署我拆成六步,照着做就行:
第一步:初始化数据库
把上面的建表 SQL 保存为init.sql,执行:
mysql -u root -p < /var/www/ai-chat/sql/init.sql第二步:导入初始知识库
找运营要一份 FAQ 文档,整理成 CSV,在管理后台上传导入。没有初始数据的话,机器人就是个空壳,问什么都命中不了。
第三步:配置 config.php
<?php // config.php define('DB_HOST', '127.0.0.1'); define('DB_PORT', '3306'); define('DB_NAME', 'ai_chat'); define('DB_USER', 'ai_chat_user'); define('DB_PASS', '你的强密码'); define('CHAT_APP_KEY', '前端接口调用凭证,建议生成一个随机的32位字符串'); define('DEBUG_MODE', false);注意:
CHAT_APP_KEY记得生成一个随机长字符串,前端每次请求带上这个 key,后端校验通过才允许调用。不然任何第三方都能往你的客服系统灌垃圾消息,会话表很快就会被垃圾数据撑爆。
第四步:配置 Nginx 站点
server { listen 80; server_name chat.example.com; root /var/www/ai-chat; index index.html; location / { try_files $uri $uri/ =404; } location ~ \.php$ { fastcgi_pass 127.0.0.1:9000; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; include fastcgi_params; } }第五步:设置目录权限
chown -R www-data:www-data /var/www/ai-chat chmod -R 755 /var/www/ai-chat mkdir -p /var/log/ai-chat && chown www-data:www-data /var/log/ai-chat第六步:验证上线
访问http://chat.example.com/api.php?action=create_session&visitor_id=test001,看接口返回是否正常。然后通过前端页面发一条"你们发什么快递",确认机器人能匹配上。
6.3 上线前的自检清单
这个清单是我自己每次上新项目都会过一遍的:
- 数据库连接串、前端 app_key 是否正确
- 时区配置是否统一为
Asia/Shanghai,否则会话时间跟客服看到的时间对不上 - 消息内容是否做了 XSS 过滤(客服端和管理端都要做,后端
htmlspecialchars至少来一圈) - 后台账号是否启用了强密码策略,
password_hash是否生效 - 接口超时时间是否设置了合理的值(轮询接口我设置了 30 秒超时)
- Cron 日志脚本是否运行正常
7. 常见问题与排查技巧实录
7.1 中文乱码问题
这是 PHP 里最经典的问题,没有之一。现象是数据库里存的正常,页面上显示乱码,或者反过来。
排查步骤按顺序来:
- 确认 PHP 文件本身是 UTF-8 编码保存——IDE 右下角看一下编码格式
- PDO 连接串必须声明 UTF-8:
new PDO($dsn, $user, $pass, [PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8mb4"]) - 确认数据库和表的字符集都是
utf8mb4,不是utf8 - 页面
<meta charset="UTF-8"> - 最后检查中间件——Nginx 有时会因响应头缺
charset造成浏览器默认按系统编码解析
其中第 2 步是最容易被忽略的。很多人的 PDO 连接是这样写的:
new PDO('mysql:host=127.0.0.1;dbname=ai_chat', $user, $pass);没指定编码,默认可能是latin1,中文一发进去就变成问号。必须这样:
new PDO('mysql:host=127.0.0.1;dbname=ai_chat;charset=utf8mb4', $user, $pass);7.2 轮询接口的性能优化
上线跑了几天后,你可能会发现会话多了页面卡顿。排查后发现瓶颈一般不在数据库,而是每次轮询都全量查消息。
优化方向有两个:
第一,轮询接口加一个last_message_id参数,前端传上次收到的最大 ID,后端只查id > last_message_id的数据。这一个改动能把绝大多数无用查询干掉。
$last_id = intval($_POST['last_message_id'] ?? 0); $sql = "SELECT sender_type, content, create_time FROM cs_messages WHERE session_id = ? AND id > ? ORDER BY id ASC LIMIT 50";第二,会话列表接口不要一次性拉全部字段。列表页只需要id, visitor_id, is_human, status, last_message, update_time,只需要查询这几列,别把整行SELECT *出去。
7.3 高并发的锁表问题
InnoDB 默认行级锁,理论上不容易锁表。但我发现实际业务里,频繁的UPDATE cs_sessions SET update_time = NOW()在上万条数据量时,如果update_time没有索引,MySQL 可能走全表扫描,造成锁的粒度扩大。
解决方案很简单:
ALTER TABLE `cs_sessions` ADD INDEX `idx_update_time` (`update_time`);另外一个容易踩的坑是INSERT和UPDATE混合使用时事务隔离级别的问题。默认的REPEATABLE READ下,如果长事务没提交,其他事务的读可能看到旧快照。解决方式:每个请求的操作尽量短平快,不要在一个请求里开事务跑多个大查询。
7.4 转人工后机器人还在回复
这个问题特别典型。访客转了人工,机器人还在自动回复,导致人工和机器人同时回复,把客户搞懵。
排查思路:确认handle_send_message里,is_human的判断是在保存访客消息之后、调用机器人引擎之前。而且一定要读取的是最新会话状态,而不是之前缓存的$session数组。我加的编码实践是:
// 在调用 get_bot_reply 之前,强制重新查一次会话状态 $stmt = $pdo->prepare("SELECT is_human FROM cs_sessions WHERE id = ?"); $stmt->execute([$session_id]); $latest_status = $stmt->fetchColumn(); if ($latest_status == 1) { return ['session_id' => $session_id, 'need_human' => true]; }7.5 知识库匹配不到内容的调试思路
如果你的系统老回"抱歉没有理解",先不要急着加关键词,按下面的顺序排查:
- 后台的"命中调试"功能里把原句贴进去,看分词结果是什么
- 确认知识库里的
keywords是不是 JSON 格式,有没有因为写错导致解析失败 - 确认阈值
min_score设得是不是太高,一般 6-8 比较合适 - 看停用词表里是不是误杀了一些关键词,比如"怎么"被过滤了,但你的关键词恰好是"怎么退货",那就全完蛋
- 知识库里的扩展问法有没有覆盖真实聊天记录里的表达方式——这是最根本的原因
经验之谈:很多团队上线客服系统,知识库第一条问题就只是复述 FAQ 原文,而真实用户根本不会那么问。上线前最好拿 100 条真实聊天记录去跑一遍匹配,命中率低于 60% 就别急着推广。这里的电子表格就是最好的打磨工具。
这套系统后续还能怎么玩
如果这套系统跑顺了,想再升级,我给几个实际可落地的方向:
第一,接入 LLM 作为兜底。规则引擎没匹配到时,把用户问题和知识库里的标准问题一起打包发给大模型接口,让它从给定资料里找答案。注意输出要限定"只依据给定资料回答,不要自行编造"。保留规则引擎的返回答类型不变,平滑升级。
第二,加入简单的情绪识别。检测到"投诉""差评""退款""生气"这类关键词时,自动提升会话权重,提醒客服优先响应。
第三,做会话满意度评价。会话结束后,访客可点"满意/不满意",不满意标记会像连坐一样指向对应知识条目,驱动运营去改进。
我个人在实际操作中最大的体会是:技术方案永远要为业务目标服务。这套系统最早的版本堆了很多花哨功能,多轮对话、敏感词拦截、客服排班,后来发现大部分功能根本没人用,反而把核心链路拖得又慢又乱。删掉之后,系统稳定性和可维护性都上来了。客服系统的核心永远是那句话——让访客的问题得到准确、快速的解答,让客服的工作变得轻松、有序。做到这两点,技术老套一点也无所谓。