
智能客服这个方向这两年几乎成了各行业网站的标配。但真要自己动手做一套很多人第一反应是“得上大模型平台、搞训练、搞部署”一听就劝退了。其实用PHP完全可以写出一套简单、实用、能直接跑起来的AI智能客服源码系统尤其是在中小型项目里PHP的部署成本和维护难度反而是优势。这篇文章我就把一套完整可复现的方案拆开讲从架构设计、数据库表结构、多级应答引擎到大模型API对接、前端对话窗口、常见问题排查全部过一遍。不管你是PHP新手还是有点基础的开发者照着这套思路都能搭出自己的智能客服。1. 先想清楚一套“简单”的智能客服到底包含什么1.1 不要一上来就做大模型接入很多初学者看到“AI智能客服”几个字下意识就想直接对接大模型API好像只要把用户问题丢给大模型就能完事。但实际做过的人都知道这思路有两个致命问题一是成本每个问题都走大模型接口碰上高峰期账单会非常难看二是效果大模型对于公司内部的业务知识、售后政策、常见问题往往答得不够准甚至一本正经地胡说八道。所以一套能落地的方案采用的是多级应答引擎的思路。从成本和准确性两个维度综合排序用户提问后依次尝试三个层级关键词规则匹配、FAQ知识库相似度匹配、大模型兜底。前两层几乎零成本且响应极快能覆盖掉80%以上的重复咨询只有前两层都命中不了时才把问题提交给大模型。这样既保住了响应速度也控制住了API成本。这套系统的整体结构拆开看其实只有三个部分前端对话窗口、PHP后端接口、数据存储层。前端窗口负责收集用户输入并展示回复PHP后端接口负责接收消息、保存会话、调用应答引擎计算回复数据存储层保存FAQ知识、会话记录、客户信息。没有任何高深组件一台普通云服务器就能全部跑起来。1.2 为什么用PHP而不是Python或Node我在好几个项目里都做过技术选型对比最后仍然选了PHP理由很实际。PHP的部署链路是所有语言里最容易的装一个Nginx、装一个PHP-FPM代码丢进去就能跑不需要编译、不需要进程守护、不需要折腾虚拟环境。这对于做网站出身的中小团队来说几乎零运维门槛。而Python方案虽然AI生态好但生产环境要处理Gunicorn、WSGI、依赖冲突新手很容易栽跟头Node方案则对异步编程经验有一定要求不是所有人都能快速上手。PHP作为接口后端配合前端对话窗口非常自然。如果你的网站本身就是PHP写的比如ThinkPHP或Laravel项目那么客服系统可以直接复用现有用户体系在同一套代码库里读取订单数据、用户资料实现“根据用户身份给出个性化回复”。这是用纯Python写一个独立客服服务所不具备的优势。这套方案适合谁如果你的需求是网站访客咨询、售前售后常见问题自动回答、深夜无人值守时能顶住基础问答那么PHP这套方案够用且好用。如果你的需求是千级QPS的高并发智能客服那PHP确实不太合适那种场景应该直接采用专业产品而不是自己从零开发。2. 核心数据表与会话管理设计2.1 三张核心表FAQ表、会话表、消息表智能客服的本质并不复杂用户发一句话系统根据“知识”给出回答。这个知识沉淀在哪里就在数据库里。一张设计合理的FAQ表是整个系统准确率的基础比后面接什么大模型都重要。FAQ知识表字段类型说明idint主键questionvarchar(255)标准问题原文keywordsvarchar(255)关键词组用逗号分隔answertext标准回答内容category_idint分类ID方便分组管理hit_countint命中次数用于统计热门问题statustinyint是否启用updated_atdatetime更新时间question字段存标准问法keywords字段是给关键词匹配层用的。比如标准问题是“你们怎么退货”keywords可以填“退货,退款,退换,七天无理由”。回答不用太长但建议把关联的业务链接也放在answer里比如售后申请入口地址这样用户能直接在对话里点击跳转。会话表字段类型说明idint主键session_idvarchar(64)前端传入的会话标识visitor_namevarchar(50)访客昵称contactvarchar(100)联系方式留空表示未留下statustinyint0待接入 1机器人处理中 2人工处理中 3已结束last_active_atdatetime最后活跃时间判断会话是否超时created_atdatetime创建时间status字段很关键它是转人工逻辑的核心。系统初始化一个会话时自动设为1机器人正常应答就一直保持一旦命中转人工条件改为2会话就进入人工客服工作台用户主动关闭对话或客服手动结束改为3。消息表字段类型说明idint主键session_idvarchar(64)所属会话sendertinyint0用户 1机器人 2人工客服message_typetinyint1文本 2图片 3商品卡片contenttext消息内容created_atdatetime发送时间消息表用来支撑人工客服查看历史聊天记录。用户进入人工工作台后客服能看到这个用户之前和机器人聊了什么不必让用户重新描述一遍问题。这是提升转人工体验最有效的小细节但很多半成品客服系统都忽略了。2.2 会话ID与上下文保存无登录状态的访客对话前后端之间靠session_id建立联系。前端在用户第一次进入页面时生成一个随机字符串之后每次发消息都带上这个标识。PHP后端以这个字段为索引从消息表里拉取最近N条历史消息构建成上下文供应答引擎使用。这里有个实操细节上下文不是把所有历史消息全塞给大模型而是只取最近6到10条。原因有两个一是对话窗口只会展示最近的消息太早的内容对当前问题判断没有意义二是控制token消耗成本能省一点是一点。如果用户聊到一半重新刷新页面前端携带的session_id不变后端依旧可以拉取历史记录体验上就像对话从未中断。还要专门设计一个上下文过期策略。有些访客挂着页面半天不说话回来又问了一个和之前完全无关的问题。如果这种情况下还把早前的历史当上下文大模型容易混淆。我的做法是每次收到新消息时对比now()与last_active_at超过30分钟就让session_id对应的可选上下文重置为空开始一轮全新对话。这个阈值不用太死板可以根据业务节奏调整。2.3 会话消息的存储与清理聊天消息属于高频写入数据一张消息表常年累积会越来越大查询性能也会越来越慢。我通常会在建表时预留一个按月份拆分的思路比如消息表名按年月后缀建由代码里的一个getTableName方法自动计算。查询旧数据时直接查对应分表避免全表扫描。历史数据的保留也要有策略。一般的业务场景客服聊天记录保留90天到180天就足够了。定期清理过期数据可以用一条简单的DELETE语句配合cron任务执行删除created_at早于保留期限且status为已结束的会话及其消息。注意不要删除状态还是“人工处理中”的会话否则客服工作台里的进行中会话会突然消失出现事故。3. 多级应答引擎从关键词到大模型3.1 第一层关键词规则匹配关键词匹配是成本最低、速度最快的一层也是整个多级应答引擎的地基。虽然听起来简单但设计得好不好直接决定用户会不会觉得“这客服智障”。实现思路是拿到用户输入内容后去掉空格、标点、表情符号遍历FAQ表中的每条记录的keywords字段。每一个关键词都用strpos判断是否包含在用户输入里。如果多个关键词同时命中同一条FAQ则两条规则同时满足如果出现多关键词命中多条FAQ的情况则选择关键词覆盖数量最多、且与用户输入长度最接近的那条作为最优答案。function matchByKeyword($text, $faqList) { $maxHit 0; $best null; $text preg_replace(/[\s\p{P}\p{S}]/u, , $text); foreach ($faqList as $faq) { $hit 0; $keywords explode(,, $faq[keywords]); foreach ($keywords as $kw) { $kw trim($kw); if ($kw ! mb_strpos($text, $kw) ! false) { $hit; } } if ($hit $maxHit) { $maxHit $hit; $best $faq; } } return $maxHit 0 ? $best : null; }这里有两个很实在的细节。第一关键词不能太短最少两个汉字一个字的匹配噪声太大第二部分业务需要配置精确匹配模式比如用户问的是“你们支持支付宝吗”如果只按关键词“支付宝”去匹配很容易命中另一条“支付宝退款到账时间多久”的知识所以要给FAQ表增加一个match_type字段0表示模糊包含1表示完全匹配。完全匹配时直接用mb_strcasecmp判断不再做包含判断。这个层级的命中率其实完全取决于知识库的质量。我的实用建议是先整理日常客服聊天记录里最高频的30个问题把标准答案写清楚上线后再根据hit_count字段持续补充。只需要认真维护几十条高质量的FAQ命中率就能很快超过一半。3.2 第二层FAQ相似度匹配关键词匹配无法解决的场景是用户提问的措辞和库里标准问题差别很大关键词完全对不上。比如FAQ里写了“如何修改收货地址”用户实际说“我地址写错了能不能改一下”。这时候就需要相似度匹配。PHP本身没有内置高级的文本向量计算能力但用自带函数已经可以实现一个足够实用的相似度方案。最常用的两个函数是similar_text和levenshtein前者计算两个字符串的相似百分比后者计算编辑距离。对于中短文本这两者配合分词能获得不错的召回效果。我的做法是先把FAQ库里的标准问题做好分词存成一张faq_words表用户输入进来后先做同样的粗粒度分词再对每个词与faq_words表做相关性计算。分词不依赖任何扩展用最简单的“二元组切分”即可把文本切成每两个字符一个组合例如“退款流程”切成“退款”“款流”“流程”三个词条。官方没有专门的中文分词扩展也能友好支持。function splitToBigrams($text) { $len mb_strlen($text); if ($len 1) return [$text]; $words []; for ($i 0; $i $len - 1; $i) { $words[] mb_substr($text, $i, 2); } return $words; }将用户输入的大词集与每一条FAQ的标准问题大词集做集合交集计算Jaccard相似度交集词数除以并集词数。这个值超过0.35就可以视为候选答案超过0.5则可以直接作为最终答案。这个阈值是我用大量真实客服语料试出来的偏低一点召回高但偶尔误报偏高一点更精准但会漏掉语义接近的说法。你可以根据自己业务的容错程度在0.3到0.5之间调整。function matchBySimilarity($text, $faqList) { $inputWords splitToBigrams($text); $bestScore 0; $best null; foreach ($faqList as $faq) { $faqWords splitToBigrams($faq[question]); $inter count(array_intersect($inputWords, $faqWords)); $union count(array_unique(array_merge($inputWords, $faqWords))); $score $union 0 ? $inter / $union : 0; if ($score $bestScore) { $bestScore $score; $best $faq; } } return $bestScore 0.35 ? $best : null; }使用levenshtein辅助判断的价值在于处理错别字场景。用户把“发货”写成“发或”大词集匹配很可能失效但编辑距离只有1加一个小于等于2的阀值就能兜住。注意levenshtein对中文按字节处理建议先把文本转成UTF-8数组按字符比较否则中文编辑距离一直偏高阈值就失效了。3.3 第三层大模型API兜底前两层命中不了说明用户的问题是知识库里没有覆盖到的新问题。这时候才调用大模型API让模型基于一段系统提示词和最近几条会话上下文给出自然语言回复。这里有个重点系统提示词一定要写好业务边界。大模型只负责回答与你的产品、服务、业务相关的问题超出范围的提问回复“这个问题我暂时无法回答建议转人工客服”。如果不加这个限制模型会一本正经地帮你写诗、写代码、聊科幻电影这在线客服场景里非常出戏。对接大模型在PHP中通常用cURL实现。下面这段代码是标准的OpenAI兼容接口调用模板用到了流式和非流式两种模式生产环境建议使用流式输出提升首字响应速度。function callLLM($apiKey, $prompt, $history, $stream true) { $url https://api.your-llm-provider.com/v1/chat/completions; $messages []; // 系统提示词 $messages[] [ role system, content 你是XX官网的智能客服助手只负责回答与XX产品、订单、售后、物流相关的问题。 . 如果用户询问其他内容请友好地表示无法回答并建议转人工。回复控制在100字以内。 ]; // 历史上下文 foreach ($history as $item) { $messages[] [ role $item[sender] 0 ? user : assistant, content $item[content] ]; } // 当前用户消息 $messages[] [role user, content $prompt]; $payload json_encode([ model your-model-name, messages $messages, temperature 0.3, max_tokens 300, stream $stream ]); $ch curl_init($url); curl_setopt_array($ch, [ CURLOPT_POST true, CURLOPT_POSTFIELDS $payload, CURLOPT_HTTPHEADER [ Content-Type: application/json, Authorization: Bearer . $apiKey ], CURLOPT_TIMEOUT 30, CURLOPT_RETURNTRANSFER true ]); $response curl_exec($ch); $err curl_error($ch); curl_close($ch); if ($err) return [error $err]; $data json_decode($response, true); return $data[choices][0][message][content] ?? [error empty response]; }temperature参数建议调低至0.2到0.4之间客服场景需要稳定准确不需要天马行空的创造力。温度太高同一个问题两次回复会差异巨大用户会明显感觉客服“不太靠谱”。大模型这层是兜底不是主力。一定要在接口调用前设好速控逻辑同一个会话ID 10秒内最多调用一次大模型接口超过则直接返回“正在为您转接人工客服”。这么做能防住恶意刷接口也能避免重复提问造成的浪费。3.4 转人工的触发条件设计智能客服做得再好也必须留一个顺畅的“人机切换”出口。我总结了几类最常见且必须转人工的触发条件。用户显式表达转人工意图。用户发了“人工”、“转人工”、“找客服”、“投诉”、“电话”等词时直接进入人工队列。这个用关键词匹配就能实现而且一定要放在最前面判断。用户情绪强烈的关键词比如“差评”、“退款投诉”、“骂人”建议同时标记会话为高优先级客服工作台优先展示。同一会话连续未命中次数达到阈值。如果用户连续问了三轮引擎都没有给出可用的回复说明当前知识库已经完全无法满足该用户继续让机器人硬撑只会拉低体验。应当自动提示“连续未识别到有效答案已为您转接人工客服”并把会话ID写入人工待接入列表。用户主动点击“转人工”按钮。这个按钮必须放在前端对话窗口的常驻位置而不是藏在菜单里。很多人做客服系统容易忽略转人工的入口一定得让用户一眼看得到。设置一个悬浮按钮“转人工”常驻在聊天框右下角用户随时点一点就能触发。4. 核心功能实现前端对话窗口与PHP后端接口4.1 前端对话窗口的搭建前端我推荐直接用原生JavaScript配合简单的HTML/CSS实现不引入任何框架。这样无论你的主站是传统MVC还是前后端分离都可以把这套对话窗口内嵌进去甚至直接放在任何静态页面上。对话窗口的核心逻辑就三件事加载历史消息、发送消息、展示新消息。页面初始化时调用后端的history接口把最近10条消息渲染到窗口里用户点击发送后用fetch把消息POST到后端后端处理完后返回机器人回复再动态插入到消息列表里。div idchat-window div classchat-header span智能客服/span button idbtn-transfer转人工/button /div div idchat-body/div div classchat-input input typetext idmsg-input placeholder请输入您的问题... button idbtn-send发送/button /div /divfunction sendMessage() { const input document.getElementById(msg-input); const content input.value.trim(); if (!content) return; const sessionId localStorage.getItem(chat_session_id) || Math.random().toString(36).slice(2) Date.now(); // 先渲染用户消息 appendMessage(user, content); // 发送到后端 fetch(/api/chat/send, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ session_id: sessionId, message: content }) }) .then(res res.json()) .then(data { appendMessage(bot, data.reply); if (data.transfer_to_human) { showTransferNotice(); } }); localStorage.setItem(chat_session_id, sessionId); input.value ; }我建议把session_id用localStorage持久化而不是只用内存变量。用户在页面内跳转再返回时浏览器刷新两次也能沿用同一个会话身份客服端能完整看到这个访客的咨询历程。对于“正在输入”的交互状态可以在发消息后、收到响应前展示一个简短的“正在思考...”气泡。机器人回复通常需要0.3到2秒如果没有任何反馈直接空窗用户会以为系统卡住了。4.2 PHP后端接口设计与请求校验后端接口我就采用最简单的路由规则一个统一的入口文件处理所有请求。/api/chat/send接收消息/api/chat/history拉取历史简简单单两个接口足够支撑整套对话功能。// index.php 路由示例 $action $_GET[action] ?? send; switch ($action) { case send: handleSend(); break; case history: handleHistory(); break; default: exit(json_encode([code 404])); }在handleSend里首先必须校验请求参数session_id是否为合法字符串message是否为空message长度不能超过500字。这里很多人容易疏忽如果不对message做清洗直接拼入后续查询或大模型请求很容易出现注入或恶意刷接口的问题。统一响应格式也是细节。我常用的结构是code0成功 1失败 2转人工、reply机器人回复内容、session_id回传原值、timestamp服务器时间。前端收到code为2时显示转人工引导并弹出人工队列提示框。这样做的好处是逻辑清晰前端只需要判断code值不依赖解析自然语言。session_id建议统一用32位以内的十六进制字符串。不要用自增id作为对外标识容易被人遍历查询出别人的聊天记录也不要用过长的随机串会让数据库索引变大变慢。4.3 接口超时与异常兜底大模型接口调用是整套系统里最不稳定的环节网络抖动、服务商限流、模型负载升高都可能导致30秒甚至更久无响应。这里一定要做好兜底给cURL请求设置显式超时上限一般不超过30秒如果超时或返回空内容不要直接把空回复发给用户而是走一个预置好的兜底话术“抱歉我现在有点忙不过来请留下联系方式客服会尽快联系您”同时把该会话标记为转人工。后端还建议加一个全局异常捕获整个发送链路里任何未捕获的异常都不会让用户看到错误堆栈统一返回一个普通且友好的提示。这既是用户体验要求也是安全要求——错误信息里可能带着路径、数据库结构等敏感内容。4.4 敏感词过滤与内容合规无论机器人还是人工客服所有发给用户的回复都应当经过敏感词过滤。敏感词表不需要做很复杂一张简单的表存着词条和分类每次回复前遍历一遍即可。如果命中敏感词把回复替换成“抱歉我无法回答这个问题请转人工客服”并把整段对话标记为人工审核回调状态。前端发送的消息同样建议过滤因为入口如果放开了垃圾文本、恶意超长文本、广告刷屏会影响后续的检索和匹配效率。对每一条用户输入先做一次敏感词处理之后再进入应答引擎能避免很多不必要的麻烦。5. 常见问题与排查技巧实录5.1 高频故障排查速查表我自己搭过几次客服系统也在生产环境踩过不少坑。下面这张表是从多个项目里整理出来的高频故障和对应的排查方向按出现概率排序。现象可能原因排查与解决机器人不回复PHP-FPM超时或接口报错查看PHP错误日志与Nginx error.log用curl直接测接口看返回回复特别慢每次请求都调大模型确认是否走了多级引擎的前两层增加FAQ命中率转人工不生效会话状态判断逻辑写错检查会话status字段是否正常更新人工队列查询条件是否正确历史消息加载不出来会话ID不一致前端session_id是否持久化后端查询是否用错了索引同一个人两个会话页面多标签导致session_id不同用localStorage统一存储多标签页共享同一session_id大模型返回乱码接口编码问题确认API返回的字符集强制转UTF-8输出数据库连接数爆满聊天请求高并发给数据库读写加连接池或限制并发通过Redis队列异步处理敏感词误伤正常回复词表太宽泛增加白名单机制或对连续出现的重复字符单独处理5.2 session会话丢失的经典坑我遇到过的最经典的坑是用户在前一个页面咨询到一半跳转到另一个页面继续咨询前端重新生成session_id导致同一个用户出现两个会话。后台客服能看到一个用户有两个甚至三个会话记录极为混乱。排查下来原因很直白前端用了内存缓存session_id页面一跳转就丢失。解决方案就是我前面提到的localStorage持久化。但还要注意另一个坑同一个浏览器开多个标签页如果不特殊处理localStorage里存的永远是第一个标签页创建的session_id用户在两个标签页里同时咨询不同的客服会话会被合并成同一个会话。处理方式是在会话初始化时给session_id加一个页面标识后缀比如[会话随机串]-[页面编号]后端看到后缀不同就当作新会话但主会话标识不变保证人工客服还能定位到同一个人。5.3 知识库扩充的批量导入方法初期一条条录入FAQ还能接受到后期客服团队一整理就是几百条手动录入的效率太低。我提供了一个简单的批量导入思路做一个仅限管理员访问的上传页面接收CSV文件字段顺序为question,keywords,answer,category_id。function importFaqCsv($filePath) { $handle fopen($filePath, r); // 跳过表头 fgetcsv($handle); $count 0; while (($row fgetcsv($handle)) ! false) { if (count($row) 3) continue; $stmt $pdo-prepare( INSERT INTO faq (question, keywords, answer, category_id, status) VALUES (?, ?, ?, ?, 1) ); $stmt-execute([$row[0], $row[1], $row[2], $row[3] ?? 0]); $count; } fclose($handle); return $count; }CSV文件导入前最好在程序里先做编码检测Windows环境下保存的CSV通常是GBK编码如果直接导入PHP应用中文很容易变成乱码。统一用mb_convert_encoding转成UTF-8后再执行插入这个步骤省掉的话后期还要反复清洗数据。导入完成之后建议立即触发一次缓存清理。FAQ数据如果已经做过缓存处理导入后不清理缓存线上搜索引擎依然拿不到新数据用户当然也得不到新答案。5.4 流式输出与长回复的取舍我前面提过可以直接用非流式输出也就是机器人等整个回复生成完毕后再一次性返回技术难度最低。但实际体验过你会发现用户等2到3秒拿到完整回复这个等待感是能清晰感知的。如果希望体验更好可以在后端改为流式输出即SSEServer-Sent Events方式。用PHP实现SSE其实不复杂核心就是将响应头设置为Content-Type: text/event-stream然后循环读取大模型接口的流式返回内容每拿到一小段就echo并flush输出到前端。前端用EventSource或fetch的ReadableStream逐段接收打字机一样显示出来。流式输出的代价是代码复杂度上升且需要处理好连接中断场景——用户中途关闭页面时后端应对应终止cURL流转否则PHP-FPM进程会一直挂着消耗资源。如果你这个是第一次搭建的客服系统我建议先用非流式跑通全部流程等整体稳定了再升级流式体验不然排查问题时会多一个变量。5.5 关于数据库表索引的三个建议聊天消息表写入频繁但查询也频繁索引设计直接影响响应速度。建议按以下三个方向加索引消息表的session_id字段必须加普通索引这是查询频率最高的字段会话表的status last_active_at组合索引用于人工工作台“进行中会话”列表的排序展示faq表的keywords不建议直接建索引因为关键词匹配用的是PHP模糊查询需要配合全文索引方案或者将关键词拆分单独建词表关联否则数据量到万级以后查询性能会明显下降。如果你的FAQ表超过一万条关键词匹配用PHP逐条遍历的性能就不够看了。到那个量级应该把关键词匹配改成MySQL的LIKE前缀查询或者直接用全文索引。不过这类系统做到万级FAQ时通常意味着团队规模已经不小了到那时再考虑引入一个专门的企业级客服产品更合适。6. 关于这套系统的进一步扩展建议这套方案做出来的客服系统完成度已经可以支撑一个小型网站或店铺的日常客服需求。但还有几个方向的扩展几乎是必经之路提前了解可以少走弯路。第一个是数据看板。在FAQ表里增加hit_count、miss_count等统计字段在后端加几个简单的统计接口今日总对话数、机器人解决率、TOP10高频问题、转人工率。这些数据可以做成内部的管理页面店家根据这些数字每周补充FAQ机器人解决率会肉眼可见地往上涨。第二个是多轮上下文增强。目前的上下文机制是基于消息记录的简单组装后续可以针对特定业务场景设计主动追问逻辑比如检测到用户询问“退款”追问一句“您是想退货还是想退款”再根据回答选择不同的FAQ路径。这种分支逻辑建议放在PHP侧做状态流转不要都丢给大模型。第三个是基于数据分析的预判式客服。如果网站有用户行为数据可以在用户浏览特定页面即将跳出时主动弹出一个对话窗口内容预埋好了该页面最常见的问题解答。这类功能用来降低跳出率和提升转化率效果显著但实现起来需要前端配合埋点工程量不算大收益却很明显。我自己使用这套系统的真实感受是智能客服的价值不在于模型多大而在于业务知识的组织方式。把一个FAQ库维护得足够精准比接一个再大的模型都踏实。初期不要追求“什么都能答”先把高频问题答对、答稳再逐步扩大知识边界。这套PHP方案虽然简单但每一层都有明确的降级策略和成本控制逻辑很适合小团队长期迭代。最后分享一个小技巧可以把每个会话的最后一轮机器人回复同时发一封邮件通知给值班客服邮件里带上用户提问内容和回复内容。这样做一方面让你随时掌握客服机器人动态另一方面也能帮助持续优化FAQ质量。运行一周后你会明显发现需要人工接管的会话数量在逐渐减少。