
簡介這份資源是一套基于PHP與ThinkPHP框架的運營級在線客服系統(tǒng)源碼重點解決客服系統(tǒng)接入AI知識庫的問題適合有一定PHP基礎(chǔ)、希望搭建企業(yè)級智能客服的開發(fā)者與運維人員。壓縮包共2010個文件約68.72MB以613個js、368個html、258個css等前端資源為主配合131個php業(yè)務(wù)代碼、3個sql數(shù)據(jù)庫腳本及大量xml、json、md配置與說明文檔整體結(jié)構(gòu)完整便于二次開發(fā)與部署。資源附帶安裝教程涵蓋fileinfo、redis插件安裝以及pcntl_signal、pcntl_fork等禁用函數(shù)的配置說明幫助讀者快速完成環(huán)境搭建。目前已有137人學習下載。通過該源碼讀者可掌握AI知識庫與在線客服的對接思路理解NLP問答匹配、緩存加速與ThinkPHP模塊化開發(fā)并獲得一套可直接運行、便于擴展的客服系統(tǒng)實踐參考。1. 從工單到知識庫PHP 在線客服接入 AI 的落地路徑很多團隊做在線客服系統(tǒng)第一版都是「人工坐席 常見問題靜態(tài)頁」用戶問一句、客服查半天響應(yīng)慢、口徑還不統(tǒng)一。這套基于 PHP 的在線客服接入 AI 知識庫方案解決的就是這個斷層把歷史工單、產(chǎn)品文檔、FAQ 沉淀成可檢索的知識庫用戶提問時先由 AI 召回并生成回答答不上來再轉(zhuǎn)人工。它適合用 ThinkPHP 做后臺、已經(jīng)有一套客服工單表結(jié)構(gòu)、想低成本加一層智能問答的團隊。整套邏輯不依賴重型框架核心是「知識入庫 → 向量或關(guān)鍵詞召回 → 拼 Prompt → 調(diào)模型 → 回寫會話」這條鏈路PHP 側(cè)負責調(diào)度和兜底模型側(cè)只做生成。下面按落地順序拆開講從環(huán)境到代碼到踩坑能直接照著改。2. 知識庫接入前的環(huán)境與數(shù)據(jù)準備ThinkPHP 側(cè)要先把地基打平2.1 為什么選 ThinkPHP 做調(diào)度層而不是純腳本在線客服系統(tǒng)本身有會話、坐席、工單、消息幾張核心表ThinkPHP 的模型和查詢構(gòu)造器能直接復用現(xiàn)有連接不用為了接 AI 再搭一套服務(wù)。常見做法是把 AI 問答封裝成一個 Service 類在消息入庫的鉤子里調(diào)用而不是單獨起一個進程。這樣會話上下文、用戶身份、歷史消息都能順手拿到省掉一次跨服務(wù)查詢。選型上不建議一上來就上向量數(shù)據(jù)庫中小體量用 MySQL 全文索引或關(guān)鍵詞匹配先跑通等知識條目過萬再考慮 embedding 方案否則運維成本會吃掉收益。環(huán)境上PHP 建議 8.0 以上ThinkPHP 6.x擴展需要curl、mbstring、json、pdo_mysql。如果本地用 phpstudy 或小皮面板注意 PHP 版本和 CLI 版本要一致否則命令行跑遷移腳本時會報擴展缺失。下面先建知識庫表。-- 知識條目表存 FAQ、文檔片段、歷史優(yōu)質(zhì)回答 CREATE TABLE kb_article ( id int unsigned NOT NULL AUTO_INCREMENT, title varchar(255) NOT NULL DEFAULT COMMENT 條目標題, content text NOT NULL COMMENT 正文用于召回和拼 Prompt, keywords varchar(500) NOT NULL DEFAULT COMMENT 逗號分隔關(guān)鍵詞提升召回, category_id int unsigned NOT NULL DEFAULT 0 COMMENT 分類對應(yīng)產(chǎn)品線, status tinyint NOT NULL DEFAULT 1 COMMENT 1啟用 0停用, updated_at int unsigned NOT NULL DEFAULT 0, PRIMARY KEY (id), KEY idx_category (category_id), FULLTEXT KEY ft_content (title,content) WITH PARSER ngram ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENTAI知識庫條目;邏輯說明content是召回主體keywords用來補全文索引對短詞不敏感的問題category_id讓不同產(chǎn)品線互不串味。WITH PARSER ngram是 MySQL 5.7 對中文全文檢索的支持沒加這個中文分詞基本失效。參數(shù)上ngram_token_size默認 2意味著至少兩個漢字才能命中單字查詢會漏這是后面避坑章要展開的點。2.2 把歷史工單清洗成知識條目直接拿原始工單喂給模型效果很差因為工單里有大量「你好」「在嗎」「轉(zhuǎn)人工」這類噪聲。清洗思路是只保留客服最終回復且用戶未再追問的會話把用戶首問作為title客服回復作為content再從首問里抽關(guān)鍵詞。下面是一個批量導入腳本。?php // 命令行執(zhí)行php think kb:import namespace app\command; use think\console\Command; use think\console\Input; use think\console\Output; use think\facade\Db; class KbImport extends Command { protected function configure() { $this-setName(kb:import)-setDescription(清洗工單并導入知識庫); } protected function execute(Input $input, Output $output) { // 只取已關(guān)閉且客服有最終回復的會話 $rows Db::name(chat_session) -alias(s) -join(chat_message m, m.session_id s.id) -where(s.status, 2) -where(m.role, agent) -field(s.id, s.first_question, m.content, m.create_time) -order(m.create_time desc) -limit(2000) -select(); $batch []; foreach ($rows as $r) { $q trim($r[first_question]); $a trim(strip_tags($r[content])); // 過濾過短和純寒暄內(nèi)容 if (mb_strlen($q) 4 || mb_strlen($a) 10) { continue; } if (preg_match(/^(你好|在嗎|轉(zhuǎn)人工|有人嗎)/u, $q)) { continue; } $batch[] [ title mb_substr($q, 0, 100), content $a, keywords $this-extractKeywords($q), category_id 1, status 1, updated_at time(), ]; } if ($batch) { Db::name(kb_article)-insertAll($batch); } $output-writeln(導入條數(shù) . count($batch)); } // 簡易關(guān)鍵詞抽取按標點和停用詞切分 private function extractKeywords(string $q): string { $stop [的,了,嗎,呢,怎么,如何,請問]; $q str_replace($stop, , $q); $parts preg_split(/[\s,。?!]/u, $q, -1, PREG_SPLIT_NO_EMPTY); return implode(,, array_slice(array_unique($parts), 0, 5)); } }邏輯說明status2表示會話已結(jié)束roleagent取客服消息避免把用戶追問也當成答案。mb_strlen過濾掉過短內(nèi)容正則過濾寒暄。extractKeywords是兜底方案停用詞表按自己業(yè)務(wù)補比如電商要加「發(fā)貨」「退款」。參數(shù)上limit(2000)是防止一次性拉爆內(nèi)存實際可以分批跑。跑完用SELECT COUNT(*) FROM kb_article確認條數(shù)再抽查幾條看content有沒有混入 HTML 標簽。提示導入前先備份kb_article清洗規(guī)則調(diào)一次可能要重跑有備份就不用后悔藥。3. 召回與拼 Prompt讓 AI 答得像你們自己的客服3.1 關(guān)鍵詞召回與全文索引的取舍召回質(zhì)量直接決定回答質(zhì)量。中小體量下MySQL 全文索引夠用但中文場景有兩個坑一是ngram_token_size最小 2單字詞查不到二是全文索引對keywords字段不生效因為上面只給title和content建了索引。常見做法是先用keywords做一次 LIKE 匹配再用全文索引兜底兩路結(jié)果合并去重。下面是對外暴露的召回方法。?php namespace app\service; use think\facade\Db; class KbRecall { /** * param string $question 用戶問題 * param int $categoryId 產(chǎn)品線 * param int $topN 返回條數(shù) */ public function recall(string $question, int $categoryId 0, int $topN 3): array { $question trim($question); if ($question ) { return []; } // 第一路關(guān)鍵詞 LIKE命中快適合短查詢 $like Db::name(kb_article) -where(status, 1) -where(function ($q) use ($question) { $q-whereLike(keywords, % . $question . %) -whereOr(title, like, % . $question . %); }) -when($categoryId 0, function ($q) use ($categoryId) { $q-where(category_id, $categoryId); }) -limit($topN) -select()-toArray(); // 第二路全文索引適合長句 $fulltext []; if (mb_strlen($question) 2) { $fulltext Db::name(kb_article) -where(status, 1) -whereRaw( MATCH(title, content) AGAINST(:q IN NATURAL LANGUAGE MODE), [q $question] ) -when($categoryId 0, function ($q) use ($categoryId) { $q-where(category_id, $categoryId); }) -limit($topN) -select()-toArray(); } // 合并去重保留順序 $merged []; foreach (array_merge($like, $fulltext) as $item) { $merged[$item[id]] $item; } return array_slice(array_values($merged), 0, $topN); } }邏輯說明whereLike那一路對keywords和title同時匹配解決全文索引對短詞不敏感的問題MATCH ... AGAINST那一路處理長句。when是 ThinkPHP 的條件構(gòu)造categoryId為 0 時不加分類過濾。參數(shù)topN建議 3 到 5太多會撐大 Prompt 導致模型跑偏太少召回不全。注意whereRaw里用了綁定參數(shù)別直接拼字符串否則有注入風險。3.2 拼 Prompt 的模板與上下文控制召回結(jié)果不能直接丟給模型要拼成結(jié)構(gòu)化 Prompt明確告訴模型「只根據(jù)以下資料回答資料沒有就說不知道」。這樣能大幅降低胡編。下面是一個可復用的 Prompt 構(gòu)造方法。?php namespace app\service; class PromptBuilder { public function build(string $question, array $articles): array { $context ; foreach ($articles as $i $a) { $no $i 1; $context . 【資料{$no}】{$a[title]}\n{$a[content]}\n\n; } $system 你是一名在線客服助手。只能依據(jù)下面提供的資料回答用戶問題。 . 資料中沒有的內(nèi)容直接回復「這個問題我需要轉(zhuǎn)人工幫您確認」。 . 回答要簡潔不超過 150 字不要編造資料外的信息。; $user 資料如下\n{$context}用戶問題{$question}; return [ [role system, content $system], [role user, content $user], ]; } }邏輯說明system里三條約束是關(guān)鍵——限定來源、限定兜底話術(shù)、限定長度。context拼接時給每條資料編號方便模型引用。參數(shù)上如果content很長要做截斷單條建議不超過 800 字總上下文控制在模型窗口的三分之一以內(nèi)留出余量給用戶問題和模型輸出。常見做法是在build前先按updated_at排序優(yōu)先用新資料。注意Prompt 里的兜底話術(shù)要和你們轉(zhuǎn)人工的關(guān)鍵詞對齊否則用戶看到「轉(zhuǎn)人工」但系統(tǒng)沒觸發(fā)轉(zhuǎn)接體驗會斷。4. 調(diào)用模型與回寫會話把 AI 回答接進現(xiàn)有消息流4.1 封裝模型調(diào)用與超時重試模型調(diào)用是整條鏈路最容易翻車的地方網(wǎng)絡(luò)抖動、限流、返回格式異常都會遇到。封裝時要設(shè)超時、重試和降級。下面用 curl 封裝一個通用調(diào)用方法兼容常見的對話補全接口。?php namespace app\service; class AiClient { private string $endpoint; private string $apiKey; private int $timeout 8; public function __construct(string $endpoint, string $apiKey) { $this-endpoint $endpoint; $this-apiKey $apiKey; } /** * param array $messages PromptBuilder 產(chǎn)出的消息數(shù)組 * return string 模型回答失敗返回空串 */ public function chat(array $messages): string { $payload json_encode([ model your-model-name, messages $messages, temperature 0.3, max_tokens 300, ], JSON_UNESCAPED_UNICODE); for ($i 0; $i 2; $i) { // 最多重試一次 $ch curl_init($this-endpoint); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER true, CURLOPT_POST true, CURLOPT_POSTFIELDS $payload, CURLOPT_TIMEOUT $this-timeout, CURLOPT_HTTPHEADER [ Content-Type: application/json, Authorization: Bearer . $this-apiKey, ], ]); $resp curl_exec($ch); $code curl_getinfo($ch, CURLINFO_HTTP_CODE); $err curl_error($ch); curl_close($ch); if ($code 200 $resp) { $data json_decode($resp, true); $text $data[choices][0][message][content] ?? ; if ($text ! ) { return trim($text); } } // 記錄日志便于排查別把 key 寫進去 trace(ai_call_fail code{$code} err{$err}, error); usleep(300000); // 等 300ms 再試 } return ; } }邏輯說明temperature設(shè) 0.3 是為了讓客服回答穩(wěn)定不要發(fā)揮max_tokens300 對應(yīng) 150 字左右。重試只做一次避免雪崩。trace是 ThinkPHP 的日志助手記錄時不要帶apiKey。參數(shù)上timeout8 秒是經(jīng)驗值超過這個時間用戶已經(jīng)等不及了不如直接走兜底。4.2 在消息入庫鉤子里接入并回寫AI 回答生成后要寫回chat_message表并標記來源方便后續(xù)統(tǒng)計命中率。下面是在消息服務(wù)里的接入片段。?php namespace app\service; use think\facade\Db; class ChatService { public function handleUserMessage(int $sessionId, string $question): array { // 1. 先入庫用戶消息 Db::name(chat_message)-insert([ session_id $sessionId, role user, content $question, create_time time(), ]); // 2. 召回 拼 Prompt $recall new KbRecall(); $articles $recall-recall($question, 1, 3); // 3. 沒召回到直接轉(zhuǎn)人工不浪費一次模型調(diào)用 if (empty($articles)) { return [type transfer, msg 這個問題我需要轉(zhuǎn)人工幫您確認]; } $builder new PromptBuilder(); $messages $builder-build($question, $articles); // 4. 調(diào)模型 $client new AiClient(config(ai.endpoint), config(ai.key)); $answer $client-chat($messages); // 5. 模型失敗也轉(zhuǎn)人工 if ($answer ) { return [type transfer, msg 這個問題我需要轉(zhuǎn)人工幫您確認]; } // 6. 回寫 AI 消息source 標記來源 Db::name(chat_message)-insert([ session_id $sessionId, role ai, content $answer, source kb, create_time time(), ]); return [type ai, msg $answer]; } }邏輯說明先入庫用戶消息保證會話完整召回為空直接轉(zhuǎn)人工省調(diào)用成本模型失敗也轉(zhuǎn)人工保證不冷場。sourcekb方便后面統(tǒng)計 AI 命中率。參數(shù)上recall的categoryId傳 1 是示例實際按用戶當前咨詢的產(chǎn)品線傳。注意config(ai.key)要放在.env或獨立配置文件里別硬編碼進代碼。提示上線前先用幾條真實問題跑一遍看召回條數(shù)和回答是否對得上別直接放全量流量。5. 避坑與排查接入 AI 知識庫最常見的五個翻車點5.1 中文全文索引查不到短詞現(xiàn)象用戶問「退款」召回結(jié)果為空但知識庫明明有退款條目。原因是ngram_token_size默認 2單字和部分雙字詞在全文索引里匹配不到。解決短查詢走keywords的 LIKE 那一路或者把ngram_token_size調(diào)到 1 并重建索引。調(diào)小會增加索引體積權(quán)衡后我一般保留 2靠 LIKE 兜底。5.2 模型回答里出現(xiàn)資料外的內(nèi)容現(xiàn)象知識庫沒有的內(nèi)容AI 也編了一段。原因是 Prompt 約束不夠強或者temperature太高。解決system里明確「只能依據(jù)資料」temperature壓到 0.3 以下并在代碼里做一次校驗——如果回答里出現(xiàn)資料中沒有的關(guān)鍵實體直接替換成兜底話術(shù)。5.3 curl 調(diào)用偶發(fā)超時導致用戶干等現(xiàn)象高峰期部分用戶消息發(fā)出后十幾秒沒反應(yīng)。原因是模型接口超時沒設(shè)或設(shè)太長。解決CURLOPT_TIMEOUT設(shè) 8 秒超時后走轉(zhuǎn)人工別讓用戶等。同時加日志統(tǒng)計超時率超過 5% 要考慮換接口或加緩存。5.4 知識庫條目重復導致召回冗余現(xiàn)象同一個問題召回三條內(nèi)容幾乎一樣的資料Prompt 被撐大。原因是導入時沒去重。解決導入前按title做一次GROUP BY去重或者用keywords做相似度過濾。我一般會在導入腳本里加一步SELECT title, COUNT(*) FROM kb_article GROUP BY title HAVING COUNT(*) 1檢查。5.5 會話上下文丟失導致多輪對話答非所問現(xiàn)象用戶追問「那這個要多久」AI 答的是上一個不相關(guān)的問題。原因是每次只拿當前問題去召回沒帶歷史。解決召回時把最近兩輪用戶消息拼進去一起查或者在 Prompt 里帶上最近一輪問答。注意別把整個會話歷史都塞進去會超上下文。6. 進階用緩存和命中率統(tǒng)計把成本壓下來跑通之后最該做的是兩件事緩存高頻問題和統(tǒng)計命中率。高頻問題比如「怎么退款」「發(fā)貨時間」每天被問幾百次每次都調(diào)模型是浪費。常見做法是用 Redis 緩存「問題 → 回答」key 用問題歸一化后的哈希過期時間設(shè) 1 小時。歸一化就是去掉標點、轉(zhuǎn)小寫、去空格這樣「怎么退款」和「怎么退款」能命中同一條。?php // 在 ChatService 調(diào)模型前加一層緩存 $cacheKey kb_ans: . md5(mb_strtolower(preg_replace(/[\s,。?!]/u, , $question))); $cached cache($cacheKey); if ($cached) { return [type ai, msg $cached, from cache]; } // ... 調(diào)模型拿到 $answer 后 cache($cacheKey, $answer, 3600);邏輯說明preg_replace做歸一化md5生成定長 keycache是 ThinkPHP 的緩存助手底層配 Redis。過期時間 3600 秒是平衡新鮮度和命中率的值知識庫更新頻繁就調(diào)短。注意緩存要區(qū)分產(chǎn)品線否則 A 產(chǎn)品的答案會串到 B 產(chǎn)品可以在 key 里拼categoryId。命中率統(tǒng)計則是給chat_message加一個source字段前面已經(jīng)加了定期跑 SQL 看 AI 回答占比和轉(zhuǎn)人工占比。如果轉(zhuǎn)人工率超過 40%說明知識庫覆蓋不夠該補條目了如果 AI 回答里用戶緊接著又追問同一問題說明回答質(zhì)量不行該優(yōu)化 Prompt 或換模型。我一般每周跑一次這個統(tǒng)計把轉(zhuǎn)人工率高的問題撈出來人工補進知識庫形成閉環(huán)。從那以后我每次接新的知識庫都強制先跑一周的召回日志再調(diào) Prompt不然根本不知道模型在哪些問題上翻車。希望幫到你。本文還有配套的精品資源點擊獲取