結論先說:規格寫得再漂亮,沒有查詢跟反例驗證過,都只是紙上的承諾;這篇把(上)定義好的三個候選 SLI 轉成真正能跑的查詢,再用 DIY 步驟證明規格跟程式碼行為對得上,最後整理常見失敗與交給值班者的一頁說明。
承接上文:(上)從使用者句子倒推出 availability、latency、dependency 三個候選 SLI,把「成功」寫成可被反駁的 good_when/eligible_when 規格,並在 FastAPI middleware 與 handler 之間,用低基數的 RequestOutcome 事件把技術分類從 handler 傳給 middleware 記錄。(下)接著把這些事件轉成 PromQL 查詢,並動手驗證這一切真的如規格所寫。
現在回到一開始的三個使用者問題。
最小定義可以是:所有 eligible /ask 請求中,technical status 為 success 的比例。
sum(rate(answer_requests_total{
route="/ask",
technical_status="success"
}[5m]))
/
sum(rate(answer_requests_total{route="/ask"}[5m]))
這是短時間觀測式,不是 28 天 SLO 的唯一查詢。
短窗適合看趨勢。
正式 SLO 要明確指定長窗、資料保留、重設 counter 時的查詢行為,以及是否排除已定義的非使用者流量。
若有 safely_refused,你可以選擇另設 task_status metric,再由產品契約決定拒答是否算 availability good。
不要為了讓比率好看,默默把拒答全部納入成功。
傳統的「P95 < 3 秒」很常用,但它不是唯一寫法。
另一種 user-journey 定義是:成功請求中,有多少比例在三秒內完成。
sum(rate(answer_request_duration_seconds_bucket{
route="/ask",
le="3"
}[5m]))
/
sum(rate(answer_request_duration_seconds_count{
route="/ask"
}[5m]))
兩種寫法回答的問題不同。
前者給你分布邊界。
後者直接給「在門檻內完成的比例」。
不要在同一個 SLO 文件裡交替使用,卻沒說明。
若使用 streaming,還要做一個 TTFT SLI。
端到端完成時間與第一個 token 的等待時間,都會影響體驗。
今天的 FastAPI 範例沒有實作 streaming。
Day 18 會補上 LLM 特有的時間分段。
dependency SLI 多半不是對外承諾。
它像健康檢查燈,告訴你 availability 變壞時先往哪裡看。
sum(rate(answer_dependency_calls_total{
dependency="llm_provider",
outcome=~"timeout|error"
}[5m]))
/
sum(rate(answer_dependency_calls_total{
dependency="llm_provider"
}[5m]))
若 fallback 成功,對 user-facing availability 的影響可能是零。
對成本、品質或延遲的影響卻可能很大。
用一組假設數字感受一下這個落差有多大。假設主要 provider 平均每次呼叫 0.02 美元、回應時間 800ms;fallback provider(例如換一個較小的模型)每次呼叫 0.06 美元、回應時間 2.1 秒。一天 10 萬次請求裡,若有 5% 觸發 fallback,這 5,000 次請求對 availability SLI 的貢獻是滿分(使用者都拿到答案),但當天的 API 成本多花了約 200 美元,而且這 5,000 位使用者的實際等待時間都超過 latency SLI 的門檻。如果 dependency SLI 只回答「有沒有壞」、不區分「直接成功」與「fallback 後成功」,這筆額外成本與體驗落差會完全消失在一片綠燈裡,直到月底帳單或使用者評價異常才被發現。
所以在 dependency metric 裡保留 fallback_success,而不是把它消失成普通 success。
rate() 不是「這五分鐘的比率」,這件事容易被誤解本文三個候選 SLI 的查詢都用了 rate(...[5m]),這個函式常被直覺理解成「過去五分鐘的平均速率」,這個理解大方向沒錯,但兩個細節值得說清楚,否則容易誤讀 dashboard 上的數字。
第一,counter 重設。Prometheus 的 Counter 型別只會遞增,但服務重啟、container 被 Day 3 那種方式強制 kill 重建,都會讓底層數值歸零。rate() 內建了偵測重設的邏輯——它會發現數值不減反增的異常下降是一次重設,並在計算時自動修正,不會讓你看到一條瞬間跌到負值的曲線。但這也代表,服務重啟前後那個時間點的計算結果,精確度會比平常低,這通常不是問題,除非你剛好在那個窗口做嚴格的 SLO 稽核。
第二,[5m] 這個視窗跟你查詢的頻率沒有直接關係。就算你每 15 秒查一次,每次查到的都是「往前推 5 分鐘」這個滑動視窗算出來的速率,不是「上次查詢到這次查詢之間」發生的事。這個特性讓短窗查詢的曲線天生比較平滑(因為每個點都跟前一個點共享了 4 分 45 秒的資料),但也代表視窗選得太長時,一次短暫的尖峰會被稀釋到看不出來——這正是①一開始提到「平均值掩蓋尾部現象」的同一個道理,只是這次發生在時間軸而不是分布上。真正需要精準抓住短暫異常時,查詢視窗要跟著調短,例如把 [5m] 換成 [1m],用犧牲曲線平滑度換取反應速度。
| 現象 | 先看什麼 | 不該立刻推論 |
|---|---|---|
| availability 下降、dependency timeout 上升 | provider trace、DNS、egress、timeout 設定 | 「模型一定壞了」 |
| latency 下降、availability 穩定 | queue、DB、retrieval、provider latency 分布 | 「CPU 一定不足」 |
| dependency error 上升、availability 未變 | fallback ratio、token 成本、quality sample | 「使用者沒有受影響」 |
| HTTP 2xx 穩定、quality sample 下降 | prompt、retrieval index、evaluator、release diff | 「SLO 都綠所以沒事」 |
SLI 只負責把人帶到正確問題附近。
它把「這裡壞了嗎」的猜測,換成「這裡的數字說了什麼」的證據。
證據仍需要人去解讀。
它不能代替 investigation。
好的 SLI 縮短的是「找到問題方向」的時間,不是「解決問題」的時間。
兩者常被混為一談,值班壓力大的時候尤其容易。
上面的 SLO spec 選了 rolling-28d,這不是隨手挑的數字。Google SRE Workbook 建議用「滾動視窗」而非日曆月,原因是自然月長度不一致(28 到 31 天),拿 2 月跟 8 月的 SLO 直接比較會混進視窗長度本身造成的誤差。28 天則正好是 4 個完整的 7 天週期,能自然對齊「工作日 vs 週末」這種週期性流量模式,不會因為視窗邊界剛好切在週五半夜,讓某一天的流量權重被稀釋或放大。
視窗長度本身也是取捨:窗越短,越能快速反映近期變化,但雜訊也越大;窗越長,數字越穩定,卻要等到視窗滾動完才會完全反映在報表上。實務上通常兩種都留——短窗(1 小時、5 分鐘)偵測正在發生的異常,長窗(28 天)衡量是否兌現長期承諾。error budget 與依視窗長度分級的告警策略,會在 Day 20 進一步展開;Day 17 先把這三個 SLI 的分子分母定義穩固,才有東西可以拿去算 budget。
一個常見誤會是把 availability、latency、dependency 想成互斥的選項,好像最後要挑一個「最重要」的當作唯一 SLO。實際上它們回答的是三個不同的使用者期待,通常會同時存在,只是各自的閾值與告警策略不同。
availability SLI ──旨在回答──> 這次請求有沒有完成?
latency SLI ──旨在回答──> 完成得夠快嗎?
dependency SLI ──旨在回答──> 下游拖累我了嗎?(診斷用)
一個請求可以同時:
availability = good(技術上完成了)
latency = bad(超過 3 秒門檻)
dependency = 顯示 provider 慢,佐證上面兩者的因果關係
三個指標一起看,才能回答「使用者體驗變差,是完全失敗、還是變慢、還是下游拖累」這種真實值班時最常見的問題。只留一個,等於主動丟掉診斷用的上下文。
這一節給讀者自行操作的步驟。
本文沒有建立 DIY 專案、安裝套件、啟動 FastAPI 或執行任何命令。
請在你的獨立 Day 17 DIY 環境中完成,避免把實驗 middleware 直接帶進未知的 production service。
在動手之前,先講清楚這七個步驟合起來要證明哪三件事——這樣即使不實際跑一次,也能看懂它在驗證前面章節的哪個論點。
第一,它驗證「分類邏輯可以獨立於 web framework 測試」(呼應 ③「可被反駁的規格」)。classify_status() 是一個純函式,不碰 HTTP、不碰 provider,任何人都能拿一組輸入手算出預期輸出,再跟程式實際跑出來的結果對照。這正是「規格要能被反駁」在程式碼層級的落地——如果分類邏輯藏在 handler 裡跟 I/O 混在一起,你沒辦法不架起整個服務就驗證它對不對。
第二,它驗證「middleware 記錄的東西跟 handler 分類的東西是同一份資料」(呼應 ⑤「handler 負責提供業務結果」)。Step 3 到 Step 5 刻意讓同一個請求同時留下三種紀錄——HTTP response、application log、Prometheus counter——目的是確認這三個地方講的是同一個故事。如果三者對不上(例如 log 說 timeout,metric 卻沒有對應的 counter 增加),代表 request.state 的傳遞路徑有漏洞,這種漏洞在 production 通常不會馬上炸開,而是安靜地讓 SLO 的分子分母失真。
第三,它驗證「label 基數真的維持低基數,不是紙上談兵」(呼應 ④「事件模型」)。Step 6 故意送出多筆帶不同內容的請求,檢查 metrics endpoint 有沒有跟著長出新的 label 組合。這是本文唯一一個「用真實流量測試 cardinality 假設」的步驟——前面章節講的都是原則,這一步是拿原則去撞一次現實。
跑完這三類驗證,你不會得到一個能上線的 production SLO,但會得到一組可重複執行的證據,證明「這份 SLI 規格跟這份程式碼的行為是一致的」——這正是 SLO 文件最常缺、卻最重要的東西:不是寫得好不好看,是有沒有辦法證明它跟系統實際行為對得上。
你需要一個已能處理 /ask 的 FastAPI 應用程式。
你也需要已安裝並暴露 Prometheus client metric 的測試環境。
若還沒有 metrics endpoint,先沿用前幾天的 Lab 設定。
不要因為這篇文章而把 production token、provider key 或內部文件帶入本機範例。
先把分類邏輯拉出 web framework。
這能避免測試每一種失敗時都要真的呼叫外部 provider。
def classify_status(status_code: int, error_kind: str | None) -> str:
if error_kind == "provider_timeout":
return "dependency_timeout"
if error_kind == "provider_error":
return "dependency_error"
if error_kind == "invalid_output":
return "validation_error"
if status_code >= 500:
return "internal_error"
return "success"
這段函式本身不是完整 production taxonomy。
它的用途是讓規則可以被例表反駁。
為什麼要先寫這個純函式,而不是直接寫 middleware? 因為分類邏輯是整個 SLI 系統裡最容易被悄悄改壞的一塊,而它偏偏也最適合寫成不需要真的發 HTTP request 就能測的東西。如果你直接在 middleware 或 handler 裡用 if/else 判斷 technical_status,之後每次想確認「provider timeout 有沒有被正確分類成 dependency_timeout」,都得真的架起服務、模擬一次逾時。抽成純函式後,這件事變成一行 assert classify_status(503, "provider_timeout") == "dependency_timeout"。跑起來時你會發現,這一步幾乎不會出錯——它太簡單了。真正容易出錯的是下一步:確認這個函式真的被 handler 正確呼叫、結果真的傳到了 middleware。
把以下案例改成你的產品語言後,寫成 unit test 或 fixture。
| 案例 | HTTP | error kind | 預期 technical status | availability good? |
|---|---|---|---|---|
| 正常回答 | 200 | 無 | success | 是 |
| upstream 逾時 | 503 | provider_timeout | dependency_timeout | 否 |
| 上游回不合法 JSON | 502 | invalid_output | validation_error | 否 |
| handler 未處理例外 | 500 | 無 | internal_error | 否 |
| 回 200 但答案缺事實 | 200 | 無 | success | 技術 SLI 是;quality 另算 |
最後一列最有價值。
若你的團隊覺得它該算 availability bad,也可以。
但那代表你決定把 technical 與 semantic reliability 混入同一個 SLI,後續 runbook 必須跟著處理更多分支。
把上一節的 middleware 改成符合你應用程式的 import 路徑。
先只對 /ask 計數。
避免 health check、/docs、metrics scrape 把分母灌大。
if request.url.path != "/ask":
return await call_next(request)
這個 early return 對固定 endpoint Lab 很清楚。
若你的服務有多個需要承諾的 route,改用 allowlist 並在 SLO spec 列出來。
不要用 startswith("/");那等於每個新路由都被悄悄納入。
跑到這一步最常見的坑,是忘記 /metrics 本身也是一個會被 middleware 攔截到的 route。 Prometheus 拉取 /metrics 端點的行為,本身就是一次 HTTP request,如果 early return 的判斷條件寫錯(例如漏了某個路徑、或用了過寬的比對),metrics 端點的每一次 scrape 都會被算成一次 /ask 請求,讓 availability 分母被 scrape 頻率悄悄灌水——scrape 通常每 15 秒一次,永遠是 success,短時間內就能把手動測試的四種案例稀釋到數字上完全看不出來。這也是為什麼 Step 7 要求手算短窗比率再跟 query 結果核對:如果兩者對不上,且差距接近某個固定週期,先去檢查是不是漏掉了 scrape 流量。
在測試雙替身或 feature flag 下,讓 /ask 依序回傳:
1. 正常 answer
2. provider timeout
3. invalid provider output
4. 未處理 internal error
每次請求後,檢查三個地方。
HTTP response:status 是否符合預期。
Application log:technical_status 與 latency_ms 是否存在。
Metrics endpoint:counter 是否只增加一個預期的 outcome。
如果同一個請求讓兩種 technical_status 都增加,先修 instrumentation。
錯誤的 telemetry 會製造錯誤的 error budget。
這一步實際跑起來,最容易發現的問題往往不是分類邏輯錯,而是「重複計數」。 常見的成因是 finally 區塊裡的 metric 呼叫被不小心放進了一個也會被 middleware 疊加機制重新觸發的路徑——例如同時掛了兩個功能重疊的 middleware(一個是這篇範例的 SLI middleware,另一個是團隊既有的 APM 或框架內建的請求日誌 middleware),兩者各自對同一個 request 增加了一次 counter。這在單元測試裡很難被抓到,因為單一 middleware 的測試本身完全正確;只有把整個 middleware stack 疊起來跑過一次真實請求,才會看到 counter 比預期多了一倍。這正是為什麼 Step 4 要求「檢查三個地方」而不是只看 HTTP response——重複計數不會反映在回應內容上,只會安靜地讓 SLO 分母膨脹。
讓測試替身回傳 syntactically valid、內容卻沒有引用正確政策的 answer。
此時預期是:
HTTP:200
technical_status:success
availability SLI:可列為 good
quality evaluator:應標為 failed 或 requires_human_review
這不是漏洞展示。
這正是 Day 7 與 Day 19 之間需要不同 SLI 的原因。
如果你把它算成 availability error,文件要明講,並用同一套規則重算歷史資料。
向 metrics endpoint 發出十個不同問題。
結果不應出現十個不同的 prompt label 或十個不同 request ID time series。
預期可見的 label 組合應接近這樣:
route="/ask"
technical_status="success|dependency_timeout|validation_error|internal_error"
release_channel="stable|canary"
若 series 數量隨使用者問題數線性增加,停止再送更多資料。
先移除高基數 label。
這一步是整個 DIY 裡最重要、卻最容易被跳過的驗證。 前六步都在確認「分類邏輯對不對」,這一步確認的是「代價可不可控」。很多人第一次跑這個實驗時,會直覺地把問題文字(例如 question 參數的內容)也塞進某個自訂 label 方便除錯,測試時看起來完全沒問題——畢竟只送了十個請求,series 數量微不足道。但這正是 ④ 節那個 50 萬條時間序列案例的起點:在小規模測試裡看不出 cardinality 問題,是因為小規模測試天生就不會觸發它。這一步的價值不是抓出本文範例程式碼的 bug(它本來就沒有把 question 當 label),而是建立一個習慣:任何新加的 label,上線前都先問一次「這個欄位的可能值有沒有上限」。
在有測試 traffic 的短時間窗中,確認 success / total 與你手算的結果相同。
例如四個案例只有一個 success,候選 availability 應是 1 / 4。
它不是 production SLO 的報表。
它只是驗證分子、分母與程式分類一致。
完成後,你應能把單一測試請求對上三種資料。
request fixture
→ HTTP 503
→ application log technical_status=dependency_timeout
→ answer_requests_total{technical_status="dependency_timeout"} +1
正常請求則應該是:
request fixture
→ HTTP 200
→ application log technical_status=success
→ answer_requests_total{technical_status="success"} +1
→ duration histogram 多一筆 observation
若只有 HTTP 看得到,而 metrics 沒變,middleware 或 export path 有問題。
若 metrics 變了、log 卻無法關聯,之後排障會很痛苦;補上 request correlation,但仍不要把 request ID 變成 metric label。
/ask 正常、timeout、validation failure、internal failure 都有可重跑的 fixture。有些 4xx 的確是未授權或格式錯誤,未必屬於已承諾的 journey。
但 rate limit 誤傷、錯誤的 auth rollout、input validation regression,也可能讓正常使用者完全不能工作。
先拆出 4xx 類型,再依產品契約決定。
「4xx 不算」不是設計。
那只是省略。
拆開來看,4xx 底下藏著至少三種完全不同的使用者情境。
401 / 403 未授權存取
→ 通常不算,因為根本不是已承諾的 journey
422 input validation 失敗
→ 若是使用者輸入錯誤,通常不算
→ 若是前端自己送出了不合規格的 request(bug),要算
429 rate limit
→ 若是正常防濫用機制擋到真正濫用者,通常不算
→ 若是限流閾值設太低,誤傷了正常使用量,要算
同一個 HTTP status code,因為背後成因不同,該不該進分母的答案也不同。這也是為什麼「排除所有 4xx」聽起來像個省事的規則,實際上是把三種需要分開判斷的情境,用一句話含糊帶過。
一個 502 可以來自 output validation。
一個 503 可以來自 provider timeout。
兩者對 retry、fallback、事故溝通的處理不一定相同。
保留受控的 technical_status,但不要把原始 exception message 當 label。
這會讓慢而失敗的請求消失。
於是 P95 變好、使用者卻更不滿。
Latency SLI 要說清楚分母是所有 eligible request,還是只看 successful request。
兩種都能用。
不能混著用。
拿具體數字看這個陷阱有多容易發生。
一小時內 1,000 筆 /ask 請求:
700 筆成功,平均 1.2 秒
300 筆失敗,其中 250 筆是撐到 10 秒 timeout 才失敗
只對成功請求 observe latency:
P95 latency 看起來是 2.1 秒,數字很漂亮
若對所有 eligible request observe latency(含失敗):
P95 latency 其實超過 8 秒
因為那 250 筆撐滿 timeout 的失敗請求也該算進分布
兩種算法都有正當用途——前者回答「成功的請求快不快」,後者回答「使用者平均要等多久才知道結果」。危險的不是選哪一種,是團隊沒有意識到自己選了哪一種,把「只看成功請求」算出來的漂亮數字,拿去回答「使用者平均要等多久」這個它從來沒有回答過的問題。
若只量 provider SDK 呼叫,會漏掉 request parsing、queue、retrieval、response serialization。
那是一個 dependency latency 指標,不是 user-facing request latency SLI。
兩個都值得有。
名字要誠實。
目標高不等於可靠。
若過去沒有 baseline、沒有可接受的維護成本,也沒有 error budget policy,四個九只是一張壓力海報。
先量一段明確樣本期。
再把候選目標、例外情況與發布決策寫出來。
一個常見的參照點是:如果系統過去 28 天實際跑出來的 availability 是 99.2%,直接把 SLO 目標訂成 99.99%,代表團隊承諾的失敗預算,比系統過去實際表現嚴格了將近 80 倍。
99.2% → 每 28 天允許約 5.4 小時的 bad 時間
99.99% → 每 28 天只允許約 4 分鐘的 bad 時間
四分鐘的預算,代表任何一次 5 分鐘以上的事故就會直接把整個月的預算燒光。如果沒有人真的打算為了這個數字投入相應的工程資源(多活架構、更嚴格的變更審查、更快的自動 rollback),這個目標只會不斷被違反,久而久之變成一個沒有人再認真看待的數字——這正是「先訂目標再找資料配合」最終的下場:不是系統變可靠了,是團隊對 SLO 這個機制本身失去信任。
bucket 決定你能看見哪些門檻。
如果產品承諾三秒,卻只有 1 和 5 秒 bucket,你無法精準算出三秒內比例。
bucket 應靠近決策門檻。
這也是為何本文範例刻意包含 3。
背後的原理:Prometheus 的 histogram 每個 bucket 都是「小於等於某門檻的累積次數」,le 就是 less than or equal。
histogram_quantile() 在算 P95 這類分位數時,只知道目標分位數落在哪一個 bucket 區間,接著假設該區間內的樣本均勻分布,用線性內插算出估計值。
如果 bucket 只有 1 秒與 5 秒兩檔,而 P95 剛好落在這個區間,Prometheus 只能在 1 到 5 秒之間內插猜測。
真實的延遲分布幾乎不可能均勻。
真正的 P95 可能落在 1.2 秒,也可能落在 4.8 秒,兩者對使用者體驗天差地別,但內插出來的估計值不會告訴你這個差異。
更麻煩的是,這個誤差不會反映在任何錯誤訊息裡——histogram_quantile() 永遠會回傳一個看起來合理的數字,不會因為 bucket 太稀疏而報錯或警告。dashboard 上的 P95 曲線一樣平滑、一樣能畫出漂亮的折線圖,唯一的問題是這條線背後的精確度,遠比它視覺上呈現的樣子低。這也是為什麼設計 histogram 時,bucket 邊界最好直接對齊 SLO 文件裡寫死的門檻數字,而不是套用框架預設值再事後才發現對不上。
這也是為什麼本文的 LATENCY histogram 特地把 3 放進 buckets。
門檻本身要落在某一個 bucket 邊界上,才能直接讀出「小於等於 3 秒的比例」這個精確數字,而不是仰賴內插去猜。
還有一種失敗不是設計錯,是設計對了,但沒有跟著系統演化。
/ask 今天只呼叫一個 LLM provider。
半年後,團隊加了 retrieval 步驟、加了第二個 fallback provider、把 handler 拆成三個內部服務。
原本的 dependency="llm_provider" label 還在,但它已經不能代表「整條依賴鏈」。
半年前:
/ask ──> llm_provider
半年後:
/ask ──> retriever ──> vector_db
──> llm_provider(主要)
──> llm_provider_fallback(次要)
如果 dependency SLI 沒有跟著加上 vector_db 與 llm_provider_fallback 這兩個新的依賴類別,值班者遇到 retrieval 超時時,仍然只會看到 dependency="llm_provider" 顯示一切正常——因為新加的依賴根本沒有被納入計算。
指標不會自己跟著架構圖更新。
每次加新依賴、拆服務、換 provider,都該回頭檢查一次 SLI 規格是不是已經跟不上系統實際的樣子。
這正是為什麼 ③ 節堅持 SLO spec 要跟 event schema 放在一起審查——它們該是同一份 PR review 的一部分,而不是分屬兩個永遠對不上的文件。
把本節六種常見失敗放回 ③ 節的四個問題檢查一次,會發現它們的共通點:都是在某一題上偷懶。
失敗模式 主要違反哪一題
──────────────────────────────────────────────
把 4xx 全排除 問題3(分子分母能不能穩定重算)
用 HTTP status 推斷全部結果 問題2(值班者知道先看哪裡嗎)
只在成功時 observe latency 問題3(分母定義是否一致)
measurement 放外部 client 後面 問題1(使用者真的會感覺到它嗎)
先訂 99.99% 再找資料配合 問題4(團隊願意因此暫停發布嗎)
histogram bucket 當自然常數 問題2(值班者能不能讀出精確數字)
六種失敗沒有一種是因為「不知道 SLI 的定義」,全部是知道定義、卻在某個環節省了一步。這也是為什麼 ③ 節的四個問題值得在每次修改 SLI 規格時重新問一遍——它不是入門知識,是每次都要重新過一次的檢查清單。
SLO 文件不需要像法律合約一樣厚。
但值班者應該不必翻程式碼才能知道紅燈代表什麼。
可以先維護這種格式。
## /ask availability SLI
使用者承諾:已授權的 `/ask` 請求能完成技術工作流程。
Good:`technical_status="success"`。
Total:所有 `route="/ask"` 且未定義為排除項目的 completed request。
Window:rolling 28 days。
Initial objective:待 baseline 後決定;本文不宣稱任何 production target。
When red:
1. 看 dependency timeout/error ratio。
2. 用 trace 或 log 對照 request outcome。
3. 比對最近 release channel 與 fallback ratio。
4. 依 runbook 決定 rollback、限流、切換 fallback 或升級供應商事件。
這份文字比 dashboard 的小數點更重要。
因為半夜收到 alert 的人需要下一步,不需要一堂監控哲學課。
它該寫得像一份交接筆記,不是一份規格書。
這份文件也不必等到系統穩定後才寫。恰好相反,越是還在快速迭代、SLI 定義還可能修改的階段,越需要這一頁說明——它強迫團隊在改動 good_when/eligible_when 的同一個 PR 裡,順手更新「when red 該看哪裡」,讓規格與應變手冊永遠保持同步,而不是規格改了三次,runbook 還停在最初的版本。
寫完模板,最好的檢驗方式是拿一個具體情境跑一遍「when red」的四個步驟,確認每一步都真的有東西可以看,而不是寫了卻沒有對應的資料來源。
凌晨 02:14,pager 響了
availability SLI 從 99.6% 掉到 97.1%(rolling 5m)
值班者打開這份一頁說明,照著 when red 走:
1. 看 dependency timeout/error ratio
→ answer_dependency_calls_total{outcome="timeout"} 同時間段飆升
初步判斷:問題可能在下游,不是本服務程式碼
2. 用 trace 或 log 對照 request outcome
→ 抽 5 筆 dependency_timeout 的 request_id
→ trace 顯示每一筆都卡在 llm_provider 的呼叫上,
耗時穩定超過設定的 timeout 門檻
→ log 裡沒有 internal_error,排除是自家 handler 的邏輯問題
3. 比對最近 release channel 與 fallback ratio
→ release_channel 沒有變化(沒有剛好在同時間發布新版本)
→ fallback_success 的比例也沒有明顯上升
→ 代表 fallback 機制本身可能也受到影響,或還沒被觸發
4. 依 runbook 決定 rollback、限流、切換 fallback或升級供應商事件
→ 排除是己方變更造成,判斷為 provider 端事故
→ 執行 runbook 裡「切換至 fallback provider」的既定程序
→ 同時開一張供應商事件工單,附上上述 trace 與 log 證據
這個走一遍的過程之所以重要,是因為它會馬上暴露文件裡「寫了但沒有資料支撐」的段落。如果第 2 步寫著「用 trace 對照」,但團隊根本沒有幫 /ask 接上 trace(例如 OTLP 還沒串到這條路徑),值班者半夜翻到這一步只會卡住,文件反而變成一份寫著做不到的事的清單。每次修改 SLI 規格,最好都順手跑一次這種假想演練,確認 runbook 上寫的每一步,此刻真的有東西可以看。
同一份模板也該對得上另一種常見情境——不是完全失敗,是變慢:latency SLI 從 98% 掉到 89%,但 availability 幾乎沒動。這時候第 1 步(dependency ratio)排除下游失聯,第 2 步的 trace 顯示是 process_time 本身拉長(不是排隊、不是可觀測性開銷),第 3 步發現 canary 剛好在 15 分鐘前擴大流量——三步合起來指向新版本讓 provider 回應變慢,而不是切換 fallback,runbook 走向是「先把 canary 流量調回去、觀察 latency 是否回穩」。
這個情境的走法跟前一個不一樣,是因為它示範了同一份「when red」清單,遇到「完全失敗」與「變慢但仍完成」這兩種不同的紅燈,會走向不同的結論。這正是 ⑥ 節強調「三個候選 SLI 不是三選一」的原因——只有同時保留 availability 與 latency 兩條線,值班者才分得出這次紅燈屬於哪一種。
SLI 不是資料庫欄位清單。
它是對使用者結果做出的可重算承諾。
寫這篇文章時反覆出現的一個模式是:每一次看起來只是「指標選錯了」的事故,往回追都會發現真正的問題出在更早的一步——不是量測工具不夠好,是沒有人先把「什麼叫成功」講清楚。GitHub 的資料一致性抉擇、Slack 的自動化代理指標、電商的 503 快速失敗,這些案例裡的監控系統技術上都運作正常,錯的是選錯了要問的問題。這也是 Day 17 花這麼多篇幅在①③兩節,而不是急著跳進程式碼的原因。
先定義 eligible event,再定義 good,再把每一種反例寫成可重跑的 fixture。
FastAPI middleware 可以替你捕捉 request 邊界。
它不能替你決定何謂成功,也不能替你判斷 LLM 回答有沒有說對。
先把 technical availability、latency 與 dependency health 分清楚,下一步才有空間把 AI 特有的品質與安全訊號接進來。
把今天走過的路收攏成一張圖,會更清楚每一節在整條鏈上的位置。
① 使用者句子
│ 「得到可用回覆」「不要等太久」「依賴故障不拖垮我」
▼
③ 可被反駁的規格
│ good_when / eligible_when,能被 fixture 反駁
▼
② ④ 事件模型
│ 低基數 event,metric / log / trace 各司其職
▼
⑤ FastAPI middleware + handler
│ middleware 量邊界,handler 給分類
▼
⑥ 三個候選 SLI
│ availability / latency / dependency,各自可查詢
▼
⑦ DIY fixture
│ 反例先行,證明規格跟程式碼行為一致
▼
⑧ 常見失敗清單
│ 校對規格有沒有踩進已知陷阱
▼
⑨ 值班者的一頁說明
紅燈亮起時,人類下一步要做什麼
這條鏈上,Google/GitHub/Slack 的三個事故(Tail at Scale 的尾端延遲、Oct 21 的可用性與一致性衝突、Jan 4 的代理指標誤導自動化決策)與 AWS Lambda、Google Borg、電商 503 三個案例,分別落在不同節點:有的告訴我我們為什麼不能只看平均值,有的告訴我們 eligible event 的定義多容易出錯,有的告訴我們代理指標一旦接上自動化決策會有多危險。它們共同的教訓其實只有一件事——量測系統本身的設計品質,決定了它在真正故障時,能不能把人帶到對的地方。這也是為什麼這篇文章的標題是「量得到不代表該量」:FastAPI、Prometheus、histogram、middleware,這些都只是把一件事「量出來」的工具,真正決定它有沒有用的,是①到④那幾個先問清楚的問題。
Day 17 把 availability、latency、dependency 組成貼近使用者體驗的 FastAPI SLI。
Day 18 要把同一套事件模型搬進 LLM application。
TTFT、queue delay、token throughput、retrieval failure 與 model failure 會出現在同一條 workflow 裡,但不該被壓成一個模糊的「AI latency」。
今天定義的 RequestOutcome、middleware 疊加順序、eligible/good 的判斷流程,到了 Day 18 不會被丟掉重來——它們是骨架,LLM 特有的訊號是要接進這個骨架的新肌肉,而不是另起爐灶的第二套系統。
這篇是 Learning SRE for the AI Era 系列的一部分。
Build → Trace → Break → Measure → Evaluate → Recover → Improve.