简介这是一份面向 PHP 开发者和运维人员的在线客服系统源码包基于 ThinkPHP 框架开发核心亮点是接入 AI 知识库让客服代表能够借助智能匹配快速回应客户咨询显著降低人工响应压力。资源附带完整安装教程覆盖 fileinfo、redis 扩展安装以及 pcntl_signal、pcntl_fork 等函数禁用配置便于在常见服务器环境中快速部署适合需要自建、二次开发或学习企业级客服系统的团队。压缩包共 2010 个文件整体约 68.72MB其中以 613 个 js、368 个 html、258 个 css 等前端资源为主负责客服界面与交互展示另有 131 个 php 文件承担后端业务逻辑配合 151 个 md、141 个 txt 说明文档和 sql、sh 等辅助脚本方便阅读代码、初始化数据库和自动化部署。目前已有 137 人学习下载。整套源码提供了运营级在线客服的实际交互界面、后端逻辑和 AI 知识库接入思路能帮助开发者快速理解知识问答匹配流程避免从零搭建节省大量调研与排错时间。1. 在线客服接知识库别再让客服半夜翻文档了任何一个做过客服系统的开发者应该都有过这种体验凌晨三点访客进来咨询问的是产品手册第 37 页的某个参数客服只能一边道歉一边翻 PDF几十份文档看完天都亮了。这个项目解决的就是这个问题——在 ThinkPHP 6 的在线客服系统里接入 AI 知识库让访客提问先走一遍向量检索从企业文档里命中片段再由大模型组织成带来源的回答答不了的时候再无缝转回人工坐席。它不是一个 PPT 级别的 Demo而是一套能把消息推送、会话分配、知识库入库、AI 回复回流串起来的参考实现。适合已经用 PHP 搭过客服系统、想低成本加 AI 能力的开发者也适合拿到现成客服源码、正发愁 AI 那块怎么改的二次开发人员。2. 架构设计ThinkPHP 6 Workerman 的实时链路是怎么串起来的2.1 从访客提问到 AI 回流一条消息走了哪几站整个消息链路如果画出来大概是这样的走向访客在浏览器聊天窗点发送消息通过 WebSocket 直接到达后台的 Workerman 常驻进程而不是走传统的 HTTP 请求。Workerman 收到消息后按照消息类型做一次事件分发把文本消息丢进一个队列队列后面挂着一个 AI worker 进程它负责做知识库检索、拼提示词、请求大模型再把结果异步推回同一个 WebSocket 连接最后落到前端聊天窗。选 Workerman 而不是 Swoole是因为 ThinkPHP 6 官方带 Workerman 的适配包不用改框架内核包一层命令就能启动二次开发成本低。同时 Workerman 基于 PHP 原生实现不像 Swoole 需要编译扩展对本地跑通和生产部署都友好得多。队列我选的是 Redis 的 List 结构理由很朴素MySQL 和 Redis 本来就是这个项目的基础设施没必要为一个队列服务额外引入一套中间件等消息量大到压垮 Redis 了再考虑换独立队列也不迟。2.2 目录结构每一层代码管什么看这棵目录树就够了项目的目录结构保留了 ThinkPHP 6 的标准骨架几个关键目录在原有结构上扩展了客服和知识库两个业务域。app/ ├── controller/ │ ├── Chat.php # WebSocket 入口控制器负责事件分发 │ ├── Conversation.php # 会话的 HTTP 接口创建会话、查历史、结束会话 │ └── Knowledge.php # 知识库管理接口上传文档、触发切分、增量入库 ├── service/ │ ├── ChatGateway.php # Workerman 事件绑定、连接映射、消息推送 │ ├── SessionDispatcher.php # 坐席分配策略AI 优先还是人工优先 │ ├── Segmentator.php # PDF/Word/TXT 文本抽取与切分 │ ├── VectorStore.php # embedding 生成与向量检索可切 ES / 专用向量库 │ └── LlmClient.php # 大模型 API 封装统一提示词模板和超时控制 ├── model/ │ ├── Session.php # 会话主表 │ ├── Agent.php # 坐席表 │ ├── Visitor.php # 访客表 │ └── KnowledgeSegment.php # 知识片段表存原文 embedding 向量 ID └── common/ └── ai_utils.php # 切分工具、相似度计算、引用格式化等公共函数需要留意的两个目录service是业务核心所有不能直接归属到 Controller 的逻辑都在这里common放的是跟框架绑定不深的纯函数比如文本切分和相似度分数计算方便单独做单元测试。chatserver是独立进程入口目录Workerman 启动命令指向的就是这个文件后面会详细讲。2.3 数据表设计会话、坐席、知识片段三张核心表在线客服这个场景最忌讳的是把会话状态全放 Redis一旦 Redis 崩了客服就变成瞎子。所以项目里 Redis 只放在线状态和临时排队队列持久化数据全部落 MySQL。核心三张表的结构如下。-- 会话主表一个访客在同一时刻只会有一个进行中的会话 CREATE TABLE session ( id int(11) unsigned NOT NULL AUTO_INCREMENT, visitor_id int(11) NOT NULL COMMENT 访客ID, agent_id int(11) DEFAULT NULL COMMENT 当前接手的坐席ID为空表示AI接待中, status tinyint(1) NOT NULL DEFAULT 0 COMMENT 0排队中 1进行中 2已结束, channel varchar(20) NOT NULL DEFAULT web COMMENT 渠道标识默认web, last_msg_at datetime DEFAULT NULL COMMENT 最后一条消息时间用于超时归档, created_at datetime NOT NULL, PRIMARY KEY (id), KEY idx_visitor_status (visitor_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 坐席表客服账号与 Workerman 的 client_id 做绑定 CREATE TABLE agent ( id int(11) unsigned NOT NULL AUTO_INCREMENT, name varchar(50) NOT NULL COMMENT 坐席显示名, workload tinyint(1) NOT NULL DEFAULT 0 COMMENT 当前接待数超阈值时不分配新会话, max_concurrency tinyint(1) NOT NULL DEFAULT 5 COMMENT 最大并发接待数, online tinyint(1) NOT NULL DEFAULT 0 COMMENT 是否在线, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 知识片段表切分后的一段文本 向量ID CREATE TABLE knowledge_segment ( id int(11) unsigned NOT NULL AUTO_INCREMENT, doc_id varchar(64) NOT NULL COMMENT 文档唯一ID用于关联源文件、批量删除, content text NOT NULL COMMENT 切分后的文本片段, vector_id varchar(128) DEFAULT NULL COMMENT 向量数据库中的记录IDNULL表示尚未完成入库, source_page varchar(40) DEFAULT NULL COMMENT 原文页码/文件名用于客服查看引用来源, created_at datetime NOT NULL, PRIMARY KEY (id), KEY idx_doc_id (doc_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;三个字段值得解释session.status的 0/1/2 三段状态决定了消息进来后是进 AI 队列还是进人工队列agent.workload是硬性负载超过max_concurrency的会话在分配时会被跳过这个策略直接决定客服体验knowledge_segment.vector_id是联向量数据库的钥匙实际文本存在 MySQL向量存在向量库两边靠这个 ID 对齐删除文档时也是靠它做批量清理。3. 核心模块拆解一条消息从进入到返回的完整链路3.1 Workerman 事件分发onMessage 里到底干了什么Workerman 启动后所有客户端连上来的连接都会触发事件回调。项目里把这些回调统一收进ChatGateway前端发来的每一条消息都带着一个type字段网关按类型分发。核心代码在启动脚本里绑定事件。?php // chatserver.php —— Workerman 启动入口 use Workerman\Worker; use Workerman\Connection\TcpConnection; require_once __DIR__ . /vendor/autoload.php; $worker new Worker(websocket://0.0.0.0:8282); $worker-count 4; // 按 CPU 核数设定进程数 $worker-onConnect function (TcpConnection $connection) { // 连接建立后先不发业务数据等待前端把 token 和 client_id 发上来 }; $worker-onMessage function (TcpConnection $connection, $data) { $payload json_decode($data, true); $type $payload[type] ?? unknown; $token $payload[token] ?? ; // 统一入口校验身份 - 解析出当前访客或坐席 - 分发 $gateway new \app\service\ChatGateway(); $identity $gateway-auth($token); switch ($type) { case visitor.message: // 访客发消息创建/复用会话推进队列 $gateway-handleVisitorMessage($identity, $payload[content]); break; case agent.message: // 坐席回消息直接推送不走 AI $gateway-handleAgentMessage($identity, $payload[content]); break; case session.transfer: // 访客或坐席触发转人工 $gateway-transferToAgent($identity, $payload[session_id]); break; default: $connection-send(json_encode([error unknown_type])); } }; $worker-onClose function (TcpConnection $connection) { // 清除连接映射坐席下线要广播给所有排队中的访客 }; Worker::runAll();这里有两个关键参数worker-count设成 4 是常见的起步值按 CPU 核心数调但别直接设成核心数因为业务里还有 curl 调用大模型接口阻塞时间较长进程太少会把连接拖死onConnect里不主动发数据是刻意为之前端的 token 校验不能依赖 WebSocket 的原始握手一定要在业务层再做一次否则伪造连接就能冒充客服。分发逻辑里visitor.message是最重要的分支它把消息写库、更新会话状态、再决定走 AI 还是人工这个过程在下一节展开。3.2 会话分配AI 优先还是人工优先策略藏在哪会话分配是客服系统里最容易被低估的模块。这个项目的策略是访客进来先建会话状态置为 0排队中然后检查是否有空闲坐席有就转给坐席没有就判断 AI 是否启用启用则自动进入 AI 接待状态。AI 接待期间访客随时可以点“转人工”把会话重新扔回人工队列。?php // app/service/SessionDispatcher.php —— 会话分配策略 namespace app\service; use app\model\Session; class SessionDispatcher { public function dispatch(int $sessionId, int $visitorId): array { $session Session::find($sessionId); // 优先找空闲坐席在线、未超负载、最近一次分配时间最早 $agent $this-pickIdleAgent(); if ($agent) { $session-agent_id $agent[id]; $session-status 1; // 进行中 $session-save(); // 推送坐席你有新会话 $this-notifyAgent($agent[id], $sessionId); return [handler agent, agent $agent]; } // 没有坐席看 AI 开关 if (config(ai.enabled)) { $session-status 1; $session-save(); return [handler ai, session_id $sessionId]; } // 都没有保持排队 return [handler queue]; } private function pickIdleAgent(): ?array { // 用 Redis 维护一份在线坐席的实时 workload $agents redis()-hgetall(agents:online); if (!$agents) return null; foreach ($agents as $agentId $workload) { if ($workload $this-maxConcurrency($agentId)) { return [id $agentId]; } } return null; } }pickIdleAgent里的redis()-hgetall(agents:online)是状态维护的关键坐席上线、接待、结束会话都要同步更新这个哈希表。用 Redis 哈希而不是直接查 MySQL是因为分配动作发生在消息链路上每秒可能几十次每次查 MySQL 会把数据库拖垮。代码里还要注意maxConcurrency($agentId)的取值来自agent表且带缓存避免每次分配都查库。AI 开关的配置放在config/ai.php第三方接入时不用改代码只改配置就行。3.3 知识库入库从 PDF 到向量的全流程知识库接入最容易翻车的环节不在模型而在“文档切得好不好”。这个项目默认以 500 字为一个片段片段之间保留 100 字重叠目的是避免一句话被切到两个片段里导致检索时语义断裂。完整流程上传文档到本地临时目录解析文本切分成片段逐条生成 embedding写入向量库最后把片段原文和 vector_id 落 MySQL。切分函数如下。?php // app/common/ai_utils.php —— 文档切分按段落和标点做两次兜底 function split_document(string $text, int $chunkSize 500, int $overlap 100): array { // 第一步按段落粗分段落是天然的语义边界 $paragraphs preg_split(/\n/u, trim($text)); $chunks []; $buffer ; foreach ($paragraphs as $para) { $para trim($para); if (mb_strlen($para) 0) continue; // 当前段落已超上限先把缓冲区塞进结果再单独拆长段 if (mb_strlen($buffer) mb_strlen($para) $chunkSize) { if ($buffer ! ) { $chunks[] $buffer; $buffer ; } // 单个段落超过上限按句子边界拆 if (mb_strlen($para) $chunkSize) { $sentences preg_split(/(?[。])\s*/u, $para); $tmp ; foreach ($sentences as $sent) { if (mb_strlen($tmp) mb_strlen($sent) $chunkSize $tmp ! ) { $chunks[] $tmp; $tmp ; } $tmp . $sent; } if ($tmp ! ) $chunks[] $tmp; continue; } $buffer $para; continue; } $buffer . \n . $para; } if ($buffer ! ) $chunks[] $buffer; // 第二步有重叠需求的给每个片段附加上一片尾部的 overlap if ($overlap 0 count($chunks) 1) { foreach ($chunks as $i $chunk) { if ($i 0) continue; $prev $chunks[$i - 1]; $tail mb_substr($prev, -$overlap); $chunks[$i] $tail . $chunk; } } return $chunks; }这个函数的两个细节很重要。第一preg_split用(?[。])处理中文长段落它只按中文句末标点切分不会把英文缩写或 URL 给切成两半第二overlap只向后附加不向前附加保证每个片段都是“自己的开头 上一片段的尾部”检索时用片段原文去匹配召回会稳很多。切分完成后每个片段调用 embedding 接口得到的向量存入向量库vector_id回填到knowledge_segment表。3.4 AI 回复回流提示词组装与引用返回AI 回复是本项目对外展示能力最直观的部分。召回阶段先从知识库里取出和访客问题相似度最高的三个片段再把这些片段拼进系统提示词要求大模型“只依据提供的片段回答不要编造引用来源用 [1][2] 标记”最后把完整响应推给前端。核心逻辑在LlmClient。?php // app/service/LlmClient.php —— 检索 组装提示词 请求大模型 namespace app\service; use app\model\KnowledgeSegment; class LlmClient { public function ask(string $question, int $sessionId): array { // 1. 向量检索取相似度最高的 3 个片段 $vectorStore new VectorStore(); $hits $vectorStore-search($question, 3); if (count($hits) 0) { // 知识库没命中直接返回提示转人工 return $this-noHitResponse($sessionId); } // 2. 组装引用文本 $context ; $sourceTags []; foreach ($hits as $i $hit) { $tag [ . ($i 1) . ]; $context . $tag . $hit[content] . \n; $sourceTags[] [ tag $tag, source $hit[source_page], score round($hit[score], 4), content mb_substr($hit[content], 0, 80) ]; } // 3. 组装提示词并调用大模型 $prompt 你是某公司的在线客服助手。请只依据下列资料片段回答用户问题。\n . 如果资料片段不足以回答问题请直接回答\资料不足已为您转人工\。\n . 回答时在句子末尾用 [1][2] 标注引用的片段编号。\n\n . 资料片段 \n . $context . \n 用户问题 \n . $question; $answer $this-callLlm($prompt); // 4. 写库、推送回前端 $messageId $this-saveMessage($sessionId, $answer, ai); return [ message_id $messageId, content $answer, sources $sourceTags, handler ai ]; } }callLlm内部就是一次普通的 HTTP 调用重点在三个超时参数上连接超时 3 秒、请求超时 30 秒、读取超时 60 秒。客服场景下访客等回复的耐心不超过 40 秒30 秒的请求超时是上限超过这个时间直接返回“AI 响应超时已为您转人工”避免前端一直白屏。sourceTags里保留了命中的原文片段和相似度分数这是二次开发时做“AI 回答可信度展示”的基础。最后saveMessage把 AI 回复写入message表和访客消息放在同一条会话流里保证客服接手时能看到完整的上下文。4. 部署与排查从零到生产跑的完整步骤和三个常见坑4.1 本地环境准备与依赖安装先把基础环境凑齐PHP 8.1 以上必须装pcntl、posix、event三个扩展Workerman 常驻进程依赖它们做信号处理和事件轮询。然后在项目根目录执行依赖安装。# Composer 安装依赖 composer install # 生成 .env 并写入数据库、Redis 配置 cp .env.example .env vim .env # 初始化数据库表 php think migrate:run # 启动 WebSocket 服务前台调试模式生产用 -d 守护进程 php think workerman start若pcntl扩展缺失启动时会报Call to undefined function pcntl_signal这个错误在 PHP 官方文档里有明确说明但很多新手第一次装扩展时容易装错版本建议直接用phpize重新编译不要用系统自带的旧版。Workerman 服务启动后用任意 WebSocket 调试工具连一下ws://127.0.0.1:8282能收到连接确认即算通过。这里需要提醒本地调试时 8282 端口是裸的 WebSocketNginx 代理的路径会不一样后面生产部署再讲。4.2 AI 服务与向量库配置AI 服务配置集中在.env里看起来像这样。AI_ENABLEDtrue AI_API_URLhttps://api.example.com/v1/chat/completions AI_API_KEYsk-your-key-here AI_MODELyour-model-name AI_EMBEDDING_URLhttps://api.example.com/v1/embeddings AI_EMBEDDING_MODELtext-embedding-v1 AI_TEMPERATURE0.3其中AI_TEMPERATURE建议固定 0.3这是客服场景血泪经验换来的值。温度太高AI 会自由发挥把“资料不足”也说成“可能是”客服接手时反而要花时间纠错0.3 以下又会让回复过于刻板连“你好”都说得像机器人。向量库如果用的是 Elasticsearch需要先建好knowledge_segment索引并配置 dense_vector 字段如果用的是轻量级向量库直接在配置文件里写地址和集合名即可。4.3 Nginx 反向代理与常驻进程生产环境访问 WebSocket 域名必须走 Nginx 的ws代理否则 80/443 端口无法直连 8282。Nginx 站点配置里加一段 upstream 和 location 就能解决。# 生产环境 WebSocket 代理 map $http_upgrade $connection_upgrade { default upgrade; close; } upstream ws_backend { server 127.0.0.1:8282; } server { listen 443 ssl; server_name chat.example.com; location /ws { proxy_pass http://ws_backend; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_set_header Host $host; proxy_read_timeout 3600s; } }proxy_read_timeout 3600s必须设置默认的 60 秒会让长时间挂机的客服连接被 Nginx 掐断前端表现为“连接已断开刷新页面”。另外要确认 PHP 进程的memory_limit至少在 256MWorkerman 每个进程要常驻内存默认的 128M 在加载 TP6 框架后很容易被挤爆。4.4 三个常见排查记录先说第一个最典型的坑运行 Workerman 后提示“Please run this script as root”或者端口被拒绝。现象是进程起不来日志里能看到 socket 创建失败的堆栈。原因是 8282 端口被系统服务占用或者当前用户没有监听权限。解决方法是先lsof -i:8282查占用换到 8000 以上的其他端口注意避开 Redis 和 MySQL 的端口段如果是在容器环境里还要确认宿主机端口映射没被占。。第二个坑是前端连上了 WebSocket但发消息后 AI 不回。日志显示消息进了队列worker 也消费了但推送时client_id查不到对应连接。原因通常是访客的连接映射存在 Redis但 Redis 里的 key 过期时间设得太短坐席挂机超过 10 分钟就被清理了。解决方法是把连接映射的过期时间改成 24 小时同时前端加心跳机制每 30 秒发一个type: ping刷新过期时间。我在多个项目里见到过这个问题都是同一套症状改完 TIME 参数就正常了。第三个坑更像玄学同样的知识库有的问题召回很准有的问题召回结果驴唇不对马嘴。原因基本不在部署而在切分和检索的阈值设置。常见做法是把相似度阈值从 0.7 调到 0.3 测试如果调到 0.3 都召不回那就是文档切分出了问题——检查一下是不是把整份 100 页 PDF 塞进了一个片段导致 embedding 模型无法聚焦。解决方法是去掉重叠切分强制按段落切并限制单片段不超过 800 字。这一步我在做知识库类项目时踩过不止一次再往后就养成了“先看片段、再测召回”的习惯。5. 二次开发改造接入既有客服系统时要动的几个地方5.1 把手动转人工改成智能分流默认实现里访客必须手动点“转人工”才能脱离 AI。但真实场景下访客不会管你是不是 AI他会直接发一句“人工客服”或者连续追问。改造思路很简单在 AI 回复的判定里加一个transfer_keywords配置把“人工”“客服”“投诉”等词条做成正则消息命中后直接跳过 AI 逻辑进入人工分配。代码在LlmClient::ask()之前加一次预检查即可。public function shouldTransferToAgent(string $question): bool { $keywords [人工, 转人工, 投诉, 真人, 客服电话]; foreach ($keywords as $kw) { if (mb_strpos($question, $kw) ! false) { return true; } } return false; }5.2 知识库热更新定时或事件触发的增量入库首次全量入库之后企业文档每周都在变不可能手动删除重建。改造做法是给knowledge_segment表加一个updated_at字段写一个定时任务每 10 分钟扫一遍文档目录比对文件 MD5变更的文档重新解析、生成向量并删除旧 vector_id。增量入库时建议先把新片段写入临时表再一次性替换不要边扫边删否则检索时会跑到半空状态。5.3 上下文与敏感词过滤默认实现是“单轮问答”访客上一条问“价格”这一条问“那保修呢”AI 是无法理解“那”指代什么的。改造思路是把最近 5 条访客消息和 AI 回复一起拼进提示词用\n\n分隔在 Call LLM 前插入。敏感词过滤建议放在消息进入队列之前用正则匹配命中直接拦截包含广告词、联系方式等典型场景。5.4 会话结束与工单沉淀访客会话结束后把 AI 的回答、访客的满意度评价、坐席的补充说明整理成一份工单写入ticket表。这个模块对售后团队特别有用AI 会话中高频出现的问题可以作为知识库新内容的来源形成“访问 - 解答 - 沉淀 - 再访问”的闭环。工单表的设计不必复杂字段带上session_id、summary、priority就够了。6. 让 AI 回复带引用来源一个值得优先落地的改造技巧客服系统接入 AI最大的信任危机是“AI 说的到底靠不靠谱”。与其让客服蒙眼转达不如在回复里直接带上引用来源片段让客服和访客都能点开查看原文。改造这个方法完全不涉及模型变更只需改两处一是在LlmClient返回的sources里保留每个命中的片段原文就是sourceTags的content二是在前端聊天窗把引用渲染成可点击的卡片。前端按sources数组渲染每项只显示tag和来源文件名。点击卡片时向后端发一个 HTTP 请求拿到完整片段原文// 前端引用来源卡片点击后拉取完整片段 async function fetchSourceDetail(segmentId) { const resp await fetch(/api/knowledge/segment?id${segmentId}); const data await resp.json(); if (data.code 0) { showModal({ title: 引用来源 [${data.data.tag}], content: data.data.content, footer: 相似度 ${(data.data.score * 100).toFixed(1)}% }); } }后端对应接口直接查knowledge_segment表把content和score返回。这个接口注意加权限校验避免站内文档被爬虫拖走最简单做法是在返回前校验会话归属访客只能查看自己所在会话中产生的引用。这里的核心价值不是“做出来”而是把 AI 的黑匣子打开一个角。客服看到引用后能快速判断 AI 是不是真的理解了文档访客也能看到企业文档确实有这样的承诺双方对 AI 的信任度都会明显提升。我后来在公司内部推这个功能时客服的使用率比之前翻了不止一倍。从那以后我每次改 AI 接入逻辑都强制走一遍同一套验证流程先看原始命中的片段是什么再看 AI 生成的结果依赖了哪个片段最后对照前端展示的引用卡片确认三者对得上。这个过程说不上多高级但能挡掉不少“AI 答错但没人知道错在哪”的事故。希望这个技巧也能帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?