結論先說:chaos experiment 不是亂拔插頭,是先寫可否證的 hypothesis、圈定 blast radius,再去注入失敗;上篇把這套骨架、AI workflow 專屬的注入點,以及三份 DIY experiment card 的設計邏輯講完,順便用三個真實事故說明「安全欄杆」為什麼不是形式主義。
這個系列從一條 trace 開始,最後回到同一件事:你以為系統會怎麼壞,和它真的會怎麼壞,中間通常隔著一個實驗。
hypothesis → steady state → controlled failure injection → observe → rollback → learn
假設要可否證:「當 LLM provider timeout 時,circuit breaker 會開啟,可降級的 /ask 會回明確狀態,availability SLI 不會把無根據答案算 good。」steady state 要先定義正常 SLI;injection 要限制 blast radius;rollback 是在開始前就準備好,不是害怕時才找按鈕。
Netflix 將 chaos engineering 描述為以實驗建立對系統承受真實擾動能力的信心;重點在受控與觀察。相關論文也說明了這個實驗取向。
這個誤解很常見,而且通常來自那個名字本身。
「Chaos」聽起來像在鼓勵混亂,「Monkey」聽起來像在鼓勵胡鬧。Netflix 早期最出名的工具確實叫 Chaos Monkey,隨機關閉生產環境的 instance;但這個名字容易讓人漏看一個關鍵字:隨機(random)不等於失控(uncontrolled)。
Chaos Monkey 隨機選的是「哪一台 instance」,不是隨機選「要不要有 blast radius 限制」、「要不要能觀察」、「要不要能立刻停」。它只在工作時間執行,只在已知會被自動取代的 instance 群組裡挑選,而且整套自動擴展與自癒機制早就存在,Chaos Monkey 只是驗證這套機制真的會被觸發。拿掉這三個限制,剩下的就只是「不小心關掉一台不該關的機器」,那不是實驗,是事故。
用一個對照表把這件事說清楚:
| 項目 | 隨便弄壞東西 | Chaos experiment |
|---|---|---|
| 對象 | 任何看起來能拔的線 | 由 hypothesis 決定,只拔驗證這個假設需要的那條線 |
| 範圍 | 沒有邊界,壞了算你倒楣 | 事先定義 blast radius,通常先在合成資料、隔離環境 |
| 觀察 | 事後從客訴或告警回推 | 實驗前就準備好要看哪些 metrics/logs/traces |
| 停止方式 | 「應該可以手動關掉吧」 | 有事先演練過、確定能執行的 abort 與 rollback |
| 產出 | 一個「好像有壞掉」的印象 | 一份可被他人檢視、可重跑的 evidence |
Day 30 接下來所有的程式碼與 experiment card,都是在把左欄的隨興做法,一步一步蓋成右欄的工程紀律。這不是把事情弄複雜;這是把「我猜系統應該扛得住」這句話,換成一句可以被檢驗的陳述。
如果你的團隊已經在做傳統的 SRE game day(模擬機房斷電、模擬資料庫主從切換),Day 30 談的東西聽起來會很熟悉。骨架完全一樣:假設、穩態、注入、觀察、回滾、學習。
差別在於 AI workflow 多了幾層傳統 game day 通常不會測的東西。傳統 game day 習慣測「這台機器死了會怎樣」、「這個 region 斷線會怎樣」;這些都還在 Infrastructure 與 Service 這兩層。AI workflow 額外多了 Dependency(provider、vector DB)、Workflow(parser、router、tool 分派)、Semantic(答案有沒有根據)、Economic(retry 有沒有把 token 燒爆)、Governance(有沒有繞過權限)——這七層框架從 Day 2 就開始鋪陳,到今天終於全部要接受實驗檢驗。
一個容易犯的錯誤,是把 AI chaos experiment 想像成一套全新的方法論,需要全新的工具鏈才能做。它不是。它是同一套 game day 紀律,套用到更多層失敗模式而已。如果你的團隊已經有 runbook、已經有 postmortem 文化,Day 30 要做的只是把 hypothesis 欄位從「資料庫斷線時服務會不會切換」,換成「provider timeout 時系統會不會誠實地說不知道」。
可以在隔離環境模擬 provider timeout、empty retrieval、tool schema mismatch、queue delay 或 evaluator service unavailable。不同 failure 的正確結果不一樣:empty retrieval 可能回 insufficient_context;高風險 tool schema mismatch 必須拒絕 action;provider outage 可能觸發既定 fallback。切勿為了「通過實驗」而讓模型編一個答案。
傳統分散式系統的 chaos 注入點,多半圍繞在網路與資源層:延遲、封包遺失、CPU 節流、磁碟寫滿、region 斷線。這套清單假設「只要底層資源撐得住,上層邏輯就會照設計運作」。
這個假設對 AI workflow 不成立,原因很單純:AI workflow 的核心步驟本身就有機率性與語意性,不是單純的資源消耗。retrieval 服務回 HTTP 200,不代表拿到的文件跟問題有關;provider 回 200,不代表產生的內容有根據;tool 呼叫成功,不代表模型組出的參數是對的。傳統注入點測的是「資源夠不夠」,AI workflow 還需要多問一句「就算資源夠,這一步的『語意輸出』是不是我們想要的那種」。
把兩種注入點攤開比較會更清楚:
傳統注入點(Infrastructure / Service 層)
網路延遲、封包遺失、CPU 節流、磁碟滿、instance 終止
AI workflow 額外的注入點(Dependency / Workflow / Semantic / Economic / Governance 層)
provider timeout → 生成步驟本身不可用
provider 回傳低品質內容 → 生成步驟「看起來」可用,其實答案沒有根據
empty retrieval → 有查詢動作,但沒有可用的上下文
retrieval 回傳不相關文件 → 有上下文,但上下文是錯的
tool schema mismatch → 動作介面版本不一致,可能誤觸未授權操作
queue / GPU 排隊延遲 → 使用者早已放棄,工作卻還在被執行
evaluator service unavailable → 品質關卡本身失靈,無法把關
前半段清單,多數團隊的 SRE 經驗已經很熟;後半段清單,才是 Day 30 真正想補的東西。「retrieval 回傳不相關文件」和「retrieval 完全失敗」是兩種不同的故障——前者甚至不會觸發任何 error,它只會讓答案悄悄變差。這正是本系列從 Day 7 開始反覆強調的:Technical Success ≠ Semantic Success,chaos experiment 只是把這句話變成可以被實驗驗證的具體案例。
另一個常見的誤解,是以為把 provider SDK 可能丟出的所有 exception 都注入一次,就等於做完了 AI chaos experiment。這會漏掉一大類真正危險的故障:沒有丟出例外、卻回傳了錯誤內容的故障。
provider 沒有 timeout、沒有丟例外,只是回了一段語氣自信但內容編造的文字;retrieval 沒有失敗,只是排序演算法把最相關的文件排到第 11 名,而系統只取前 5 筆。這些情境不會出現在任何一份 exception 清單裡,因為程式碼的角度來看,這些呼叫都「成功」了。AgentChaos 這篇論文特別把這種情境獨立出一類,稱為 value fault——回應成功送達,內容卻被竄改或本來就有問題,和讓呼叫直接失敗(crash fault)是完全不同的測試對象。之後 ⑤ 段會展開這篇研究的細節,但這裡先記住一個原則:測「呼叫失敗會怎樣」只覆蓋了故障空間的一半,另一半是「呼叫『成功』但內容是錯的會怎樣」。
provider timeout、DB unavailable、tool provider failure 這三個 scenario,不是隨便挑的。它們對應三種完全不同的失敗性質,而且剛好卡在 AI workflow 最常被忽略的三個交界點上。
provider timeout 測的是生成能力本身消失時,workflow 會不會誠實。這是最容易被低估的一種故障,因為多數團隊已經對「API 掛了」有直覺:加個 try/except,回個 500,事情好像就處理完了。但 chaos experiment 真正要看的不是「有沒有處理」,是「處理之後回傳的狀態有沒有把 task_status 和 HTTP status 混在一起」——這正是 ⑧ 段整篇要講的東西。
DB unavailable 測的是資料層失聯時,系統會不會把過期或不完整的資訊當成最新資料。這個 scenario 特別容易在 RAG workflow 裡被忽略,因為很多團隊會在 retrieval 前面放一層快取,DB 掛了、快取還在,看起來像沒事,但快取裡的資料可能已經過期好幾天。實驗要驗證的是這個「看起來沒事」背後有沒有明確的 freshness 判斷,而不是「反正有拿到東西就算了」。
tool provider failure 測的是執行能力失效時,系統會不會因為想要「看起來完成了」而做出危險的猜測或重送。這是三者裡風險最高的一種,因為它牽涉到 side effect——一個讀取型的查詢失敗了,頂多是使用者看到錯誤訊息;一個寫入型或付費型的 tool 呼叫在失敗後被系統誤判為「應該重試」,可能就是重複扣款或重複建立訂單。
三者合起來,剛好覆蓋 AI workflow 最基本的三段管線:能不能生成、有沒有資料、能不能動作。任何一段在故障時說謊,使用者拿到的都是一個看起來正常、實際上不可信的結果。
# Provider timeout
- Hypothesis:符合條件的讀取型請求會進 degradation,不執行 tool action。
- Steady state:以 Day 14 SLI 與 Day 18 latency 定義。
- Injection:僅在隔離環境讓 fake provider timeout。
- Observe:SLI、breaker state、trace、alert 是否符合預期。
- Abort/rollback:停止注入,恢復 fake provider;超出 blast radius 立即終止。
- Evidence:request_id、版本、時間窗、結果與未預期現象。
# DB unavailable
- Hypothesis:retrieval DB 不可用時,系統回明確的暫時不可用或 `insufficient_context`;不把舊快取誤標成最新資料。
- Steady state:retrieval 成功率、資料版本與 `/ask` outcome 在定義 window 內正常。
- Injection:僅讓 fake DB client 回傳 `TimeoutError`。
- Observe:retrieval span、資料版本、SLI、dependency alert 與使用者回應。
- Abort/rollback:停止 fake timeout,恢復 fake DB;資料版本不符或影響超出範圍時立即終止。
- Evidence:request_id、index version、時間窗與 alert 狀態。
# Tool provider failure
- Hypothesis:tool provider 失敗時,讀取型工作可回受控降級;需要寫入的 action 不會重送或猜測成功。
- Steady state:tool success ratio、non-idempotent action count 與 safety status 正常。
- Injection:僅讓 fake tool provider 回傳可辨識 failure。
- Observe:tool span、retry count、safety status、SLI 與相對應 alert。
- Abort/rollback:停止 injection,恢復 fake tool;出現未授權 action 或 blast radius 超標時立即終止。
- Evidence:request_id、tool version、action decision 與 alert 狀態。
三份實驗要分開執行,一次只注入一種 failure。不要同時拔三條線,否則無法判斷是哪一條造成結果。
如果你不打算真的去跑 Day30/DIY/ 這個實作專案,這裡先說明每一份實驗卡片對應到本文後半段哪個具體論點,讓你不用跑程式也能看懂它在檢查什麼。
Provider timeout 對應 ⑧ 段落「將 failure 放在 dependency adapter」與 ⑨ 段落的 scenario matrix 第一列。它要驗證的具體陳述是:provider 呼叫 timeout 之後,answer_workflow() 回傳的 task_status 是不是誠實地標成 not_completed,而不是因為 HTTP handler 順手回了 200,就讓下游的 SLI 查詢把這筆請求誤算成任務成功。
DB unavailable 對應本文尚未寫成程式碼、但在 ⑨ 段落 matrix 有明確定義的 empty retrieval 一列的延伸情境——DB 連不上和 DB 回空結果,在使用者體感上很像,但根因與該有的處置不同:DB 連不上通常代表「我們現在不知道答案」,應該回覆技術性的暫時不可用;empty retrieval 代表「我們查過了,但沒有相關資料」,應該回覆語意性的 insufficient_context。實驗要驗證系統有沒有把這兩種情況混成同一種泛用的「查無資料」錯誤訊息。
Tool provider failure 對應下篇 ⑬ 段落「tool schema mismatch 要停在 policy boundary」的姊妹情境:schema mismatch 測的是「參數格式不對時會不會被攔下來」,tool provider failure 測的是「工具本身連不上時,系統會不會因為想完成任務而做出不安全的重試」。兩者合起來,才涵蓋 tool 呼叫失敗的完整故障空間。
Day30/DIY/ 底下有一個對應的最小實作,把 ChaosContext、ModelProvider、answer_workflow() 這幾個在 ⑦、⑧ 段落出現的程式碼片段,組成一個可以真的用 uvicorn 跑起來的 FastAPI 服務。如果你照著它的 README 跑一輪,這裡列出幾個實際會踩到、但文章程式碼片段本身不會提醒你的細節。
第一個坑是 Prometheus 的 /metrics 路徑。prometheus-client 的 make_asgi_app() 掛載到 FastAPI 時,/metrics(沒有結尾斜線)預設會被 FastAPI 的路由規則導向 /metrics/,回應是 307 Temporary Redirect 而不是直接回 200。如果你在自己的 curl 指令或 Prometheus scrape config 忘了處理這個轉址,看起來會像是「metrics 端點壞了」,但其實只是路徑差一個斜線。這種小地方恰好呼應下篇 ⑩ 段落講的「觀測不是把所有東西都記下來,而是要讓因果鏈能被正確重建」——如果連 scrape 本身都被 307 卡住,後面的因果鏈根本無從查起。
第二個坑是「這個最小實作只做了 provider timeout 和 empty retrieval,沒有做 circuit breaker、queue delay 或 tool schema mismatch 的完整版本」。這不是疏漏,是刻意的範圍控制:下篇 ⑭、⑮ 段落講的 breaker 狀態機與 deadline 傳遞,牽涉到跨 request 的共享狀態與時間控制,如果一次全部塞進同一個 DIY 專案,會讓「驗證核心 guardrail 設計」這個目的被實作複雜度淹沒。如果你想繼續往下做,SimpleBreaker(下篇 ⑭ 段落)和 RequestDeadline(下篇 ⑮ 段落)的程式碼片段可以直接複製進這個專案延伸,但先想清楚你要驗證的是狀態轉換本身,不是把三個故障類型一次做完的成就感。
第三個坑,也是最容易被忽略的一個:這個 DIY 服務所有 request 都是同步處理(def ask(...) 而非 async def ask(...)),這代表 provider timeout 的模擬(直接 raise 例外,而非真的等待)不會佔用 event loop,但如果你把它換成真正呼叫外部 API、用 asyncio.sleep() 模擬延遲,同步 handler 會直接卡住整個 worker,讓你在還沒觀察到 provider timeout 這個目標故障之前,先意外地製造了一個「服務對所有 request 都沒回應」的第二個故障。這正是 ③ 段落強調「不要同時打壞三條線」的原因——即使只是切換同步/非同步這種看似無關的實作細節,也可能在不知不覺間讓你的實驗混入沒有計畫的第二個變因。
三者都會製造壓力或失敗,但要回答的問題不同。
| 方法 | 主要問題 | 典型輸入 | 成功條件 |
|---|---|---|---|
| 單元測試 | 這段程式在已知條件下是否正確? | 明確 mock 與固定輸入 | 回傳值、例外、分支符合 contract |
| 整合測試 | 元件接起來後是否照約定互動? | 測試資料庫或 fake provider | schema、權限、資料流正確 |
| Load test | 在預期流量下是否夠快、夠穩? | 受控併發與 request mix | latency、error、飽和度在目標內 |
| Chaos experiment | 一項假設失效時,系統是否仍遵守承諾? | 一個受控 failure injection | 使用者結果、降級、偵測與回復符合假設 |
把「我打了很多 request」稱為 chaos experiment,和把任何 try/except 稱為 resiliency 一樣,聽起來很努力,資訊量卻不多。
Load test 可以發現 queue 積壓。
Chaos experiment 則驗證「retriever timeout 時 queue 積壓會不會觸發既定降級」。
前者看容量曲線。
後者看故障模型是否正確。
單元測試可以驗證 fallback_response() 回傳指定 JSON。
Chaos experiment 要驗證從 /ask 進來後,真的會走到 fallback,並且 metrics、logs、traces、alert 和使用者畫面沒有互相說謊。
因此,今天不取代 Day 10 到 Day 29 的測試。
它把那些日子寫下的承諾拿出來驗收。
Google 的 SRE Book 對 testing 的提醒很實際:測試通過只能降低特定變更帶來的不確定性,不能代替對真實系統行為的理解。Testing for Reliability
AI workflow 更需要保留這個界線。
同一個 provider timeout,可能讓一條不涉及 tool 的問答安全地降級,也可能讓另一條付費流程必須直接拒絕。
測試名稱相同,不代表 service promise 相同。
Netflix 的 chaos engineering 框架影響了整個業界。當 AI agent 系統成為主流後,研究社群開始把同一套方法論套進來,AgentChaos 與 ReliabilityBench 是兩個代表作。
AgentChaos 直接攔截 LLM API 的 HTTP 回應,在不改動 agent 原始碼的前提下注入三種 fault:crash(完全失敗)、omission(截斷)、value(修改內容)。實驗結果顯示,不同 agent 架構面對同一組故障時表現差異巨大:pass@1 最多掉了 50 個百分點,而且哪一套系統比較耐打,在不同底層模型上排名一致——換句話說,韌性是系統實作的問題,不是模型挑得好不好的問題。這正是 Day 30 要驗證的重點:你的系統如何應對故障,而不是故障會不會發生。
ReliabilityBench 則把 agent 可靠性拆成三個維度:Consistency(重複執行的穩定性)、Robustness(對輸入變體的耐受)、Fault Tolerance(在 rate limiting、timeout、partial response 下的恢復)。它最值得留意的發現是:rate limiting 造成的可靠性衝擊,比單純的 timeout 還大——在他們的量測裡,光是 rate limit 就讓成功率掉了 2.5%。Rate limit 不是乾脆的完全失敗,而是要判斷該退避多久、要不要重試的灰色地帶,這比一次性 timeout 更難處理。
這兩份研究共同印證了同一件事:對 AI 系統而言,chaos experiment 不是加分項,是必需品。傳統的 smoke test 與 uptime 檢查抓不到「API 回 200、但降級路徑被寫壞」這類故障,只有透過有計畫的實驗,團隊才找得到這些藏起來的假設。
看到「pass@1 最多掉 50 個百分點」或「rate limiting 讓成功率掉 2.5%」這種具體數字,很容易產生一種錯覺:既然已經有嚴謹的研究量化過 agent 系統的韌性,是不是可以直接引用這些數字,當成自己系統的可靠度證明,省下重跑一次 chaos experiment 的功夫?
答案是不行,而且理由跟 ⑥ 段 experiment card 裡 scope 欄位要寫清楚 service version、prompt version、retrieval index 的理由完全一樣。AgentChaos 的實驗對象是特定的 agent 架構(ReAct、Reflexion 之類)搭配特定的底層模型;ReliabilityBench 的 2.5% 數字,是在 scheduling、travel、customer support、e-commerce 四種特定任務情境下量測出來的。你的 /ask workflow 用的 prompt 版本、retrieval 策略、tool 定義、fallback 邏輯,沒有一項會和論文裡的實驗對象完全一樣。這些研究真正有價值的地方,不是那個具體百分比,而是它們揭露的故障類型清單與故障之間的相對嚴重程度——例如「rate limiting 比單純 timeout 更難處理」這個結論,值得你檢查自己的系統有沒有把兩者用同一套 retry 邏輯處理;但「你的系統在 rate limiting 下會掉多少百分點」這件事,只有你自己的 experiment card 跑過一輪才知道。
換句話說,學術 benchmark 告訴你要測什麼,不能替你回答你的系統測出來結果如何。這正是 Day 30 從 ⑥ 段開始要建立的整套 experiment card 流程存在的理由:別人的研究可以幫你節省「想不到有哪些故障類型」的時間,省不下「親自跑一次、留下屬於你系統的 evidence」這個步驟。
最小的實驗文件不必很漂亮。
它必須讓不在現場的人能回答:你改了什麼、為什麼現在可以開始、何時要停、怎麼知道結果可信。
先建立一份 experiment-card.yaml。
以下示例只適合讀者自己的隔離環境。
其中的 service 名稱、版本號與門檻都要換成你的資料。
experiment_id: ce-ask-provider-timeout-v1
title: "LLM provider timeout 不應產生無根據答案"
environment: isolated-lab
owner: your-name-or-team
reviewer: another-engineer
scope:
service: ask-api
endpoint: POST /ask
request_selector:
header: X-Chaos-Scenario
value: provider-timeout
max_requests: 20
max_duration_seconds: 300
allowed_data_classification: synthetic-only
hypothesis:
when: "primary provider timeout"
then:
- "read-only questions return a declared degraded response"
- "no tool action is attempted"
- "task_success SLI excludes unsupported answers"
- "an operator can find the root trace by request_id"
steady_state:
observation_window_minutes: 10
checks:
- "ask_api_ready == 1"
- "no active incident"
- "synthetic baseline request has task_status=completed"
- "error budget is not already exhausted"
injection:
method: "fake provider raises TimeoutError only for selected header"
start_condition: "all steady_state checks pass"
stop_condition: "max_requests or max_duration_seconds reached"
observe:
metrics:
- "ask_requests_total"
- "ask_outcomes_total"
- "dependency_failures_total"
- "circuit_breaker_state"
logs:
- "request_id"
- "failure_class"
- "degradation_reason"
traces:
- "root request span"
- "provider span"
- "validator span"
abort:
conditions:
- "request reaches a non-synthetic tenant"
- "tool_action_attempted_total increases"
- "unexpected 5xx ratio exceeds declared threshold"
- "observer cannot correlate request_id within two minutes"
action: "disable injection and stop the run"
rollback:
action: "remove selected-header injection and re-run one baseline request"
evidence:
required:
- "experiment card revision"
- "start and stop time"
- "request_id list"
- "dashboard snapshot or query result"
- "trace links or trace identifiers"
- "actual versus expected outcome"
這份 card 刻意把 environment、資料分類與 selector 寫得很早。
多數 chaos 事故的根源,是 injection 函式在不該影響的地方被打開。
「只在 local 執行」不是保護措施。
能被程式與網路邊界驗證的限制,才比較接近保護措施。
例如只接受 loopback address、只接受 synthetic tenant、需要獨立 header,並把注入模組從 production image 排除,都是可檢查的限制。
如果做不到其中一項,不要用「應該不會有人誤用」補上。
直接縮小實驗,或暫緩執行。
experiment_id 是可重跑的識別字。
它用來識別可重跑的實驗,和 incident 編號或單純檔名不同。
同一種注入換了 service version、資料版本或假設,就應換一個 revision。
owner 不是背鍋的人。
它表示結果出現灰色地帶時,誰負責讓結論落地。
reviewer 至少要看過 blast radius、abort 和 rollback。
一個人寫 card 又一個人判定「可以開始」,很容易把自己的盲點一起自動化。
max_requests 比「小量流量」更可操作。
若你只說小量,大家會各自想像 1%、10% 或 1,000 個 request。
allowed_data_classification: synthetic-only 把資料治理放進實驗本身。
對 AI workflow,prompt、retrieved document 和 tool argument 常比 error log 更敏感。
即使 injection 沒有破壞資料,也可能把不該留存的內容送進 trace backend。
hypothesis 必須能被否證。
「系統有韌性」無法被否證。
「provider timeout 後,read-only request 不會執行 tool,而且會在 3 秒內回明確 degraded status」可以。
steady_state 不是儀式。
它避免你在一個本來就紅燈的環境裡製造第二個變因,最後只能寫出「看起來更紅了」。
abort 條件要用觀察得到的現象描述。
「感覺有風險就停」在操作現場幾乎等於沒有條件。
evidence 是為了讓結果能被反駁。
如果只有一句「我們測過了,沒問題」,那不是 evidence。
以下程式碼示範一種概念:在應用程式內放一個只接受明確 scenario 的 fake provider。
它不是 production feature flag 的通用實作。
它的目標是讓讀者能在隔離 Lab 重現 failure path,而不需要切斷真實 provider。
建立 app/chaos.py:
from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum
class ChaosScenario(StrEnum):
NONE = "none"
PROVIDER_TIMEOUT = "provider-timeout"
EMPTY_RETRIEVAL = "empty-retrieval"
TOOL_SCHEMA_MISMATCH = "tool-schema-mismatch"
@dataclass(frozen=True)
class ChaosContext:
scenario: ChaosScenario
synthetic_tenant: bool
def parse_chaos_context(
scenario_header: str | None,
tenant_header: str | None,
) -> ChaosContext:
if scenario_header is None:
return ChaosContext(ChaosScenario.NONE, synthetic_tenant=False)
if tenant_header != "synthetic-lab":
raise PermissionError("chaos injection requires synthetic-lab tenant")
try:
scenario = ChaosScenario(scenario_header)
except ValueError as exc:
raise ValueError("unknown chaos scenario") from exc
return ChaosContext(scenario, synthetic_tenant=True)
這裡有三個刻意的設計。
第一,沒有 header 就是 NONE。
正常 request 不需要知道 chaos module 的存在。
第二,scenario 不是任意字串。
列舉值讓實驗清單可以被 code review,也避免 timeout-please 這類拼字在 handler 裡默默失效。
第三,scenario header 本身不夠。
必須同時帶 synthetic tenant 才能走注入路徑。
真實系統可以使用更強的邊界,例如獨立 environment、mTLS identity、allowlist、測試專用 gateway 或不含注入程式碼的 production artifact。
不要把這個簡化示例誤讀成 security control 的完整清單。
上面這三個設計看起來有點小題大作,直到你看過 Cloudflare 在 2025 年 11 月 18 日的那次全球中斷。
事故的起點只是一次資料庫權限變更:11:05 UTC 部署後,ClickHouse 分散式查詢在存取 table metadata 時,開始把 default 和 r0 兩個 schema 的欄位重複算了一次。這個查詢的輸出,恰好是 Bot Management 用來產生 feature 設定檔的來源。設定檔筆數因此直接翻倍。
問題在於,Bot Management 程式裡有一段寫死的假設:feature 數量最多是 200 個,而且從沒被驗證過「如果真的超過會怎樣」。翻倍後的設定檔一超過這個數字,程式在 Result::unwrap() 上直接 panic。核心 CDN 開始對外狂噴 5xx,FL2 proxy 崩潰,舊版 FL proxy 則悄悄把所有請求的 bot score 都算成 0。這種「沒有 crash、但答案全錯」的退化,比直接掛掉更難第一時間發現。
更糟的是接下來的部分:Turnstile 驗證碼因此載入失敗,而 Cloudflare Access 的登入驗證,以及 Dashboard 本身的登入,都依賴 Turnstile。也就是說,工程師想打開控制台去關掉出問題的 feature 時,控制台本身也連不進去。整起事故耗時約六小時,Cloudflare 自己形容是 2019 年以來最嚴重的一次。
這起事故值得對照 parse_chaos_context 的第三個設計決定:scenario header 不能單獨生效,必須同時帶 synthetic tenant。理由完全相同。把「這是我們自己系統產生的資料」當成天生可信,是一種常見但危險的假設。Cloudflare 這次垮掉的起點,不是外部攻擊,也不是流量洪峰,而是一份「自己生成、自己消費」的設定檔,從未被要求證明自己不會超過那個寫死的上限。
事後報告承諾的修法也印證了同一個原則:把 Cloudflare 自己產生的設定檔,當成使用者輸入一樣驗證。對 chaos experiment 而言,這代表一件事:internal 不是 trusted 的同義詞。「這個 payload 是我們自己 service 產生的」本身不構成不需要 schema 檢查的理由。Cloudflare 官方事後報告
ChaosScenario 這個列舉值,除了限定合法輸入,還有第二個隱藏的作用:它強迫團隊主動維護一份「目前允許存在的 scenario 清單」,過時或不該再出現的邏輯不會被留在程式碼裡等著被意外觸發。這句話講起來像常識,但 2012 年的 Knight Capital 用 4.6 億美元的代價證明了它有多容易被忽略。
Knight Capital 是一家美國的做市商,2012 年 8 月 1 日為了搭上紐約證交所新推出的 Retail Liquidity Program,把新的訂單路由程式部署到八台生產伺服器上。這次部署重用(repurpose)了一個舊旗標欄位,原本這個欄位是用來觸發一套叫「Power Peg」的下單邏輯——Power Peg 早在 2003 年就已經停用,但程式碼從未被真正移除,只是被閒置在系統裡整整八年。部署當天,技術人員手動把新程式碼推送到八台伺服器,過程沒有第二人覆核,公司也沒有書面程序要求覆核;其中一台伺服器沒有成功套用新版本,仍然執行著帶有舊旗標邏輯的舊版本。開盤後,新程式碼把這個旗標重新賦予新用途,那台沒更新的伺服器卻把它解讀成「啟動 Power Peg」——而 Power Peg 的邏輯不會檢查母單是否已經成交完畢,會不斷對市場送出子單。
真正讓人在意的不是這段死碼復活本身,是接下來的 45 分鐘。系統其實有留下訊號:開盤到 9:30 之間,內部系統送出了 97 封提及「Power Peg disabled」的 email。根據 SEC 官方調查文件的原文用語,這些訊息「並非以系統警報的形式設計,因此沒有人在第一時間去看」——97 次機會,一次都沒被當成 alert 對待。工程團隊發現異常後,因為沒有 kill switch、也沒有書面應變程序,只能在即時交易、每分鐘處理數百萬股的情況下臨場排查;過程中他們一度把已經正確部署的伺服器上的新版程式碼移除,想要讓所有機器「回到一致狀態」,結果反而讓壞掉的 Power Peg 邏輯在更多節點上跑起來,火上加油。整起事故歷時約 45 分鐘,Knight Capital 因此虧損超過 4.6 億美元,超過公司當時的現金儲備,最終被迫尋求緊急收購。SEC 官方行政處分令
這起事故和 ChaosScenario 列舉值的設計哲學,講的是同一件事的正反兩面。列舉值逼你明確列出「哪些 scenario 現在合法存在」,任何不在清單裡的輸入會被直接拒絕;Power Peg 的問題恰恰相反——它是一段沒有人主動下決定要不要保留的死碼,靜靜地留在系統裡,等一個意外重新賦予意義的旗標把它喚醒。如果 Knight Capital 在部署新程式碼時,像 parse_chaos_context 拒絕未知 scenario 字串那樣,對「這個旗標現在只能對應到哪一段邏輯」做嚴格檢查,那台沒更新成功的伺服器頂多是功能故障,不會變成一場金融事故。
第二個更值得 Day 30 記住的教訓,是「97 封信不是 alert」這件事。下篇 ⑰ 段落會談到 alert routing 如何在實驗期間分辨「預期注入」與「真的事故」,但 Knight Capital 的故事提醒我們更基本的一層:在你設計怎麼分辨兩種 alert 之前,要先確保系統真的把異常訊號設計成 alert,而不是設計成一堆會被正常郵件流量淹沒的 email。你的 chaos experiment card 裡的 observe 欄位,不能只是「哪裡有訊號」,還要包含「這個訊號有沒有被賦予 owner、severity 與明確的第一步動作」——沒有這三件事的訊號,寫得再詳細也等於沒發生過。
Cloudflare 的故事講的是「不要信任自己人產生的資料」,Knight Capital 講的是「沒有 kill switch 時,人會在事故現場臨時發明出更糟的應變」。還有第三種常被忽略的失效模式:你準備用來止血的復原路徑,本身依賴著正在故障的那套系統。
2021 年 10 月 4 日,Meta(當時的 Facebook)旗下 Facebook、Instagram、WhatsApp、Messenger 全球同時離線約六小時。起因是一次例行的骨幹網路容量維護:一道指令原本應該只是評估全球骨幹網路的可用容量,但負責檢查指令安全性的稽核工具(audit tool)沒能攔下一條有問題的指令,讓它真的被執行,結果把 Meta 全部資料中心之間的骨幹連線整個斷開。骨幹一斷,連鎖反應立刻開始:Meta 的 DNS 伺服器設計成「連不到資料中心就判定自己不健康,主動撤回 BGP 廣播」,於是全球 DNS 解析跟著失效——即使實體伺服器本身完全沒事。
真正讓這起事故拖到六小時的,不是找根因慢,是連「用來修復系統的工具」都跟著壞掉了。DNS 失效連帶弄壞了 Meta 內部原本用來偵測與處理事故的工具,因為那些工具同樣仰賴同一套已經斷線的內部網路與 DNS。遠端修復管道全滅後,工程師被迫親自到資料中心現場處理;但資料中心的實體安全設計本來就是「很難進去,進去了也很難碰到硬體」,這個設計的原意是防止未授權存取,這次卻讓授權的工程師自己也寸步難行,進一步拖慢了復原速度。Meta 官方工程部落格
這件事對 Day 30 的 experiment card 有一個很具體的提醒。experiment-card.yaml 裡的 abort 與 rollback 欄位,寫的不只是「停止注入」這個動作本身,還隱含一個更根本的前提:執行這個動作的通道,不能經過被測系統。如果你的 abort 機制是「呼叫同一個 /ask service 的管理 API 去關閉 chaos flag」,而這個 service 剛好就是你正在測試、可能已經被你弄壞的那個 service,你等於複製了 Meta 這次事故的結構——理論上存在的復原路徑,在真正需要它的那一刻,恰好也連不上。實務上比較安全的做法,是讓 abort 開關獨立於被測 service 之外:一個獨立的 feature flag 系統、一個不共用同一組 DNS 或網路路徑的控制平面,或者最簡單的,直接移除注入用的 header 而不依賴被測系統自己提供的任何關閉端點。
接著在 FastAPI 的 request boundary 解析它。
from fastapi import FastAPI, Header, HTTPException
from app.chaos import ChaosContext, parse_chaos_context
app = FastAPI()
@app.post("/ask")
def ask(
question: str,
x_chaos_scenario: str | None = Header(default=None),
x_tenant: str | None = Header(default=None),
) -> dict:
try:
chaos = parse_chaos_context(x_chaos_scenario, x_tenant)
except (PermissionError, ValueError) as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return answer_workflow(question=question, chaos=chaos)
真正的 application 可能是 async handler、queue consumer 或 gateway middleware。
位置可以不同,原則不變:注入範圍由 request context 決定,而且 context 必須可見、可記錄、可拒絕。
不要根據 random.random() 決定是否讓 provider timeout。
隨機注入在練習時很有趣,在調查時會讓你失去可重現性。
若你真的需要抽樣,記錄 seed、選取規則與所有被選到的 request_id。
Google SRE Book 也提醒,統計式測試本身未必可重跑;記錄隨機行為可讓故障更容易被重現為 release test。Testing for Reliability
接下來建立一個 provider adapter。
把 failure injection 放在 adapter 邊界,才能確認 timeout、retry、breaker 與 fallback 是由真實 workflow 邏輯處理,而不是在 endpoint 偷塞一個假回覆。
from dataclasses import dataclass
from app.chaos import ChaosContext, ChaosScenario
class ProviderTimeout(Exception):
"""A simulated upstream timeout for an isolated chaos run."""
@dataclass(frozen=True)
class ModelReply:
text: str
model_name: str
class ModelProvider:
def generate(self, prompt: str, chaos: ChaosContext) -> ModelReply:
if chaos.scenario is ChaosScenario.PROVIDER_TIMEOUT:
raise ProviderTimeout("simulated provider timeout")
return ModelReply(
text="Synthetic baseline answer.",
model_name="fake-model-v1",
)
不要在注入 scenario 裡假裝 provider 回了一個正常答案。
那只是在測試自己寫的替身,不是在測試 timeout handling。
接著由 workflow 決定哪一種回應符合 contract。
from dataclasses import dataclass
@dataclass(frozen=True)
class AskResult:
technical_status: str
workflow_status: str
task_status: str
quality_status: str
safety_status: str
degraded: bool
answer: str | None
reason: str | None
def answer_workflow(question: str, chaos: ChaosContext) -> AskResult:
documents = retrieve_documents(question, chaos)
try:
reply = provider.generate(build_prompt(question, documents), chaos)
except ProviderTimeout:
return AskResult(
technical_status="degraded",
workflow_status="provider_unavailable",
task_status="not_completed",
quality_status="not_evaluated",
safety_status="no_tool_action",
degraded=True,
answer=None,
reason="model_provider_timeout",
)
return validate_reply(reply, documents)
這段範例故意不把 task_status 寫成 completed。
HTTP handler 可能最後仍回 200、202 或產品定義的可用回應碼。
但 task 沒完成就是沒完成。
Day 7 談過 Technical Success ≠ Semantic Success。
Day 30 的 chaos 實驗應驗證這條區分沒有在 fallback 路徑消失。
如果你的產品有經過審核的 read-only fallback,可以回覆快取內容或固定說明。
前提是它的資料時間、適用範圍與使用者可見狀態都符合產品承諾。
「模型掛了,隨便編一段看起來合理的內容」不是 degradation。
那叫把 outage 轉成品質事故。
建立一份 scenario matrix。
它會比一長串 if 更能幫助 review。
| Scenario | Technical outcome | Workflow outcome | User-visible outcome | Tool policy | Evidence |
|---|---|---|---|---|---|
| provider timeout | dependency failure recorded | generation stopped | 明確暫時不可用或既定 fallback | 不執行 tool | provider span、breaker、reason |
| empty retrieval | HTTP 可正常完成 | context insufficient | 說明無法根據資料回答 | 不猜測、不執行寫入 tool | result count、index version |
| tool schema mismatch | tool call rejected | action not dispatched | 要求修正或改走人工流程 | 預設 deny | schema version、policy decision |
| queue delay | request queued or expired | deadline reached | 可取消或稍後取得結果 | 依 contract | queue age、deadline、request_id |
| evaluator unavailable | delivery path preserved | quality mark pending | 不把未評估當通過 | 依風險決定 hold | evaluator availability、release state |
matrix 的價值在於迫使團隊選擇。
同一個 empty retrieval 在低風險 FAQ 可以回 insufficient_context。
在需要提供合規條文的工作流,可能必須升級為 human review。
同一個 evaluator unavailable 在線上服務可能允許先交付、後續標記。
在 release gate 則應阻擋 promotion。
沒有「AI failure 的通用正確答案」。
只有你是否把使用情境、風險與可觀察狀態寫清楚。
fallback=true下面這種設計看起來統一,其實會抹掉關鍵差異。
{
"success": true,
"fallback": true,
"answer": "稍後再試"
}
問題不在文字。
問題在於它沒有說明 retrieval 是空的、provider timeout、tool 被拒絕,還是 evaluator 暫時不可用。
這會讓使用者、on-call engineer 和 release manager 看到同一盞燈,卻各自猜不同的事故。
較好的最低限度是分出穩定的 outcome class。
{
"technical_status": "degraded",
"workflow_status": "provider_unavailable",
"task_status": "not_completed",
"degraded": true,
"retry_after_seconds": 30,
"request_id": "req-example"
}
reason 的細節可留給 log 和 trace。
回應本身不應洩漏 provider credential、內部 topology 或未遮罩文件。
下集預告:下篇會把這些設計真的跑起來——一步步在隔離 SRE Lab 執行 provider-timeout 實驗、追加 empty retrieval、tool schema mismatch、circuit breaker 與 queue delay 四種情境,最後示範怎麼把單次實驗的發現,收斂成長期可靠性工作。
這篇是 Learning SRE for the AI Era 系列的一部分:從 SRE Lab 到 Production AI Reliability。
Build → Trace → Break → Measure → Evaluate → Recover → Improve.