結論先說:deadline、有限 retry 與 circuit breaker 只是半套;沒有誠實的降級回應、看得懂「誰在重試」的觀測,以及先定義好的 SLI 與 admission 順序,resilience 設計在事故現場一樣會失靈。
一句話摘要:技術上撐住不是重點,讓使用者拿到誠實的結果、讓 on-call 五分鐘內知道先做什麼,才是 resilience 真正要交付的東西。
上篇處理了容量觀察、AI workflow 的 retry 陷阱、service contract、deadline 分層、backoff jitter 與 circuit breaker 的狀態機。下篇接著處理:技術故障怎麼翻譯成誠實的 API response、metrics/logs/traces 如何回答「誰在重試、為何停止」、fallback 何時才算真的成功、admission control 怎麼保護最重要的工作,以及一次完整的 failure injection 操作流程與 runbook。
要避免這種 response:
{
"status": "ok",
"answer": "公司遠端工作每週可以三天。"
}
當 retriever 或 provider 已失效,這段回答沒有可驗證來源。
比較誠實的 contract 可以長這樣:
{
"technical_status": "degraded",
"workflow_status": "provider_unavailable",
"quality_status": "not_evaluated",
"safety_status": "not_applicable",
"task_status": "not_completed",
"answer": null,
"user_message": "目前無法完成查詢,請稍後再試。",
"request_id": "example-request-id"
}
若可安全使用快取,則必須加上版本資訊:
{
"technical_status": "degraded",
"workflow_status": "cache_fallback",
"quality_status": "not_fresh",
"task_status": "completed_with_stale_data",
"source_version": "policy-index-v12",
"source_updated_at": "TODO",
"user_message": "以下內容來自較早的已驗證資料。"
}
completed_with_stale_data 是否算好事件,要由 SLO 定義決定。
不能在 incident 後才把它偷偷算進 success。
上面的 contract 範例解決了「回什麼」的問題。但還有一層更早的決策,這篇文章目前為止還沒直接講到:如果系統同時有好幾種功能都受到影響,哪一個先降級、哪一個要撐到最後,這件事應該在什麼時候決定?
2012 年聖誕夜,Netflix 在美國東岸遇到間歇性故障,剛上線不久的 SLO 監控立刻發現一件事:他們 99.9% 可用性的月度 error budget,在短短六個小時內就被燒光了。Netflix Architecture: Lessons Learned from Holiday Season Failures
那一刻,團隊面前擺著兩個選項:
選項一:忽略 error budget 已耗盡的事實,全力搶修
風險:如果修不好,聖誕夜這個全年最高流量的時段
可能完全宕機
選項二:主動關閉非核心功能,換取核心功能撐住
代價:使用者會明顯感覺到某些功能不見了
Netflix 選了第二種。他們把推薦系統暫時關掉,暫停新用戶註冊,把介面簡化——這些平常被視為「重要但非必要」的功能全部先讓路,只保留一件事:讓使用者按下播放鍵之後,串流真的能開始播放。
結果是 Netflix 在那年最高流量的時段,避開了完全宕機。
這個案例值得注意的地方,不是「degradation 有用」——這一點本文前面已經談過很多次。真正的重點在於這個決策鏈是誰、在什麼時候定下來的:
這正好呼應本文⑫段落 P0/P1/P2 的分級精神:request class 的優先順序表,不該在流量真的爆掉的那一刻才臨時畫出來。同一個原則,從「該不該接受新請求」延伸到「該不該保留某個現有功能」,本質上是同一種決策,只是動用的時機不同。如果連「哪些功能可以先犧牲」都要等到事故現場才討論,degradation 在 runbook 裡就只是一個名詞,不是真的能被執行的動作。
Day 10 到 Day 20 已經建立了 metrics、logs、traces 與 quality outcome 的分工。
今天補的是 resilience context。
Prometheus metric 不要帶 request_id。
它適合用來看總量、比例與變化:
ai_dependency_attempts_total{
dependency="primary_model",
outcome="timeout"
}
ai_dependency_retry_total{
dependency="primary_model",
reason="bounded_transient_retry"
}
ai_circuit_breaker_state{
dependency="primary_model"
}
ai_degraded_responses_total{
reason="provider_unavailable"
}
ai_request_queue_wait_seconds_bucket{
workflow="policy_qa"
}
若 request_id 放在 label,幾千個 request 就會製造幾千條 time series。
那會讓監控系統先遭遇容量問題。
is_retry 維度時,incident 現場會發生什麼事上面 ai_dependency_attempts_total 這條 metric 看起來已經涵蓋了 attempt 這個維度,但少一個很容易被忽略的欄位:這次呼叫,究竟是一個新的原始請求,還是同一個原始請求的第 N 次重試?
2025 年 9 月 12 日,Cloudflare 的 Dashboard 與 API 發生一次故障,根因跟今天講的內容完全對應。Cloudflare Blog:A deep dive into Cloudflare's September 12, 2025 dashboard and API outage
觸發點是一個前端 bug:Dashboard 的 React 程式碼裡,某個 useEffect 的依賴陣列包含了一個「每次 render 都會被重新建立」的物件,導致這個 effect 應該只執行一次,卻在每次渲染時都重新觸發一次,對後端 Tenant Service 送出重複的 API 呼叫。短時間內,Tenant Service 收到的流量暴增到平常的數倍,超過了原本的容量預估。
事故排查期間,Cloudflare 自己在事後報告裡承認了一個關鍵的監控間隙:
「我們很難快速區分新的請求與重試。」
團隊當下面對的畫面是:某條 metric 的請求量暴增了,但無法立刻判斷這是「真實使用者變多了」還是「某個元件的重試風暴把自己的流量疊高了」。前者可能需要擴容,後者需要先找到並修掉觸發重試的那個 bug——擴容反而只是讓更多重試打進系統。監控系統沒有預留足夠的維度去回答「這次呼叫是新請求還是重試」,這個資訊缺口拖慢了根因判斷的速度。
把這個教訓翻回今天的 metric 設計,ai_dependency_attempts_total 這條 metric 光有 dependency 與 outcome 兩個 label 還不夠完整,補上一個低基數的 is_retry(布林值,不是 attempt 編號)維度,才能讓 on-call 在 incident 現場第一眼就分辨「這波流量突增,有多少比例是原始請求、多少比例是重試」:
ai_dependency_attempts_total{
dependency="primary_model",
outcome="timeout",
is_retry="false"
}
ai_dependency_attempts_total{
dependency="primary_model",
outcome="timeout",
is_retry="true"
}
這個欄位是布林值,基數只有兩種取值,不違反前面「metric 不放高基數欄位」的原則,卻能直接回答 Cloudflare 那次事故裡卡住團隊的問題:這波暴增,是新使用者,還是我們自己的 retry loop 在跟自己過不去。
log 適合保留可關聯的細節:
{
"event": "dependency_retry_scheduled",
"request_id": "example-request-id",
"trace_id": "example-trace-id",
"dependency": "primary_model",
"attempt": 1,
"max_attempts": 2,
"failure_kind": "timeout",
"backoff_ms": 137,
"remaining_deadline_ms": 451,
"prompt_version": "policy-qa-v7"
}
這個 log 讓 on-call 能回答:
可以替 workflow 建立這類 span 關係:
POST /ask
├─ admission_control
├─ retrieval
├─ primary_model attempt=1
├─ retry_backoff attempt=1
├─ primary_model attempt=2
└─ degradation_response
每個 attempt 可以加 attribute:
ai.dependency.name=primary_model
ai.retry.attempt=2
ai.retry.max_attempts=2
ai.failure.kind=timeout
ai.deadline.remaining_ms=188
ai.breaker.state=closed
避免把完整 prompt、使用者個資或未遮罩文件放進 span attribute。
trace 是診斷工具,不是敏感資料的旁路備份。
每一份 evidence 至少要能串起:
request_id
trace_id
workflow_name
workflow_version
deployment_id
model_route
prompt_version
沒有版本資訊的 retry 圖,會讓你知道「很多人在重試」,卻不知道哪次變更導致它開始發生。
primary provider 掛了,fallback 回了回答。
那算成功嗎?
答案取決於服務承諾。
對低風險摘要服務,fallback 可能是成功。
對人資規範或醫療建議,fallback 若沒有通過來源驗證,可能只能算受控失敗。
technical availability
API 能否回受控結果?
workflow availability
workflow 是否跑完正確分支?
quality success
回答是否有足夠來源支持?
task success
使用者目標是否真的完成?
以下 policy 是一個起點:
sli_name: policy_qa_completed_task_ratio
good_event:
technical_status: success
workflow_status: completed
quality_status: pass
safety_status: pass
task_status: completed
excluded_event:
- client_cancelled_before_admission
degraded_event:
- provider_unavailable
- cache_fallback
degraded_event 是分類,不等於好事件。
要不要把安全且已告知陳舊度的 cache fallback 算進 SLI,需要產品與風險 owner 共同決定。
下列情況都值得建立候選 alert:
告警門檻必須根據 baseline 與使用者影響校準。
本文不替任何 production 環境設定固定數字。
當每個 request 都進來,系統只剩兩種命運:變慢或崩潰。
admission control 讓系統多一個選擇:在入口就根據容量與優先級拒絕或延後部分工作。
這不討喜。
但明確的 429 或「請稍後再試」通常比等待二十秒後的模糊錯誤好。
P0:人類正在互動、且具有明確 deadline 的讀取請求
P1:可稍後完成的摘要或報表
P2:批次 reindex、experiment、非緊急 evaluation
這不是固定分級。
它要反映你的產品與使用者風險。
但至少要讓 batch 工作不會在尖峰時搶走人類互動的全部 capacity。
「admission control」聽起來像是入口設一道 429 就了事。Stripe 的公開工程文章說明了真實生產環境通常需要更細的分層。他們面對的問題很直接:單一使用者的突發流量,或一支失控的腳本,就足以拖垮所有人共用的基礎設施。他們疊出的四層防護,剛好對應「入口拒絕」到「事故時的最後防線」這條光譜:Stripe Engineering Blog:Scaling your API with rate limiters
Request Rate Limiter
單一使用者每秒請求數上限(token bucket)
↓ 仍過量
Concurrent Requests Limiter
同一使用者「進行中」請求數上限
↓ 仍過量
Fleet Usage Load Shedder
為關鍵操作保留容量,丟棄非關鍵請求
↓ 仍過量
Worker Utilization Load Shedder
依優先序漸進丟棄(先丟 test mode 流量)
這條光譜最有意思的地方在觸發頻率:Request Rate Limiter 單月拒絕了數百萬次請求;Concurrent Limiter 每月觸發約一萬兩千次;而作為最後防線的 Worker Utilization Shedder,全月只拒絕了約一百個請求。三層之間差了四個數量級。
這個數字分布說明一件事:多數過載,是被最前面、最溫和的那層擋下來的。真正需要動用「主動丟棄使用者請求」這種痛苦決策的情況,其實很少發生——但正因為少,才更需要事先設計好,而不是等它發生時才臨場決定要丟誰。對照本文 P0/P1/P2 的分級,Stripe 案例裡的「test mode 流量優先被丟」正是一個具體的 P2 範例:它不影響真實交易,卻能在事故時第一個被犧牲,換取 P0 流量的容量。
實作上還有一個容易漏掉的細節:四層都用 Redis 維護 per-user 的 token bucket,限流邏輯本身也設計了 fallback——Redis 故障時,限流器不能連帶變成新的單點故障,讓「保護系統」的機制反而先讓系統掛掉。這呼應本文一路強調的原則:resilience 機制本身也是系統的一部分,同樣需要被設計成不會在故障時把問題疊加上去。
from threading import BoundedSemaphore
class AdmissionGate:
def __init__(self, max_in_flight: int) -> None:
self._slots = BoundedSemaphore(max_in_flight)
def try_enter(self) -> bool:
return self._slots.acquire(blocking=False)
def leave(self) -> None:
self._slots.release()
handler 可以依 try_enter() 的結果:
allow → 執行 workflow
reject → 回受控 overload response
這段不處理 multi-instance coordination。
它也不會自動得出 max_in_flight。
那個數字要從 dependency concurrency、deadline、服務資源與實測決定。
{
"technical_status": "rejected",
"workflow_status": "admission_control",
"task_status": "not_started",
"retry_after_seconds": "TODO",
"request_id": "example-request-id"
}
若你不確定何時可以重試,就不要硬填一個看似精準的秒數。
可以先不提供 retry_after_seconds,並在產品介面上說明暫時繁忙。
以下是讀者可在隔離 DIY 環境自行完成的流程。
不要把這些步驟對 production provider、真實資料庫或有寫入權限的 tool 執行。
在沒有注入故障前,紀錄:
- request rate
- queue wait
- primary model latency
- retry count
- breaker state
- degraded response ratio
- task status 分布
如果沒有歷史 dashboard,至少把單次 smoke run 的觀察記錄為 baseline。
它不是 SLO 證明。
它只是這次實驗的比較基準。
先選 hard_timeout scenario。
將 fake client 的 fail_with_timeout 設為 True。
不要同時加入 slow retriever、錯誤 tool schema 與 provider timeout。
一次塞三種問題,只會得到一條無法解釋的 trace。
可用你自己的 HTTP client 或測試函式呼叫 /ask。
請固定記錄 request_id。
預期不應是「模型答對」。
預期是:
attempt 不超過設計上限
backoff 不超過剩餘 deadline
breaker 狀態有可解釋轉換
response 是明確 degradation
不存在 write action
以低流量、隔離測試的方式重複呼叫。
觀察到門檻後,新 request 應 fail fast,而不是每條都重新等待 timeout。
記錄:
- 第幾次失敗後 breaker open
- open 後 latency 是否下降
- retry metric 是否停止增加
- 使用者 response 是否一致
將 fake client 改回 healthy。
等待你的 cooldown。
接著只讓受控 probe 或少量 request 通過。
成功後 breaker 應回 closed。
若 probe 仍失敗,breaker 應回 open。
這一步驗證 recovery,不只是 failure。
實驗記錄最少包含:
## Experiment record
- scenario: hard_timeout
- environment: isolated DIY only
- request_id: TODO
- workflow_version: TODO
- fake dependency configuration: fail_with_timeout=true
- expected: bounded retry then degradation
- observed: TODO
- unexpected behavior: TODO
- rollback: fake dependency restored to healthy
- unresolved question: TODO
observed: TODO 在尚未自行跑過前是正確寫法。
不要把文章範例的預期結果冒充成你已驗證的結果。
同樣是 provider timeout,觀測到的圖可能代表不同問題。
retry attempts ↑
queue wait →
breaker closed
task success 略降
可能代表 transient error 被有限 retry 吸收了。
也可能代表流量還太小,看不出壓力。
下一步不是宣布策略成功。
而是確認 token cost、總 latency 與 task outcome 是否仍在可接受範圍。
retry attempts ↑
queue wait ↑
in-flight ↑
provider latency ↑
這比較像重試正在放大過載。
下一步可檢查:
breaker open ↑
API latency ↓
task success ↓
degraded response ↑
這不一定是 bug。
breaker 本來就會犧牲部分 work,保護整體服務。
但它提醒你要和產品確認 degradation 是否可接受,以及 cache、queue 或人工路徑能否補上需求。
「latency 變好」本身不是好消息。
它也可能只是系統更快地拒絕使用者。
寫一份最小 runbook,不要等事故來了才找答案。
# Primary model dependency slow / unavailable
## Trigger
- breaker open ratio rises, or
- queue wait approaches workflow deadline, or
- degraded response ratio exceeds the agreed baseline.
## First checks
1. Confirm deployment_id, model_route, prompt_version, and index version.
2. Check provider latency and timeout rate.
3. Check in-flight requests and admission rejection ratio.
4. Confirm whether retries are bounded and whether breaker is open.
5. Inspect one correlated trace using request_id and trace_id.
## Safe actions
1. Pause non-critical batch work.
2. Enforce the existing admission-control policy.
3. Keep write actions in pending / human-review mode.
4. Use only pre-approved fallback routes.
## Do not do
- Do not globally increase timeout without checking queue growth.
- Do not increase retry count during an active dependency incident.
- Do not route high-risk actions to an unevaluated fallback model.
- Do not claim cache content is fresh when its version is unknown.
## Escalation evidence
- incident window
- dependency and breaker state
- affected workflow / version
- sampled request_id and trace_id
- degraded response ratio
- token and cost change, if available
runbook 的目標不是替 on-call 做所有判斷。
它是先阻止最危險的直覺反應:把 timeout 拉長、把 retry 加大,然後希望事情自己好。
一個成熟系統不會把 retry 視為免費保險。
它會為 retry 設 budget。
概念上可以監控:
retry_ratio = dependency_attempts / original_requests
當 retry ratio 高於正常範圍,代表服務做了額外工作。
即使原始 request rate 不變,dependency 的負載也已變大。
以下設定不能只存在某段程式碼裡:
| 設定 | 需要的決策 |
|---|---|
| timeout | 使用者 deadline 與下游 latency 分布 |
| max attempts | failure 類型、成本、冪等性 |
| retryable errors | 明確 error taxonomy |
| breaker threshold | 故障域與可承受的 probe 數量 |
| fallback route | quality、safety 與相容性評估 |
| degradation response | 產品、法遵與風險接受度 |
| cache staleness | 資料 owner 與更新承諾 |
若沒有人對一項設定負責,incident 時它就會變成「以前有人填的數字」。
每次設計 fallback 時,問:
這條路徑會不會讓模型在沒有來源時回答?
這條路徑會不會讓一個未知狀態的 action 被做兩次?
兩題中任一題答不清楚,就不該把它當自動 fallback。
讀者完成自己的隔離 DIY 後,可逐項核對:
韌性不是每個請求都要完成。
它是服務在壓力下還能做出一致選擇:保留重要工作、停止無用重試、保護下游,並且不拿未驗證答案冒充成功。
capacity 規畫讓你看見排隊何時開始。
deadline 讓每一層知道何時停止。
retry 與 jitter 適合處理少量、暫態、可安全重送的失敗。
circuit breaker 阻止已知失敗的 dependency 吃掉更多資源。
degradation 把技術故障翻譯成對使用者誠實的結果。
下一篇不會假設這些設計真的有效。
我們會用 controlled chaos experiment 去驗證它。
Day 30 會把 provider timeout、retrieval failure 與 tool failure 變成有 hypothesis、blast radius、rollback 與 evidence 的實驗。
今天寫的 deadline、retry、breaker、degradation contract,就是明天的實驗假設。
這篇是 Learning SRE for the AI Era 系列的一部分。
Build → Trace → Break → Measure → Evaluate → Recover → Improve.