iT邦幫忙

2026 iThome 鐵人賽

DAY 28
0
AI Engineering

Backend 工程師的 Azure GenAI 實戰系列 第 28

Day 28:GenAI Evaluation:exit code 只發給重跑會給同一個答案的那一層

  • 分享至 

  • xImage
  •  

模型版本換了、prompt 改了一行、chunk 參數動了一格——CI 全綠。這個系列寫到現在,這棵樹上有近兩千個 unit test 與 behave 的 37 個 scenario,但沒有一個能回答「答案的品質有沒有退化」:它們驗的是狀態碼、錯誤信封、SSE 事件詞彙,不是內容。Day 28 補這一層,而且動手前要先回答一個結構問題:品質斷言裡,哪些有資格決定 exit code?讀完這篇你會帶走一份兩層 eval 的設計——dataset 格式、judge 契約、人工判定的證據鏈,以及「哪些斷言永遠不准碰 gate」這條線怎麼用結構而不是紀律守住。

你已經在做 eval 了,只是它散在三個檔案裡

盤點這個 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,不為了測試去改被測物。

分界線:能不能當 gate,差別不在準不準

十題裡的斷言天生分成兩種。「回應的 status 是 answered」「sources 裡必須有 returns-policy 這份文件」「跨租戶的文件不得出現」——這些是程式能判的,重跑一百次答案相同。「答案有沒有涵蓋『標準購買 30 天可退』這個事實」——這要另一個模型當 judge 來讀,而同一份答案送審五次,verdict 可能翻。

所以分界的判準不是斷言重不重要,也不是 judge 準不準,是重跑會不會給同一個答案。CI gate 的全部價值建立在「紅了就是真的壞了」上;把一個會抖的判定放進 gate,工程師很快就會學會 rerun until green,那時 gate 已經死了,只是還在動。

https://ithelp.ithome.com.tw/upload/images/20260828/201682884mgH1rRfYG.png
忍喵:「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,不是靜默跳過。

Day 28 兩層斷言與 exit code 歸屬:tools/eval_cases.json 的一題分成 deterministic 與 judged 兩個 key。確定性層(重跑給同一個答案)從 deterministic 流向 gate_exit_code(results)——簽名裡沒有 judged 參數——再分出 exit 0(gate 跑了,全過)與 exit 1(gate 跑了,被測物有問題)。判讀層(同一份答案重審可能翻)從 judged 流向 pass B 生成與 pass C judge ×5(事實欄只回 id、另有自由文字欄,verdict 由程式推導),再到報告(逐輪 outcome 序列,量不到印 NOT MEASURED);報告到 gate 之間只有一條標注「沒有這條路(結構上)」的虛線斷路。另一側 setup 失敗(dataset 不合法、corpus 載不進來、缺憑證)直接走 exit 2——受測物根本沒被測到

Dataset 格式:每個欄位都是一個裁決

一題長這樣(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_factsexpected_facts 一樣是一等公民。 這個 corpus 裡退貨窗有兩個數字:標準購買 30 天、促銷品 14 天,檢索校準證實模型兩個都看得到。只驗「有答到 30 天」的話,一個把兩個數字都寫出來的答案照樣通過——雙數字陷阱要靠「不得主張 14 天」這條才真的被強制。

[]null 是兩件事。 citations_subset_of: [] 的意思是「sources 必須為空」(zero-hit 那題就用它),null 的意思是「不主張封閉性」而且必須附一行理由。must_citemust_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。同一份檔案的兩個時期,不是要維護的兩份東西。

Judge 契約:事實欄只回 id,verdict 是程式算的

判讀層的執行分三個 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_claimsrationale 則是自由文字。而裡面沒有 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。

Human feedback:權威是人,但人的裁決要綁得住東西

判讀層的最終權威從來不是 judge,是人。但「人審過了」這句話要能被查證,就得回答:人審的是哪一次生成?

我的第一版把人工 verdict 欄位放在 dataset 裡,後來撤回:dataset 裡的 verdict 沒有綁任何一次實際生成,人可能是在判「這題的預期合不合理」而不是「這次的答案過不過」,兩件事混在一起就沒有可比基準。定案是:dataset 只放 case-level 期望(什麼應該為真),人工裁決記在 run evidence 裡,綁 run_idanswer_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 是循環論證。

https://ithelp.ithome.com.tw/upload/images/20260828/20168288mvMwKXmPUh.png
忍喵:「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 不等於「可以不看就公開」。

Day 28 人工判定證據鏈:pass B 的答案(模型輸出,不可位元重現)產出當下就算好 answer_sha256 與 sources_sha256;hash 進 evidence sidecar(--evidence-out:ids、counts、outcome、hash,沒有專用的答案/來源全文欄位——每輪 raw_response 仍是模型文字),答案文字與同一組 hash 進 answers companion(--answers-out,寫入順序在 sidecar 之前,因為答案已 billed)。判定前對帳(從 companion):answer_sha256 對答案 bytes、sources_sha256 依序重建 doc_id/chunk_id/heading_path/content 陣列再 canonical 編碼,與 sidecar 比對,8 案全 MATCH 才開始讀;最後人工判定(權威)綁 run_id 與 answer_sha256,記在 run evidence,不寫回 dataset

第二次 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 抓的是答案多講的東西,「少講限定詞」這個形狀抓不到。記為已知限制,不假裝蓋住。

這天的誠實邊界

  • 檢索品質這一天沒有量。 所有檢索走詞彙式 fake,它的 docstring 自己寫著 “Retrieval quality observed here means nothing.” 真要量 recall@k,路在 Day 13 的工具+一次 ephemeral Search session。
  • zero-hit 那題的零 hits 來自詞彙式 fake,證明結構性 no-answer 路徑會走,不外推「真 Search 在同一題也回零筆」。
  • 同一個模型自審有已知偏誤,且只有一個 deployment、沒有對照組,說不出「換一把尺差多少」。
  • 十題蓋不住 corpusstreaming-ssetoken-budget 兩份文件沒有專屬題目(校準顯示它們湊不出乾淨的引用斷言)。缺口記下,不硬湊。
  • judge prompt 不在 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 Actionmicrosoft/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 服務

  • Azure OpenAI in Microsoft Foundry(chat-mini,常駐、純 token 計費)——pass B 生成與 pass C judge,本次 live run 共 48 個邏輯模型請求
  • 沒有其他了。不開 Search、零新建資源——確定性層完全離線可跑

下一篇

Eval 是「上線之後怎麼知道它還好」的其中一塊。Day 29 把視角拉到全局:production readiness checklist——security、reliability、observability、cost,上線前該逐項檢查什麼。


本文由作者規劃與撰寫,AI(Claude)協助草稿整理與程式碼驗證;技術內容與觀點由作者確認並負責。


上一篇
Day 27:用 Application Insights 追一次 LLM request:缺的自己補,多的自己關
下一篇
Day 29:Production Readiness Checklist:標籤是主張,會失敗的指令才是檢查
系列文
Backend 工程師的 Azure GenAI 實戰35
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言