測試一個非確定性系統
確定性回放、性質測試、專門騙 LLM 客戶端的故障伺服器。核心材料:docs/testing.zh.md 與 packages/test-support/。
先玩再講。情景 A 是一台真的 HTTP 故障伺服器:行為排成隊,每接一個請求消耗一個,看客戶端怎麼接招。情景 B 是確定性回放:拿一份真實會話日誌,一鍵推導回放腳本重跑,再動手篡改一行,看 diff 怎麼當場作證。
測非確定性系統的思路只有一條:把不確定的部分圈起來,讓其餘一切確定。DSH 的測試分層(docs/testing.zh.md)就是圍着這條思路搭的。單元測試盯邊界情況、錯誤路徑、事件順序和併發競態;CI 的覆蓋率門禁對 packages/*/*/src 按文件要求 100%(AGENTS.md 第 65 行 Commands 一節),文檔同時把話説死:行覆蓋率是必要條件,永遠不是充分條件,沒跑過的行往往是該刪的死程式碼,而非該補的測試。
帶密鑰的真實 API 測試是另一層。這裏有句很有身份特色的話:
docs/testing.zh.md「帶密鑰策略」一節,核對日期 2026-08-13
推理對自家便宜,冒煙測試就往真裏做:啓動真實示例、發一條提示詞、檢查外部世界。斷言也有講究:e2e 要重新讀文件、重跑命令來驗證結果,對 agent 自身輸出做關鍵詞探測會讓作弊的 agent 通過。缺密鑰的環境自動跳過,不阻塞任何人。
重頭戲是回放。上一課講過(見 LLM 適配層),每個流式分片都以 assistant/chunk 事件原樣落進會話日誌。dsh-llm-replay 插件把這件事反過來用:拿一份錄好的 session.jsonl,把 chunk 事件按 (turn, step) 分組,每組就是當年一次模型調用的完整分片序列。測試時真實 agent 照常跑,只是模型那頭換成回放適配器,逐幀吐回錄製的分片。不確定性只存在於錄製那一次,之後每次重跑都逐字節一致,不需要 API Key。
這就是日誌即測試資產的意思:fixture 不用手寫 mock 數據,直接就是生產格式的會話日誌本身。快照測試拿它固定整個組裝後的行為,改一行程式碼導致行為分叉,diff 當場標紅。還有個精巧的細節在 fork(分叉會話):子會話的日誌開頭繼承了父會話的種子事件,回放推導腳本時必須從 seedLength 邊界之後開始切,否則父會話的分片會被誤當成子會話的調用重放:
const text = readFileSync(childFile, 'utf8')
const header = parseSessionHeader(text)
// Derive the child's script from its own events only — events AT OR after the seed
// boundary.
const ownEvents = parseSessionLog(text).slice(header.seedLength)
children.push({
recordedId: header.id,
createdAt: header.createdAt,
entries: deriveReplayScript(ownEvents),
primary: false,
})
packages/test-support/llm-replay/src/index.ts,核對日期 2026-08-13。程式碼塊保留源碼原文。跨平台紀律也從這裏來:簽入的 fixture 必須在 macOS 和 Linux 上都能回放,錄出來的快照哪個平台掛了就修 fixture 本身。AGENTS.md 第 123 行的原話是「fix fixtures, not normalizers」:修 fixture,別寫歸一化器。歸一化器是在測試和現實之間墊棉花,墊多了就測不到現實了。
回放測的是行為不變,還差一塊:傳輸層的花式死法。連接被拒、發一半 socket 重置、正常關閉但沒發 [DONE]、限流帶 Retry-After、乾脆停滯不動,每一種在適配器和恢復層眼裏都是不同的東西,用進程內 mock 全測不到,因為 mock 繞過了 fetch、SSE 分幀、socket 終止和空閒看門狗這些真實邊界。所以 DSH 造了 dsh-llm-mock-server:一台真的 Node HTTP 伺服器,説 OpenAI 方言,行為完全由腳本控制,每接一個請求消耗一個行為,腳本耗盡就明確報錯(設計動機見 Agent Note 2026-07-25-scriptable-llm-wire-fault-server.zh.md)。開發者想手動復現故障,改一下 base URL 和 key 就能把任何應用接上來。
它還有個 random 模式,按權重隨機抽故障做壓力測試,seed 公開且可復現。預設權重表本身就是一份清單,列着 LLM 客戶端在野外會遇到什麼:正常成功佔 48、慢速成功 10、半途掐綫(partial_disconnect)10,然後是各佔 5 的連接重置、斷流、空回覆和限流,伺服器錯誤 4,觸頂 max_tokens、停滯掛起和 503 各 2,最刁的兩種各佔 1:流正常收尾卻沒發完的 partial_eof,和吐出壞 JSON 的 malformed_json。源碼註釋還專門提醒:這是可調的測試壓力配置,並非生產事故頻率的估算。
出處:packages/test-support/llm-mock-server/src/index.ts 第 56 至 70 行的 DEFAULT_MOCK_LLM_RANDOM_WEIGHTS,核對日期 2026-08-13。
伺服器的操守很剋制:只報告協議層事實,不判斷可不可以重試,策略歸 harness 自己。真實組合測試讓請求依次穿過 DeepSeek 適配器、agent loop 和重試插件,驗證的東西很具體:請求次數精確、重試步驟帶編號、失敗的半截分片不泄漏進歷史、正常 EOF 的半截輸出歸類為 STREAM_CLOSED 且預設不重試。
最後一件武器對付的是沒人想得到的交錯。協議形態的程式碼(分片流、事件日誌、收件箱調度)輸入空間是組合爆炸的,示例測試只能固定想到的用例。DSH 給每個協議形態的包配一個 fast-check 驅動的性質測試:生成器造出逼真但對抗性的輸入(重複索引、滯後分片、缺 block-start 的畸形流),斷言的不是具體輸出,是不變式,比如組裝出的塊數不能超過見過的不同索引數、重複調用的結果必須穩定。失敗自動列印可復現的 seed。
它的戰績寫在 Agent Note 的第一行:
block-end 的真實 bug。」同一索引處重複的 block-end 會改寫已經完成的塊,而這個 bug 是在 happy path 100% 行覆蓋率之下存活的。
出處:.agents/notes/implemented/testing/2026-06-11-property-based-testing.zh.md,核對日期 2026-08-13。修復後的「首次關閉優先」防禦見 LLM 適配層一課的源碼面板。
這套基礎設施還有個副產品:BENCHMARK.md 給的官方基準測試路徑就是 Python SDK 加 minimal 變體,每個任務獨立 workspace。測試體系搭紮實了,跑評測只是換個輸入。
覆蓋率是必要不充分按文件 100% 是 CI 門禁,但它只證明行被執行過。真 bug 藏在交錯序列裏,那是性質測試的地盤;藏在傳輸邊界裏,那是故障伺服器的地盤。
fixture 就是會話日誌回放 fixture 不是手搓的 mock,是生產格式的 session.jsonl 本身。錄一次,處處重放,跨平台必須過,掛了修 fixture 不修歸一化器。
故障伺服器不做策略它只誠實地按腳本掐綫、限流、停滯,重不重試是 harness 的事。測試基礎設施保持中立,才能同時給適配器、loop 和重試層作證。
Grok Build:也有腳本化 mock,但停在 HTTP 層
Grok Build 的 xai-grok-test-support 裏有一台 MockInferenceServer(crates/codegen/xai-grok-test-support/src/mock_server.rs):預設 echo 模式回聲最後一條用戶消息,支持按路徑排隊的腳本化響應(精確控制狀態碼、body 和 SSE 事件),一台伺服器同時伺候 chat-completions、responses、messages 三種 API 方言,所有請求帶 header 全量記錄供斷言。思路和 DSH 的故障伺服器同源。差距在覆蓋面:從已核對的源碼看,它做的是 HTTP 響應層面的腳本化;DSH 的故障伺服器往下多打了一層,socket 重置、發一半掐綫、停滯掛起這些傳輸層死法都進了行為詞彙,還配了可復現的加權隨機模式。回放這塊,Grok 用 xai-sqlite-journal 做持久化,但基於已公開證據,未見把生產日誌直接推導成回放腳本的等價機制。
Claude Code:閉源產品的測試黑箱
還原源碼(restored-src)裏可見的測試痕跡有限,這符合還原的性質:從產物反推出來的是產品程式碼,測試程式碼本來就不隨產物發佈。所以這裏能下的結論只有一條:基於已公開證據,外界無法評估 Claude Code 的測試體系長什麼樣。這恰好反襯出開源 harness 的一個價值:DSH 的測試策略、覆蓋門禁、fixture 紀律全部寫在倉庫裏,測試基礎設施本身也是可以被學習和複用的交付物。
設計一條屬於你的不變式
假設你要給 BlockAssembler 再補一條性質測試。生成器會隨機吐出合法與畸形交錯的分片流(重複 block-end、缺 block-start、滯後 delta)。參考本課講的組裝塊數不變式,再寫出兩條你認為值得斷言的不變式,並説明每條防的是哪類真實故障。然後推演:錄製的快照 fixture 在 macOS 通過、Linux 上因為路徑分隔符 diff 掛了,按「fix fixtures, not normalizers」的紀律,你改哪裏,為什麼不在比對器裏把路徑統一替換掉?