**一句話先講完:**同一個 request 能不能被安全交付,不能只看 HTTP 有沒有回
200;要把 retrieval、model、tool、parser、agent 五個節點各自的失敗狀態,接回 metrics、logs、traces,並決定什麼時候該叫醒值班的人、什麼時候只該進 evaluation queue。
上篇把 workflow 拆成 retrieve、model_output、tool_call、response、agent_loop 五個可失敗的節點,用 empty retrieval、LLM timeout、tool schema mismatch、parser error、agent loop 五種 failure mode 逐一說明對外安全狀態該怎麼設計,並在 Day12/DIY/ 用一組 deterministic fixture 驗證這些安全反應確實存在。這一篇接著把這些節點的結果,串回可觀測性系統與值班流程。
fixture 能回答「某個輸入應落在哪一種安全狀態」。
它不能取代 production 的觀測。
真正的 request 經過 provider、retrieval index 與 tool service 時,還需要三種互補證據。
Metrics:哪一類 failure 正在增加?
Logs:這個 request 的決策與錯誤細節是什麼?
Traces:哪個節點先慢下來,後面又發生了什麼?
不要把 trace_id 放進 Prometheus label。
它是高 cardinality 的 request 識別字,適合留在 log 與 trace,不適合用來建立長期 time series。
metric 只保留可聚合的低 cardinality 維度:
ai_workflow_outcomes_total{
workflow="policy_qa",
node="retrieve",
status="insufficient_context"
}
ai_workflow_step_duration_seconds{
workflow="policy_qa",
node="model_output",
provider="primary"
}
對應的 structured log 才保留 request 層級關聯:
{
"event": "workflow_completed",
"request_id": "req_...",
"trace_id": "trace_...",
"node": "response",
"status": "quality_failed",
"retrieval_doc_count": 1,
"retrieval_version": "policy-index-2026-09",
"model_name": "selected-model",
"prompt_version": "policy-v4"
}
這裡刻意沒有 prompt、完整文件、完整 user question 或 tool credential。
可觀測性不是免責的資料收集器。
先定義誰可讀取、保存多久、如何遮罩,才把 metadata 送到 trace 或 log backend。
Day 2 已經建過 Prometheus + Loki + Tempo 這套組合,這裡值得把「為什麼是三種」講清楚,因為在 AI workflow 裡,這三者分工比傳統 web service 更明顯:
Metric 回答「量」的問題:
最近 15 分鐘,insufficient_context 的比例是不是比平常高?
→ 觸發你去看,但不會告訴你為什麼
Log 回答「這一筆」的問題:
這個 request_id 走到 quality_failed,retrieval_doc_count 是多少?
index_version 是舊的還是新的?
→ 給你單一事件的決策細節
Trace 回答「順序與延遲」的問題:
retrieve 花了多久?model_output 花了多久?
是不是某個節點突然變慢,把後面的 deadline 都吃光了?
→ 給你節點之間的時間關係
三者串起來的典型排查流程長這樣:先在 metric 上看到 ai_workflow_outcomes_total{status="quality_failed"} 這條線突然往上跳,這一步只能告訴你「有異常」,不能告訴你「為什麼」;接著你用同一個時間窗篩出對應的 structured log,看到一批 request 的 retrieval_version 全部指向同一個舊版 index;最後你抓其中一個 trace_id 去 Tempo 裡看完整的 span,確認是 index 重建作業(reindex job)跟 retrieval 服務重疊執行,導致那段時間查到的是半新半舊的資料。這條路徑走完,你才有足夠的證據寫進 incident timeline,而不是猜測。少了任何一層,這條路徑都會斷:只有 metric,你知道「壞了」但不知道「哪裡壞」;只有 log,你能查單筆但看不出趨勢;只有 trace,你能看單次呼叫的細節但抓不到「這是不是普遍現象」。
以下四個結果都可能讓 HTTP 200,卻不該被同一條 success rate 掩蓋:
| technical status | quality status | safety status | task status | 解讀 |
|---|---|---|---|---|
| success | passed | passed | completed | 可交付的正常結果 |
| success | failed | passed | not_completed | 模型有回覆,但引用或事實檢查失敗 |
| success | passed | blocked | not_completed | 內容可讀,但 action 不被授權 |
| timeout | unknown | passed | not_completed | workflow 在品質檢查前停止 |
Day 8 的三條軸線在這裡仍然適用。
不要做一個平均所有結果的「AI Health Score」。
如果 quality_failed 上升,先看 index、prompt 或 model release。
如果 tool_validation_failed 上升,先看 tool schema、呼叫端版本或 permission policy。
如果 llm_timeout 上升,先看 provider latency、deadline、queue 與 retry。
相同的紅色圖表,不一定有相同的值班動作。
假設某個 policy QA 系統這週的技術層 success rate 是 99.2%,看起來相當健康。但如果把上面那張四行表格的四種組合分開統計,可能長這樣:
success + quality passed + safety passed → 91.0%(真正可交付)
success + quality failed(citation 不存在) → 6.5%(Air Canada、MyCity 這類事故的溫床)
success + safety blocked(tool 未授權) → 1.7%(本來就該被擋下來,不是異常)
timeout(品質檢查前中止) → 0.8%(technical failure)
如果 dashboard 只顯示「success rate 99.2%」,這串數字會讓值班者以為系統幾乎完美——但其中 6.5% 的請求,使用者拿到的是格式正常、語氣自信,內容卻可能是編造或過期資訊的回答。這正是本篇反覆強調的重點:「success」在這裡只是 HTTP 傳輸層的判定,不是使用者任務是否被正確完成的判定。 Air Canada 的聊天機器人案例(正常回應、內容卻是編造的優惠政策)與②段 MyCity 的錯誤法律建議,都落在 success + quality failed 這一行——都不會出現在「success rate 99.2%」這個單一數字裡,只有拆開技術、品質、安全、任務四個維度分別統計,才看得見它們。
光說「要分開統計」還太抽象,實務上通常會在 metrics 裡替每個 response 打上多個 label,讓 Prometheus 可以分別查詢:
workflow_requests_total.labels(
technical_status="success",
quality_status="failed",
safety_status="passed",
).inc()
有了這組 label,dashboard 上除了原本那條「整體 success rate」的曲線,還可以疊上一條專門盯 quality_status="failed" 的曲線:
sum(rate(workflow_requests_total{technical_status="success"}[5m]))
/
sum(rate(workflow_requests_total[5m]))
→ 這是傳統的 success rate,Air Canada、MyCity 事故發生當下這條線幾乎不會掉
sum(rate(workflow_requests_total{quality_status="failed"}[5m]))
/
sum(rate(workflow_requests_total{technical_status="success"}[5m]))
→ 這條線才是「回應正常送達,但內容不可信」的比例,理論上事故發生時它應該先動
這裡要小心一個 cardinality 陷阱:technical_status、quality_status、safety_status 這三個欄位本身值域很小(各自只有個位數種狀態),可以放心當 label;但千萬不要把 citation_id、document_id 這種高基數欄位也塞進同一組 label,那樣會讓 Prometheus 的時間序列數量爆炸,跟 Day 2 提過的「label 只放低基數欄位」原則是同一件事,只是這次的違規對象換成了 workflow 層的語義欄位,而不是傳統的 user_id。
假設 primary model timeout,系統立即切到 fallback model。
這個策略不能直接判定對錯。
先檢查 fallback 是否仍在 request deadline 內,是否有相同 tool calling contract,是否受相同資料治理限制,以及是否會改變 task 的品質條件。
不好的 fallback:
primary timeout
→ 無視 deadline 轉送另一個 provider
→ fallback 產生無法解析的 action
→ 仍回 HTTP 200
較安全的 fallback:
primary timeout
→ 確認剩餘 deadline 與 retry budget
→ 選擇相容 model route,或停止
→ 重新套用 parser、citation、tool authorization checks
→ 記錄 fallback_triggered 與最終 task status
fallback 是 Day 13 的備援問題。
它不會取消 Day 12 的 contract。
不論選到哪個 provider,empty retrieval 仍不能變成臆測答案,未授權 action 仍不能執行。
上面的文字流程可以收斂成一個明確的守門函式,逼自己在寫 fallback 邏輯時,把每一個檢查項目都列出來,而不是憑直覺判斷「應該可以切吧」:
def may_fallback(
*,
remaining_deadline_ms: int,
fallback_min_latency_ms: int,
primary_tool_contract_version: str,
fallback_tool_contract_version: str,
primary_data_region: str,
fallback_data_region: str,
) -> tuple[bool, str]:
if remaining_deadline_ms <= fallback_min_latency_ms:
return False, "fallback would not fit remaining deadline"
if primary_tool_contract_version != fallback_tool_contract_version:
return False, "fallback model does not share tool calling contract"
if primary_data_region != fallback_data_region:
return False, "fallback would cross data residency boundary"
return True, "fallback allowed"
這段程式碼故意把「資料主權(data residency)」也放進檢查項目,因為這是實務上很容易被忽略的一項:如果 primary provider 的資料處理地區跟 fallback provider 不同,切換 fallback 可能讓原本符合某個地區法規(例如歐盟資料不出境)的請求,突然違反那個法規——即使功能上完全正常,這仍然是一種需要被擋下來的 failure mode,只是它的風險不在「答案錯不錯」,而在「這次呼叫本身合不合規」。
把 may_fallback 的四個回傳條件,逐一對回本節開頭那段「不好的 fallback」流程圖,會更清楚每個檢查在防的是哪一種具體失敗:
may_fallback 檢查項 |
對應的壞情境 | 如果不檢查會發生什麼 |
|---|---|---|
remaining_deadline_ms <= fallback_min_latency_ms |
「無視 deadline 轉送另一個 provider」 | fallback 呼叫本身就需要更長時間建立連線(新的 provider、新的認證),結果反而讓使用者等得更久 |
primary_tool_contract_version != fallback_tool_contract_version |
「fallback 產生無法解析的 action」 | fallback model 用不同的 schema 版本輸出 tool call,validator 拿舊版 schema 去驗,直接判定格式錯誤,或更糟——validator 也跟著切到寬鬆模式,讓不該通過的 action 通過 |
primary_data_region != fallback_data_region |
隱藏在「仍回 HTTP 200」底下、不會反映在錯誤訊息裡的合規風險 | 技術上請求正常完成,沒有任何錯誤訊息,違規卻已經發生,通常要等到稽核或法規檢查才會被發現 |
三項都通過才回傳 True |
「較安全的 fallback」流程圖裡的第二步 | 沒有這一步,primary timeout 幾乎必然直接觸發切換,中間所有前提條件都被跳過 |
這張表格也回答了一個容易被問到的問題:既然 fallback 是 Day 13 要談的備援主題,為什麼 Day 12 要先寫這段程式碼?答案是,may_fallback 檢查的四個條件全部屬於 Day 12 關心的 contract 完整性(deadline、schema、資料邊界),Day 13 要談的是「要不要有 fallback、fallback 的可用性怎麼設計」這種基礎設施層的問題——兩者處理的是同一個決策的不同面向,前者決定「這次切換安不安全」,後者決定「系統有沒有東西可以切」。
每一個新的 workflow node,在進 production 前可以先寫一張卡。
Node:tool_call
Failure:schema_version 不相容
User-visible result:無法完成操作,未執行變更
Machine-readable status:tool_validation_failed
Evidence:trace_id、tool name、expected schema version、received schema version
Safe action:拒絕呼叫,必要時轉人工
Retry policy:不 retry malformed action
Owner:tool integration team
Regression fixture:tool_schema_mismatch
這張卡很短,但能迫使團隊在 incident 前決定責任。
它也避免兩種常見的事後爭論。
第一種是「模型錯了,應該不是我們的問題」。
第二種是「有 log,為什麼還查不到誰執行了什麼」。
模型輸出不可靠是已知條件;系統如何限制、驗證與記錄它,才是工程責任。
同樣的格式套用在 retrieve 節點的 failure,可以看出這張卡的結構如何適應不同 failure mode:
Node:retrieve
Failure:retrieval 最高相關性分數低於門檻
User-visible result:告知目前找不到足夠資料,並提供轉人工管道
Machine-readable status:insufficient_context
Evidence:trace_id、retrieval_doc_count、retrieval_top_score、retrieval_version
Safe action:不呼叫 model 生成具體結論
Retry policy:同一份未變更的 index 不需要重試;index 更新後可視為新請求
Owner:retrieval / knowledge base team
Regression fixture:empty_retrieval、stale_index
兩張卡放在一起看,會發現「Owner」這一欄特別關鍵。tool_call 的責任在整合團隊,retrieve 的責任在知識庫團隊——如果沒有先把這個分工寫清楚,事故發生時很容易變成互踢皮球:整合團隊說「模型輸出的 action 格式沒問題,是文件本身就是舊的」,知識庫團隊說「index 更新是照排程跑的,沒有人告訴我們這次更新影響了哪些查詢」。failure card 把這種各說各話提前攤在檯面上,逼團隊在事故發生前就決定好邊界。
假設 insufficient_context 的觸發率某週突然從平常的 2% 跳到 11%。有了上面那張 retrieve node 的 failure card,會議討論可以跳過「這是誰的問題」的開場,直接照卡片欄位走:對照 Evidence 拉出 retrieval_top_score 分佈,發現整體偏低;對照 Owner 找知識庫團隊,追查出三天前一批文件被重新分類、舊 index 還沒針對新分類重跑;對照 Safe action 確認「不呼叫 model 生成具體結論」這條防線有被正確執行,代表使用者沒收到編造答案,降低了急迫性但仍需修根因;對照 Regression fixture,確認這次對應的正是既有的 stale_index,不需要新增 fixture,只需要在 index 更新流程補一道檢查。
整場討論沒有花時間重新定義「什麼算故障」「這是誰的鍋」,因為這些問題在卡片寫好的當下就已經有答案。真正花時間討論的,是卡片上不該預先寫死的部分——這次具體的根因、以及要不要調整流程本身。這正是 failure card 存在的意義:把能事先講清楚的部分講清楚,把值班者的注意力留給真正需要臨場判斷的部分。
不要讓每一筆 quality_failed 叫醒值班者。
單一 request 的引用不符,通常應保存 fixture candidate、標記 dataset 或進人工複核 queue。
以下情況才比較接近 operational alert:
短時間內 llm_timeout 比率跨過服務定義的門檻
同一 deployment 後 parser_error 突然升高
tool_validation_failed 在 schema rollout 後集中出現
agent_loop_limit 導致 token 或 cost 急升
alert 是要人立刻處理的 service risk。
evaluation 是要判斷系統品質是否退化的回饋迴路。
human review 則處理高影響、低確定性或需要業務判斷的個案。
三者都重要,混在同一個 channel 通常只會讓人開始靜音。
②段提過的紐約市 MyCity 聊天機器人,後續處理剛好示範了這三條路徑沒被正確使用會是什麼樣子。同一個 deployment 持續產生大量涉及勞動法、居住權的 quality_failed 結果,理論上已經滿足「operational alert」的條件。但市府實際的回應,既不是暫停高風險類別的回答(alert),也沒有讓法規問題轉真人複核(human review),而是停在「先加一行免責聲明,之後再慢慢修」。
這不是說 MyCity 的做法完全沒道理——政府服務要考慮的因素比一般 SaaS 產品複雜,貿然關閉一個公開宣傳過的服務也有政治成本。但這起事故清楚示範了三條路徑沒有被明確區分時的後果:如果一開始就把「涉及現行法規的錯誤建議」列為必須升級的類別,處理的優先順序可能完全不同。三條路徑的區分,本質上是替「這件事有多急」先做好分類,而不是等事情鬧大了才臨時決定。
把上面的原則寫成程式碼會長這樣,重點不在語法本身,而是這個判斷順序:先問「有沒有立即風險」,再問「有沒有規模訊號」,最後才是「值不值得留給下一輪迭代」。
def triage(event: WorkflowEvent) -> Literal["page", "evaluation_queue", "human_review"]:
# 第一層:涉及人身安全、法規遵循或財務損失的單一事件,
# 即使只有一筆,也不能等統計數字累積才處理。
if event.safety_status == "blocked" and event.risk_tier == "high":
return "human_review"
# 第二層:有明確的規模或速率訊號,代表這不是單一個案,
# 而是系統性問題正在擴散,需要立刻有人介入。
if event.node == "model_output" and event.recent_timeout_rate > TIMEOUT_ALERT_THRESHOLD:
return "page"
if event.node == "tool_call" and event.recent_validation_fail_rate > SCHEMA_ALERT_THRESHOLD:
return "page"
# 第三層:其餘的 quality_failed,先進佇列,等累積到一定量
# 或被標記為高頻模式時,再決定要不要升級。
return "evaluation_queue"
把 MyCity 的案例套進這個判斷式,「涉及現行法規的錯誤建議」理應在第一層就被攔下——safety_status 應該標記為 blocked(給出可能違法的建議,本質上是一種需要被擋下的高風險輸出),risk_tier 則是 high(勞動法、居住權直接影響使用者的財務與法律處境)。如果分流邏輯有明確把這兩個欄位串起來,第一筆記者發現的錯誤建議就會直接進 human_review,而不是靜靜躺在某個「之後修」的待辦清單裡等下一次迭代。
即使 fixture verification 通過,以下事項仍是 UNKNOWN,直到你在受控環境用真實整合測試驗證:
provider SDK timeout 是否真的被正確取消
client disconnect 時 downstream work 是否停止
retry 是否會重複造成有副作用的 tool action
trace、log、metric 是否能以 request_id / trace_id 正確關聯
敏感欄位是否在所有 exporter 與 log sink 被遮罩
fallback model 是否與 primary model 維持相同 contract
這不是 DIY 的缺點。
它是證據邊界。
先用 deterministic fixture 固定「預期安全行為」,再以 staging integration test 和 production telemetry 驗證真實 dependency 的行為。兩種證據不能互相冒充。
這不是作者謙虛地說「DIY 做得不夠完整」,而是刻意畫出的一條界線。deterministic fixture 能回答的問題形式永遠是「給定這個輸入,系統是否做出了正確的安全反應」;它回答不了「這個輸入在生產環境會不會真的發生」「真實 provider 的 timeout 行為是否跟 mock 出來的一樣」。
這條界線最常被「我們的單元測試都過了,應該沒問題」這句話跨越。單元測試過了,只代表「程式碼在你設想的情境下行為正確」,從未代表「你設想的情境涵蓋了生產環境會出現的所有情境」——④段 Chevrolet 的聊天機器人事故就是提醒:如果測試案例裡從來沒出現過「使用者傳一句話改寫系統指令」這種輸入,再多單元測試也不會發現這個缺口。fixture 只能證明「已知情境下的安全行為」,真實世界的未知情境,永遠需要 staging 整合測試與 production telemetry 來補上。
把「fixture 能證明什麼」跟「staging/production 才能證明什麼」放在一起看,邊界會更具體:
| 問題 | deterministic fixture | staging 整合測試 / production telemetry |
|---|---|---|
| 給定這組輸入,系統有沒有回傳正確的 status? | 能回答 | 能回答,但速度慢、成本高 |
| provider 真實 timeout 分佈長怎樣? | 不能回答(timeout 是 mock 出來的) | 能回答 |
| 這個情境在生產環境多久發生一次? | 不能回答 | 能回答 |
| 使用者會用什麼方式繞過系統設計者沒想到的路徑? | 不能回答(fixture 只涵蓋已知情境) | 部分能回答,仍需持續觀察真實流量 |
| 修好一個 bug 之後,會不會又壞掉? | 能回答(regression fixture 的核心用途) | 通常太慢,不適合當日常防線 |
這張表格也解釋了為什麼本篇的 DIY 選擇只做 fixture,而不強求「完整」——一個沒有真實 provider 帳號、沒有 staging 環境的讀者,能在自己電腦上誠實做到的部分,就是表格左欄那幾項;右欄那幾項需要的是團隊在自己的生產環境累積,任何文章的 DIY 都沒辦法代勞。
status 欄位不是內部例外名稱的轉錄。
它是 API 對 client、客服、dashboard 與後續 workflow 的共同語言。
如果 API 把所有錯誤都映射成 500,呼叫端只能重試。
如果 API 把所有非技術失敗都藏進 200,呼叫端又可能把不可交付的結果當成功。
先決定你的 contract 要讓 client 知道什麼。
再決定 HTTP code、response body 與 observability event 要怎麼表達。
以下範例將「HTTP transport 是否正常」和「任務能否交付」拆開:
from dataclasses import asdict, dataclass
from typing import Literal
TaskStatus = Literal["completed", "not_completed", "requires_human_review"]
WorkflowStatus = Literal["success", "degraded", "failed"]
@dataclass(frozen=True)
class AskResult:
request_id: str
trace_id: str
workflow_status: WorkflowStatus
task_status: TaskStatus
status_code: str
answer: str | None
user_message: str
retryable: bool
def insufficient_context(request_id: str, trace_id: str) -> dict:
result = AskResult(
request_id=request_id,
trace_id=trace_id,
workflow_status="degraded",
task_status="not_completed",
status_code="insufficient_context",
answer=None,
user_message="目前找不到足夠資料,無法確認這個問題。",
retryable=False,
)
return asdict(result)
這裡的 user_message 不必暴露「向量資料庫查詢失敗」或內部 index 名稱。
但它必須清楚告訴使用者:這次沒有完成,系統也沒有假裝知道答案。
retryable 同樣不能從 exception class 直接推論。
空 retrieval 對同一份未變更知識庫重送十次,通常不會突然長出資料。
相反地,短暫網路逾時可能值得在剩餘 deadline 內再試一次。
| 對外 status | client 可以做什麼 | 內部需要保留什麼 | 不要傳給 client 的內容 |
|---|---|---|---|
insufficient_context |
改問法、補文件或轉人工 | retrieval count、index version、query class | 文件原文、embedding、內部 collection 名稱 |
llm_timeout |
稍後重試;高影響任務可轉人工 | provider route、attempt、deadline remaining | provider credential、完整 prompt |
tool_validation_failed |
修正輸入或要求授權 | schema version、policy decision、tool name | allowlist 全表、授權規則細節 |
parser_error |
由服務安全失敗;不要請使用者猜格式 | parser version、raw-output digest、trace link | raw model output 中的敏感內容 |
requires_human_review |
等待人工確認 | risk reason、approval state、actor | 審核人個資或內部風險分數 |
這個分層能避免兩種反效果。
第一種是為了 debug 把 prompt、文件與錯誤堆疊直接回傳到前端。
第二種是安全到只回「發生錯誤」,導致使用者與客服都不知道下一步。
user_message 時,先想像客服會怎麼用它這張分層表格常被忽略的一個讀者,是客服團隊,而不只是前端工程師。當使用者帶著「系統說找不到答案」聯絡客服,客服人員手上通常只有這個 user_message,不會有內部 log 的存取權限。如果 user_message 寫得太籠統(例如「發生錯誤,請稍後再試」),客服除了複誦同一句話之外無法多做什麼;如果寫得太技術(例如直接把 retrieval_version: policy-index-2026-09 塞進去),使用者跟客服都看不懂,等於沒說。
比較實用的做法,是讓 user_message 本身帶有明確的下一步動作,而不是單純描述狀態:
不夠好:"系統目前無法回答這個問題。"
夠好的:"目前找不到與這個問題直接相關的內部規定,
建議改用更具體的關鍵字重新描述,或聯繫 HR 窗口確認。"
差別在於後者把「使用者接下來該做什麼」講清楚了,這件事本身就是本節反覆強調的「contract 是對外的共同語言」——語言不只是「告知狀態」,還包括「告知下一步」。
retry 常被寫在 SDK wrapper 裡,只有一個 max_retries=3。
這太少資訊了。
每次決定是否重試,至少依賴工作類型、錯誤類別、剩餘時間與副作用風險。
from dataclasses import dataclass
from enum import StrEnum
class FailureClass(StrEnum):
TRANSIENT = "transient"
OVERLOAD = "overload"
INVALID_REQUEST = "invalid_request"
UNSAFE_TO_REPLAY = "unsafe_to_replay"
UNKNOWN = "unknown"
@dataclass(frozen=True)
class RetryDecision:
should_retry: bool
reason: str
def decide_retry(
*,
failure: FailureClass,
attempt: int,
max_attempts: int,
remaining_ms: int,
is_idempotent: bool,
) -> RetryDecision:
if not is_idempotent:
return RetryDecision(False, "operation has side effects")
if attempt >= max_attempts:
return RetryDecision(False, "retry budget exhausted")
if remaining_ms <= 0:
return RetryDecision(False, "request deadline exhausted")
if failure in {FailureClass.INVALID_REQUEST, FailureClass.UNSAFE_TO_REPLAY}:
return RetryDecision(False, "failure will not be corrected by retry")
if failure in {FailureClass.TRANSIENT, FailureClass.OVERLOAD}:
return RetryDecision(True, "bounded retry is allowed")
return RetryDecision(False, "unknown failure fails conservatively")
這段程式沒有幫你選出正確的 max_attempts。
它做的是把原本藏在 except Exception 裡的判斷攤開。
reviewer 現在可以問:建立 ticket 的操作為何被標成 idempotent?OVERLOAD 的等待是否會超過 request deadline?未知錯誤為什麼能重試?
這才是有用的 code review 問題。
不要先算出固定的 sleep 時間,再回頭檢查 deadline。
如果剩餘時間只有 100 毫秒,sleep 500 毫秒後再 retry,使用者早已拿不到結果。
示意流程如下:
收到 temporary failure
↓
確認 operation 可安全重放?否 → 停止
↓ 是
確認 remaining deadline 是否足夠一次 attempt 加上 backoff?否 → 停止
↓ 是
計算有 jitter 的有限 backoff
↓
記錄 retry_scheduled,再執行下一次 attempt
Google SRE 對 retry 的重點不是「每一層都更努力」,而是避免多層各自重試,把一次失敗放大成大量後端呼叫。Google SRE Book:Addressing Cascading Failures
在這條 workflow,通常由最了解 provider outcome 的 model adapter 負責 retry。
上層 orchestration 應傳入 deadline 與 budget,並讀取結果;它不應再對同一個 provider timeout 疊一組重試。
FailureClassdecide_retry 好不好用,取決於呼叫端能不能正確把「原始錯誤」分類成 FailureClass 裡的其中一種。這一步經常被跳過,直接把所有例外都丟進 UNKNOWN,結果整個分類系統形同虛設。實務上可以先建一張對照表,讓「這個 provider 錯誤該怎麼分類」變成團隊共識,而不是每個開發者各自猜:
| Provider 回應 | FailureClass |
為什麼這樣分類 |
|---|---|---|
HTTP 429 Too Many Requests |
OVERLOAD |
資源暫時不足,等待後有機會成功 |
HTTP 503 Service Unavailable |
OVERLOAD |
服務端過載或維護中,通常是暫時的 |
| 連線逾時、DNS 解析失敗 | TRANSIENT |
網路層的暫時性問題,與請求內容無關 |
HTTP 400 Bad Request(schema 不符) |
INVALID_REQUEST |
請求本身有問題,重送不會改變結果 |
| 已知會產生副作用的 action(如已送出的付款) | UNSAFE_TO_REPLAY |
即使技術上可以重送,業務風險不允許 |
| SDK 拋出未分類的例外 | UNKNOWN |
保守處理,預設不重試,等釐清後再放行 |
這張表格最後一列刻意選擇「保守」而不是「樂觀」:遇到不認識的錯誤就假設它不該被重試,等有人實際排查、確認這類錯誤確實安全可重試之後,再把它挪到 TRANSIENT 或 OVERLOAD。反過來設計——遇到不認識的錯誤預設可以重試——聽起來比較「不會漏掉真正該重試的情況」,但代價是任何新出現、還沒被分類的錯誤,都可能被無限制地重送,這正是前面 retry storm 案例裡實際發生的事。
Day 12 的練習不要求你模擬真實 provider outage。
先用 deterministic fixture 確認安全邊界,成本低,也不會把測試流量打到外部服務。
請由讀者自行操作;以下指令沒有在本次撰寫中執行。
先進入 Day 12 的獨立專案:
cd Day12/DIY
uv sync
uv run python scripts/verify_fixtures.py
執行前,為每個 fixture 寫下你預期會發生的事。
不要只看 process exit code。
| Fixture | 觸發輸入 | 預期可見結果 | 必須證明的停止行為 |
|---|---|---|---|
empty_retrieval |
空文件集合 | insufficient_context |
不呼叫 model,不產生 answer |
llm_timeout |
fake model timeout | llm_timeout 或受控降級 |
attempt 不超過上限,deadline 不被穿透 |
tool_schema_mismatch |
缺少必要欄位的 action | tool_validation_failed |
tool executor 沒被呼叫 |
parser_error |
非 JSON 或壞 contract | parser_error |
不交付 raw output 當 answer |
agent_loop_limit |
重複相同 tool input | agent_loop_limit |
loop 在上限前結束 |
如果你的驗證腳本只印出 PASS,請打開 fixture result,確認它至少包含 status、node 與可關聯的 request identifier。
一個只說「通過」的測試,日後很難告訴你是哪個 contract 改壞了。
如果先看到綠色的 ✓ All verification checks passed! 才回頭檢查 fixture 內容,大腦很容易把「測試通過」直接等同於「這個 fixture 涵蓋了正確的情境」——但兩件事其實獨立。一個 fixture 可能結構完整、status 合法、也順利通過驗證,驗證的情境本身卻可能是錯的(例如 insufficient_context 的門檻設得太寬鬆,導致明明該觸發卻沒觸發)。先寫下預期,再對照實際結果,才能檢查「這個 fixture 有沒有測到我以為它在測的東西」,而不只是「有沒有跑起來」——工具能保證斷言有沒有成立,不能保證斷言本身有沒有抓對問題,後者永遠需要人在跑測試前先想清楚。
跑完後,逐條回答:
這個 failure 是 technical、quality、safety,還是 task completion 問題?
client 會知道這次沒有完成嗎?
下一次自動重試是否安全?
哪個 node 產生了決策?
trace 與 log 能否以 request_id / trace_id 關聯?
這個 fixture 是否漏掉了某個有副作用的 action?
前四題應從 response contract 與 fixture assertion 就能回答。
最後兩題通常需要觀察資料模型與整合環境,不能靠單元測試假裝已驗證。
def unsafe_handle_timeout(client, payload):
for _ in range(10):
try:
return client.run(payload)
except TimeoutError:
continue
return {"ok": False}
這段程式看起來很努力,實際上缺少 deadline、backoff、錯誤分類、idempotency 與 evidence。
它也沒有告訴呼叫端:是 timeout、預算耗盡,還是 action 可能已經被對方接受。
最糟的情況不是程式最後回 False。
是第九次 request 已經建立 ticket,第十次 timeout 又讓系統相信什麼都沒發生。
拿這段 unsafe_handle_timeout 對照本篇前面 ③ 段的 call_model_with_deadline 和 ⑮ 段的 decide_retry,可以清楚看到差異不在「重試次數」,而在「每次重試前有沒有回答足夠的問題」:
unsafe_handle_timeout 的隱藏假設:
「只要重試次數夠多,總有一次會成功」
→ 沒有問:這次操作重放安全嗎?
→ 沒有問:使用者的 deadline 還在嗎?
→ 沒有問:這是暫時性錯誤,還是請求本身就有問題?
改寫後的版本應該長這樣:
收到 TimeoutError
→ 查 is_idempotent:這個 action 是否安全重放?
→ 查 remaining_deadline_ms:使用者還能等多久?
→ 查 attempt vs max_attempts:預算還有嗎?
→ 都通過才 retry,並記錄 retry_scheduled 與 attempt 編號
如果把這個壞示範也寫成一個 fixture(輸入:一個會連續 timeout 十次的 mock client;預期輸出:在第 max_attempts 次之後停止,且回傳的 status 明確標示是 llm_timeout 還是 agent_loop_limit,而不是一個語意不明的 {"ok": False}),跑 verify_fixtures.py 時就能立刻抓到「這段程式碼沒有回傳可追問的證據」這個問題——這正是 fixture-based 驗證真正的價值:它不是用來證明程式碼「能執行」,而是用來檢查程式碼「失敗的方式夠不夠誠實」。
incident 時最常見的句子是:「我們看到很多 timeout。」
這還不夠。
你需要能把圖上的異常,落到某次 workflow、某個 node、某個版本與一個可重跑案例。
Metric spike
→ deployment / model route / index version
→ trace_id 與 workflow node
→ structured log 的安全摘要
→ 已去識別化 fixture candidate
→ regression test
每一段都應有清楚的 join key。
request_id 可用來串 API log;trace_id 適合串跨 service span;task_id 則在非同步 workflow 或人工複核時維持同一件工作的身分。
不要把完整 prompt 當 join key。
它不穩定,也容易把敏感內容帶進不該看到的系統。
requires_human_review」。這個流程刻意不把「重現了模型原文」列成目標。
對非確定性模型而言,安全決策和 contract 通常比逐字輸出更值得固定。
如果你需要檢查 prompt change 是否仍符合品質,Day 21 的 dataset 與 evaluator 會接手;Day 12 先把不安全或不可交付的路徑擋在邊界。
回頭套用④段提過的 Replit 資料庫刪除事故:凍結 metadata(node 是 tool_call,status 本該是 tool_validation_failed 卻被放行執行);縮成不含真實 schema 與憑證的 fixture(input 是「查詢回傳空結果+當前 policy 為 code_freeze」);寫出 expected safe outcome(status 應為 tool_validation_failed,安全動作是拒絕執行並標記人工確認);讓這個 fixture 在修正前失敗、修正後(加上 freeze 狀態檢查)通過;原始的完整 trace 留在權限受控系統,fixture 只保留最小重現資訊。
這正是 Day12/DIY/app/fixtures.py 裡 tool_schema_mismatch 這類 fixture 背後真正的設計精神:它們看起來只是幾行 Python dict,但每一個都對應著「某個真實或可預見的情境,一旦被系統誤放行會造成什麼後果」的具體推演。寫 fixture 不是為了湊測試覆蓋率,而是把「我們已經知道這種情況很危險」這件事,變成一個不會被下一次重構意外刪掉的斷言。
完成 DIY 後,追加檢查下列項目。
[ ] 每個 workflow node 有 machine-readable status,而非只有 exception message。
[ ] response body 能區分「可重試」與「需要使用者補資料」。
[ ] retry 決定同時檢查 deadline、attempt budget 與 idempotency。
[ ] timeout retry 有可查的 attempt 與最終停止理由。
[ ] parser 或 validator 失敗時,raw output 不會越過 response boundary。
[ ] tool validation fixture 能證明 executor 沒有被呼叫。
[ ] metric、log、trace 至少可用 request_id 或 trace_id 對回同一 request。
[ ] incident fixture 與原始敏感 trace 有不同資料保存邊界。
[ ] 以上屬於讀者自行執行後才能取得的證據;本文未執行 DIY。
這份清單的目的不是多收集幾個 status。
它要讓 failure 有停止點、使用者結果與調查路徑。
清單裡每一項打勾容易,但打勾背後要能回答具體問題,否則這張清單只是自我安慰。以其中兩項為例:
「retry 決定同時檢查 deadline、attempt budget 與 idempotency」這一項打勾,代表你能具體回答:如果現在剩餘 deadline 只有 800ms,下一次 retry 的 backoff 會不會直接被跳過?如果這個 action 帶有副作用(例如已經送出一次扣款請求),重試前有沒有先查 idempotency key 確認上一次呼叫沒有部分成功?如果答不出這兩個具體情境,代表 retry policy 目前只是「重試次數設成 3」的表面實作,而不是本篇 ⑮ 段講的那種真正可審查的判斷式。
「incident fixture 與原始敏感 trace 有不同資料保存邊界」這一項打勾,代表你能具體指出:原始 trace(可能包含使用者個資、完整 prompt 內容)存放在哪個保存週期較短、存取權限較嚴的地方;縮減後的 fixture(拿掉個資、只留下重現 bug 所需的最小欄位)存放在哪個可以進版本控制、供全體工程師查閱的地方。如果兩者其實放在同一個資料夾、用同一組存取權限,代表 ⑰ 段講的「兩種證據不能互相冒充」在你的系統裡還只是一句口號。
清單真正的用途是拿來問自己這種具體問題,而不是拿來湊一份「已完成」的驗收報告。
HTTP 200
≠ workflow completed
≠ answer grounded
≠ tool action authorized
≠ response safe to deliver
AI workflow 的可靠性不是把 timeout 降到零,也不是逼 Retriever 永遠找到文件。更務實的做法是:知道哪個節點失敗,保留足夠證據,然後在不能安全完成時停下來。
下一篇會把問題拉回 infrastructure:當 provider、vector store 或任何依賴失效時,什麼是 SPOF?什麼是多開幾台卻沒有真的 failover?
這篇是 Learning SRE for the AI Era 系列的一部分。
Build → Trace → Break → Measure → Evaluate → Recover → Improve.