模型版本換了、prompt 改了一行、chunk 參數動了一格——CI 全綠。這個系列寫到現在,這棵樹上有近兩千個 unit test 與 behave 的 37 個 scenario,但沒有一個能回答「答案的品質有沒有退化」:它們驗的是狀態碼、錯誤信封、SSE 事件詞彙,不是內容。Day 28 補這一層,而且動手前要先回答一個結構問題:品質斷言裡,哪些有資格決定 exit code?讀完這篇你會帶走一份兩層 eval 的設計——dataset 格式、judge 契約、人工判定的證據鏈,以及「哪些斷言永遠不准碰 gate」這條線怎麼用結構而不是紀律守住。
盤點這個 lab 現有的東西:三個既有工具各蓋住一層 eval,只差沒有人給它們一個總名字。
| 既有產物 | 蓋住哪一層 | 已經做對的事 |
|---|---|---|
tools/compare_retrieval.py(Day 13) |
檢索層 golden set | 凍結查詢集、預先登記 chunk id、一次只動一個變數 |
tools/tenant_smoke.py(Day 15) |
權限層 regression | PASS/FAIL/INCONCLUSIVE 三態;絕不對空結果集下斷言 |
tests/bdd/features/(behave) |
契約層 regression | HTTP 狀態碼、錯誤信封、SSE 詞彙 |
第一列就是教科書上說的 golden questions:一組凍結的題目,加上「我希望系統怎麼答」的預先登記。這個 lab 在 Day 13 就寫了一份,只是當時它叫「檢索比較工具」。
真正缺的只有一層:生成品質。答案有沒有落在來源裡、有沒有答到該答的事實、有沒有把不該混進來的數字講出來——這些沒有任何一個既有測試在看。
Day 28 的產出就是這一層:十題 golden-question dataset(tools/eval_cases.json)與一支兩層 runner(tools/eval_run.py)。
設計理由的完整版在 docs/evaluation.md。生產 code 一行未動:runner 自己把 sample corpus 灌進 fake retriever,不為了測試去改被測物。
十題裡的斷言天生分成兩種。「回應的 status 是 answered」「sources 裡必須有 returns-policy 這份文件」「跨租戶的文件不得出現」——這些是程式能判的,重跑一百次答案相同。「答案有沒有涵蓋『標準購買 30 天可退』這個事實」——這要另一個模型當 judge 來讀,而同一份答案送審五次,verdict 可能翻。
所以分界的判準不是斷言重不重要,也不是 judge 準不準,是重跑會不會給同一個答案。CI gate 的全部價值建立在「紅了就是真的壞了」上;把一個會抖的判定放進 gate,工程師很快就會學會 rerun until green,那時 gate 已經死了,只是還在動。
忍喵:「rerun until green 不是假想情境。gate 一開始抖,紅色就再也叫不動人——這條線畫在『重跑會不會一樣』上,畫對了。」
常見的做法是給 judge 打分數、低於閾值就 fail build。這條路我否決了,理由是自己家的量測紀律:十題是手挑的,任何從它導出的分數都會被讀成母體估計;而 Day 13 已經實測過一次「分數沒有鑑別力」的教訓(RRF 分數),不需要再學一遍。
於是 dataset 裡每一題的斷言掛在兩個不同的 key 底下:
deterministic:status、引用集合、跨租戶檢查。只有它決定 exit code。
judged:expected facts、forbidden facts、rubric。結構上碰不到 exit code。
「結構上」三個字是設計重點。兩層如果是同一個陣列裡每條斷言帶一個 layer 欄位值,那打錯一個字串就會把判讀性斷言默默升級成 gate;反過來,也可能把該擋 build 的斷言悄悄降級成參考資訊。兩個 key 讓這種錯根本寫不出來:欄位名錯了,dataset validator 直接拒收。
Runner 端同一條線再守一次。決定 exit code 的函式簽名裡就沒有 judged 參數(節錄,docstring 略):
def gate_exit_code(results: Mapping[str, CaseResult]) -> ExitCode:
if any(result.verdict != Verdict.PASS for result in results.values()):
return ExitCode.GATE_FAILED
return ExitCode.OK
results 是 deterministic 層的結果 map,CaseResult 這個型別根本沒有 judged 欄位——你連「餵一個 judged 失敗進去」都構造不出來。而且有一條可執行的測試直接斷言這件事,擋的是未來某個好心人「幫它加個參數」:
def test_gate_exit_code_signature_takes_only_deterministic_results() -> None:
sig = inspect.signature(gate_exit_code)
assert list(sig.parameters) == ["results"]
annotation = str(sig.parameters["results"].annotation)
assert "CaseResult" in annotation
assert "judged" not in annotation.lower()
exit code 本身是三分的,不是布林:
| exit code | 意思 | 為什麼不能併 |
|---|---|---|
0 |
gate 跑了,全過(判讀層結果不論) | — |
1 |
gate 跑了,被測的東西有問題 | 這是唯一能觸發「回去查系統」的碼 |
2 |
setup 失敗:dataset 不合法、corpus 載不進來、要 judge 但憑證缺失 | 受測物根本沒被測到;併進 1 會讓 CI 把「沒跑成」讀成「跑了而且壞了」,併進 0 會讓一條沒執行的檢查看起來是綠的 |
最後那半句是 Day 27 剛踩過的形狀(fail-open 的等待迴圈),這裡直接寫進 enum:--judge 缺憑證是 2,不是靜默跳過。

一題長這樣(tools/eval_cases.json 第一題,節錄):
{
"id": "acme-refund-window-standard",
"question": "How many days does a customer have to return a standard purchase for a full refund?",
"principal": { "tenant": "acme", "user": "eval-agent", "groups": [] },
"protects": "Day 14 answered path: single-document must_cite/subset agreement",
"deterministic": {
"status": "answered",
"must_cite": ["returns-policy"],
"citations_subset_of": ["returns-policy"],
"must_not_cite": []
},
"judged": {
"expected_facts": [{ "id": "fact_standard_window_30_days",
"text": "a standard purchase may be returned within 30 days of delivery" }],
"forbidden_facts": [{ "id": "fact_14_days_for_standard",
"text": "a standard purchase carries a 14 day return window" }],
"rubric": null
}
}
幾個欄位背後各有一個裁決,挑值得說的講:
protects 必填。 每一題都要寫明它守的是哪一天的哪個決定。沒有出處的題目是願望,不是回歸測試;等它真的紅了,你需要知道紅的是誰的合約。
forbidden_facts 跟 expected_facts 一樣是一等公民。 這個 corpus 裡退貨窗有兩個數字:標準購買 30 天、促銷品 14 天,檢索校準證實模型兩個都看得到。只驗「有答到 30 天」的話,一個把兩個數字都寫出來的答案照樣通過——雙數字陷阱要靠「不得主張 14 天」這條才真的被強制。
[] 與 null 是兩件事。 citations_subset_of: [] 的意思是「sources 必須為空」(zero-hit 那題就用它),null 的意思是「不主張封閉性」而且必須附一行理由。must_cite 與 must_not_cite 則只准陣列不准 null——對它們而言空陣列與「不主張」外延相同,能用一種寫法表達的事,不給第二種。
status 可以是 null,而且必須可以。 其中一題(acme-unanswerable-contact)問了一個 corpus 裡不存在的資訊(倉管的電話),直覺上該斷言 no_answer——但不行。Day 14 定案 no-answer 是結構性判斷:零 retrieved hits 才成立;而這一題撈得到五個 acme chunk,模型自己的拒答會以 answered 回來。
「問了資料裡沒有的問題」在合約上有兩個合法終態,dataset 必須尊重這個不對稱,於是 status: null(不斷言,附理由)必須存在。斷言真正落在判讀層:答案必須表明來源沒有這項資訊,且不得出現任何電話號碼。
還有一個欄位是被現實逼出來的。原始設計只有「引用子集」與「排除」兩個原語,校準時發現 opsdemo 租戶的三份文件詞彙高度重疊,試了五種問法都撈不出單一文件的乾淨子集——而把問句改到湊出來,等於把測試調到迎合 fake。正確的動作是承認 schema 缺一個原語:must_cite(這些文件必須在場)就是這樣長出來的。dataset 遷就不了現實的時候,先懷疑 schema,不是先改題目。
最後講這份檔案的身分。第一次寫下來時它是 golden questions(描述你希望系統怎麼答);等有東西壞過一次、你把那題連同 protects 加進去之後,它就是 regression dataset。同一份檔案的兩個時期,不是要維護的兩份東西。
判讀層的執行分三個 pass:pass A 用 fake LLM 跑確定性斷言(零 provider 呼叫);pass B 用真 chat-mini 生成受測答案;pass C 把答案連同 sources 與事實清單送給同一個 chat-mini 當 judge,每題重複 5 次。judge 的輸出長這樣:
{
"covered_fact_ids": ["fact_standard_window_30_days"],
"missing_fact_ids": [],
"violated_fact_ids": [],
"unsupported_claims": [],
"rationale": "..."
}
三個事實欄(covered/missing/violated)只回 dataset 登記過的 id;unsupported_claims 與 rationale 則是自由文字。而裡面沒有 verdict 欄位——這是刻意的。讓模型自己說 pass/fail 的問題在於它可以自我矛盾——{"verdict": "pass", "missing_facts": ["fact_x"]} 形式上完全合法,而你沒有裁決規則。所以 verdict 由 runner 從事實集合推導,模型沒有發言權(節錄,docstring 略):
def derive_judge_verdict(output: JudgeOutput) -> Literal["pass", "fail"]:
if output.missing_fact_ids or output.violated_fact_ids or output.unsupported_claims:
return "fail"
return "pass"
而在推導之前,回應要先過四條集合不變量:covered 與 missing 的聯集恰等於輸入的 expected id 集合、兩者不相交、violated 必須是 forbidden 集合的子集、三個陣列裡只准出現已知 id。任一條不成立就是 ERROR(parse),該輪記 INCONCLUSIVE。
這四條擋的是形狀層的偏移——judge 讀的 answer 與 sources 都是不可信資料(Day 21 為此開過一整篇),nonce 圍欄只是 instruction-level mitigation;被帶偏的 judge 回了不存在的 id、漏掉或重複已知 id,解析階段就擋下,帶不進 verdict。
但它不是 prompt injection 的完整結構防線:一個合法 expected id 該分進 covered 還是 missing,不變量判不了;unsupported_claims 是自由文字,只看空不空。留在結構層的保證是集合完整性,加上 verdict 由程式推導——模型從頭到尾沒有那個欄位。
這四條不變量還教了我一件沒預料到的事。有兩題的要求只在 rubric 裡(「答案必須表明來源沒有這項資訊」),expected_facts 是空的。推演一下:expected 集合為空,不變量逼得 missing_fact_ids 必為空;forbidden_facts 之外 judge 又不能自己發明 violated id——於是一條 rubric-only 的要求能到 pass、能到 ERROR(parse),就是永遠到不了 fail。一個結構上不可能失敗的斷言不是斷言,是裝飾。
schema 不動。修法是給每一題至少補一條正向事實(fact_states_sources_do_not_cover_this),讓既有不變量自己去做工。後來的 live run 證明這一修是承重的:兩題 rubric 案例的 judge 都回了這個 id——同一個回應在兩個 commit 之前會是 parse error。
判讀層的最終權威從來不是 judge,是人。但「人審過了」這句話要能被查證,就得回答:人審的是哪一次生成?
我的第一版把人工 verdict 欄位放在 dataset 裡,後來撤回:dataset 裡的 verdict 沒有綁任何一次實際生成,人可能是在判「這題的預期合不合理」而不是「這次的答案過不過」,兩件事混在一起就沒有可比基準。定案是:dataset 只放 case-level 期望(什麼應該為真),人工裁決記在 run evidence 裡,綁 run_id 與 answer_sha256(某一次跑出了什麼)。
然後第一次 live run 給了這個設計一記重擊。那次 run 技術上無可挑剔:exit 0、十題 deterministic 全 PASS、八題 40 輪 judge 全 pass、evidence sidecar 完整記錄了每題的 answer_sha256。
直到坐下來要做人工判定才發現——沒有任何 artifact 留下答案本文。sidecar 依 Day 22 的紀律是 reference-only(ids、counts、hash,沒有承載答案本文的欄位),這個紀律本身是對的;錯的是整條管線裡沒有第二份檔案承載文字,於是每個 hash 都指向一個不存在的東西。
而模型輸出不可位元重現,這些答案永遠回不來了。讀 judge 自己的 rationale 代替也不行:那是 judge 對答案的宣稱,拿它來審 judge 是循環論證。
忍喵:「hash 全都在,答案全都不在。存證存到只剩指紋沒有本體——production 的 log 管線也常這樣死:只留 id 不留 payload,事後才發現要看的就是 payload。」
這個缺陷的性質要單獨記一筆:runner 從頭到尾都是對的,測試全綠,live run 成功——它只有走到「人要讀答案」這最後一步才顯現。Day 24 的教訓是「有些 bug 非跑不可」;這裡再進一層:有些 bug 非走完不可。
修法不是把文字塞進 sidecar(那會毀掉 reference-only 這條紀律),是加一份 opt-in 的 companion 檔案(--answers-out):帶可讀的問題、答案、來源全文,加上同一組 hash。一舉兩得——人可以判定了,而且 sidecar 的 digest 從「不可重算」變成「可重算」。
判定前先用一支獨立小工具從 companion 重算兩個 hash——answer_sha256 對答案的 UTF-8 bytes;sources_sha256 得先依 retrieval 順序重建 {doc_id, chunk_id, heading_path, content} 陣列、canonical 編碼後才 hash——跟 sidecar 對帳,八案全 MATCH 之後才開始讀。先證明你讀到的就是被判的那份答案,再談判定。
「答案本文只進 companion」也有結構背書:transcript 走 callback 傳遞,寫 sidecar 的函式拿到的型別裡沒有答案欄位。說準一點:sidecar 沒有的是專用的答案/來源全文欄位,它仍保存每輪 judge 的 raw_response——那是模型寫的文字,自由文字欄也可能逐字引用答案,所以 reference-only 不等於「可以不看就公開」。

第二次 live run(2026-08-23,japaneast,chat-mini)就是在這套完整證據鏈上做的:48 個帶 usage 的邏輯模型請求(8 題 ×〔1 生成+5 judge〕)、provider 回報 usage 合計 94,725 tokens(依 Day 9 的口徑,這是歸屬計帳不是帳單;SDK 層的重試次數沒有量,48 不是 HTTP attempt 數),八題人工逐題判定,8/8 與 judge 一致。
上一段的數字很漂亮,所以這一節要把它講小。
不導出任何比率。 十題是手挑的,40 輪重複是固定的,從它們算出的任何 pass rate、groundedness 分數、「judge 準確率」都會被讀成母體估計。報告只印逐輪 outcome 序列,量不到的印 NOT MEASURED。
五輪一致只能說「本次未觀察到翻轉」,不能說「judge 是穩定的」。翻轉出現能證明會晃;不出現什麼都證明不了。
8/8 一致代表零校準訊號。 人與 judge 沒有分歧,就沒有「他們在哪裡分岔」的資料——這是這次 run 的限制,不是成就。同理,這一輪一次 judged fail 都沒出現:pass 路徑跑得很透,fail 路徑(含 rubric 通道)結構上到得了、實際上沒被走過。
有一種失敗形狀在現行 schema 裡沒有通道。 人工判定時發現一例:來源寫「most items 30 天內可退」並掛三個條件,答案講成無條件的 30 天全額退。judge 判 pass 沒有錯——事實有涵蓋、沒有禁止事實、沒有無據陳述。unsupported_claims 抓的是答案多講的東西,「少講限定詞」這個形狀抓不到。記為已知限制,不假裝蓋住。
streaming-sse、token-budget 兩份文件沒有專屬題目(校準顯示它們湊不出乾淨的引用斷言)。缺口記下,不硬湊。prompts/ 生產目錄(本 milestone 不碰生產 code),所以沒有 Day 8 的 loader fail-fast,只有 tool 內常數+hash 存證。USE_FAKE_LLM=true 不等於沒有憑證。 這輪自己踩的:計劃假設 fake 模式不會打到外面,但 .env 裡有真 key,跑出了未經授權的真呼叫(操作者當時觀察為 8 個邏輯請求;原始 log 沒有保存,這個數字不可重算)。「用 fake 所以不會產生費用」是推論,不是保證——唯一的保證是憑證不存在。這套是手工的最小可行 eval,往上有現成的路,列為延伸、本系列未驗過(查核 2026-08)。Microsoft Foundry 的 evaluation 工具內建 groundedness 等 evaluator,也支援自訂 evaluator。
CI 側有一個官方 GitHub Action(microsoft/ai-agent-evals,該頁標注含 preview 項目),但它評的是 Microsoft Foundry Agents——前提是 Foundry project 加 Foundry agent,輸入要 agent-ids。
對本篇這種自有 FastAPI/RAG runner,它不是 drop-in,要用得先把系統包成 Foundry agent。
本篇的分界線也要限縮在自己的範圍講:這個 lab 的 blocking exit code 只交給確定性層。統計式、閾值式的 eval 結果仍然可以當 CI 訊號(那個 Action 自己就帶信賴區間與顯著性檢定),但那是另一套校準與治理設計,不是這支 runner 的 gate。
用到的 Azure 服務:
chat-mini,常駐、純 token 計費)——pass B 生成與 pass C judge,本次 live run 共 48 個邏輯模型請求Eval 是「上線之後怎麼知道它還好」的其中一塊。Day 29 把視角拉到全局:production readiness checklist——security、reliability、observability、cost,上線前該逐項檢查什麼。
本文由作者規劃與撰寫,AI(Claude)協助草稿整理與程式碼驗證;技術內容與觀點由作者確認並負責。