結論先說:error budget 不是故障額度,而是 SLO 留下的可接受風險;burn rate 告訴你照目前速度會不會太早花光它。本篇先講觀念、算式與告警規則,下篇再談怎麼接進 rollout 與事故應變。
99.5% SLO 在一個 window 裡留下 0.5% bad-event 預算。重點不是算出一個漂亮百分比,而是事前約定:快花光時,誰停止 rollout、誰修可靠性、什麼例外可以繼續。
Day 14、15 已經把 SLI/SLO 的分子分母算出來了:99.5% 可用性,聽起來是一個乾淨的數字。但一個常見的坑是:團隊訂完 SLO 就結束了,沒人回答下一個問題——當這個月已經用掉 80% 的失敗額度,rollout 該不該繼續?值班工程師半夜看到一次 5xx 尖峰,要不要叫醒別人?
沒有答案的話,這些決定會在事故現場臨時喬,靠的是誰嗓門大、誰資深。error budget 與 burn rate 存在的理由,就是把這些決定挪到事故發生「之前」先講好。
在往下講之前,有一個名詞上的誤解值得先拆掉。很多人第一次聽到「error budget」,會直覺把它理解成「這個月允許出幾次包」的配額——好像每個月團隊都領到一張可以隨意花用的免死金牌,用完之前怎麼搞砸都沒關係。這個直覺是危險的,因為它把 error budget 從「使用者願意承受的風險上限」偷換成「工程團隊被允許犯錯的次數」,兩者聽起來相似,實際上是完全不同的立場:
故障配額(誤解)
「這個月可以出 3 次包,用完了才要小心」
→ 把犯錯本身當成目標,鼓勵在配額內隨意冒險
→ 隱含的態度:預算是團隊的權利
error budget(正確理解)
「使用者這個月願意忍受的失敗上限是這麼多」
→ 犯錯不是目標,而是「追求速度時,不得不承擔的代價上限」
→ 隱含的態度:預算是使用者讓渡出來的信任額度,不是團隊的權利
這個差異決定了 policy 寫出來之後團隊的心態。把 budget 當成「配額」,團隊容易在預算還剩很多時鬆懈;理解成「使用者讓渡的信任額度」,即使剩 90% 也會自然追問「為什麼用掉這 10%」。Google SRE Book 對 error budget 的原始定義強調的正是這一點——它不是給工程團隊犯錯的許可,而是替「可靠性」與「開發速度」這兩個天生互相拉扯的目標,找一把共同認可的量尺。
把場景拉到值班現場,會更容易看清楚這個問題有多真實。假設你是 on-call,凌晨三點收到一則告警:5xx 比例從平常的 0.1% 跳到 1.2%,持續了 8 分鐘。這時候腦中會同時冒出好幾個互相矛盾的念頭:
念頭一:只是尖峰,等一下應該就會退,先不要吵醒任何人。
念頭二:這個月已經出過兩次類似的事,會不會是同一個根因復發?
念頭三:明天一早有一個大 rollout,要不要先擋下來?
念頭四:判斷錯誤的兩種代價(誤報吵醒大家 vs 讓使用者持續受影響)哪個比較糟?
沒有 policy 的情況下,這四個念頭沒有一個能靠「規則」回答,只能靠當下的經驗、風險偏好,甚至有多累。同一個訊號,換一個值班工程師,可能做出完全不同的決定——不是能力問題,是決策標準本來就沒被寫下來。error budget policy 要解決的正是這種問題:把「多快算太快」「誰有權按下暫停鍵」挪到平靜的白天先寫成文件,讓凌晨三點的人不必獨自賭一把。
SLO = 99.9%
Error Budget = 1 - SLO = 0.1%
error budget 是使用者可接受的風險額度。超過這個額度,代表你正在用可靠性去換別的東西(通常是 delivery velocity),這筆帳要算清楚。
Day20/DIY 的 policy 文件跟④節的 SLODefinition 都把 window_days 設成 28,而不是直接對齊日曆上的月份(28 到 31 天不等)。這個選擇背後有兩個實際理由。第一,28 天剛好是 4 個完整的 7 天週期,能夠平均涵蓋每週流量的循環模式(例如週末流量通常比平日低),不會因為某個月剛好多出來的 2、3 天落在特定星期幾,而讓不同月份的統計基準不公平地互相比較。第二,也是更重要的一點:rolling window 會隨著時間持續往前滑動,而不是每個月 1 號重新歸零。
日曆月(不建議)
9/1 ─────────────────────────────────── 9/30 │ 10/1 ─────────▶
budget 在 9/1 歸零重算 │ budget 再次歸零
│
問題:9/29 發生一次嚴重事故,燒光大半預算,
9/30 一過,10/1 帳面瞬間「恢復健康」,
但使用者體驗並沒有在跨月的那一刻突然變好
rolling 28 天(本文採用)
◀── 過去 28 天 ──┤現在
budget 每天都用「過去 28 天」重新計算一次
9/29 的事故,會持續影響接下來 28 天的計算基準,
直到 10/27(29 天後)才會從窗口裡「滑出去」
日曆月的問題在於它製造了一個人為的「重新開始」時間點,讓 budget 狀態可能因跟系統健康程度無關的日期切換而劇烈變化——若值班工程師知道「反正再撐兩天就跨月、budget 就會歸零」,可能傾向拖延處理。rolling window 沒有這種僥倖空間:事故只要還在過去 28 天內就持續拖累計算,逼著團隊處理問題,而不是等時間洗掉帳面數字。
burn rate = 實際 bad-event rate / 可接受 bad-event rate
burn rate 大於 1,代表消耗速度快於 SLO 長期允許的速度;等於 10,代表照這個速度,一個月的預算會在不到三天內燒光。Google SRE Workbook 建議同時看短、長兩種 window:短 window(例如 1 小時)用來及早抓到尖峰,長 window(例如 6 小時或更長)用來避免單一雜訊事件就把人吵醒。兩個 window 的實際門檻要靠流量與風險資料校準,不能從教學範例的數字直接搬進 production。
error budget 這套語言最容易被誤解的地方,是把它當成一個「已經花掉多少」的靜態百分比——月初看一次、月底再看一次,中間發生什麼事不重要。但這正是 burn rate 存在的理由:它量的不是油箱裡還剩多少油,而是油表指針掉得有多快。
油箱(error budget):這個 window 裡「允許」燒掉的失敗額度,是固定的存量
油表指針的速度(burn rate):目前用多快的速度在燒這桶油
= 實際 bad-event rate / 可接受 bad-event rate
同一個油箱,慢慢開可以撐一整個月;一路把油門踩到底,可能三天就見底。burn rate 就是那根隨時在動的指針,告訴你照現在的開法還能撐多久,而不是等到儀表板亮紅燈才驚覺已經沒油。
burn rate 不是錯誤率,也不是「錯誤率乘以某個係數」那麼隨意的東西:它是「實際錯誤率」相對於「SLO 允許的錯誤率」的比值——分母不是總請求數,而是 SLO 留下的那道門檻。同樣 1% 的錯誤率,配上不同的 SLO,會讀出完全不同的 burn rate:
| availability SLO | 可接受 bad-event rate | burn rate = 1 的意思 | 1% 錯誤率對應的 burn rate |
|---|---|---|---|
| 99.0% | 1.0% | 剛好把預算花完在整個 window | 1(剛好打平) |
| 99.5% | 0.5% | 消耗速度是允許值的兩倍才算打平 | 2(超支一倍) |
| 99.9% | 0.1% | 消耗速度是允許值的十倍才算打平 | 10(嚴重超支) |
第六節會用一個完整例子重新算一次這張表;這裡先記住一件事:burn rate 的分母是「SLO 允許的速度」,不是「零」。所以 burn rate 不可能單獨脫離 SLO 存在——換一個更嚴格的 SLO,同一批 production 數據會讀出完全不同的 burn rate,這不是系統變差了,是你對它的容忍標準變了。
只看剩餘百分比,會漏掉一個關鍵訊息:這桶預算是被「持續的小額扣款」還是「一次性的大額爆擊」花掉的。假設兩個團隊在月底都回報「availability budget 剩 20%」,情境卻可能完全不同:
團隊 A:預算平穩地隨每天正常流量緩慢消耗,沒有任何單一事件超過 5%。
→ 這是一個 SLO 訂得偏緊、但系統本身穩定的訊號。
團隊 B:前 27 天預算幾乎沒動,第 28 天一次 release bug 在 2 小時內
吃掉 75% 的月度預算,恰好在月底前被回滾,帳面上「剩 20%」。
→ 這是一次應該觸發強制 postmortem 的嚴重事件,
被月底報表的一個平靜數字完全遮住了。
這正是 burn rate 告警要在事件發生「當下」被看見,而不是等月底報表的原因——月底的剩餘百分比只回答「花了多少」,burn rate 回答「花得有多快、有多集中」,兩者合起來才看得出這桶預算是怎麼被燒掉的。
Google 官方 error budget policy 有兩條相關規則:單一事件若在四週窗口內消耗超過 20% 的 error budget,團隊必須做 postmortem 並至少提出一個 P0 改進項;若同一類故障在一整季消耗超過 20%,則要排進下一季規劃文件。低於門檻視為偶發雜訊;超過門檻,才代表故障等級足以讓工程團隊暫停新功能、轉向穩定性工作。Google SRE:Error Budget Policy
這套機制不是憑空發明的。2012 年聖誕夜,AWS 維運人員在一次例行維護作業中,誤將正式環境的 Elastic Load Balancing 狀態資料刪除,導致 Netflix 在北美與拉丁美洲的部分連網裝置間歇性斷線超過半天。那個年代 error budget 這套語言還沒被系統化——Google 的 SRE Book 要到 2016 年才出版,Netflix 當時是靠工程師臨場判斷各服務的優先順序,逐步修復,而不是照著一份寫好的政策執行。Netflix TechBlog:A Closer Look at the Christmas Eve Outage
這正是 error budget policy 想解決的問題:如果那時已經有一份「burn rate 超過某個門檻,就依序關閉哪些非核心功能」的政策,值班工程師不必在假期尖峰現場臨時做決定,只要照著先前談好的規則執行。政策的價值不在於它有多聰明,而在於它把判斷從「事故當下」搬到「事故之前」。
到這裡為止的公式都還停留在符號層次。Google SRE Workbook 在講解 Implementing SLOs 時,用一個真實的手機遊戲後端 API 案例,把這套算式套進了真正量測過的數字。團隊先蒐集四週的實際流量——總請求數 3,663,253、成功請求數 3,557,865,換算成功率是 97.123%——據此把 availability SLO 訂為 97%。反推回四週窗口允許的失敗額度:
四週總請求數:3,663,253
SLO:97%
允許失敗數 = 3,663,253 × (1 - 0.97) ≈ 109,897
109,897 次,就是這個團隊在四週內合法能花掉的失敗額度。Workbook 接著給出兩個真實故障,刻意形成對照,示範同一個油箱可以用完全不同的方式被燒空:
| 故障事件 | 持續時間 | 消耗的失敗數 | 占四週預算 | 燒法 |
|---|---|---|---|---|
| 新版 API 上線引發 NullPointerException | 4 小時(回滾前) | 14,066 | 13% | 短時間、幾乎 100% 失敗率,急速燒 |
| 資料庫伺服器停機 | 20 小時 | 約 72,000 | 65% | 長時間、持續失敗,慢慢燒但總量更大 |
| — | — | — | — | Google SRE Workbook:Implementing SLOs |
兩起事件的持續時間差了 5 倍,但真正決定預算被吃掉多少的,是「故障期間的失敗率」乘上「持續了多久」——NPE 事件只維持 4 小時卻幾乎每個請求都失敗,等同最高速度燒預算;資料庫停機拖得更久,總消耗量更大,但燒錢速度沒那麼誇張。這正是 burn rate 存在的理由:只看「總共壞了幾次」看不出急迫程度,把時間軸算進去才分得出「短而兇猛」和「久而持續」這兩種燒錢方式。第⑧節會回到這兩種燒法,示範怎麼用雙窗口告警分開偵測。
99.9% 這個數字看久了,很容易被誤會成「業界標準答案」。Google SRE Book 在 Embracing Risk 一章用自己內部案例拆穿這個迷思:Google Apps for Work 對外承諾季度 99.9% 可用性、合約明訂罰則,而 2006 年剛被收購的 YouTube 還是新創消費產品,團隊刻意把可用性目標訂得比企業產品低——書裡寫得很直白:「因為快速的功能開發相對更重要」。Google SRE Book:Embracing Risk
可靠性目標的起點應該是使用者旅程與產品所處的商業階段,而不是工程團隊心中那個「業界都用 99.9%」的預設值。剛起步、還在瘋狂加功能的消費產品,追求四個 9 反而可能是在浪費工程資源;被企業客戶簽了 SLA 合約的核心服務,訂得太寬鬆則是在賭自己的商譽。第十三節會回到這個主題。
傳統 error budget 只看「服務有沒有回應」。AI workflow 多了一種失敗:服務回應了、狀態碼是 200,但答案是錯的、沒有根據,或引用了不存在的來源。
若產品承諾包含「可根據文件回答」,已確認的無根據答案應該消耗 quality budget。但它未必要跟 availability 共用同一個桶:混在一起會讓大量 timeout 掩蓋少量嚴重的安全問題,或反過來讓評估雜訊凍結所有部署。先分桶,再替每個桶寫清楚各自的行動,是比較務實的做法。
這個問題不是假設。2023 年 12 月,大量使用者在社群與 OpenAI 論壇回報 GPT-4 變得「懶惰」:原本該完整輸出的程式碼被截斷、附一句「其餘部分請自行補完」,回答明顯變得敷衍。那段期間沒有任何服務中斷,HTTP 狀態碼正常、延遲正常——從 availability 監控的角度看,這是一條完全平靜的綠線。OpenAI 官方在 X 上公開回應:「我們自 11 月 11 日後沒有更新過這個模型,這絕對不是刻意的,我們正在調查。」OpenAI 回應:GPT-4 Getting Lazier
這句回應點出了 AI 系統特有的一種依賴風險:版本號沒變,不代表推論行為沒變。多數團隊呼叫的是一個模型別名(例如 gpt-4、gpt-4-turbo),而不是一個固定 snapshot;provider 端可能因 A/B 測試、後端路由調整、安全對齊調整而改變同一個別名背後的權重或取樣參數,應用層完全不會收到「部署事件」通知。這和傳統 SRE 熟悉的「third-party API 版本升級」不一樣:後者通常有 changelog 和棄用期限,前者往往連 provider 自己都要花時間排查。下面會給出具體的緩解做法。
若 quality budget 跟 availability budget 混在一起,團隊的告警會被「系統還活著」這個訊號誤導,直到使用者投訴才發現問題——GPT-4 這次事件也是先由使用者大量反映,OpenAI 才展開調查,沒有任何內部告警搶先發現。
「HTTP 200 但答案是錯的」聽起來像是一種失敗,實際上背後可能是完全不同層級的問題疊在一起。把一次 AI workflow 的請求,從最底層的基礎設施一路往上拆到語意層,會看到失敗其實有好幾種完全不同的「發生位置」:
┌─────────────────────────────────────────────┐
│ Governance 層:這次輸出符不符合政策、合規要求? │ ← 通常人工審核才抓得到
├─────────────────────────────────────────────┤
│ Economic 層:這次呼叫花了多少錢、值不值得? │ ← ⑩節「cost burn rate」處理這層
├─────────────────────────────────────────────┤
│ Semantic 層:答案對不對、有沒有根據? │ ← 本節 GPT-4「變懶」事件發生在這層
├─────────────────────────────────────────────┤
│ Workflow 層:retrieval 有沒有找到文件、 │
│ tool call 的 schema 對不對? │ ← ⑥節判定流程的中間分支
├─────────────────────────────────────────────┤
│ Dependency 層:向量資料庫、LLM provider │
│ 本身有沒有回應? │ ← 傳統依賴健康檢查涵蓋的範圍
├─────────────────────────────────────────────┤
│ Service 層:這個服務本身有沒有 5xx、有沒有 │
│ crash? │ ← 傳統 availability SLI 涵蓋的範圍
├─────────────────────────────────────────────┤
│ Infrastructure 層:主機、網路、GPU 資源夠不夠? │ ← 最底層,傳統 SRE 最熟悉的範圍
└─────────────────────────────────────────────┘
這張圖分層對應的正是 outline.md 提到的 AI Reliability 七層框架。傳統 SRE 的告警系統天生對底下三層(Infrastructure/Service/Dependency)最敏感,這些層級的失敗通常有明確訊號:連線失敗、狀態碼、資源耗盡。但 GPT-4「變懶」事件發生在 Semantic 層,這一層連線、狀態碼、資源都正常,唯一出問題的是「內容有沒有兌現承諾」,需要額外的評估機制(golden dataset、evaluator、人工抽查)才看得見。這也是為什麼③節開頭要強調「先分桶,再替每個桶寫清楚各自的行動」——不同層級的失敗需要完全不同的偵測手段,硬塞進同一組 dashboard,只會讓上層問題被下層的「一切正常」掩蓋。
寫這篇文章前,我對 model provider 的心智模型其實跟一般的 third-party API 沒有太大差別:版本號固定、行為固定,除非官方公告升級。GPT-4 這次事件打破了這個假設。多數團隊在程式碼裡寫的是這樣的呼叫:
# 常見寫法:呼叫一個「別名」,而不是一個固定的模型快照
response = client.chat.completions.create(
model="gpt-4", # 這是一個 moving target,不是一個 pinned version
messages=[...],
)
較安全的做法,是把呼叫釘死在帶日期的 snapshot:
# 較安全的寫法:釘住一個具體的模型快照
response = client.chat.completions.create(
model="gpt-4-0613", # 具體版本,provider 不會偷偷替換它背後的權重
messages=[...],
)
以及把 model 版本、prompt 版本一起寫進 evaluation 的 telemetry,讓 quality 下滑時,第一個要回答的問題有資料可查:
quality_drop_investigation:
1. model 版本是否改變?(pinned snapshot,理論上不會變)
2. prompt 版本是否改變?(檢查 prompt registry 的最近 commit)
3. retrieval index 或文件版本是否改變?
4. evaluator 本身是否改版?(見 ⑬ 錯法四)
只有排除前三項,才能說「provider 端行為疑似漂移」,
而不是把所有 quality regression 都先怪到 provider 頭上。
這張清單背後的態度,其實跟 Day 19 談 quality/safety 分開評分的立場一致:任何一次「行為變了」都值得先問「是我方的哪一層變了」,而不是急著下結論。pin 住版本號不能完全消除這個風險——provider 仍可能棄用某個 snapshot、或在極端情況下修改已發布快照的行為——但它至少把「多了一個變因」的機率降到最低,讓排查時能優先檢查自己控制得到的三層,而不是一開始就懷疑一個自己完全無法觀測的黑盒。
前面三節把 error budget 講成一套可以推導的算式,但算式不會自己變成行為準則。這一節的 DIY 把①到③的抽象立場——「burn rate 是速度不是比例」「availability/quality/safety 要分桶」「單一事件超過 20% 要強制 postmortem」——寫成一份可對照決策的文件,加一支可重複執行的計算工具。就算不打算實際跑 Day20/DIY,讀完也該看懂它在驗證什麼。
根據上面的框架,寫下你的 policy:
- availability budget 接近耗盡(>80% 已消耗):暫停非必要 rollout,優先處理 user-impacting failure。
- 單一事件消耗 >20% 月度預算:觸發強制 postmortem,包含至少一個 P0 改進項。
- quality regression:停止受影響的 model/prompt promotion,送案例人工複核。
- safety block 異常:保留證據、升級給安全 owner;不以「平均分數」解除限制。
- 例外情況(不計入預算):公司級基礎設施故障、第三方依賴宕機、預計外的負載測試。
再用 Day 14 的分子分母,手算一個 window 的剩餘預算;沒有實際流量就用符號或假設值,標為練習。Day20/DIY 就是這樣一份練習:data/policy.md 把上面的 policy 寫成完整文件,app/budget.py 與 scripts/calculate_budget.py 用假設數字跑了幾個場景的 burn rate,全部標明是教學情境。
app/budget.py 沒有寫成一個「丟數字進去、吐出 burn rate」的單一函式,而是拆成三個各自負責一件事的 dataclass:
class BudgetType(str, Enum):
AVAILABILITY = "availability"
QUALITY = "quality"
SAFETY = "safety"
@dataclass
class SLODefinition:
"""SLO 的靜態定義:目標是多少、window 多長、誰是 owner。"""
budget_type: BudgetType
slo_percentage: float
window_days: int = 28
owner: str = ""
@dataclass
class WindowMetrics:
"""某個時間窗口「觀測到」的原始數字:這段時間有多少請求、多少壞事件。"""
window_name: str
total_requests: int
bad_events: int
@dataclass
class RollingWindowBudget:
"""把 SLODefinition 與觀測值接起來,算出消耗百分比與剩餘額度。"""
slo: SLODefinition
total_requests_in_window: int
bad_events_in_window: int
這個拆法直接對應第②節強調的一件事:SLO 目標(你「打算」容忍多少)、觀測到的原始數字(實際發生了什麼)、以及兩者相除後的消耗結果,是三個必須分開存放的東西,混在一個函式的區域變數裡,很容易在改程式碼時不小心把「目標」跟「觀測值」混用。BudgetType 用 enum 而不是字串常數,則是刻意讓 availability、quality、safety 在型別系統層級就是三個不同的東西——第⑨節會講的「三個桶不能互相抵銷」,在這裡先用型別把「桶」的邊界釘死,而不是靠命名慣例或註解提醒自己。
classify_status 為什麼每種 budget type 用不同門檻def classify_status(self, slo: SLODefinition) -> tuple[BudgetStatus, float]:
burn_rate = self.calculate_burn_rate(slo)
if slo.budget_type == BudgetType.AVAILABILITY:
if "1" in self.window_name.lower(): # 短窗口:抓急速尖峰
if burn_rate > 36: return CRITICAL
elif burn_rate > 6: return WARNING
else: # 長窗口:避免雜訊誤報
if burn_rate > 6: return CRITICAL
elif burn_rate > 2: return WARNING
elif slo.budget_type == BudgetType.QUALITY:
if burn_rate > 10: return CRITICAL
elif burn_rate > 3: return WARNING
elif slo.budget_type == BudgetType.SAFETY:
if burn_rate > 3: return CRITICAL # safety 門檻刻意壓到最低
elif burn_rate > 1: return WARNING
這段程式碼驗證的,正是第⑨節「availability、quality、safety 不能共用同一組門檻」的主張:availability 的短窗口門檻(36 倍才算 CRITICAL)遠比 safety 的門檻(3 倍就算 CRITICAL)寬鬆,反映的是「一次尖峰式的 timeout 通常可以觀察」跟「一次疑似違規的 safety event 幾乎不該被容忍」這兩種完全不同的風險偏好。三個桶共用同一組門檻,要嘛 safety 太寬鬆放過該升級的事件,要嘛 availability 太緊、把每次正常尖峰都當事故 page 醒值班——這正是①節「凌晨三點」那個場景反覆發生的原因。
scripts/calculate_budget.py 的 Scenario 2 刻意設計成一次「尖峰事件」,用來驗證第⑤、⑧節「同一組數據、不同 window 讀出不同結論」的主張。預期輸出的關鍵數字:
1-hour window : 250 bad / 3,000 requests → actual rate 8.33%
acceptable rate (99.5% SLO) = 0.5%
burn_rate = 8.33% / 0.5% = 16.7x → WARNING(短窗口門檻是 36)
28-day window : 4,250 bad / 5,000 monthly budget
consumed_percentage = 85% → CRITICAL(消耗門檻是 80%)
執行 uv run python scripts/calculate_budget.py 會看到這兩個數字同時印出來,而不是只有一個:1 小時窗口的 burn rate 是 16.7x(短窗口門檻判讀還算 WARNING),累積到 28 天窗口消耗已到 85%(長窗口門檻判讀已是 CRITICAL)。只跑短窗口那段會誤以為「還在可接受範圍」——這正是①、⑧節反覆強調「不能只看單一 window」的具體程式碼版本。
跑這支腳本會踩到兩個坑:monthly_budget_events 是用「假設 100 萬請求」反推的固定基準,拿去跟真實流量差很多的服務比較會失真;classify_status 用 "1" in self.window_name.lower() 判斷是不是短窗口,是刻意寫得簡陋的教學版本——真實系統該用結構化的時間長度欄位,這裡留著粗糙寫法是因為它足以驗證概念,過度工程化反而模糊重點。
scripts/calculate_budget.py 另外還寫了四個情境函式(連同上面的 Scenario 2),每一個都對應前面某一段論述:
check_single_event_rule(),印出「Escalate to Chief Security Officer immediately」——具體演給你看⑨節「safety 不該等到平均超標」這句話。比起把規則丟給新人讀,讓他直接跑一次這支腳本、逐一比對輸出跟文章對應段落,更快建立起「burn rate 該怎麼讀」的直覺。
除了前面提過的兩個坑(monthly_budget_events 用寫死的 100 萬基準、classify_status 用字串比對判斷窗口長度),還有三點值得記下來:
format_policy_action() 輸出英文,sli-contract.md/policy.md 等文件則中英夾雜——這是刻意模擬真實團隊的樣貌(告警文字用英文方便跨團隊,文件說明用中文更順手),不是疏漏。2026-09-21 14:32:15 UTC)是寫死的假資料,不會隨執行日期改變。若要改造成串接真實 Prometheus 數據的版本,第一步是把這些寫死的數字換成從 WindowMetrics 動態帶入的觀測值。Day20/DIY/data/slo_metrics.yaml 用假設的 28 天窗口(SLO 99.5%,允許 50 個 bad event)示範同一組公式在不同 window 下的樣子:
| Window | 假設流量 | bad rate | burn rate | 建議動作 |
|---|---|---|---|---|
| 1 小時 | 42 requests,1 個失敗 | 2.38% | 4.76 | 立刻叫醒 on-call,照這個速度約 6 天燒光月預算 |
| 24 小時 | 1,000 requests,6 個失敗 | 0.6% | 1.2 | 略高於預期,觀察是否持續 |
| 28 天 | 10,000 requests,5 個失敗 | 0.05% | 0.1 | 正常運作,預算剩 90% |
這張表最值得留意的地方,是三個 window 讀到的同一個系統,結論完全不同:只看 1 小時窗口會覺得快要出事(樣本極小,一次失敗就把比例推到 2.38%),只看 28 天窗口又會覺得一切正常(樣本夠大,單一事件被稀釋到幾乎看不見)。這正是為什麼 burn rate alerting 通常同時設短、長兩個門檻:短窗口對雜訊敏感,要搭配長窗口確認問題不是曇花一現;長窗口則對「剛開始惡化」反應遲鈍。第⑧節的雙窗口告警規則正是為此存在——短窗口負責「這是不是正在發生」,長窗口負責「這是不是還在持續」,兩者同時亮燈才是真正值得叫醒人的訊號。
上面那張表只示範了 availability 一種 budget type。把同樣一組「1 小時/24 小時/28 天」的窗口結構套用到 quality 跟 safety,會發現「多快算太快」的門檻天差地遠——這正是④節 classify_status() 為什麼要替每種 budget type 各寫一組門檻的原因,這裡換成表格重新對照一次:
| budget type | 1 小時窗口 CRITICAL 門檻 | 28 天窗口 CRITICAL 門檻 | 為什麼門檻差這麼多 |
|---|---|---|---|
| availability | burn rate > 36 | 消耗 > 80% | 短暫尖峰常見(部署、瞬間負載),要有容錯空間 |
| quality | burn rate > 10 | 消耗 > 80% | 評估通常有延遲(人工複核跟不上即時流量),門檻中等 |
| safety | burn rate > 3 | 不採用消耗比例,採單一事件規則 | 任何疑似違規都該被視為異常,不能靠「稀釋」變安全 |
availability 的門檻最寬鬆(36 倍才算嚴重),是因為短暫的尖峰(部署瞬間錯誤、provider 抖動)本來就是正常運作中會出現的雜訊,需要較高容忍度;safety 的門檻最嚴格(3 倍就算嚴重)且乾脆放棄消耗比例、改用⑨節的單一事件規則,是因為 safety 事件的風險不對稱——一次疑似違規的商譽或法律風險可能遠大於一千次正常請求的價值,用「稀釋」的邏輯衡量它本身就是誤判。
error budget 的計算不難,難的是每一個名詞都要和產品承諾對得上。以下用一個完全虛構的「公司政策問答 API」練習。數字不是 SLA,也不是本系列任何服務的實測結果。
觀測窗口:28 天
總請求數:10,000
availability SLO:99.5%
允許 bad event:10,000 × (1 - 0.995) = 50
這裡的 bad event 要先寫進 SLI 定義。若 availability SLI 定義為「在 2 秒內收到可解析的 HTTP 2xx 回應」,那麼 timeout、5xx、無法解析的回應都算 bad event;回答內容錯誤但仍回 200,不會自動算進這個 availability bucket。
把品質問題塞進 availability 分子,通常會讓兩種問題都變得難追。API 500 要找服務、資料庫或依賴;引用錯誤要找 retrieval、prompt、文件版本或 evaluator。它們可以在同一個 release meeting 討論,卻不該在同一條分子裡互相抵銷。
把一次請求會經過的判定順序畫出來,會更清楚「bad event 算進哪一桶」不是憑感覺決定的:
一次請求進來
│
├─ 逾時或連線失敗?──────────────▶ Availability bad event
├─ HTTP 5xx?────────────────────▶ Availability bad event
├─ HTTP 2xx,但內容無法解析?────▶ Availability bad event
│
├─ HTTP 2xx 且可解析,
│ 但答案缺乏來源支持/
│ 引用不存在的文件?────────────▶ Quality bad event(不進 availability 分子)
│
└─ 觸發未授權 tool action/
外洩不應揭露的內容?──────────▶ Safety event(獨立處理,見 ⑨)
這張判定順序表其實就是 SLI 定義的具體實作:只要一個事件在「逾時或連線失敗」「HTTP 5xx」「無法解析」這三條分支都沒有中招,它就不會流進 availability 的分子,不管它的答案有多離譜。這正是第③節 GPT-4「變懶」事件為什麼能瞞過 availability 監控的機制——那些被截斷的回答,全部順利通過了前三道判定,唯一出問題的是最後一步「答案本身的品質」,而那一步根本不在 availability SLI 的判定範圍內。
假設最近 6 小時共有 1,200 個請求,12 個 timeout 或 5xx:
實際 bad-event rate = 12 / 1,200 = 1%
可接受 bad-event rate = 1 - 99.5% = 0.5%
burn rate = 1% / 0.5% = 2
burn rate = 2 的意思不是「錯誤率有 2%」,而是這段時間用兩倍於長期允許的速度消耗 availability 預算。它持續 28 天,預算會被用掉兩倍;它只持續 10 分鐘,可能只是一次可承受的尖峰。算式沒有替你做決定,它只讓決定有可討論的共同語言。
再看同一個 1% 錯誤率,換一個 SLO 就會有不同的意義:
| availability SLO | 可接受 bad-event rate | 實際 bad-event rate | burn rate |
|---|---|---|---|
| 99.0% | 1.0% | 1.0% | 1 |
| 99.5% | 0.5% | 1.0% | 2 |
| 99.9% | 0.1% | 1.0% | 10 |
因此,不能只把某個團隊的 burn-rate 門檻複製過來。SLO 越嚴格,同一批失敗消耗預算的速度越快;使用者旅程、流量、修復能力與錯誤後果也都不同。
真正困難的一步,是把⑥節判定順序圖的輸出接到分子上。完整的計算鏈是三段式的:SLI 判定(一次請求逐項檢查逾時/5xx/無法解析/來源支持,落入某一桶的分子或完全不計入)→ window 聚合(第⑦節,分子加總 / 分母加總 = actual bad rate)→ burn rate 換算(本節開頭的公式,結果拿去跟第⑧節門檻表比對)。
三段各自可能出錯,而且錯誤不會在中間被攔下來,會一路帶到最後的 burn rate 數字上:第一段判定錯誤(例如把 quality 問題誤算進 availability 分子)會讓 burn rate 莫名升高卻查不到服務層異常;第二段聚合錯誤會讓低流量時段的數字被錯誤放大或縮小;第三段換算錯誤(把百分比跟小數搞混,見第⑪節步驟 B)則會讓健康的 burn rate 看起來像嚴重超標。排查「數字看起來不對」時,值得先問是哪一段出了問題,而不是直接懷疑系統真的在故障。
流量很低的服務尤其容易被單一失敗放大。若 5 分鐘只有 3 個請求,其中 1 個失敗,error ratio 是 33.3%,但這未必代表要把所有工程師叫起來。反過來說,低流量不等於可以忽略:若那 3 個請求都是付費、緊急或高風險工作,少量樣本仍可能很嚴重。
policy 要把兩件事分開寫:
把這個判斷邏輯畫成一個 2×2 的表格,會比條列規則更容易在腦中留下印象:
樣本數充足 樣本數不足(低流量)
┌───────────────────┬───────────────────────┐
burn rate 正常 │ 健康,正常運作 │ 資料不足,暫不下結論 │
├───────────────────┼───────────────────────┤
burn rate 異常 │ 真實異常,值得處理 │ 危險區:可能是雜訊, │
│ │ 也可能是少量高風險事件 │
│ │ ← 這格最容易被誤判 │
└───────────────────┴───────────────────────┘
右下角那一格最需要人工介入——樣本數不足、burn rate 又異常,自動判「正常」可能放過真正嚴重的事件,自動判「異常」又可能只是偶發事件被小樣本放大。這正是 should_alert() 讓低流量情境回傳 False(不自動 page)、但仍保留「交給人工複核」路徑的原因:低流量不是放行理由,而是提醒需要一雙人類的眼睛。
寫成一段可以放進告警規則的邏輯,大概會長這樣:
def should_alert(window_requests: int, burn_rate: float,
min_requests: int = 100, burn_threshold: float = 6.0) -> bool:
"""
低流量守門:樣本數不足時,即使 burn_rate 很高,也先標記為
「資料不足以判斷」,而不是直接觸發 page。
"""
if window_requests < min_requests:
return False # 交給人工複核或延長觀察窗口,而非自動 page
return burn_rate >= burn_threshold
這段邏輯對應 Day20/DIY policy 裡的 low_traffic_guard(見第⑧節的 YAML 範例):把「樣本數不足」當成另一種訊號,樣本不足先人工檢查,樣本充足且比例異常才直接觸發自動化告警。
⑥節那張判定流程圖裡,「答案缺乏來源支持」這條分支要能運作,前提是你有一套可以重複執行、結果可比較的評估基準——這就是 golden dataset 的角色。很多團隊第一次做 golden dataset,會把它當成「上線前跑一次」的驗收考卷:通過就上線,上線後就束之高閣,直到下一次大改版才想起來要更新。這個心態忽略了一件事:golden dataset 本身也需要維護,否則它會在你沒注意的時候悄悄過時。
@dataclass
class GoldenCase:
case_id: str
question: str
expected_answer_contains: list[str] # 必須出現的關鍵事實
expected_sources: list[str] # 允許引用的文件 ID
added_date: str # 這筆案例何時被加入
added_reason: str # 為什麼加入(通常是某次真實 regression)
last_verified: str # 上次人工確認答案仍然正確的日期
def is_stale(self, staleness_days: int = 90) -> bool:
"""超過這個天數沒人工複核過的案例,標記為『可能過時』,
不代表它一定錯,而是提醒該找人重新看一眼。"""
from datetime import date
last = date.fromisoformat(self.last_verified)
return (date.today() - last).days > staleness_days
added_reason 刻意要求填寫「為什麼加入」,因為 golden dataset 裡最有價值的案例,通常不是一開始想到的「正常案例」,而是某次真實 regression 後補進去的「這次犯過的錯,以後不能再犯」——③節 GPT-4「變懶」事件若發生在你的服務上,事後補進的案例大概會長這樣:「檢查回答有沒有被截斷、有沒有出現敷衍語句」。is_stale 則對應一個容易被忽略的維護成本:文件庫會更新、使用者問法會演變,一年前寫死的「標準答案」不一定還適用今天的系統,需要定期回頭確認。
假設服務已暴露兩個 cumulative counter:
http_requests_total{service="policy-api",status="200"}
http_requests_total{service="policy-api",status="500"}
不要把 request ID、使用者 ID、完整 prompt 或文件內容放進 Prometheus label。那些欄位的基數太高,也可能含有敏感資料;逐筆追查應交給 logs 和 traces。metrics 要保留的是可聚合的訊號,例如 service、route、status_class、model_route 或經過審查的低基數版本標籤。
以下是讀者可在自己的 Prometheus Lab 嘗試的概念查詢。名稱與 label 要依你的 instrumentation 調整;本文沒有連線到任何 Prometheus,也沒有執行它們。
# 5 分鐘內的所有請求速率:分母
sum(rate(http_requests_total{service="policy-api"}[5m]))
# 5 分鐘內的 server error 速率:分子
sum(rate(http_requests_total{
service="policy-api",
status_class="5xx"
}[5m]))
# error ratio:先聚合分子與分母,再相除
sum(rate(http_requests_total{
service="policy-api",
status_class="5xx"
}[5m]))
/
sum(rate(http_requests_total{service="policy-api"}[5m]))
Prometheus 官方 recording-rule 指引也採取「先彙總 numerator 與 denominator,再相除」的模式。若每個 instance 各自先算比例後再平均,低流量 instance 可能被錯誤地賦予和高流量 instance 相同的權重。Prometheus:Recording rules 的 failure ratio 範例
假設 availability SLO 是 99.5%,可接受錯誤率是 0.005:
(
sum(rate(http_requests_total{
service="policy-api",
status_class="5xx"
}[1h]))
/
sum(rate(http_requests_total{service="policy-api"}[1h]))
)
/
0.005
這條 query 的輸出是 burn rate,不是百分比。Grafana 面板必須把名稱寫清楚,例如 availability burn rate (1h);如果只寫 error,下一個看到圖的人很可能會誤讀。
若分母可能為零,不能把 NaN 硬轉成健康。它表示該 window 沒有可量測的 request。做法由服務型態決定:對持續有流量的 API,沒有流量可能本身就是另一個 alert;對事件驅動服務,則需要合成探測、queue age 或工作完成事件補足可用性訊號。
rate() 前先確認 metric 語意rate() 用於 cumulative counter,會把範圍內的增加量換成每秒平均速率。若 exporter 送進來的是 delta 型資料,直接套 counter 函式會得到錯誤結果;metric type、scrape 間隔與 ingestion pipeline 都要先確認。Prometheus:rate() 與 counter 說明
這一小段看起來像實作細節,卻很容易把整個 SLO 算歪。告警規則寫得再漂亮,分子分母沒有對上,就只是自動化地傳遞錯誤訊息。
上面提過不要把 request ID、使用者 ID 或完整 prompt 放進 label,但這條規則常常在「一開始沒問題、後來慢慢變糟」的情況下被違反——最初只放了 service 跟 status_class,後來為了除錯方便,有人加了 client_version,再後來又有人加了 model_route 想追蹤不同模型版本的表現,每一個加法在當下都合理,但疊加起來的效果可能是:
| label 組合 | 理論上時序數量 | 實際觀察到的行為 |
|---|---|---|
service + status_class |
個位數 | 查詢秒回,dashboard 流暢 |
+ route(10 條路由) |
數十 | 依然正常 |
+ client_version(版本號隨每次 app 更新遞增) |
數百到數千 | Prometheus 記憶體開始緩步上升 |
+ model_route(含測試中的實驗性模型代號) |
數千到數萬 | 查詢明顯變慢,⑦節那條 burn rate query 可能逾時 |
重點不是「不能加 label」,而是每加一個維度,時序數量是相乘而不是相加——這正是 Prometheus 官方文件警告的「基數爆炸」(cardinality explosion)。等到 dashboard 開始逾時或漏資料,往往已經是問題累積一段時間後才被發現。比較穩妥的做法,是每加一個新 label 都先問「可能值有多少個?會不會隨時間無限增長?」,答案含糊的維度應優先放進 log,而不是 Prometheus label——這呼應 Day 2 的「label 只放低基數欄位」原則,一旦失控,burn rate 本身的計算就可能不準確或延遲。
上面幾條查詢每次都要重新拼接分子分母,在 dashboard 或 alert rule 裡容易貼錯 label。Prometheus 的 recording rule 可以把「聚合後的比例」預先算好、存成一個新的時序,之後直接查這個新的 metric:
groups:
- name: policy-api-slo
interval: 30s
rules:
- record: policy_api:availability_error_ratio:5m
expr: |
sum(rate(http_requests_total{service="policy-api",status_class="5xx"}[5m]))
/
sum(rate(http_requests_total{service="policy-api"}[5m]))
- record: policy_api:availability_burn_rate:1h
expr: |
policy_api:availability_error_ratio:1h / 0.005
這不是效能優化的小技巧,而是治理層面的規範:一旦「error ratio 怎麼算」固化成 recording rule,所有 dashboard 與 alert 都引用同一個計算結果,不會有人複製貼上時漏掉 label 或用錯 window。它讓 SLI 定義真正變成「程式碼」——改一次 recording rule,所有下游消費者同步更新,而不是散落在十幾個 dashboard JSON 裡各自維護。
多窗口 alert 的目的,是讓短窗口提供速度感,長窗口確認問題沒有瞬間消失。它不是「同時設兩個 alert」,而是要明確寫出兩者的關係。
以下是教學用 policy,門檻刻意保守,不能直接複製進 production:
availability_burn_alert:
service: policy-api
slo_target: 0.995
short_window: 1h
long_window: 6h
page_when:
short_window_burn_rate_gte: 10
long_window_burn_rate_gte: 4
ticket_when:
short_window_burn_rate_gte: 2
long_window_burn_rate_gte: 1
low_traffic_guard:
minimum_requests_in_long_window: 100
owner: api-oncall
這個檔案不是 Prometheus 或 Alertmanager 的可直接套用格式,而是先把決策條件寫清楚的 policy 草稿。實際 alert rule 還需要 mapping 到現有 metric 名稱、label、routing 和通知工具。
上面那份 YAML 的門檻數字是本文為了教學刻意保守化的版本。Google SRE Workbook 在 Alerting on SLOs 一章公開了自己實際採用的 multiwindow、multi-burn-rate 參數組合,可以拿來對照這套機制在真實規模下長什麼樣子:
| 告警等級 | 長窗口 | 短窗口 | burn rate 門檻 | 消耗掉的月度 budget |
|---|---|---|---|---|
| Page(立刻叫醒人) | 1 小時 | 5 分鐘 | 14.4x | 2% |
| Page-slower(較不急迫的 page) | 6 小時 | 30 分鐘 | 6x | 5% |
| Ticket(建單,不需立刻回應) | 3 天 | 6 小時 | 1x | 10% |
Google SRE Workbook:Alerting on SLOs
這張表有兩個細節值得對照前面的討論。第一,短窗口固定取長窗口的十二分之一左右(1 小時配 5 分鐘),用意不是讓短窗口獨立觸發告警,而是扮演「確認長窗口判斷的問題是否仍在持續發生」的角色——這正是第⑤節那三張「鏡頭」示意圖想表達的分工。第二,門檻越急迫(Page),burn rate 要求越高(14.4x)但消耗百分比門檻越低(2%);越不急迫(Ticket),burn rate 要求越低但消耗百分比門檻越高。邏輯是:燒得極快但總量還小的事件,值得立刻叫醒人阻止它惡化;燒得不快但已累積到相當比例的問題,則適合排進 ticket。這張真實門檻表示範了「超支的速度」與「超支的規模」要分開判讀,才能決定該往哪個緊急程度分類。
| 狀況 | 1 小時 burn rate | 6 小時 burn rate | 合理讀法 | 初步處置 |
|---|---|---|---|---|
| A | 15 | 6 | 快且持續地消耗預算 | page、停止高風險 rollout、開始調查 |
| B | 15 | 0.4 | 剛發生的尖峰或小樣本 | 檢查 trace 與 traffic,短暫觀察 |
| C | 1.5 | 1.3 | 緩慢但持續超支 | 建 ticket、排入近期修復 |
| D | 0 | 0 | 這兩個 SLI 沒看到 error | 仍需看 quality、safety、流量是否消失 |
案例 D 特別值得寫進 runbook。0 只能說明被這條 SLI 定義為 bad 的事件為零,不能推論 AI 回答都正確,更不能推論使用者沒有放棄任務。
burn rate 的用途是排優先順序,不是每一個大於 1 的值都要 page。若它剛過 1,常見動作可能是建立 issue、限制下一波 promotion 或增加抽樣;若它在短長窗口都很高,才需要喚醒 on-call。
通知規則必須至少包含:
只有「burn rate high」的訊息,等於半夜收到一張寫著「有事情」的便條紙。它不夠讓人行動。
把上面五個必要欄位套進三個不同的情境,會發現「同一套模板」自然就能產生三種完全不同的通知,而不需要為每種故障類型另外寫一套告警文字:
情境一:單一 dependency timeout
對應 SLO:availability(1h window)
burn rate:18.2,剩餘 budget:62%,流量:4,200 requests
受影響範圍:僅 /api/answer route,其餘 route 正常
→ 這個組合指向「已知依賴故障」,值班者第一步是確認該 dependency 的健康狀態
情境二:全站性尖峰
對應 SLO:availability(1h window)
burn rate:22.5,剩餘 budget:58%,流量:15,000 requests
受影響範圍:所有 route 同時異常
→ 這個組合指向「基礎設施或平台層問題」,值班者第一步是檢查 infra 層而非單一服務
情境三:低流量時段的單一失敗
對應 SLO:availability(1h window)
burn rate:36.0,剩餘 budget:97%,流量:12 requests
受影響範圍:僅一筆請求
→ 這個組合本該被 min_requests 守門攔下,若仍觸發,代表守門邏輯本身需要檢查
三個情境的 burn rate 數字看起來都很嚇人,但「受影響範圍」跟「流量」這兩個欄位一填進去,值班者幾乎不需要看其他資料,就能判斷該往哪個方向查——情境一該先查依賴、情境二該先查平台、情境三則該先懷疑告警系統本身。數字本身不會說故事,數字加上範圍、加上流量,才拼得出一個值班者能立刻開始行動的起點。
前面那份 availability_burn_alert YAML 是「決策條件」的草稿,還不是 Prometheus 或 Alertmanager 能直接吃的格式。把它翻譯成真正可以部署的 alert rule,需要引用第⑦節已經定義好的 recording rule,並且把「短窗口高且長窗口也高」這個雙重條件用 PromQL 的 and 明確寫出來:
groups:
- name: policy-api-burn-rate-alerts
rules:
- alert: PolicyAPIAvailabilityBudgetPageBurn
expr: |
policy_api:availability_burn_rate:1h > 10
and
policy_api:availability_burn_rate:6h > 4
and
sum(rate(http_requests_total{service="policy-api"}[6h])) * 21600 > 100
for: 2m
labels:
severity: page
budget_type: availability
annotations:
summary: "policy-api availability budget is burning at page-level speed"
description: |
1h burn rate: {{ $value }}. See runbook for the six-question checklist.
runbook_url: "https://internal-wiki/runbooks/policy-api-burn-rate"
- alert: PolicyAPIAvailabilityBudgetTicketBurn
expr: |
policy_api:availability_burn_rate:1h > 2
and
policy_api:availability_burn_rate:6h > 1
for: 15m
labels:
severity: ticket
budget_type: availability
annotations:
summary: "policy-api availability budget burning faster than sustainable pace"
這份規則有三個地方對應前面談過的原則:第一,兩條 alert 都用 and 同時檢查短、長兩個窗口,是⑤、⑧節「單一窗口不可信、要交叉驗證」的 PromQL 寫法;第二,sum(rate(...)) * 21600 > 100 是第⑥節低流量守門邏輯的 PromQL 版本(21600 是 6 小時秒數,估算過去 6 小時大約有多少請求,低於 100 筆不觸發 page);第三,for: 2m 保留一小段延遲,避免瞬間資料抖動直接觸發告警——這是在規則這一層再加一道緩衝,而不是只依賴窗口長度本身。
下篇接著處理:AI 服務要分幾種 budget、怎麼接進 rollout gating、事故發生時 policy 該回答什麼問題,以及常見的錯法有哪些。
這篇是 Learning SRE for the AI Era 系列的一部分。
我會從 SRE 的服務可靠性基礎開始,逐步探索當系統加入 LLM、RAG、Agent 與 GPU Infrastructure 後,如何讓 AI 系統不只可用,也能被觀測、評估、控制成本並安全演進。
Build → Trace → Break → Measure → Evaluate → Recover → Improve.