一句回覆不是整段對話:用真實 API 串接來測試多輪 AI 行為
· 中文 · Claude Sonnet 5 代筆
前一篇文章介紹過用第二顆 Claude 當裁判、對照真實資料評分單一句真實回覆的機制。那套機制抓得住很多東西——語氣、誠實度、有沒有守住某條規則——但每一個情境都是單獨、孤立的一輪:模擬一句訪客訊息進去,拿一句真實回覆出來評分。聊天機器人有些真正的行為,只存在於一連串對話輪次之間。真人交接真的結束之後三輪,AI 有沒有正確認知「已經結束」這件事?訪客的意圖從單純好奇轉成認真洽談的那一刻,AI 有沒有停止回答履歷問題、正確切換進交接的開場提問?單輪情境測不到這些——對話裡沒有「更早之前」可以讓 AI 答對或答錯。
機制:串接真實回覆,不是串接錄好的腳本
最初的想法是把一段真實對話錄一次、凍結成固定腳本,之後每次都重播它。後來發現這個做法成本其實跟另一個方案完全一樣——不管哪種設計,每一輪都是一次真實
API 呼叫——卻多背了一個實際的缺點:每次行為規則一改,凍結的腳本就過時了,得手動重錄。最後採用的設計是在同一個測試檔案裡,直接串接真實呼叫:早一輪的真實回覆,變成晚一輪對話歷史的一部分,透過模組層級的變數傳遞下去。Vitest
本來就會依照定義順序,依序執行同一個 describe() 區塊裡的測試,不需要額外排程——只需要一個防呆機制(requireDefined()),如果晚一輪的測試不小心在它依賴的那一輪之前跑,就丟出一個講清楚原因的錯誤,而不是死在一個莫名其妙的
undefined。
情境也可以分支——同一句開場提問,接上兩種不同的真實訪客回覆,探索兩種不同的走向。兩條分支讀的是同一個祖先節點的真實輸出,讀取不會互相干擾。真正需要小心的地方,是「寫入」。
抓到的東西——包括藏在測試本身裡的 bug
把這棵樹擴充到更多情境的過程中,真的抓到一個 bug,但不是聊天機器人的——兩條 sibling 分支,一條是訪客最後證實是真的潛在客戶,另一條是訪客單純好奇,共用了同一份假造的、代表聊天機器人訪客資訊資料庫的記憶體物件。現實中這兩段對話永遠不會同時存在,一位訪客只會是其中一種。但在測試裡,好奇訪客那條分支後跑,它真實呼叫
submit_lead 把一個陌生訪客的聯絡資訊寫進了那份共用的假資料——而另一條分支上更晚的一個節點,把這份殘留資料讀出來,當成自己訪客的資訊。AI
如實回報了它剛剛拿到的資訊,裁判卻把它判定成捏造,因為對照的參考事實對不上。這個 bug 完全出在測試的管線設計上:兩條現實中永遠不會同時存在的
sibling 分支,仍然需要真正獨立的假資料狀態,不能只是分開的變數名稱、卻指向同一份底層儲存。
串接的形式也讓一個單輪情境很少觸發的基礎設施問題浮上檯面:模型呼叫完工具之後,偶爾會回傳一句空白的最終回覆,而不是它該送出的文字。早期測試裡,重試一次大致擋得住這個問題,但比較長的串接對話會更頻繁地呼叫工具,好幾次真實執行都出現「連重試那一次也是空的」的情況。與其再加一層 prompt 措辭賭它不會發生,重試機制多給了一次機會——真正判定失敗之前,最多打三次真實請求——這個改動確實測量得出差距縮小了,而不是假裝取樣雜訊不存在。
分數是一個合規率,不是一個勾選框
有一個情境測的是:AI 已經知道一位回訪訪客的姓名跟訴求時,會不會跳過多餘的提問,直接進到提議真人交接——真正用上已知資訊來加速,而不只是不重問一次。這個修正在紙面上看起來乾淨俐落。但拿真實 API 連續跑六次,只過了四次。有兩次,AI 還是重問了規則明講已經有答案的那個問題。這不代表修正沒有生效——修正之前的那個具體問題模式(既重問、又順便捏造細節)確實消失了,取而代之的是偶爾一次、程度較輕的疏漏。這是一個能力較小的模型,面對一份相當密集的系統 prompt,遵從率大概落在三分之二左右,而不是百分之百——面對這種數字,誠實的做法是把它記錄下來,而不是繼續無止盡地微調 prompt 措辭,賭一個這顆模型可能本來就給不出的全綠。