結論先說:availability 不能只看 28 天的長期比率,也不能假設依賴失敗只有「全倒」一種樣貌;能不能在事故發生的當下就看到、看懂,取決於量測系統有沒有先把 good 的定義寫成可測的程式碼。
上篇把 availability 的分母拉回產品契約:先定義什麼是有效請求、什麼算好結果,再用低基數 label 讓 Prometheus 累計,PromQL 才從分子分母算出比率。下篇接著把上篇的 classifier 動手做出來,看依賴失敗與部分降級要怎麼量,再回頭問一個問題:28 天的長 window 跟事故當下的短 window,為什麼不能只看其中一個。
SLO window 回答的是長期承諾;incident response 則要知道現在是否正在快速吃掉 error budget。只看 28 天比率,尖峰時段 20 分鐘的全面故障很容易被過去 27 天的正常流量沖淡。
長 window:是否接近違反長期 SLO?
短 window:現在的失敗率是否異常?
事件資料:哪個 status、版本、依賴或區域正在失敗?
這不等於要對每一個 5xx 發 Pager。Google SRE 對 SLO alert 的建議是追蹤顯著的 error-budget consumption,並在 precision、recall、detection time 與 reset time 之間取捨。Google SRE Workbook:Alerting on SLOs
burn rate 這個詞不需要想得太玄:如果 28 天的 SLO 是 99.5%,代表這 28 天總共只能「燒掉」0.5% 的 error budget,把這個總量平均攤到 28 天,就是正常情況下每天該燒掉的速度。burn rate 就是「目前實際消耗的速度」除以「這個正常速度」——burn rate = 2,代表照這個速度燒下去,28 天的 budget 會在 14 天內用完;burn rate = 14.4,代表過去 1 小時燒掉的預算,等於平常兩天份的量,這正是 Google SRE Workbook 建議拿來觸發 page 的量級之一。同時看短窗(如 5 分鐘)與長窗(如 1 小時)兩個 burn rate,用意是讓兩者都超標才真正告警:只看短窗容易被一次流量尖峰誤觸;只看長窗又會讓快速惡化的事件被拖慢發現。這正好回應前面「28 天、1 小時看到的不是同一件事」——burn rate 是把這句話變成一個可以寫進告警規則的數字。
只用短窗告警(例如 5 分鐘 bad rate 超過門檻就 page),會被一次流量尖峰或一次短暫的 deploy 誤觸;只用長窗告警(例如 6 小時平均),又會讓快速惡化的事件拖到使用者早就大量受害才被發現。Google SRE Workbook 給的解法不是「選一個折衷的窗口長度」,而是同時盯緊短窗與長窗兩個數字,兩者都超過門檻才真正告警:
短窗(例如 5 分鐘)超標 + 長窗(例如 1 小時)也超標
→ 現在正在快速惡化,且已經持續了一段時間,不是單次尖峰
→ page
只有短窗超標,長窗沒有
→ 可能只是一次瞬間尖峰,先觀察,不急著叫醒人
→ 記錄但不 page
只有長窗超標,短窗沒有
→ 惡化速度已經趨緩,可能正在自然恢復
→ 依 error budget 消耗程度決定是否需要人工介入
具體門檻要配合前面定義的 burn rate 倍數一起讀。若 SLO 是 99.5%(也就是 28 天內只能燒掉 0.5% 的 error budget),Google SRE Workbook 建議的其中一組告警組合大致是:
| burn rate | 短窗 | 長窗 | 代表的意義 | 建議動作 |
|---|---|---|---|---|
| 14.4 | 5 分鐘 | 1 小時 | 照這個速度,28 天的 budget 會在 2 天內燒光 | page,立即處理 |
| 6 | 30 分鐘 | 6 小時 | 28 天的 budget 會在約 4.7 天內燒光 | page,但可稍緩 |
| 1 | 6 小時 | 3 天 | 正常消耗速度的上限 | 記錄,日常檢視 |
寫成 PromQL,14.4 倍那一條大致長這樣:
(
1 - (
sum(increase(ask_sli_events_total{sli_eligible="true", sli_result="good"}[5m]))
/
sum(increase(ask_sli_events_total{sli_eligible="true"}[5m]))
)
) > (14.4 * 0.005)
and
(
1 - (
sum(increase(ask_sli_events_total{sli_eligible="true", sli_result="good"}[1h]))
/
sum(increase(ask_sli_events_total{sli_eligible="true"}[1h]))
)
) > (14.4 * 0.005)
0.005 是 SLO 允許的失敗比例(1 - 99.5%);14.4 * 0.005 就是這個燒錢速率下,短窗與長窗各自要超過的失敗率門檻。and 讓兩個條件都成立才觸發——這正是前面說的「兩個窗口都超標才 page」在查詢語言裡的樣子。
這一套機制不是憑空設計出來的學術練習。2025 年 10 月 20 日凌晨,AWS us-east-1 因為 DynamoDB 的 DNS 紀錄被意外清空,任何一個依賴 DynamoDB 的下游服務,如果裝了這種多視窗 burn-rate 告警,短窗與長窗會在幾分鐘內同時被點爆——完全不可用的依賴,燒錢速度遠遠超過 14.4 倍,這正是這套告警機制設計來抓的情境(詳見第⑦節的完整故事)。反過來說,如果告警規則沒有短窗這一層,只看長窗(例如 6 小時平均),這種在數小時內大致恢復的事故,很可能要等告警視窗跑完大半才會觸發——那時候,使用者早就已經受害了好幾個小時。
上面談的都是「花了很久才恢復」的故事,值得對照一個相反的例子:2025 年 12 月 5 日,Cloudflare 為了防護一個 React Server Components 漏洞(CVE-2025-55182),把 WAF 的緩衝區從 128KB 調大到 1MB;緊接著又停用了一個內部 WAF 測試工具,這個變更透過全域設定系統部署,沒有走漸進式推送機制,在數秒內就傳播到整個網路。程式碼裡有個 bug:當 killswitch 被套用到某條 execute 規則時,系統試圖存取一個已經被跳過、根本不存在的 execute 物件,丟出例外。時間軸壓縮到令人意外的地步:
08:47 UTC 設定變更部署
08:48 UTC 全域傳播完成(不到一分鐘)
08:50 UTC 宣告事故
09:11 UTC 開始恢復(還原設定)
09:12 UTC 完全恢復
從發生到完全恢復,總共大約 25 分鐘。Cloudflare:Cloudflare outage on December 5, 2025
把這次事故放進 burn-rate 的框架裡看,會發現一件矛盾但重要的事:「秒級全域傳播」原本聽起來是最危險的部署方式,沒有金絲雀、沒有漸進式 rollout,理論上應該讓爆炸半徑最大化。但因為受影響的流量(約占全部 HTTP 流量的 28%,只限舊版 FL1 proxy 加 Managed Ruleset 的特定組合)觸發的失敗率極高,短窗 burn rate 幾乎瞬間衝頂——不需要等 1 小時的長窗補上確認。同一套自動化用同樣速度還原設定,24 分鐘內完成偵測、宣告、修復。這說明 burn-rate 告警抓的不是「變更速度快不快」,而是「失敗訊號夠不夠明確、夠不夠快被看見」——這也是為什麼它跟第⑦節要談的 AWS DynamoDB 事故(十幾個小時才完全恢復)差距這麼大:後者的故障沿依賴鏈層層放大,每一層都需要各自偵測與修復;前者是同一個全域機制,壞得快也修得快。
這裡也印證了「availability 不是只有 up 或 down」:這次故障只命中「舊版 FL1 proxy」加「客戶部署了 Managed Ruleset」這個特定組合,其餘流量完全正常。若 SLI 只有全站層級的 up/down 開關,這種條件式的部分失效會被平均掉,看起來只是「錯誤率有點上升」;唯有像第④節那樣把低基數 label 分開看,才能在告警觸發當下就推測出故障可能只跟某個特定組合有關。
另一個容易被忽略的細節:Google SRE Workbook 建議的 28 天窗口,是「過去 28 天」的滾動視窗,每天往前推移一天,而不是「本月 1 號到今天」的月曆月。差別看起來瑣碎,實際影響很大:月曆月的長度在 28 到 31 天之間跳動,同一個 SLO 百分比換算出來的允許失敗時間,每個月都不一樣,錯誤預算的比較也失去意義;更麻煩的是,月初的頭幾天永遠只有極少量歷史資料,availability ratio 會在月初劇烈震盪,跟系統實際表現無關,純粹是視窗太短造成的統計雜訊。rolling window 用固定的 28 天長度換掉月曆邊界,讓「這 28 天燒了多少 budget」在任何一天問,都有一致的比較基準,也是上面所有 increase(...[28d]) 查詢背後的假設。
一旦短窗與長窗同時超標,這條告警規則最終要接到值班系統。用 Alertmanager 的話,粗略的路由設定大致是:
route:
receiver: default
routes:
- matchers:
- alertname = "AskAvailabilityBurnRateCritical"
receiver: pagerduty-primary-oncall
continue: false
- matchers:
- alertname = "AskAvailabilityBurnRateWarning"
receiver: slack-sre-channel
group_wait: 5m
14.4 倍那一條規則對應 AskAvailabilityBurnRateCritical,直接 page 值班;6 倍那一條對應 AskAvailabilityBurnRateWarning,先進團隊頻道,讓人判斷要不要主動接手,而不是半夜被叫醒。這組對應關係要回頭跟第⑥節開頭那張門檻表一起讀——表格裡的「建議動作」欄位,就是這裡 receiver 該接到誰的依據。
對這個 Lab 而言,不必馬上把上面的 PromQL 與 Alertmanager 設定寫進 production;先把「短窗 + 長窗同時超標」這個判斷邏輯記在腦裡,理解它在解決哪一種兩難,比背下 14.4 這個數字更重要——數字要依你實際的 SLO 目標重新推算。可以先把下面幾個問題放進 dashboard,而不是急著寫 production pager rule:
過去 1 小時:bad / valid 是多少?
過去 6 小時:bad event 的主要 reason 是什麼?
過去 28 天:剩餘 error budget 是多少?
現在是否有足夠 valid requests 讓比例可解讀?
告警門檻、burn-rate multiplier、通知對象與 escalation policy 都需要真實流量、產品影響與值班能力才能定。本文不替尚未量測的 Lab 宣稱任何 pager 設定已驗證。
AI workflow 常同時依賴 retrieval index、embedding service、LLM provider、tool API 與 policy engine。這些其中一個壞掉,不一定要讓整個 /ask 回 500;但「可以降級」必須是明確、可測的服務契約。
| 故障 | 可能行為 | availability 是否 good |
|---|---|---|
| retrieval 無回應 | 回覆暫時無法查詢知識庫 | 取決於產品是否承諾此狀態可用 |
| LLM provider timeout | 切到已核准的 fallback model | 若仍在 deadline 內且 contract 成立,可為 good |
| citation validator 故障 | 拒答並提示稍後再試 | 通常是 valid-but-bad,除非契約明定人工流程接手 |
| tool API 403 | 不執行工具,回傳權限說明 | 若權限拒絕正確且是預期行為,可能為 good |
| tool API 500 | 停止 workflow,回明確錯誤狀態 | 通常是 bad |
2025 年 10 月 19 日深夜 11:48(PDT),AWS us-east-1 的 DynamoDB 區域端點開始出現大量 API 錯誤。根因是 DynamoDB 內部負責管理 DNS 的自動化系統裡,一個潛伏的競態條件:兩個獨立運作的 DNS Enactor 元件同時想更新同一個區域端點的 DNS 紀錄,一個套用了舊的變更計畫,另一個幾乎同時把它刪除,結果是這個區域端點的 DNS 紀錄被清空——dynamodb.us-east-1.amazonaws.com 一度完全解析不到任何 IP。AWS 官方事後報告的時間軸大致是:11:48 PM 事件開始,12:38 AM 找到根因,1:15 AM 部分內部服務靠臨時處置恢復連線,2:25 AM DNS 資訊完全恢復,2:40 AM DynamoDB 本身完成復原,但要到隔天下午 2:20 PM 左右,所有下游服務才完全恢復正常。AWS:Summary of the Amazon DynamoDB Service Disruption in the Northern Virginia (US-EAST-1) Region
這次事故最值得放進本節討論的地方,是故障如何沿著依賴鏈往外擴散,而且擴散路徑裡包含了 health check 本身:EC2 內部負責追蹤租用狀態的 DWFM(DropletWorkflow Manager)依賴 DynamoDB 做狀態檢查,DynamoDB 打不通,DWFM 的狀態檢查開始大量失敗;新啟動的 EC2 instance 網路設定推送延遲,連不上網路;Network Load Balancer 的健康檢查看到這些新機器連不上,判定「不健康」並從 endpoint 列表移除——原本該保護使用者的健康檢查,反而成了放大故障的一環。Lambda、SQS 陸續受影響,甚至連 IAM 憑證驗證都牽連在內:Redshift 的使用者群組解析要繞經 us-east-1 的 IAM API,於是一個區域性 DNS 問題,變成了全球 Redshift 客戶都連不上的問題。
對照本節開頭那張表格,這裡沒有一個依賴「單純地 down 掉」——DynamoDB 自己短短幾分鐘內就從技術上恢復,但依賴它的 EC2 租用管理、NLB 健康檢查、Lambda 事件處理各自用不同機制把故障放大並延後,使得完整恢復要再等上超過十二小時。這正是本節反覆強調的:「依賴失敗」不是一個 boolean,它是一連串「這一層原本該保護使用者,結果反而成為故障放大器」的鏈條——而其中一環,恰好就是本文第②節談過的 health check 本身。
上面兩個案例都有一個明確的「壞掉」時刻——DynamoDB 的 DNS 紀錄被清空、DWFM 狀態檢查大量失敗。但依賴失敗更常見的樣貌,其實更安靜。2026 年 1 月 22 日 20:25 UTC,Cloudflare 一個自動化的路由政策設定變更,移除了 Bogota 基礎設施原本該有的 prefix filter,產生一條過度寬鬆的政策:比對條件寫成「route-type internal」,結果符合的是「任何非外部路由」。後果是 Cloudflare 內部骨幹網路間彼此重新分送的所有 IPv6 前綴,全部被這條政策接受,並廣播給 Miami 的所有 BGP 鄰居——連原本屬於 Meta(AS32934)的路由,都被重新廣播給上游供應商 Lumen(AS3356)。一部分流量因此被意外導流經過 Miami 資料中心,尖峰時約有 12 Gbps 的非客戶流量被防火牆規則丟棄,Miami-Atlanta 骨幹鏈路壅塞,部分客戶流量出現延遲上升與封包遺失。Cloudflare:Route leak incident on January 22, 2026
這起事故從頭到尾,沒有任何一個 Cloudflare 服務被判定為「down」——沒有 5xx 暴增、沒有服務被下線。它的樣貌是「部分流量延遲上升、部分封包遺失」,若 availability SLI 只有「服務是否可達」這種粗粒度二元訊號,這種劣化幾乎完全看不到;必須另外量測延遲分佈與封包遺失率才捕捉得到。更值得注意的是偵測方式:事故 20:25 UTC 發生,直到 20:40 才開始調查,中間 15 分鐘完全沒有自動化告警,是網路團隊自己發現流量不對勁才啟動處理;20:44 宣告事故,20:50 人工還原,前後 25 分鐘——事後報告點名這次偵測仰賴人工調查,而非自動化異常偵測。
跟 12 月 5 日事故並排看:兩者「修復速度」差不多快,但 12 月 5 日有自動化告警瞬間觸發,這次卻有 15 分鐘偵測空窗、完全靠人發現。就算回滾機制一樣快,若沒有對應訊號讓短窗 burn rate 及時抓到異常,「有沒有人發現」這一步本身就可能吃掉本來不必燒掉的 error budget——不是所有依賴失敗都會讓分子分母立刻反映異常,有些故障需要專門設計來偵測劣化而非中斷的訊號。
這裡最常見的作弊方式,是把「fallback 已觸發」一律算成成功。使用者若仍取得符合契約的結果,fallback 當然可以是 good;但 fallback 若默默拿掉 citation、縮短答案或改變安全限制,SLO 要依新契約重新判定。不要為了維持綠色曲線,讓服務在背後偷換功能。
每次 fallback 或 partial result,都應在 structured log 或 trace 加上可搜尋的欄位:
{
"request_id": "req_7d4e",
"trace_id": "trace_91ab",
"workflow_name": "policy_qa",
"response_status": "completed",
"sli_result": "good",
"fallback_triggered": true,
"primary_model": "provider-a/model-x",
"served_model": "provider-b/model-y",
"prompt_version": "policy-qa-v3"
}
request_id 和 trace_id 可讓值班者從 SLO 圖一路追到那一筆 workflow。它們不能出現在 Prometheus label;同一個欄位在不同觀測層有不同用途,這正是可觀測性設計該分層的原因。
以下內容是讀者自行實作的教材。本文沒有建立 Day15/DIY、沒有安裝套件、沒有啟動服務,也沒有執行測試;請在自己的 Day15 DIY 專案中完成並對照結果。
這個 DIY 要驗證的,是第①到④節反覆強調的一件事:「good 的定義必須被寫成程式碼、被測試保護,而不是留在文章或口頭共識裡」。五個步驟依序是:定義契約 → 用測試固定契約 → 讓 metric 由契約產生(而不是由 HTTP status 猜)→ 用兩組流量驗證分母沒有算錯 → 用一張不說謊的 dashboard 呈現結果。記住這個順序——先有契約才有 metric,而不是反過來從一堆 metric 裡猜契約——比背下任何一段 PromQL 語法更有用。
建立 app/availability.py。先把可變動的產品決策集中在常數與 classify_availability(),不要散落在 FastAPI route、PromQL 與 dashboard JSON 裡。
from dataclasses import dataclass
from enum import StrEnum
class SliResult(StrEnum):
GOOD = "good"
BAD = "bad"
EXCLUDED = "excluded"
@dataclass(frozen=True)
class AvailabilityEvent:
valid_request: bool
latency_ms: int
contract_valid: bool
response_status: str
@dataclass(frozen=True)
class Classification:
eligible: bool
result: SliResult
reason: str
GOOD_RESPONSE_STATUSES = frozenset(
{"completed", "insufficient_context", "requires_human_review"}
)
MAX_LATENCY_MS = 10_000
def classify_availability(event: AvailabilityEvent) -> Classification:
if not event.valid_request:
return Classification(False, SliResult.EXCLUDED, "invalid_request")
if event.latency_ms > MAX_LATENCY_MS:
return Classification(True, SliResult.BAD, "deadline_exceeded")
if not event.contract_valid:
return Classification(True, SliResult.BAD, "invalid_response_contract")
if event.response_status in GOOD_RESPONSE_STATUSES:
return Classification(True, SliResult.GOOD, "contract_satisfied")
return Classification(True, SliResult.BAD, "workflow_failed")
這是一個 Lab contract,不是所有 AI 服務的 policy。MAX_LATENCY_MS、good statuses 與 invalid request 的定義,必須與你在 Day 14 寫下的 SLO spec 對齊。常見的坑是把這幾個常數直接寫死在 FastAPI route handler 裡,而不是集中在這個模組——一旦改動就容易漏掉某一處。
frozen dataclass 加 StrEnum這不是風格偏好,是用型別系統擋掉一整類錯誤:
frozen=True 讓 AvailabilityEvent/Classification 建立後不能被改動——若有程式碼在分類完之後又回頭「修正」latency_ms,通常代表更嚴重的邏輯問題(例如把重試延遲疊加算成一次請求),frozen 讓這種修改在執行期直接丟 FrozenInstanceError。dataclass 而非 dict,讓欄位打錯字(如 contract_valie)在建構時就被抓到,而不是等某條測試剛好覆蓋到那個分支才被發現。SliResult 用 StrEnum 而非裸字串,避免「意思相同、拼法不同」的字串到處長出分身——若某處拼成 "Good",字串比對會靜靜地永遠是 False;StrEnum 把唯一合法拼法集中在一處,拼錯字會在 import 或型別檢查階段就現形。這三點合起來,是把「什麼算 good」從一份可以被隨手改動的共識,變成被型別系統與測試共同鎖住的契約,呼應第④節「用事件分類取代猜 status code」。
建立 tests/test_availability.py。fixture 的目標是把產品討論變成可重跑的例子,而不是測 Python 語法。
import pytest
from app.availability import (
AvailabilityEvent,
SliResult,
classify_availability,
)
@pytest.mark.parametrize(
("event", "eligible", "result", "reason"),
[
(
AvailabilityEvent(True, 230, True, "completed"),
True,
SliResult.GOOD,
"contract_satisfied",
),
(
AvailabilityEvent(True, 340, True, "insufficient_context"),
True,
SliResult.GOOD,
"contract_satisfied",
),
(
AvailabilityEvent(True, 10_001, True, "completed"),
True,
SliResult.BAD,
"deadline_exceeded",
),
(
AvailabilityEvent(True, 400, False, "completed"),
True,
SliResult.BAD,
"invalid_response_contract",
),
(
AvailabilityEvent(False, 0, False, "invalid_request"),
False,
SliResult.EXCLUDED,
"invalid_request",
),
],
)
def test_classify_availability(event, eligible, result, reason):
actual = classify_availability(event)
assert actual.eligible is eligible
assert actual.result is result
assert actual.reason == reason
執行指令依你的 DIY 專案設定為準。若使用 Day 目錄的 uv 專案結構,可從 Day15/DIY 執行:
uv run pytest
預期結果不是某個漂亮的 coverage 數字,而是每一筆 fixture 都清楚表明它是 good、bad 或 excluded。若你改掉一個判定,先問「服務契約是否真的改了?」再更新測試;不要只為了測試轉綠而改預期值。
這個步驟驗證的是第④節的核心論點:分類邏輯要能被測試保護。實際跑起來時,比較容易踩的坑不是測試失敗,而是「測試全部通過,但少了關鍵情境」——例如沒有為 contract_valid=False 但 response_status="insufficient_context" 這種組合寫 fixture(模型誠實拒答,但回傳的 JSON 缺了必要欄位),第一次遇到時只能臨場決定要不要算 good,而不是照著已討論過的規則走。建議完成前四個 parametrize 案例後,回頭想想服務契約裡還有哪些真實會發生的組合沒被涵蓋。
把「誠實拒答但缺欄位」寫成 fixture:
(
AvailabilityEvent(True, 280, False, "insufficient_context"),
True,
SliResult.BAD,
"invalid_response_contract",
),
這裡 response_status 是白名單裡的 "insufficient_context",但 contract_valid=False。因為 contract_valid 的檢查排在 response_status 之前,這筆事件會落在 invalid_response_contract 分支判定為 BAD——即使模型「說了實話」。它測的不是新分支,而是「兩個訊號互相矛盾時,判斷順序有沒有照原本想的方式運作」;哪天有人把這兩個 if 對調,這筆 fixture 會是第一個失敗的測試。
在自己的 API route 完成 response validation 後,將結果轉成 counter。這裡用 prometheus_client 示範介面;實際 import 與 metrics endpoint 請依你的 Day2 Lab 配置調整。
from prometheus_client import Counter
from app.availability import classify_availability
ask_sli_events_total = Counter(
"ask_sli_events_total",
"Availability SLI events for /ask",
("sli_eligible", "sli_result", "response_status"),
)
def record_sli_event(event) -> None:
classification = classify_availability(event)
ask_sli_events_total.labels(
sli_eligible=str(classification.eligible).lower(),
sli_result=classification.result.value,
response_status=event.response_status,
).inc()
刻意不要把 reason 加成 metric label。若 reason 的值被錯誤訊息或第三方內容污染,基數會失控。只要 response_status 是固定列舉值,保留它通常足夠做 first-level breakdown;更細的理由到 logs 與 traces 查。
跑這一步時常見的落差是:metric 已經正確遞增,但 Grafana 或 Prometheus 上完全看不到新資料。檢查順序建議由外而內:/metrics 有沒有被 Prometheus 的 target 成功 scrape(up{job="..."} 是不是 1)、counter 的 label 拼字是否每次一致(prometheus_client 對同一組 label 只會建立一條 time series,拼字打錯會悄悄建出另一條沒人查詢的線)、scrape interval 是否還沒輪到下一次(Day 2 Lab 若沿用預設 15 秒,剛送出的請求要等下一次才看得到)。
/metrics 被 scrape 之後長什麼樣prometheus_client 用 make_asgi_app() 掛上 /metrics 後,直接 curl 應該看到類似輸出:
# HELP ask_sli_events_total Availability SLI events for /ask
# TYPE ask_sli_events_total counter
ask_sli_events_total{response_status="completed",sli_eligible="true",sli_result="good"} 8.0
ask_sli_events_total{response_status="completed",sli_eligible="true",sli_result="bad"} 2.0
ask_sli_events_total{response_status="invalid_request",sli_eligible="false",sli_result="excluded"} 5.0
若這裡的 # TYPE 顯示 gauge 而非 counter,代表 metric 定義被改錯型別,後面所有 increase()/rate() 都會失準。每一行是獨立的時間序列,sli_result="Good"(大寫)跟 sli_result="good" 對 Prometheus 是兩條不同的線——label 拼字必須固定,否則查詢結果會被悄悄拆成兩份而沒人發現。
若 curl 完全看不到 ask_sli_events_total,問題在應用程式本身(檢查 record_sli_event() 有沒有被呼叫到);若 curl 看得到數字但 up{job="..."} 是 0,問題在網路或 target 設定,不是程式碼。
請自行送出或模擬兩組 event:
情境 A
- 8 筆 valid + good
- 2 筆 valid + bad
情境 B
- 5 筆 invalid request
預期:情境 A 的 availability 是 8 / 10 = 80%。情境 B 不應把結果改成 8 / 15,因為那五筆不在 valid-request 分母。
若你的 Prometheus 可以查詢,可用下面兩個 query 對照 classifier 的輸出:
sum(increase(ask_sli_events_total{sli_eligible="true",sli_result="good"}[1h]))
sum(increase(ask_sli_events_total{sli_eligible="true"}[1h]))
請記錄你使用的時間窗、測試流量來源與 scrape interval。短時間內 counter 沒出現,不一定是 classifier 壞掉,也可能是 scrape 尚未發生;不要跳過資料流檢查就直接改 query。
這一步驗證的是第①、⑤節反覆強調的分母定義:「有效請求」與「全部請求」是兩個不同的集合。若算出來的比率變成 8 / 15,代表某處把 sli_eligible=false 的事件也算進了分母——回頭檢查步驟 3 的 label 賦值,而不是懷疑 PromQL 語法。
比起手動送幾筆事件再肉眼核對,更可靠的做法是寫一支腳本,把「建構情境 A 與 B → 呼叫 classifier → 統計三種結果 → 斷言分母不含 excluded」串起來,每次改動 classifier 都能重跑:
from app.availability import AvailabilityEvent, SliResult, classify_availability
SCENARIO_A = (
[AvailabilityEvent(True, 300, True, "completed")] * 8
+ [AvailabilityEvent(True, 10_500, True, "completed")] * 2
)
SCENARIO_B = [AvailabilityEvent(False, 0, False, "n/a")] * 5
def summarize(events):
counts = {SliResult.GOOD: 0, SliResult.BAD: 0, SliResult.EXCLUDED: 0}
for event in events:
counts[classify_availability(event).result] += 1
return counts
def main():
a = summarize(SCENARIO_A)
combined = summarize(SCENARIO_A + SCENARIO_B)
denominator_a = a[SliResult.GOOD] + a[SliResult.BAD]
denominator_combined = (
combined[SliResult.GOOD] + combined[SliResult.BAD]
)
print(f"Scenario A denominator: {denominator_a} (expect 10)")
print(f"Combined denominator: {denominator_combined} (expect 10, NOT 15)")
assert denominator_a == 10
assert denominator_combined == 10, "invalid requests leaked into denominator"
print(f"Availability: {a[SliResult.GOOD]}/{denominator_a} = "
f"{a[SliResult.GOOD] / denominator_a:.1%}")
if __name__ == "__main__":
main()
這支腳本的重點不是印出漂亮的百分比,是最後那一行 assert——把「分母不該被污染」寫成斷言,而不只是印出來讓人肉眼核對,這個驗證就能接進 CI:只要分母計算有誤,腳本會直接以非零 exit code 失敗。
第一版 dashboard 不必塞滿圖,四個 panel 就能先看出定義是否一致:
| Panel | 問題 | 觀察重點 |
|---|---|---|
| valid events | 這個比例有多少樣本? | 低流量時不要把 100% 當結論 |
| availability ratio | good / valid 是多少? | 分子分母是否與 spec 相同 |
| bad by status | 壞在何處? | timeout、contract、workflow 是否突然偏高 |
| excluded events | 被排除的是什麼? | 突然上升可能是 client 或 validation 變化 |
若 Grafana 顯示 No data,先確認資料有沒有進來(metric endpoint 是否有 counter、target 是否 UP、time range 是否包含測試事件),再討論 SLO。
這張 dashboard 對應第⑨節「常見的四種錯算」:valid events 樣本數只有個位數、ratio 面板卻顯示精確到小數點後兩位,就是「低流量假裝有精準數字」;某次改動後 excluded 曲線無故墊高、bad by status 同時下降,通常代表分類規則被悄悄放寬,而不是系統真的變健康。
四個 panel 對應的 PromQL:
# valid events(分母的分子——排除掉 excluded 的事件數)
sum(increase(ask_sli_events_total{sli_eligible="true"}[1h]))
# availability ratio
sum(increase(ask_sli_events_total{sli_eligible="true",sli_result="good"}[1h]))
/
sum(increase(ask_sli_events_total{sli_eligible="true"}[1h]))
# bad by status
sum by (response_status) (
increase(ask_sli_events_total{sli_eligible="true",sli_result="bad"}[1h])
)
# excluded events
sum(increase(ask_sli_events_total{sli_eligible="false"}[1h]))
四條 query 共用同一個 metric,差別只在 label filter 與是否用 sum by。這是故意的設計:全部從同一個 ask_sli_events_total 切出來,任何一個 panel 對不上,都可以直接懷疑是查詢寫錯,而不必先懷疑接錯了 data source。
以上五步只需要 Python 直譯器,不需要 Docker 或 Prometheus server。若想把 /metrics 接上 Day 2 建好的 Prometheus + Grafana,會碰到三個實務環節:prometheus.yml 要多加一個 scrape target 指到 DIY 服務的 host:port(注意容器內的 localhost 指的是容器自己,不是宿主機);Grafana 沿用 Day 2 既有的 Prometheus datasource 即可,不需要每個 Day 各建一份;這個 DIY 的 counter 跟 Day 2 示範的 http_requests_total 是兩個獨立 metric,彼此不會自動關聯,要疊在同一張 dashboard 看,得先手動確認兩者的時間範圍與 scrape interval 一致。
跑完五個步驟之後,回頭把每一步對應到前面章節的具體論點,比記住程式碼本身更值得留下來:
| 步驟 | 對應章節論點 | 若這步驟被跳過,會發生什麼 |
|---|---|---|
| 1. 寫下契約 | 第①節「valid request 是產品契約問題,不是 schema 驗證問題」 | 判斷規則散落在 route handler 各處,沒有單一真相來源 |
| 2. fixture 保護判定 | 第④節「用事件分類取代猜 status code」 | 改一次規則沒人知道,測試轉綠只是巧合,不是保證 |
| 3. classifier 輸出 metric | 第④節「在請求完成後才記錄」 | metric 可能在請求還沒真正結束前就被記,變成預測而非量測 |
| 4. 兩組流量驗證分母 | 第①、⑤節「有效請求」與「全部請求」是兩個集合 | excluded 事件悄悄污染分母,比率虛假偏高卻沒人發現 |
| 5. 不說謊的 dashboard | 第⑨節「常見的四種錯算」 | 低流量時裝作有精準百分比,或分類被悄悄放寬卻沒人注意 |
這張表格是一個提醒:五個步驟是同一條論證鏈——先有契約、契約被測試鎖住、metric 由契約而非猜測產生、用已知流量驗證分母沒有算錯、最後才用不說謊的方式呈現——被拆成五段可以個別重跑的練習。若只做步驟 1 跟 3、跳過 2 跟 4,會得到一個「看起來能動」的 metric pipeline,但沒有東西能告訴你它算出來的數字,跟你原本想量的是不是同一件事。
request_id、prompt、user ID 或 exception message。10_000 ms 是 Lab 假設,尚非 production SLO。本文沒有替你執行以上任何步驟。完成後,請將你的結果、環境版本與觀察到的限制寫入 Day15 的 DIY README;它是下一次改 classifier 時最有用的對照資料。
這四種錯算有一個共同的結構:都是「某個中間環節把失敗吸收掉了,而量測邏輯只看得到吸收之後的結果」。retry 吸收了第一次失敗、cache 吸收了依賴故障、後端寫出 token 這個動作吸收了使用者實際看到內容這件事、excluded 分類本身吸收了 bad event 的存在。吸收失敗不是壞事——retry 跟 cache 都是刻意設計的韌性機制,目的正是讓使用者少感受到一點故障。問題出在量測邏輯如果只站在吸收層的後面看,會把「韌性機制正在拚命工作」誤讀成「系統很健康」,兩者在數字上長得一模一樣,但代表的風險完全不同:前者代表系統的餘裕正在被消耗,後者代表真的沒事。
若 client 先收到 timeout,重試後才成功,server-side counter 可能只看見第二次成功。對使用者而言,第一次等待仍可能違反 latency contract。
Client 送出請求 (t=0s)
↓
Server 處理逾時,回傳 error (t=8s)
↓
Client 自動重試 (t=8s)
↓
Server 成功回應 (t=9.2s)
server_attempt_availability:兩次 attempt,一次 bad、一次 good → 50%
user_journey_availability:使用者等了 9.2 秒才拿到答案,且中途看到一次錯誤 → 是否符合 latency contract,要另外定義
要決定量 server attempt、client journey,或兩者都量;名稱要寫清楚,例如 server_attempt_availability 與 user_journey_availability,不能混在同一條線上。只回報前者,會讓一個對使用者而言明顯變慢、甚至短暫閃過錯誤畫面的體驗,在 dashboard 上看起來跟完全順暢的請求一樣好。
有些團隊發現 retry 會扭曲數字之後,第一個直覺是把 sli_eligible/sli_result 的 counter 拆成兩層:一層記每一次 attempt,一層記「這一輪 retry 全部結束後」的最終結果。這個方向是對的,但常見的實作漏洞,是用「同一個 request 物件」去累計兩層 counter,而不是用一個貫穿整輪重試的識別碼(idempotency key 或 client-generated request id)去串連:
async def ask_with_journey_tracking(payload: AskRequest, deps) -> AskResponse:
journey_id = payload.idempotency_key # 由 client 產生,跨重試保持不變
attempt = 0
last_result = None
while attempt < MAX_ATTEMPTS:
attempt += 1
started_at = monotonic()
try:
result = await run_ask_workflow(payload, deps)
record_attempt_event(journey_id, attempt, "good")
record_journey_event(journey_id, attempts=attempt, result="good")
return result
except WorkflowTimeoutError:
record_attempt_event(journey_id, attempt, "bad")
last_result = "timeout"
continue
record_journey_event(journey_id, attempts=attempt, result="bad", reason=last_result)
raise HTTPException(status_code=502, detail=last_result)
record_attempt_event() 對應 server_attempt_availability,每次呼叫都記一筆;record_journey_event() 只在整輪重試真正結束時記一筆,帶著 attempts 欄位——這欄位不做 label(重試次數分佈可能很廣,容易讓 cardinality 失控),而是進 log 或 trace 供事後排查。少了 journey_id 貫穿整輪重試,兩層 counter 各自遞增互不相干,只會得到兩條看起來合理、卻對不起來的曲線。
cache hit 可以讓使用者順利完成請求,因此在使用者 availability 中可能是 good。它卻可能遮住 retrieval 或 model provider 已經失敗。保留 dependency health、fallback rate 與 cache-hit rate,才能知道「服務還能回答」和「後端已經失火」是否同時存在。這正是第⑦節談的依賴降級:cache 本身常常就是那個「fallback 已觸發」卻沒有被記錄下來的環節——如果連 cache 都失效,使用者看到的會是毫無預警的全面故障,而不是逐步惡化的訊號。
具體要保留哪三條線,用 PromQL 表示大致是:
# 使用者感受到的 availability:cache hit 也算 good,這條線在依賴故障時可能仍然很高
sum(increase(ask_sli_events_total{sli_eligible="true", sli_result="good"}[1h]))
/
sum(increase(ask_sli_events_total{sli_eligible="true"}[1h]))
# 後端真實健康度:retrieval 與 model provider 是否真的能回應(不管有沒有被 cache 擋掉)
sum(increase(dependency_health_check_total{dependency="retrieval", result="ok"}[1h]))
/
sum(increase(dependency_health_check_total{dependency="retrieval"}[1h]))
# cache 承擔了多少流量:這條線越高,代表「使用者 availability 看起來健康」有多少是靠 cache 撐住的
sum(increase(cache_lookup_total{result="hit"}[1h]))
/
sum(increase(cache_lookup_total[1h]))
三條線一起看,才回答得出「使用者 availability 99.9%、cache-hit rate 也高達 95%、但 retrieval 健康度只剩 40%」這種情境——它代表系統目前完全靠 cache 撐著門面,一旦 cache TTL 到期或被清空,使用者馬上會感受到斷崖式的下降。只看第一條線的人,會在 cache 失效的那一刻才第一次意識到後端早就在失火,而不是提前看到警訊。
streaming response 在後端開始送出 token 時可能被記成成功,但使用者在中途斷線,或 browser 根本沒有完成渲染。若產品承諾串流回答,應另外設計 completion signal——例如在 SSE(Server-Sent Events)或 WebSocket 的資料流末端送出一個明確的 event: done 事件,並要求前端在收到這個事件、且成功渲染最後一段內容後,才回報一次完整的 user-journey-good 事件;server 只是把資料寫進 socket,不代表使用者真的看到了完整答案。server 成功寫出第一個 byte 不等於 user journey 完成。
用 SSE 的格式具體表示,後端在串流結束時大概要多送這樣一行:
data: {"token": "。"}
event: done
data: {"status": "completed", "total_tokens": 214}
關鍵在 event: done 這一行必須帶著跟第④節同一套 response_status 語彙——這裡的 "completed" 要能對應到 classify_availability() 認得的值,而不是前端另外發明一套跟後端不同步的狀態字串。前端收到這個事件後,才透過一個獨立的 endpoint(例如 POST /client-events/journey-complete)回報「使用者的瀏覽器端確實收到了結束訊號」;如果前端在最後一個 token 之後五秒都沒收到 event: done,代表連線可能中途斷了,這時前端應該自己標記一次 journey_incomplete,而不是預設沉默、讓 server 端的 metric 誤以為一切正常。這類 client-side SLI 很有價值,但收集與隱私設計也更複雜,先把邊界寫清楚:要不要收集匿名的完成率、要不要區分「使用者主動關掉分頁」與「連線被意外中斷」,這些都是先於任何程式碼的產品決策。
排除項目必須少、穩定、可稽核。某版本上線後 bad event 增加,卻同時把 response_validation_failed 改標 excluded,SLO 會變綠,使用者不會。用第⑤節的小數字驗算一次就能看出這個把戲多有效:原本 9,940 good / 10,000 valid = 99.4%,把其中 300 筆 bad 事件悄悄改標成 excluded 之後,分母變成 9,700,比率瞬間「進步」到 9,940 / 9,700——這個數字本身已經超過 100%,明顯是算錯,但真實世界裡更常見的手法是只改標一小部分、讓比率剛好回到 SLO 門檻之上,不會大到讓人起疑。每次修改分類規則都應視為 reliability change,記錄變更時間、owner 與理由,並在 dashboard annotation 留下痕跡——這樣下一次比率無故變好時,值班的人第一個念頭會是去查 annotation,而不是恭喜自己。
假設週一 10:00 部署 prompt-v4 後,模型仍大量回 200,但 parser 對新的欄位格式不相容。若你的 SLI 寫成 http_status < 500,availability 仍接近 100%。客服開始收到「畫面空白」回報,dashboard 卻一片綠。
改用本文的 contract-aware classifier,這些回應會變成:
valid_request=true
contract_valid=false
response_status=completed
sli_result=bad
reason=invalid_response_contract
值班的人可以依這個順序處理:
bad by status 是否與部署時間重合。prompt_version=prompt-v4 篩選失敗樣本。把這五步對應成時間軸,兩種監控方式的差距會更清楚:
| 時間 | 只看 HTTP status 的 dashboard | 用 classifier 的 dashboard |
|---|---|---|
| 10:00 | 部署完成,一切正常 | 部署完成,一切正常 |
| 10:03 | 仍是綠燈 | bad by status{reason="invalid_response_contract"} 開始上升 |
| 10:05 | 仍是綠燈 | 短窗 burn-rate 觸發 warning |
| 10:12 | 仍是綠燈 | 長窗 burn-rate 同時超標,critical alert 觸發,值班被 page |
| 10:20 | 第一筆客服工單進來 | 已在查 trace,鎖定 prompt_version=prompt-v4 |
| 10:45 | 值班才剛開始查「使用者說空白是什麼意思」 | rollback 已完成,fixture 已補上這個 response shape |
差距不是監控工具本身的能力,是分類邏輯有沒有站在正確的地方看事情。HTTP status 這一欄從頭到尾都是對的——伺服器確實回了 200,這不是謊言,只是回答了一個使用者從來沒問過的問題。
這不是完整的 incident runbook,但它把「使用者說空白」連到可量測的事件分類。根因可能在 prompt、parser、SDK 或 deployment;availability SLI 的責任不是猜根因,而是用可靠的失敗訊號讓調查有地方開始。
如果這次事故裝了第⑥節那種多視窗 burn-rate 告警,短窗與長窗會在部署後幾分鐘內同時超標——因為 contract_valid=false 的比例會立刻跳升,不需要等使用者投訴才被發現。這也是本文一路主張「用 classifier 產生 metric,而不是用 HTTP status 猜」的實際回報:同一次事故,兩種量測方式看到的告警時間點,可能差了好幾個小時,而這幾個小時裡,使用者看到的都是同一片空白畫面。
把這次假想的 prompt-v4 事故跟第①節談過的 Cloudflare BYOIP 事故並排看,會發現兩者的根因結構驚人地相似:兩邊都是「每一次操作本身都回報成功」,兩邊事後追查都指向「測試覆蓋率沒有涵蓋這個特定情境」。差別只在於規模與領域——一邊是內部自動化任務對參數語意的誤判,一邊是 prompt 變更後 parser 對欄位格式的誤判——但「contract 被違反,卻沒有任何一層丟出錯誤」這個故障形狀完全一樣。這也是為什麼第⑧節的 DIY 驗收清單裡,特別把「classifier 對正常完成、誠實拒答、逾時、contract failure、無效 request 都有 fixture」列成必須項目,而不是「有測試就好」:測試的價值不在於數量,在於有沒有涵蓋這種「技術上完成、契約卻悄悄壞掉」的組合。
如果這次部署接上一個以 SLI 為門檻的自動化 rollout gate,第 5 步「暫停 rollout」甚至不需要等人手動按下按鈕:
# 簡化示意:canary 階段的自動晉升規則
rollout_gate:
metric: ask:availability_ratio:5m
promote_if: value >= 0.995
halt_if: value < 0.99
evaluation_window: 5m
min_sample_size: 200
min_sample_size 這個欄位呼應第⑤節「樣本數不夠時,百分比本身就是雜訊」——canary 階段流量通常遠少於全量,若沒有這個門檻,一個只服務了 20 個請求的 canary,很容易因為統計雜訊被誤判為「不健康」而擋下一次其實沒問題的部署,或者相反地被誤判為「健康」而放行一次其實已經壞掉的部署。這正是第⑤節「樣本不足時寧可顯示樣本不足,也不要硬算比率」的原則,套用在部署決策而不是值班儀表板上的樣子——同一個統計陷阱,會在觀測層與部署自動化層都出現,因為兩者最終都在問同一個問題:這個比率,現在能不能被信任。
availability 的分母是產品判斷,不是監控工具的預設值。先定義 valid request,再定義好結果;接著把判定寫成測試保護的 classifier,最後才用 counter、PromQL 與 dashboard 量它。
把這條主線倒過來看一次會更清楚為什麼順序不能顛倒:如果先寫 PromQL 再回頭補定義,等於是先決定了要用哪把尺,才去問要量什麼——尺的刻度會反過來限制你能看見的東西,http_status < 500 就是最典型的例子,這把尺天生看不見 parser 壞掉、看不見 retrieval 取到不相關文件、看不見串流中途斷線。反過來,先寫 classifier、用測試把判定鎖住,PromQL 只是把已經想清楚的定義投影到時間序列上,尺是為了問題而磨的,不是問題被尺框住。
這樣做不會讓 AI 回答突然變正確,也不會讓 retrieval 突然只取回相關文件。它能做到的事情更基本,也更容易被低估:讓「系統正在說謊」跟「系統正在誠實地報告故障」這兩種狀態,在 dashboard 上看起來不一樣。第②到第⑦節談的所有機制——health check、user journey、classifier、PromQL、多視窗告警、依賴降級——最終都是為了守住這一件事。一個系統已經把使用者留在空白畫面,監控卻還告訴你一切正常,這是比故障本身更糟的狀況,因為它連「該不該緊張」這個最基本的判斷,都從值班的人手上拿走了。
Day 15 把 availability 的分子、分母從 HTTP status code 的預設假設中拉回產品判斷。Day 16 要處理另一個更常被誤讀的訊號:延遲。平均 latency 會把「大多數請求很快、少數請求極慢」的真相壓成一個看起來健康的數字,下一篇會實際刻意讓 5% 的請求變慢,觀察 P50、P95、P99 各自說了什麼。
這篇是 Learning SRE for the AI Era 系列的一部分。
Build → Trace → Break → Measure → Evaluate → Recover → Improve.