iT邦幫忙

2026 iThome 鐵人賽

0
AI Engineering

Learning SRE for the AI Era:從 SRE Lab 到 Production AI Reliability系列 第 49 篇

Day 29(上)|Capacity、Retry、Circuit Breaker、Degradation:別用重試把事故推給下游

  • 分享至 

  • xImage
  •  

GitHub:darkstar1227/learning-sre-for-ai-era

結論先說:capacity headroom、deadline、有限 retry、backoff 加 jitter 與 circuit breaker 必須一起設計;只加 retry,常會把一個下游的短暫故障放大成整個服務的排隊事故。degradation 的部分留到下篇。

一句話摘要:系統快撐不住時,最有價值的能力不是「再試一次」,而是知道哪一種請求值得等、哪一種應立即停下。

Day 28 把 prompt、model、index 與 tool schema 納入 change management。

今天處理另一個更狼狽的現場:版本沒有變,流量卻突然放大,或某個 dependency 開始變慢。

這時候 API 可能還會回 200。

但 worker 已經被等待中的請求塞住。

排隊變長。

timeout 接連發生。

retry 又把更多工作送回下游。

很快地,一個原本局部的問題就變成事故。

本文的實作內容是給讀者在隔離 DIY 環境自行操作的設計與程式碼。

我不會在這裡建立、安裝、啟動或驗證任何 DIY 專案,也不會碰 production dependency。

① 先把問題講清楚:一個慢 dependency 如何拖垮整條 /ask

假設服務處理一條企業政策問答流程:

POST /ask
  ↓
request admission
  ↓
retriever
  ↓
prompt builder
  ↓
primary model provider
  ↓
validator
  ↓
response

正常時,primary model 在 1.2 秒左右完成。

尖峰時,provider 的 p95 變成 6 秒。

如果 API handler 沒有 deadline,每個請求都會一直等。

同時進來的請求累積在 worker、connection pool 與 queue 裡。

接著有人看見 timeout,補了一段「失敗就 retry 三次」的程式。

事情沒有變好。

每個原始請求現在最多會製造四次下游呼叫。

若一百個人同時送出問題,provider 面對的不是一百個請求,而可能是四百個彼此重疊的請求。

這就是 retry amplification。

對 LLM workload 而言,放大的不只 QPS。

還有 token、排隊時間、connection、GPU slot,以及雲端帳單。

先把一條失敗鏈畫出來:

provider latency ↑
  ↓
application in-flight ↑
  ↓
worker / connection pool 被占住
  ↓
queue delay ↑
  ↓
client deadline 到期
  ↓
application retry ↑
  ↓
provider load 再次 ↑
  ↓
更多 timeout

這不是「程式碼寫得不夠積極」。

它是排隊系統的自然反應。

Google SRE 對 overload 的建議也把 load shedding、queue management 與明確的服務優先級放在一起看,而不是把所有工作都硬塞進後端。Google SRE Book:Handling Overload

② Traditional SRE:容量先看排隊,再看 CPU 圖

很多容量討論從 CPU 70% 開始。

CPU 當然重要。

但對一個主要等待外部 provider 的 API,它往往不是第一個飽和點。

請求可能在等待網路、等待 connection、等待模型 queue,或等待某個同步 semaphore。

此時 CPU 看起來甚至很悠閒。

使用者卻已經在等。

Little's Law 是實務提醒,不是考題

在穩定狀態下,Little's Law 可寫成:

L = λ × W

L 是系統內平均工作數。

λ 是到達率。

W 是每件工作在系統停留的時間。

如果到達率不變,provider latency 從 1 秒拉到 8 秒,in-flight work 就會接近八倍。

不需要流量暴增,queue 也會爆。

這條公式不會替你選 timeout。

它會提醒你:latency 變差時,容量問題已經在發生,即使錯誤率還沒上升。

一個常見誤解:以為 Little's Law 只適用「整條系統」

第一次接觸 Little's Law 的人,很容易把它當成一個只能套在「整個服務」上的巨觀公式:算出全站的 L、λ、W,得到一個總結性的數字,然後結案。

這是一種常見誤解。

Little's Law 對「任何一個穩定狀態的排隊子系統」都成立,不只是整條 /ask。

它可以套在整個 workflow,也可以分開套在每一段:

retriever 這一段
  L_retriever = λ × W_retriever

primary model 這一段
  L_model = λ × W_model

validator 這一段
  L_validator = λ × W_validator

分開套用的價值在於:它會告訴你 in-flight work 具體堆在哪一段,而不是只告訴你「系統整體變擠了」。

如果只算整條 workflow 的 L,你只能看到一個總和;把公式拆進每一段之後,才看得出「原來 90% 的 in-flight work 堆在 model 呼叫這一段,retriever 幾乎沒有排隊」——這個資訊決定了你該去哪裡加 headroom,而不是整條路徑都無差別擴容。

另一個常見誤解:以為 GPU 推論的排隊行為跟一般 API 一樣

傳統 Web API 的排隊模型通常假設「一個 worker 一次處理一個 request」,request 數與 worker 數線性對應。

GPU 推論不是這樣運作的。

多數推論服務會做 dynamic batching:GPU 不是一次跑一個 request,而是把短時間內到達的多個 request 合併成一個 batch 一起送進模型,藉此提高 GPU 利用率。

這件事對 Little's Law 的套用方式有具體影響:

傳統 API worker pool
  in-flight ≈ 正在處理的 request 數
  多一個 worker,大致多一分處理能力

GPU batch inference
  in-flight 可能都卡在「等待湊滿一個 batch」
  batch size 開太大 → 平均等待時間拉長,但吞吐量提高
  batch size 開太小 → 延遲低,但 GPU 使用率低、單位成本高

也就是說,GPU 推論服務的 W(單一請求的停留時間)本身包含一段「等待被湊進 batch」的時間,這段時間跟後面模型真正計算的時間,是兩種完全不同性質的延遲來源——前者是排隊策略決定的,後者是模型大小與硬體決定的。如果只看整體 latency,你分不出這次變慢是「batch window 設太保守」還是「模型本身真的算得比較慢」。這正是為什麼今天表格裡把「dependency」與「應用程式」拆成兩層分開量測:GPU batching 這類排隊行為,往往藏在「dependency latency」這個籠統的數字底下,不會自己浮出來。

先量四個地方

對今天的 /ask,先把容量觀察拆成四層:

位置 要量什麼 為什麼
入站 request rate、reject count、client deadline 判斷流量與是否已開始拒絕
應用程式 in-flight、worker utilization、queue wait 找到服務自己是否塞車
dependency latency、timeout、breaker state 判斷慢的是誰
工作流 retry attempts、fallback rate、task outcome 判斷「成功」是否只是技術假象

只看 provider error rate 有一個盲點。

provider 變慢但還有回應時,error rate 可以接近零。

你的 user journey 仍然可能已經超時。

capacity headroom 不是一個神奇百分比

有人會說:「保留 30% headroom。」

它可以是起點,不能是答案。

真正需要回答的是:

  • 流量尖峰會在幾秒內到來,還是幾分鐘內到來?
  • 新 worker、GPU pod 或 connection pool 要多久才可用?
  • dependency 的安全吞吐量是多少?
  • 哪些 request 可以延後,哪些不能?
  • 系統能否在 provider 慢時回傳可接受的降級結果?

若 autoscaling 需要五分鐘才拉起,而行銷活動在十秒內灌進流量,autoscaling 不是你第一道保護。

admission control、queue limit 與快取才是。

一個不要假裝精準的容量記錄表

開始時,寧可誠實記錄假設:

workflow: policy_qa
observation_window: TODO
steady_request_rate_rps: TODO
observed_p95_latency_seconds: TODO
max_safe_in_flight: TODO
queue_wait_budget_seconds: TODO
primary_provider_limit: TODO
fallback_provider_limit: TODO
assumption_owner: TODO

TODO 不好看。

但它比填一個沒有量測來源的「100 RPS」可靠。

等到有隔離環境的 load test 與 trace,再用觀察值替換它。

③ AI Era Extension:重試會重新花 token,也可能重送 action

傳統 HTTP dependency 的 retry 已經要小心。

AI workflow 多了幾層麻煩。

模型呼叫不是免費的字串函式

同一個 prompt 重送一次,通常會新增一次 input token 與 output token 計費。

若模型其實已經完成,只是 client 在回應路上超時,重送還可能產生兩份不同回答。

如果第二次回覆被接受、第一次晚到,trace 上會出現看似「成功」的兩次 call。

產品上則可能出現兩個不一致的結果。

Agent retry 不能假設冪等

讀取型 retriever query 通常能安全重試。

「建立付款」、「寄送通知」、「更新工單」這類 tool action 則未必能。

下游回 timeout 不代表它沒有執行。

它可能已經成功寫入,只是回覆丟了。

因此非冪等 action 的預設策略不是盲目重送。

而是:

確認 idempotency contract
  ↓
使用 idempotency key 或 query action status
  ↓
無法確認時停止,交由人工或明確標記 pending

把「沒有收到成功回覆」翻譯成「一定沒做」是事故常見的前半段。

idempotency key 實際上在做什麼

「使用 idempotency key」不是打開一個開關,而是一份需要 client 與 server 共同遵守的合約;只加一個參數不會自動產生保護效果:

1. client 在第一次呼叫前,自行產生一組唯一 key(例如 UUID)
2. client 把這個 key 放進 request(header 或 body)
3. server 收到請求時,先查這個 key 是否已處理過
   已處理 → 直接回傳當初的結果,不重新執行 action
   未處理 → 執行 action,並把「key → 結果」存起來
4. client 因 timeout 重送同一個 request 時,帶的是同一把 key
   server 因此能辨認「這是同一個意圖的重送」,不是新的一次付款

關鍵在於:idempotency key 保護的是「同一個使用者意圖只會被執行一次」,不是讓重送本身變安全。它至少需要三件事同時成立,缺一個都會失效:

  • client 端每個邏輯上獨立的操作都產生新 key,重送時沿用舊 key(而不是每次重試都重新產生一把新 key——那樣 server 永遠認不出這是重送)。
  • server 端把「key → 結果」的紀錄,跟實際寫入動作放進同一個交易,不能先寫入資料再非同步記 key,中間那個窗口一樣會重複執行。
  • 這筆 key 紀錄有明確的保留期限,且期限要比 client 可能重送的最大時間窗長,否則 key 過期後的重送又變回未保護的重複執行。

如果這三個條件有一個你答不出來,就代表「idempotency key」目前只是名字,不是真正的保護,前面決策樹裡「確認 idempotency contract」這一步就還沒過。

fallback model 不是品質保證

今天的架構可以是:

Primary model
  ↓ unavailable / timeout
Fallback model
  ↓
validator
  ↓
degraded response or answer

fallback model 可能有不同的 context window、tool calling 格式、延遲與安全行為。

它也可能不能處理 primary model 專用的 prompt。

所以 fallback 需要自己的 evaluation cases,不是把 model 名稱改掉就叫高可用。

如果 fallback 沒有通過 source validation,正確結果可能是 insufficient_context。

不是硬生出一段聽起來流暢的回答。

retry 的成本要用 task outcome 看

以下兩條 trace 都可能 HTTP 200:

Trace A
attempts=1
tokens=900
task_status=completed

Trace B
attempts=4
tokens=4_100
task_status=failed

若 dashboard 只展示 request success rate,A 與 B 幾乎沒有差別。

若看 cost_per_completed_task,差別很大。

這是 Day 23 成本觀測與今天 resilience 設計要接起來的地方。

實例:Retry Storm 的 200 倍成本差異

一個 API 故障期間,無防護的重試在短短 30 秒內消耗了 $2 的 token 成本;同樣的情境下,如果使用 circuit breaker 機制,成本只有 $0.01,相差 200 倍。

這個數字反映的不只是「重試多了 200 倍」,而是:TianPan.co:The Retry Storm Problem in Agentic Systems

失敗的工具呼叫(429 rate limit)
  ↓
盲目重試(每次重新送入 8,000 token 的完整上下文)
  ↓
4 個 LLM 呼叫 × 8,000 token = 32,000 input tokens
  ↓
用於完全「什麼都沒達成」的工作

vs.

失敗的工具呼叫
  ↓
Circuit breaker 立即開啟
  ↓
停止後續重試
  ↓
降級回應

無 circuit breaker 的系統,每一個失敗都變成了一個成本黑洞。這也是為什麼 Day 23 的成本監控與今天的 resilience 設計必須一起看:不是「降低成本」,而是「防止成本被失敗放大」。

④ 先寫 service contract:哪些結果可重試、哪些不可

寫 retry 前,先把錯誤分成可決策的類型。

HTTP status code 不夠。

同樣是 503,讀取型 retrieval 和付款 tool 的處理可能完全相反。

下面用純 Python 資料結構描述今天要保留的 decision context:

from dataclasses import dataclass
from enum import Enum


class OperationKind(str, Enum):
    READ = "read"
    WRITE = "write"


class FailureKind(str, Enum):
    TIMEOUT = "timeout"
    RATE_LIMIT = "rate_limit"
    TRANSIENT_NETWORK = "transient_network"
    VALIDATION = "validation"
    AUTHORIZATION = "authorization"
    UNKNOWN = "unknown"


@dataclass(frozen=True)
class DependencyRequest:
    request_id: str
    operation_kind: OperationKind
    idempotency_key: str | None
    deadline_ms: int


@dataclass(frozen=True)
class RetryDecision:
    should_retry: bool
    reason: str
    max_attempts: int

這段程式的重點不是 class 數量。

它是逼你把 operation_kind 與 idempotency_key 放進 decision 前提。

沒有這些資訊,generic retry decorator 只能猜。

範例策略:把禁止重試寫得比允許重試清楚

def decide_retry(
    request: DependencyRequest,
    failure: FailureKind,
) -> RetryDecision:
    if failure in {FailureKind.VALIDATION, FailureKind.AUTHORIZATION}:
        return RetryDecision(False, "deterministic_failure", 1)

    if request.operation_kind is OperationKind.WRITE:
        if not request.idempotency_key:
            return RetryDecision(False, "write_without_idempotency_key", 1)
        return RetryDecision(False, "verify_write_status_first", 1)

    if failure in {
        FailureKind.TIMEOUT,
        FailureKind.RATE_LIMIT,
        FailureKind.TRANSIENT_NETWORK,
    }:
        return RetryDecision(True, "bounded_transient_retry", 2)

    return RetryDecision(False, "unknown_failure", 1)

這個範例刻意保守。

它對 write action 回傳 verify_write_status_first,沒有直接 retry。

實際產品可以在 idempotency contract 完整時做不同選擇。

但那應由 action owner 明確決定,不能藏在共用 helper 裡。

實例:Rate Limit 不是重試問題,是架構問題

表格中第二行「rate limit」背後有一個經常被低估的現實。在生產環境中,429(Too Many Requests)是團隊規模超過原型階段後最常見的故障。每個主流 LLM provider(OpenAI、Anthropic、Google、AWS Bedrock)都強制施加嚴格的 requests-per-minute 與 tokens-per-minute 限制。

許多團隊的直覺反應是「加上 exponential backoff 重試」。但這樣解決的只是 symptom,不是根本問題。真正的生產策略應該是:

Primary provider 回 429
  ↓
立即檢查 Retry-After header
  ↓
超過預算? → 故障轉移至 fallback provider
  ↓
Fallback 也 429? → 降級回應,不再重試

根本差異在於:naive retry 假設「過一陣子就好了」,但在高流量下,重試會進一步加劇 provider 的過載。正確做法是在 API gateway 層(而非應用層)實施 per-tenant rate limiting,防止單一客戶的突發耗盡整個共享配額,或採用多 API key 負載平衡,讓每個 key 保持在配額上限以下。

這個架構決策不在 retry logic 裡,而在 routing 層。

一張可貼進 runbook 的策略表

失敗 read query write action 使用者結果
connect timeout 有 deadline 的有限 retry 查詢 action status 暫時不可用或 pending
rate limit 尊重 retry-after 或受控退避 不重送未知結果 請稍後再試
validation error 不 retry 不 retry 明確指出輸入問題
authorization error 不 retry 不 retry 拒絕,保留 audit event
empty retrieval 不 retry model 來「猜」 不適用 insufficient_context
schema mismatch 不 retry 同一 payload 不執行 action 安全拒絕與人工處理

表裡的使用者結果必須由產品接受。

「我們不知道要回什麼」不是 engineering 可以用 HTTP 500 解決的問題。

⑤ deadline、timeout 與 cancellation:先決定誰有權等多久

timeout 經常被當成一個常數。

實際上它至少有三個層次:

client deadline
  > service deadline
    > dependency timeout

外層必須比內層長一點,讓服務還有時間整理降級結果、寫必要的 log 或停止後續工作。

若 dependency timeout 比 service deadline 長,service 已經放棄,底下的呼叫卻還在跑。

那是背景資源洩漏的溫床。

讀者可自行放進隔離環境的 deadline helper

from dataclasses import dataclass
from time import monotonic


@dataclass(frozen=True)
class Deadline:
    expires_at: float

    @classmethod
    def after_ms(cls, timeout_ms: int) -> "Deadline":
        return cls(expires_at=monotonic() + timeout_ms / 1_000)

    def remaining_ms(self) -> int:
        remaining = int((self.expires_at - monotonic()) * 1_000)
        return max(0, remaining)


def dependency_timeout_ms(deadline: Deadline, reserve_ms: int) -> int:
    remaining = deadline.remaining_ms() - reserve_ms
    if remaining <= 0:
        raise TimeoutError("service deadline exhausted")
    return remaining

這個 helper 留一段 reserve_ms。

保留時間不是拿來做更多 retry。

它是讓 application 能取消後續步驟、產生受控回覆與記錄 outcome。

反例:每一層都給五秒

client timeout = 5 s
API timeout = 5 s
retriever timeout = 5 s
model timeout = 5 s

看起來整齊。

實際上沒有任何收尾空間。

當 model 花滿五秒才丟錯,API 很可能已經沒時間做 validation 或 degradation。

每層都用同一個數字,也使得 trace 很難看出到底誰先耗掉預算。

cancellation 也需要可觀測性

當 client 斷線或 deadline 到期,application 應盡可能取消還在等待的工作。

取消不是失敗的同義詞。

它是資源保護策略。

請分開記錄:

technical_status=cancelled
workflow_status=aborted_by_deadline
quality_status=not_evaluated
task_status=not_completed

把 cancelled 都算 500,會讓 error rate 很難解釋。

把 cancelled 都算 success,則會把被放棄的使用者旅程洗掉。

⑥ exponential backoff 加 jitter:退避不是固定排隊

最直覺的 retry 看起來像這樣:

for attempt in range(3):
    try:
        return call_dependency()
    except TimeoutError:
        sleep(1)
raise RuntimeError("dependency unavailable")

問題在於大量請求會同時失敗,也會在一秒後同時重試。

下游剛想喘口氣,下一波又整齊地撞上去。

這種同步重試通常被稱為 thundering herd。

AWS 對 timeout、retry 與 backoff 的說明特別提醒:retry 要有限,並用 jitter 避免客戶端在同一時間重送。AWS Builders' Library:Timeouts, retries, and backoff with jitter

一個可讀的 full-jitter 退避函式

from random import uniform


def backoff_seconds(
    attempt_number: int,
    *,
    base_seconds: float = 0.2,
    cap_seconds: float = 2.0,
) -> float:
    exponential_cap = min(
        cap_seconds,
        base_seconds * (2 ** attempt_number),
    )
    return uniform(0, exponential_cap)

attempt_number 從零開始時,第一個等待落在 0 到 0.2 秒。

第二次等待落在 0 到 0.4 秒。

它不保證更快。

它的目的,是讓大量 client 的重試分散。

jitter 解決的不只是「重試」,是任何同步觸發的定時行為

到這裡容易產生一個誤解:以為 jitter 只是「重試專用的裝飾」,只要 retry loop 裡有 sleep(random) 就算做到了。

實際上 jitter 要處理的病根,是「大量獨立單位因為共用同一種計時規則,在時間軸上被壓成同一個瞬間」,重試只是最常見的一種觸發方式,不是唯一一種。

Clerk 在 2025 年 9 月的一次資料庫事故,正好是這個病根在完全不涉及 retry logic 的地方發作的例子。Clerk 官方 Postmortem:Database Incident (September 14–18, 2025)

事情的起點跟重試毫無關係。Clerk 執行在 Cloud Run 上,容器對外部連線設定了固定 15 分鐘的生命週期。多年來,PostgreSQL 供應商那邊有一個從未被寫進任何設計文件的效能缺陷:連線授予(lock manager 的鎖授予邏輯)帶有 O(n²) 的時間複雜度——沒有人設計它去做 rate limiter,但它長期意外扮演了這個角色,讓原本應該同步到期的連線,實際上從來沒有真正同時過期過。

9 月 14 日,資料庫供應商修好了那個效能缺陷。連線授予變成幾乎瞬間完成。那個從未存在於文件裡的隱性節流,就這樣被拿掉了。

接下來的連鎖反應,跟今天前面畫的那條失敗鏈長得幾乎一模一樣,只是把「provider timeout」換成「固定生命週期的連線」:

連線授予變快(隱性節流消失)
  ↓
所有容器的連線,因為生命週期都固定 15 分鐘
  ↓
開始同步過期(bus bunching)
  ↓
每 15 分鐘,所有容器同時向資料庫要求新連線
  ↓
資料庫鎖競爭瞬間飆高
  ↓
API 請求失敗與延遲飆升

團隊在 9 月 17 日做了一次 query 優化,尖峰一度完全消失——他們以為問題解決了。結果一小時後,同一個「每 15 分鐘一次」的尖峰又規律地重現。真正的根因,直到 9 月 18 日才被定位到:不是 query 效能,是連線生命週期的同步。

最終修復方式,正是本文一直在講的東西:在連線池策略中加入 jitter,把原本整齊劃一的 15 分鐘生命週期打散,讓連線的過期時間點分散在一個時間窗內,而不是全部命中同一秒。

這個案例對今天要傳達的觀念有兩點補充:

  • jitter 要打散的對象,不只是「重試的間隔」,任何「大量獨立單位使用同一條計時規則」的地方都可能同步化——連線生命週期、cron schedule、cache TTL、health check 間隔都是同類問題。
  • 「同步觸發」的風險有時候是被某個看不見的機制長期壓住的,拿掉那個機制(即使拿掉的理由完全正當,例如修好一個效能缺陷)反而會讓潛藏的同步風險第一次真正發作。這呼應 Day10 談過的「fault 可以潛伏很久,直到某個看似無關的變更把它觸發」。

retry loop 還要檢查剩餘 deadline

from time import sleep


def call_with_retry(request, deadline, client):
    last_error = None

    for attempt in range(2):
        try:
            timeout_ms = dependency_timeout_ms(deadline, reserve_ms=100)
            return client.call(timeout_ms=timeout_ms)
        except TimeoutError as error:
            last_error = error
            delay = backoff_seconds(attempt)
            if deadline.remaining_ms() <= int(delay * 1_000) + 100:
                break
            sleep(delay)

    raise RuntimeError("bounded retry exhausted") from last_error

這個範例故意把上限寫成兩次總嘗試。

不是「先試一次再 retry 兩次」。

命名與 metric 也應採用同一個定義,避免 incident 時有人把 attempts=3 理解成總共三次,另一些人理解成三次重試。

不該 retry 的訊號

以下狀況通常不會因為重送同一請求而變好:

  • prompt 或 tool payload 已經不符合 schema。
  • 使用者沒有權限。
  • source validation 已經指出資料不足。
  • request 的 business deadline 已到。
  • breaker 已開啟。
  • action 是非冪等,且無法查詢狀態。

把這些送回 retry loop,只是把決策往後拖。

⑦ Circuit Breaker:不要一直敲一扇已經鎖住的門

circuit breaker 把 dependency 的近期失敗轉成一個明確狀態。

最常見的狀態圖是:

closed
  ↓ failure threshold crossed
open
  ↓ cooldown elapsed
half_open
  ├─ probe succeeds → closed
  └─ probe fails    → open

closed 是正常呼叫狀態。

open 時不再把新請求送到已知有問題的 dependency。

half_open 只讓少量 probe 通過,確認下游是否恢復。

Microsoft 的 circuit breaker pattern 文件也把 fail fast 與防止對失敗服務持續呼叫視為這個模式的核心。Microsoft Architecture Center:Circuit Breaker pattern

為什麼 half-open 只能放少量 probe

half_open 容易被想成「試著再打一次」,但它要處理一個具體風險:dependency 剛恢復時最脆弱,若一次放行所有排隊請求,等於讓一群等了十秒的 client 同時撞上一個容量還沒回穩的服務——這是同一種 thundering herd,只是發生在 breaker 從 open 轉回 closed 的那一瞬間,而不是重試迴圈裡。

正確語意是:只放行極少數 probe(例如一次一個,成功後才逐步增加),其餘請求仍被擋著,直到 probe 證明下游撐得住。上面簡化版的 CircuitBreaker 沒有實作這個限制——allow_call() 一進入 HALF_OPEN 就對所有呼叫回傳 True,這也是它被列進「省略清單」的原因,production 版本必須補上。

先定義 breaker 的 scope

不要讓一個 tenant 的壞輸入把全站 breaker 打開。

也不要把每一個 request 都做成獨立 breaker,最後永遠累積不到訊號。

合理 scope 可能是:

provider + model route + region

或:

retrieval service + index version

scope 是架構決定。

它要由 dependency 的故障域與流量隔離方式決定。

簡化的 in-memory breaker 範例

這段只適合讓讀者理解狀態轉換。

多 instance production 需要共享或一致的狀態設計,或選擇能容忍各 instance 局部判斷的策略。

from dataclasses import dataclass
from enum import Enum
from time import monotonic


class BreakerState(str, Enum):
    CLOSED = "closed"
    OPEN = "open"
    HALF_OPEN = "half_open"


@dataclass
class CircuitBreaker:
    failure_threshold: int = 3
    reset_after_seconds: float = 10.0
    failures: int = 0
    opened_at: float | None = None

    def state(self) -> BreakerState:
        if self.opened_at is None:
            return BreakerState.CLOSED
        if monotonic() - self.opened_at >= self.reset_after_seconds:
            return BreakerState.HALF_OPEN
        return BreakerState.OPEN

    def allow_call(self) -> bool:
        return self.state() is not BreakerState.OPEN

    def record_success(self) -> None:
        self.failures = 0
        self.opened_at = None

    def record_failure(self) -> None:
        self.failures += 1
        if self.failures >= self.failure_threshold:
            self.opened_at = monotonic()

這個版本省略了幾件 production 必要的事:

  • concurrent update 的鎖定。
  • half-open probe 的數量限制。
  • 依 failure kind 排除 validation error。
  • 跨 process 的狀態。
  • breaker configuration 的版本與 owner。

省略它們不是代表不重要。

而是今天先把行為契約講明白。

常見誤解:circuit breaker 只是包了一層 try/except 的 retry

很多人第一次實作 circuit breaker,寫出來的其實是一個帶記憶的 retry decorator:「連續失敗幾次就多睡一下再試」。這跟真正的 circuit breaker 差在一個關鍵動作:breaker open 時,呼叫根本不會發生。

retry with backoff(沒有 breaker)
  每次呼叫都真的打到 dependency,只是失敗後等久一點

circuit breaker open
  呼叫在本地就被擋下來,dependency 完全收不到這次請求

dependency 已經過載時,這個差異特別重要:retry 加大 backoff 只是讓敲門頻率變低,dependency 收到的請求量還在;breaker open 之後,敲門這件事本身停止,dependency 得到的是真正的喘息空間。Google SRE 談 cascading failure 時,也把「讓後端有機會恢復」列為關鍵手段之一。Google SRE Book:Addressing Cascading Failures

常見誤解二:以為 breaker 狀態可以只存在單一 instance 裡

上面的範例把 failures 與 opened_at 存成 process 內欄位。教學範例裡沒問題,但放進十個 replica 的生產服務時:

10 個 instance 各自維護 breaker state
  ↓
dependency 開始失敗
  ↓
每個 instance 要各自累積到 failure_threshold 才會 open
  ↓
在全部 open 之前,dependency 實際承受的失敗嘗試
可能是單機門檻的 instance 數倍

每個 instance 都「正確地」按照自己的規則行動,集體行為卻不是設計者想要的「快速停止」——單機 breaker 的 failure_threshold=3,在十個 instance 的集群裡實際等於 3 × 10 這個量級。

這不代表單機 breaker 沒有價值,它仍比完全沒有好,是最小可行的起點。但若故障域是跨所有 instance 共用(例如同一個 provider endpoint),breaker 狀態理想上也該共用——常見做法是放進 Redis,讓集群讀到同一個 open/closed/half-open 狀態。這正是「省略清單」裡「跨 process 的狀態」真正代表的風險:決定 breaker 保護力道是除以 1 還是除以 instance 數。

breaker 開啟時回什麼

這是最常被忽略的半邊。

breaker 擋下 downstream call 後,API 還是得回覆使用者。

可能的選項如下:

情境 可接受的 degradation 不可接受的 degradation
政策問答且有版本明確的快取 回快取並標示資料版本與時間 把舊資料說成最新規範
無可靠來源可查 insufficient_context 或暫時不可用 讓模型憑印象補答案
低風險摘要 排隊、稍後通知或較短回答 靜默丟失請求
寫入型 tool action 停止並顯示 pending / human review 重送未知狀態的 action
安全審查 dependency 失效 拒絕高風險 action 因安全服務故障而放行

degradation 的設計要讓使用者知道系統做了什麼。

不是把原本完整回答換成語氣更堅定的猜測。

警世故事:$47,000 的無防護迴圈

沒有 circuit breaker 的成本是什麼樣的?曾有一個以 LangChain 打造的多 agent 系統,其中兩個 agent 互相詢問同樣的問題,每次都失敗、每次都重試,循環了 264 小時,累積成本達 $47,000。awesome-agent-failures:LangChain A2A 47K infinite loop 案例

每次失敗看起來都只是「不確定答案,問問別人」,但沒有 circuit breaker 時,這句話變成了自動迴圈。若有一個簡單的 breaker 在失敗 3 次後打開,成本會在最初幾十次失敗時就停止——circuit breaker 不只是技術細節,是成本保險。

⑧ SRE Lab:建立可注入延遲的 fake dependency

今天的實驗不需要真的切斷 provider。

先做一個可重現的 fake client。

為什麼是 fake dependency,不是真的打壞 provider

前面幾天的故障注入(Day 3 的 Chaos)已經示範過對自己控制範圍內的服務(自家 API、自家資料庫容器)做真的故障注入。今天的對象不一樣:primary model provider 是第三方服務,讀者沒有辦法、也不應該去讓它真的變慢或斷線。

這帶出一個今天特別重要的設計原則:resilience 邏輯本身的正確性,跟它保護的那個 dependency 是誰,應該是兩件可以分開驗證的事。 deadline 有沒有正確倒數、backoff 有沒有加上 jitter、breaker 有沒有在對的次數後轉成 open——這些問題的答案不需要真的問過 OpenAI 或 Anthropic 才能知道。只要能可靠地製造出「慢」與「失敗」這兩種行為,就能把 resilience 邏輯本身測到位。

FakeModelClient 存在的意義正是這個:它是一個行為可控制的替身,讓「今天寫的 deadline、retry、breaker 邏輯到底對不對」這個問題,跟「Gemini 或 GPT 今天心情好不好」這個問題完全脫鉤。

讀者可以在自己的隔離 DIY 專案加入:

from dataclasses import dataclass
from time import sleep


@dataclass
class FakeModelClient:
    latency_seconds: float = 0.0
    fail_with_timeout: bool = False

    def call(self, *, timeout_ms: int) -> dict[str, str]:
        sleep(self.latency_seconds)
        if self.fail_with_timeout:
            raise TimeoutError("simulated provider timeout")
        if self.latency_seconds * 1_000 > timeout_ms:
            raise TimeoutError("simulated client deadline")
        return {"answer": "simulated grounded answer"}

這個 fake client 的價值在於可控制。

同一條測試可以重複得到 timeout。

不用賭外部 API 剛好壞掉。

也不會花 token。

這段程式碼對應前面哪個論點

latency_seconds 模擬第一段「primary model 尖峰時 p95 變成 6 秒」的現象,只是換成讀者可以自己指定的秒數;fail_with_timeout 模擬更極端的狀況:provider 直接連線失敗,而不是「有回應,只是慢」。

兩者分開設計,對應⑥段落「慢但最終會回應」與「根本連不上」這兩種不同的 retry 情境——只測 fail_with_timeout 會漏掉「慢但還在跑」這種更常見、也更難處理的情境,它不會讓 retry loop 立刻失敗,而是讓 in-flight work 持續累積,直到上層 deadline 耗盡才被迫中止。

建立四個 scenario,而不是只測「成功」

SCENARIOS = {
    "healthy": {
        "latency_seconds": 0.05,
        "fail_with_timeout": False,
    },
    "slow_but_within_deadline": {
        "latency_seconds": 0.15,
        "fail_with_timeout": False,
    },
    "deadline_exhausted": {
        "latency_seconds": 0.50,
        "fail_with_timeout": False,
    },
    "hard_timeout": {
        "latency_seconds": 0.01,
        "fail_with_timeout": True,
    },
}

這四種情境的預期不可相同。

Scenario 預期 technical status 預期 workflow 結果 預期 breaker
healthy success completed closed
slow but within deadline success completed,latency 升高 closed
deadline exhausted timeout degraded 或 not completed 依連續失敗情況
hard timeout timeout bounded retry 後降級 可能 open

如果所有 case 都只 assert status_code == 200,你沒有測到 resilience。

讀者可自行執行的最小觀察腳本

把下列內容放進自己的測試或 smoke script 時,可以先用 print 觀察 decision。

def run_scenario(name: str, config: dict[str, object]) -> None:
    client = FakeModelClient(**config)
    deadline = Deadline.after_ms(300)

    try:
        result = call_with_retry(
            request=None,
            deadline=deadline,
            client=client,
        )
        print({"scenario": name, "result": result, "outcome": "completed"})
    except RuntimeError as error:
        print(
            {
                "scenario": name,
                "error": str(error),
                "outcome": "degraded_or_failed",
            }
        )


for scenario_name, scenario_config in SCENARIOS.items():
    run_scenario(scenario_name, scenario_config)

這份範例不是 production client。

它沒有網路 cancellation、async scheduling、distributed breaker 或 provider SDK error mapping。

先用它驗證「選擇什麼結果」;再把相同 contract 對應到自己的框架。

讀者實際跑一次,預期會看到什麼

接上前面的 Deadline.after_ms(300)(300 毫秒 service deadline),四個 scenario 的預期行為:

  • healthy(50ms)→ completed,遠低於 deadline,剩餘時間充裕。
  • slow_but_within_deadline(150ms)→ completed,但已逼近上限;deadline 收緊到 200ms 就會翻成 degraded。
  • deadline_exhausted(500ms)→ degraded;dependency_timeout_ms() 呼叫前就算出剩餘時間不夠,直接拋出 TimeoutError,不會真的等滿 500ms。
  • hard_timeout(10ms,fail_with_timeout=True)→ degraded;每次呼叫都在 10ms 內失敗,bounded retry 嘗試兩次後才拋出 RuntimeError。

讀者實際跑的時候容易踩到的坑

把程式碼接上自己的 DIY 專案執行時,這幾個地方容易被誤以為是 bug,其實是刻意設計:

  • deadline_exhausted 不會真的等 500 毫秒——dependency_timeout_ms() 呼叫前就會檢查剩餘 deadline,不夠就直接拋錯,這正是⑤段落「reserve_ms 讓系統提早放棄」的實際行為。
  • 「兩次總嘗試」容易被誤讀成「重試兩次」,這正是⑥段落強調的命名陷阱——先確認自己讀的是哪種定義,再判斷 retry 是否正常運作。
  • 這組 scenario 不會觸發 half-open(reset_after_seconds 預設 10 秒);要實際觀察到,需要刻意重複呼叫並等待,下篇的 failure injection 步驟會把它獨立處理。
  • 四個 scenario 都只測 OperationKind.READ;寫入型 action 沒有安全的 fake 版本可示範,因為「確認 idempotency contract」需要一個真正有狀態的 server 端配合。

下集預告

上篇處理完 capacity、deadline、retry 與 circuit breaker 的行為契約。下篇會把技術結果翻譯成誠實的 API response、補齊 metrics/logs/traces 的 resilience 觀測、定義 SLI 與 admission control,並帶讀者走一次完整的 failure injection 操作流程。


這篇是 Learning SRE for the AI Era 系列的一部分。
Build → Trace → Break → Measure → Evaluate → Recover → Improve.


上一篇
Day 28(下)|CI/CD 與 AI Change Management:model 換版也是 deployment
下一篇
Day 29(下)|Capacity、Retry、Circuit Breaker、Degradation:別用重試把事故推給下游
系列文
Learning SRE for the AI Era:從 SRE Lab 到 Production AI Reliability 共 58 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言