結論先說:SLI 是量測結果,SLO 是團隊的目標,SLA 是對外承諾;三者混在一起,會讓 dashboard、排程與合約各說各話。
Day 13 處理的是「一個元件倒下之後」怎麼切換;今天往回問一個更根本的問題:切換做得再好,要拿什麼指標證明系統真的可靠?99.9% 本身沒有意義。先問「誰在什麼情況下,得到什麼結果?」才知道分子、分母與門檻怎麼定。
今天不會替這個 Lab 宣告任何 production SLO,也不會執行 DIY。下面的程式、資料與查詢,是讓你在自己的環境中把承諾寫成可檢查規格;數字必須在你有流量、產品需求與 owner 之後再決定。
很多團隊第一次討論 SLO,會在同一場會議裡輪流說出三句話:
我們的 uptime 要 99.9%。
客戶說不能慢。
Prometheus 有一張 dashboard。
三句都可能是真的,卻還不能組成可靠性目標。
uptime 沒說明量測單位。
「不能慢」沒說明哪一種使用者操作。
dashboard 則只是資料顯示的位置,不會自動替你選擇服務承諾。
先把責任拆開,討論才不會變成百分比選美。
| 名詞 | 真正回答的問題 | 典型產物 | 誰要一起負責 |
|---|---|---|---|
| SLI | 我們用什麼可重複規則量測使用者經驗? | good event、分子、分母、資料來源 | 工程與產品 |
| SLO | 在一段時間內,我們願意承擔多少失敗? | 目標、window、error budget、行動 | 產品、工程與服務 owner |
| SLA | 對外要負什麼可執行的商務或法律責任? | 合約條款、適用範圍、補償、除外條款 | 法務、商務與服務 owner |
SLI 不等於一條 PromQL。
PromQL 是把 SLI 的資料定義算出來的工具;如果 good event 定義錯了,查詢寫得再漂亮也只是在快速地得到錯答案。
SLO 也不是把某個百分比貼到 Grafana。
它是一個取捨:為了維持某個使用者體驗,團隊願意留多少改動速度,又願意接受多少失敗。
SLA 的語氣最嚴肅。
一旦放進對外承諾,例外條件、時區、維護窗口、計算方式和補償流程都要能被第三方理解。Lab 裡的 99.5% 不應直接複製到合約。
「99.9%」這個數字也容易騙人。技術上它等於每月約 43.8 分鐘停機,但實際業務影響取決於那些分鐘是集中發生還是分散、定義中是否排除了規劃維護與第三方依賴故障。2025 年 10 月的 AWS DynamoDB 事件就是這種落差的示範:標稱 SLA 對應的預期停機遠低於一次 DNS 競態條件實際造成的中斷時間,根本原因不是程式碼品質,而是 SLA 定義的邊界沒把隱藏依賴(連 DNS 都會故障)算進去。
Google SRE 在 Shakespeare 搜尋服務的做法則說明分母與門檻要先於百分比決定:他們不是隨便選一個數字,而是針對不同操作類型分別設定目標,例如「99% 的 Get RPC 在 100 毫秒內完成」,再依吞吐量型與延遲敏感型使用者拆出不同門檻。分類錯了,再漂亮的百分比都會誤導判斷。
「99.9%」還有一個更容易被忽略的陷阱: 它在多個相依服務疊加之後不是還原成同一個數字, 而是會相乘變小。資安研究者 Troy Hunt 拆解過 Azure 的 SLA 條款: 單一服務的 99.9% 承諾, 換算下來每月最多容許約 45 分鐘停機; 但如果一個系統疊了三層各自承諾 99.9% 的相依服務(例如 API + 資料庫 + 訊息佇列), 組合後的實際可用性只剩 0.999 × 0.999 × 0.999 ≈ 99.7%, 換算成停機時間直接變成三倍。原因很單純: 只要任何一層掛掉, 使用者感受到的就是整體失敗, 而 SLA 並不承諾「三層剛好同時故障」, 所以每一層各自允許的停機視窗是可以疊加的, 不是共用同一份預算。更值得注意的是補償結構本身也有落差: Azure 的服務額度是分級計算, 停機 45 分鐘到將近 8 小時只退該項服務帳單的 10%, 超過 8 小時才退到 25%, 而且只退「出問題的那項服務」的帳單, 不是整個帳戶。這代表 SLA 條款保護的是供應商的責任上限, 不是使用者的實際業務損失——把這個落差寫進合約前, 產品與法務都該先看過真實數字, 而不是只看「99.9%」這四個字。Troy Hunt:The Cloud Never Goes Down
這個「相乘不是相加」的直覺, 對 Day 14 的 Lab 也有直接影響。/ask 這條路徑至少疊了 API 層、retrieval 層與 LLM provider 層三個環節, 若日後真的要對外承諾 SLA, 不能只看單一環節的可用性, 而要先問: 「使用者旅程完整跑一次, 需要經過幾個各自可能失敗的環節?」再回頭反推組合後的可用性上限。
把 Troy Hunt 拆解出來的分級補償結構具體攤開,會更容易看出「條款保護的是誰」這句話的意思:
| 月停機時間 | 對應可用性區間 | 服務額度(該項服務帳單退款比例) |
|---|---|---|
| 少於 45 分鐘 | ≥ 99.9% | 無退款,視為達標 |
| 45 分鐘 ~ 約 8 小時 | 遠低於 99.9%,但供應商仍只視為輕微違約 | 10% |
| 超過 8 小時 | 嚴重違約 | 25% |
這張表最不對稱的地方,在於「45 分鐘」與「8 小時」之間橫跨近十倍的落差,卻共用同一個 10% 退款級距——停機 50 分鐘與 7 小時 50 分鐘的業務損失可能天差地遠,供應商的補償責任卻完全相同。這正是「SLA 條款是供應商責任上限,不是使用者損失的保險」最具體的數字證據:條款把供應商的財務曝險鎖在一個可預測的天花板內,不會隨使用者實際損失等比例增加。
光把三個定義背下來還不夠,因為現實中的混用通常不是「完全搞錯定義」,而是把三者的責任邊界悄悄挪動,挪到誰都沒注意到的地方。三個最常見的場景,值得先攤開來看。
第一種混用是「把 SLO 直接當 KPI 發獎金」。SLO 的本質是團隊自己選定、隨時可以因為使用者需求改變而重新協商的內部目標;一旦把它綁上績效考核,團隊會有理性誘因把目標訂得容易達成,而不是訂得對使用者最有意義。Google SRE Book 特別提醒這個陷阱:SLO 應該被當作「工程決策的輸入」,而不是「用來評分某個團隊做得好不好的尺」——一旦變成後者,所有人的行為都會朝著「讓數字好看」而非「讓使用者滿意」偏移。
第二種混用是「把 SLA 的措辞直接套進內部 SLO」。SLA 條款要能被法務、對方公司、第三方稽核員理解,因此它傾向使用保守、模糊、留有大量除外條款的語言(「合理範圍內」「排除不可抗力」)。這種語言放進工程內部的 SLO 討論會製造麻煩:工程師需要知道「denominator 精確是什麼」,而不是「在合理範圍內大概是什麼」。把 SLA 條款複製貼上當 SLO 用,等於讓一份寫給仲裁庭看的文件去指導凌晨三點的 on-call 決策。
第三種混用是「SLI 隨著 incident 而變動」。這種情況最隱蔽:資料本身沒有造假,只是每次 incident 後續有人「順手」把某類失敗挪出分母,讓下次同樣的失敗不再影響數字。單次調整可能有充分理由(例如確實發現某類流量不該計入),但如果沒有版本紀錄與 review 流程,六個月後回頭看,會出現「同一個 99.5%,實際涵蓋的失敗範圍完全不同」的情況——這也是本文稍後在 ⑤ 段要求為 SLI 加上 sli_definition_version 的原因。
這三種混用有一個共同特徵:沒有一步是明顯錯誤,都是「看起來合理的小調整」。正因如此,SLI/SLO/SLA 的責任分工不能只講一次就結束,而要變成一份能被隨時拿出來對照的規格——這正是 Day 14 後半段要建立的東西。
前面提到 Google 在 Shakespeare 搜尋服務上,針對不同操作類型分別設定了時間目標,這個決定背後其實還有另一層更少被提到的細節:Google 不只是拆分「操作類型」,還進一步拆分了「使用者類型」。他們區分出兩種截然不同的使用情境——一種是延遲敏感型(latency-sensitive)使用者,這類人期待互動式介面在極短時間內回應,寧可犧牲少量吞吐量也要換取即時性;另一種是吞吐量型(throughput-oriented)使用者,例如批次分析或背景索引作業,這類流量對單筆請求的延遲不敏感,但在意整體處理速度與資源效率。如果把這兩種使用者混在同一組 SLO 底下,會發生一個常見的失衡:為了滿足延遲敏感型使用者,系統被迫用犧牲吞吐量的方式換取低延遲(例如降低 batch size、增加並行度),結果讓吞吐量型使用者的整體處理時間被拖慢;反過來,如果優化方向偏向吞吐量,延遲敏感型使用者又會感受到明顯變慢。兩種使用者的最佳化方向本質上互相拉扯,用同一套目標服務兩種人,等於讓系統永遠處在「兩邊都不夠好」的妥協點上。
這個教訓可以直接對照到 /ask 這條路徑。政策問答場景裡,同樣可能同時存在互動式使用者(員工在對話介面裡即時發問,期待秒級回應)與批次使用者(HR 系統夜間跑一批政策合規檢查,對單筆延遲毫不在意,只在意整批能不能在維護窗口內跑完)。如果只用一組 SLO 涵蓋這兩種流量,很容易在某次容量規劃討論中卡住:工程團隊想知道「為了達到 P95 2 秒的目標,需要多少 GPU 或 provider 配額」,卻沒發現這個目標其實是被夜間批次流量的尖峰吞吐量拉高的,真正的互動式使用者體驗遠比目標寬鬆得多。多軌 SLO 不是把問題複雜化,而是承認「不同使用者對可靠性的期待本來就不一樣」,讓每一組目標對應一種真實可辨識的使用情境,而不是用一個平均數字掩蓋兩種矛盾的需求。
拆開之後,兩軌的門檻設計原則完全不同,值得並排寫清楚,而不是各自憑直覺喊一個數字:
| 維度 | 互動式旅程(員工即時發問) | 批次旅程(HR 夜間合規檢查) |
|---|---|---|
| 量測重點 | 單筆延遲(使用者正在等) | 整批完成時間(有沒有人在等特定一筆) |
| 典型門檻 | P95 在 2 秒內完成 | 整批(例如 5,000 筆)在維護窗口(例如 4 小時)內全數完成 |
| 失敗定義 | 單筆超過門檻 | 整批超過窗口,或窗口內完成率低於某比例 |
| 容量規劃輸入 | 尖峰併發請求數 | 批次總量與可用窗口長度 |
| 過度樂觀的陷阱 | 用平均延遲掩蓋長尾等待 | 用「批次通常會完成」掩蓋窗口邊緣偶爾溢出的情況 |
兩軌一旦拆開,容量規劃的問題也會變得可以分開回答:「互動式旅程需要多少 GPU 配額才能撐住尖峰併發」與「批次旅程的整批窗口要抓多長,才能容納最壞情況下的重試與降級」,是兩個各自獨立、可以分開估算的問題,混在一起反而誰都算不準。
實務上要不要一開始就拆多軌 SLO,取決於流量規模:如果目前 /ask 的批次流量占比極小、或者根本還不存在,硬拆兩組目標只是在製造管理負擔,先用單一 SLO、但在 event 裡保留 journey_type 這類可拆解的 label(前面第 ② 段已經提過同樣的建議),等批次流量真的出現規模、且與互動式流量的優化方向明顯衝突時,再回頭拆分即可。
拆出多軌 SLO 之後,幾乎必然會有人提議把這幾條 SLI 加權平均成一個「整體健康分數」給主管看。這個提議看似合理,卻悄悄丟掉了拆分多軌 SLO 原本想保留的資訊:旅程 C(高風險轉真人)只佔整體流量 0.5%,若照流量比例加權,這條旅程無論表現多差,對整體分數的影響永遠微乎其微——稀釋問題只是換了個位置在「聚合」這一步重新發生,而不是被解決。
Google SRE Workbook 傾向讓每條 SLI 保留獨立的分子分母,需要總覽時用並排燈號而非加權平均。第 ⑫ 段會用一個具體的「主管要一個數字」場景,把這個問題與折衷做法攤開細講,這裡先點出風險。
Day 7 已經把 technical success 與 semantic success 分開。
Day 14 要把那個判斷落在每一筆事件上。
以公司政策問答的 /ask 為例,使用者不是來要求一個 200。
他想知道自己能不能遠端工作、假單應該怎麼送,或系統是否已經把問題交給真人。
可先把最小旅程寫成這樣:
登入的使用者送出問題
↓
服務驗證請求並建立 request_id
↓
Retriever 取得可用政策內容,或誠實回報資料不足
↓
模型與 validator 產生符合 response contract 的結果
↓
使用者在延遲門檻內收到結果或明確下一步
這個旅程會產生幾種長得很像、意義卻不同的事件。
| 情境 | HTTP | workflow 狀態 | 對使用者是否可用 | availability SLI 的初步判斷 |
|---|---|---|---|---|
| 有來源支持的政策答案 | 200 | completed |
是 | good event |
| 找不到文件,清楚回覆資料不足 | 200 | insufficient_context |
通常是 | good event,前提是這是產品承諾 |
| 高風險問題送人工處理 | 202 或 200 | requires_human_review |
視契約而定 | 先由產品定義 |
| provider timeout | 504 | timed_out |
否 | bad event |
| 回 200,卻缺少必要欄位 | 200 | contract_invalid |
否 | bad event |
| 使用者送壞 JSON | 422 | invalid_request |
不是服務失效 | 通常不進 availability 分母 |
| 已登入使用者被系統錯誤拒絕 | 403 | authorization_error |
否 | 多半應進分母 |
最後兩列是常見陷阱。
所有 4xx 都排除,看起來能讓數字很好看;但若權限服務故障,使用者大量收到 403,這正是可用性問題。
反過來,把每一個格式錯誤都算進分母,則會讓惡意流量或測試腳本替真實使用者決定 SLO。
結論不是背一張 status-code 對照表,而是為每個類型寫出理由、資料來源與 owner。
Netflix 在 2012 年 AWS 停機後重新檢視衡量方式。團隊原本監控各個微服務的 uptime,但發現即使內部服務故障,使用者透過 fallback 可能完全感受不到。Netflix 改用 Playback Starts Per Second (SPS),量測使用者按下播放鍵後成功開始播放的比例。這比確認某個 process 是否存活,更接近使用者能否完成任務。Netflix 甚至在「紙牌屋」首播的最高流量時段故意執行 Chaos Monkey(隨機關閉伺服器),系統自癒且沒有影響使用者。這也是本文採用使用者結果作為 SLI 的原因。
值得注意的是,Netflix 選擇「播放開始」而非「整段影片播放完畢」作為量測點,這個切點本身就是一次工程取捨的結果。「播完整部影片」聽起來更貼近「使用者真的滿意」,但分母會被大量與服務品質無關的因素污染——使用者中途離開去接電話、切換到別的 app、單純看到一半沒興趣了,這些都會被誤記為「失敗」。「播放開始」則是一個服務端可以確實控制、且與使用者意圖高度相關的邊界:使用者按下播放鍵,代表他已經做出選擇;接下來影片串流是否成功建立,才是屬於服務可靠性的問題。這個取捨提醒我們:定義 SLI 的量測點時,要找「使用者意圖已確定、但結果仍由系統決定」的那個瞬間,而不是「使用者主觀滿意與否」的整個過程——後者屬於產品分析與 evaluation 的範疇,不是 availability SLI 該扛的責任。
/ask 看起來是單一端點,但如果把使用者旅程攤開,會發現裡面藏著至少三種不同的「使用者意圖」,各自的成功定義不盡相同:
旅程 A:一般政策問答
使用者想要一個有來源支持的答案
→ good = completed + 有 citation + 在門檻內
旅程 B:追問/多輪對話
使用者延續前一輪脈絡繼續問
→ good 除了上面條件,還要求 context 沒有遺失
旅程 C:高風險問題(例如涉及法遵、資遣)
使用者需要的是「正確轉交给真人」而非「AI 自己回答」
→ good = 正確辨識風險並在門檻內轉交,而不是模型自己生成答案
如果把這三種旅程壓進同一個 availability SLI,會發生兩種常見的失真。一種是「旅程 C 被旅程 A 的量大稀釋」:假設每天 10,000 筆問答裡只有 50 筆屬於高風險轉真人,即使這 50 筆全部誤判、AI 自己回答了本該轉人工的問題,整體 availability 依然會停留在 99% 以上,dashboard 完全看不出這個對業務影響可能最大的失敗類別正在發生。另一種是反過來,把「多輪對話中 context 遺失」與「單輪問答成功」用同一套判準衡量,會讓多輪對話的細微退化被單輪流量的高成功率蓋過去。
實務上不必一開始就拆成三條獨立的 Prometheus metric family,但至少要在 event 中保留能夠拆解的 label(例如 journey_type),讓未來需要拆分時,資料已經在那裡,而不是等發現問題才回頭補埋點。這正好呼應 Day 2 建立 Observability Stack 時的立場:先讓資料結構留有餘裕,而不是急著把每個指標都做成一條漂亮的折線圖。
一個實用的 SLI 不需要一開始就有十幾個 label。
先讓每個完成中的 /ask 都能輸出一個低基數事件即可。
{
"event_name": "ask_completed",
"request_id": "req_01J...",
"workflow_status": "completed",
"technical_status": "success",
"response_contract_valid": true,
"valid_request": true,
"latency_ms": 840,
"sli_eligible": true,
"sli_result": "good",
"prompt_version": "policy-qa-v3",
"service_version": "2026.09.21"
}
這裡刻意同時保留 workflow_status 與 sli_result。
前者協助調查發生了什麼;後者是針對特定 SLI 的判斷。不要要求 dashboard 在查詢時臨時猜測 completed、insufficient_context 和 requires_human_review 哪些算成功。
request_id、prompt 版本與模型識別是高基數或高變化欄位。
它們適合 logs 與 traces,不應放進 Prometheus 的 metric labels。
把 request_id 做成 label 的後果不是「可以查得很細」,而是每一筆請求都可能製造一條新 time series。Prometheus 會被用來處理最不適合它的資料,值班時也會先感受到記憶體帳單。
這句警告值得展開,因為初次踩到這個坑的人通常會覺得「不過是多存一點資料,有什麼大不了」。Prometheus 的儲存模型(TSDB)以「每一組唯一的 label 組合」建立一條獨立的 time series,每條 time series 各自維護記憶體中的 chunk 與索引。假設 ask_sli_events_total 只有 route、sli_result、sli_definition_version 三個低基數 label,實際存在的 time series 數量頂多是幾十條——route 可能的值有限、sli_result 只有 good/bad/excluded。但只要多加一個 request_id 這種近乎全域唯一的 label,time series 數量會直接跳到「歷史上出現過的請求數」這個量級,而且這些 time series 一旦建立就不會消失,只會隨著時間持續累積在記憶體與磁碟索引裡,直到超過 retention 才被清除。
這不是「查詢會變慢」這種可以忍受的效能問題,而是記憶體用量與請求量直接掛鉤的結構性風險:平常流量正常時可能沒事,一旦遇到流量尖峰或者某個異常客戶端瘋狂重試(正是 Day 12 討論過的 retry storm),time series 數量會跟著洪水般暴增,Prometheus 進程本身反而先於被監控的服務發生 OOM。用來偵測問題的系統,先於它要監測的系統倒下——這和 Meta 2021 年那次 backbone 事故裡「監控依賴同一條被監控的網路」是同一種結構性失誤,只是規模與領域不同:一個是網路拓樸層級的共病,一個是可觀測性系統自己的資料模型設計錯誤。
所以「高基數欄位放 logs/traces、低基數欄位放 metrics」不是風格建議,而是尊重每個系統原本設計要處理的資料形狀:metrics 系統為「少量、長期累積、拿來算 rate 與 aggregate」而生;logs/traces 系統則為「大量、短期查詢、拿來鑑識單一事件」而生。把資料放錯系統,兩邊都會用最貴的方式做最不適合的事。
可把資料放到正確層次:
Prometheus metrics
├─ sli_eligible
├─ sli_result
├─ route
├─ deployment
└─ model_route(僅固定、有限的 routing 類別)
Logs / traces
├─ request_id
├─ trace_id
├─ user or tenant identifier(依隱私政策處理)
├─ prompt_version
├─ retrieval document identifiers
└─ validator explanation
這不是犧牲可觀測性。
它是把「趨勢告警」和「單筆鑑識」分給最合適的訊號。
上面講的「Prometheus 進程先於被監控服務 OOM」不是紙上談兵的假設,業界已經有具體案例可以對照。有團隊曾在一個請求計數器上加了一個 user_id label,用意單純:想按使用者追蹤流量,方便日後查「這個使用者最近打了幾次 API」。系統當時有百萬等級的活躍使用者,這個決定在三個月內悄悄產生了超過五百萬條獨立 time series。
危險的地方在於,這個事件一開始完全沒有觸發任何告警——metrics 本身看起來「正常」,數字照樣在跳動,dashboard 照樣能畫出線。真正的傷害是隨著 time series 數量持續累積、記憶體與索引用量跟著單調上升,Prometheus 進程逐步被推向極限,最終被 OOM killer 強制關閉。這一關閉,連帶弄壞了整個監控系統本身:Grafana dashboard 全部變成空白,Prometheus 無法維持穩定啟動狀態,原本毫秒級的查詢延遲到 30 秒甚至完全無回應。事後恢復需要開戰情室、回滾程式碼、外加大量的手動修復工作。
這個案例值得放進 Day 14,不只是因為它剛好示範了「加一個 label 就能拖垮監控」這句話不是誇飾,而是它精準對照了本文一直在強調的兩件事:第一,cardinality 爆炸是無聲的——在傷害變得明顯之前,它已經在背景裡持續累積,沒有錯誤碼、沒有例外、也沒有任何一行 log 主動告訴你「這個決定會出事」;第二,用來偵測問題的監控系統,可能先於它要監測的目標系統倒下——這正是本文前面提到的 Meta 2021 backbone 事故(監控依賴同一條被監控的網路)在可觀測性資料模型層級的翻版,只是這次共病的對象換成了 Prometheus 自己的 TSDB 設計,而不是網路拓樸。
回頭對照本文 event contract 的兩份清單:sli_eligible、sli_result、route、sli_definition_version 這幾個欄位之所以安全,是因為它們的可能值集合天生有界——sli_result 永遠只會是 good/bad/excluded 三選一,不會隨著使用者數量或請求量成長而膨脹。user_id(或 request_id)之所以危險,正是因為它的可能值集合等於「曾經出現過的使用者(或請求)數量」,這個數字只會隨著產品成長單調遞增,永遠沒有上限。把這種欄位放進 label,不是「多存一點資料」的小決定,而是把 Prometheus 的記憶體用量與業務成長直接掛鉤——業務越成功,監控系統死得越快。OpenObserve:The Prometheus Cardinality Bomb
前面的 JSON 只是概念上的 event 長相,實際串進 Day 2 那套 FastAPI + Prometheus 的 Lab 時,這個 event 應該在 request 生命週期的哪一個時間點被建立、又該在哪裡分岔成「一份給 log、一份給 metric」,值得具體走一遍。概念上的骨架大致如下:
from dataclasses import asdict
from time import perf_counter
import structlog
from fastapi import APIRouter, Request
logger = structlog.get_logger()
router = APIRouter()
@router.post("/ask")
async def ask(request: Request, payload: AskRequest):
started_at = perf_counter()
request_id = request.state.request_id # 由中介層在最上游建立
try:
result = await run_ask_workflow(payload, request_id=request_id)
event = build_ask_event(
request_id=request_id,
payload=payload,
result=result,
latency_ms=int((perf_counter() - started_at) * 1000),
)
except AskWorkflowError as exc:
event = build_ask_event_from_error(
request_id=request_id,
payload=payload,
error=exc,
latency_ms=int((perf_counter() - started_at) * 1000),
)
# 給 logs / traces:完整事件,含高基數欄位,供單筆鑑識
logger.info("ask_completed", **asdict(event))
# 給 Prometheus:只取低基數子集,供趨勢與 burn-rate 判斷
ASK_SLI_EVENTS_TOTAL.labels(
route="/ask",
sli_definition_version=event.sli_definition_version,
sli_eligible=str(event.sli_eligible).lower(),
sli_result=event.sli_result,
).inc()
return event.to_response()
這段程式碼刻意把「建立 event」與「event 要送去哪裡」拆成兩個獨立步驟:build_ask_event() 產出包含所有欄位(含 request_id、prompt_version 等高基數資料)的完整物件,再分別餵給 logger.info()(全部欄位都留著)與 ASK_SLI_EVENTS_TOTAL.labels()(只挑出四個低基數欄位)。這個結構讓「哪些欄位進 metrics、哪些進 logs」變成程式碼裡一眼可見的事實,而不是散落各處、要靠讀過整個 codebase 才能拼湊出來的隱性規則。
上面那段程式碼已經把「哪些欄位進 metrics」寫得很清楚,但這只在「寫程式碼的人記得遵守這個約定」的前提下有效。真實團隊會換人、會有人趕在deadline前臨時加一行程式碼、會有 code review 忙起來只掃過一眼就核准。前面提到的 user_id cardinality 爆炸案例,起點往往不是一次蓄意違規,而是某個人在某次緊急修 bug 時,臨時多加了一行 .labels(..., debug_user_id=user_id) 想方便自己當下排查,改完就忘記拔掉。code review 如果沒有特別留意,這種一行之差很容易被放行。
比較穩妥的做法,是在程式碼裡加一層執行期防呆,讓「意外把高基數欄位塞進 label」直接變成一個會被立刻發現的錯誤,而不是等三個月後 Prometheus 記憶體用量異常才回頭排查:
ALLOWED_SLI_RESULT_VALUES = {"good", "bad", "excluded"}
ALLOWED_ROUTE_VALUES = {"/ask"}
def record_sli_event(
*,
route: str,
sli_definition_version: str,
sli_eligible: bool,
sli_result: str,
) -> None:
"""Only ever accepts values from a pre-declared, bounded set."""
if route not in ALLOWED_ROUTE_VALUES:
raise ValueError(f"unexpected route label value: {route!r}")
if sli_result not in ALLOWED_SLI_RESULT_VALUES:
raise ValueError(f"unexpected sli_result label value: {sli_result!r}")
ASK_SLI_EVENTS_TOTAL.labels(
route=route,
sli_definition_version=sli_definition_version,
sli_eligible=str(sli_eligible).lower(),
sli_result=sli_result,
).inc()
這個函式故意只暴露一個限制過的介面,任何呼叫端想傳入 route 或 sli_result 以外的值,都會在應用程式自己的測試環境裡立刻拋出例外,而不是安靜地被 Prometheus 接受、變成一條新的 time series。sli_definition_version 沒有被同樣限制成一個固定集合,是刻意的:這個欄位本來就預期會隨版本更新而增加新值(v1、v2……),限制它反而會讓每次版本升級都要先改這個白名單;但它的成長速度是「每次規則變更才增加一個」,跟 user_id「每個使用者一個」的成長速度完全不同量級,所以留著不設限仍然安全。這個區分——同樣是「不設硬編碼上限的欄位」,成長速度和成長機制決定了它是安全還是危險——比單純記一句「不要放高基數欄位」更貼近實務判斷需要的細緻度。
AskEvent 這份 event 定義不會停在今天的樣子。半年後,團隊可能需要新增一個欄位記錄「這次 retrieval 用了幾個文件」,或者把 workflow_status 拆得更細。這裡有一個經常被低估的原則:新增欄位與改變既有欄位的意義是完全不同等級的變更,前者通常安全,後者幾乎一定需要走過本文第 ⑤ 段的版本流程。
安全的演進(不需要改 sli_definition_version)
+ 新增一個選填欄位(例如 retrieved_document_count)
+ 新增一個此前不存在的 workflow_status 值,但先歸類為 bad(保守預設)
+ 為 log 增加除錯用的欄位,不影響任何 metric label
需要走版本流程的演進(必須改 sli_definition_version)
+ 修改既有欄位的可能值集合的「意義」(例如把某個 workflow_status
從 bad 改判為 good,如第 ⑤ 段 degraded_completed 案例)
+ 收緊或放寬 latency 門檻
+ 改變分母的篩選條件(例如新增或移除一種排除類別)
分辨的關鍵不是「這個變更牽動了幾行程式碼」,而是「這個變更會不會讓某一筆過去被判定為 good 的請求,換到新規則下變成 bad(或反過來)」。新增欄位、新增一個保守預設為 bad 的狀態值,都不會讓既有請求的判定結果改變,可以放心地隨版本自然演進;但任何會讓過去和未來的判定結果不一致的變更,都必須被記錄成一次明確的版本升級,否則就會落回本文第 ①、⑤ 段一路警告的「SLI 隨 incident 悄悄變動」陷阱——只是這次觸發變動的不是一次 incident 後的臨時調整,而是一次看似無害的欄位重構。
如果 good event 只存在於會議紀錄,半年後沒有人知道某個 label 是誰定的。
先用標準函式庫把規則寫成小函式。這段程式不依賴特定監控套件,方便先對 fixture 討論行為。
from dataclasses import dataclass
GOOD_WORKFLOW_STATUSES = {
"completed",
"insufficient_context",
}
@dataclass(frozen=True)
class AskEvent:
valid_request: bool
workflow_status: str
response_contract_valid: bool
latency_ms: int | None
system_rejection: bool = False
def is_sli_eligible(event: AskEvent) -> bool:
"""Only product-meaningful /ask requests enter this availability SLI."""
return event.valid_request
def is_good_event(event: AskEvent, latency_limit_ms: int = 10_000) -> bool:
if not is_sli_eligible(event):
return False
if event.system_rejection:
return False
if event.latency_ms is None or event.latency_ms > latency_limit_ms:
return False
if not event.response_contract_valid:
return False
return event.workflow_status in GOOD_WORKFLOW_STATUSES
這份 classifier 有幾個刻意不做的判斷。
它沒有檢查答案是否為真。
它也沒有把 requires_human_review 預設視為 good event。
前者屬於 Day 7 之後的 evaluation 與人工審查;後者要看「使用者是否在 SLO window 內得到可行下一步」是不是這個 API 的契約。
把尚未決定的事情寫成 UNKNOWN 或留在 spec 的待決欄位,比自己替產品承諾一個答案可靠。
接著建立 fixture。每一筆都要能看懂自己為什麼在分子或分母裡。
CASES = {
"grounded_answer": AskEvent(
valid_request=True,
workflow_status="completed",
response_contract_valid=True,
latency_ms=820,
),
"honest_unknown": AskEvent(
valid_request=True,
workflow_status="insufficient_context",
response_contract_valid=True,
latency_ms=640,
),
"provider_timeout": AskEvent(
valid_request=True,
workflow_status="timed_out",
response_contract_valid=False,
latency_ms=10_001,
),
"http_200_but_invalid": AskEvent(
valid_request=True,
workflow_status="completed",
response_contract_valid=False,
latency_ms=510,
),
"malformed_payload": AskEvent(
valid_request=False,
workflow_status="invalid_request",
response_contract_valid=False,
latency_ms=0,
),
"system_denied_user": AskEvent(
valid_request=True,
workflow_status="authorization_error",
response_contract_valid=False,
latency_ms=220,
system_rejection=True,
),
}
for name, event in CASES.items():
print(
name,
{
"eligible": is_sli_eligible(event),
"good": is_good_event(event),
},
)
預期判讀如下。
| fixture | eligible | good | 原因 |
|---|---|---|---|
grounded_answer |
True |
True |
合法請求、契約完整、在門檻內完成 |
honest_unknown |
True |
True |
產品允許誠實拒答,且回應格式完整 |
provider_timeout |
True |
False |
使用者有有效需求,服務未在門檻內交付 |
http_200_but_invalid |
True |
False |
transport 成功不等於 response contract 成功 |
malformed_payload |
False |
False |
此範例將無效請求排除於 availability 分母 |
system_denied_user |
True |
False |
系統造成的拒絕應被看見 |
這是規格測試,不是 production 測試。
真正上線前,還要由產品 owner 確認「資料不足」和「人工審查」是否真的符合使用者可接受結果。
上面六個 fixture 都對應已經在 event contract 裡出現過的狀態。但一個 classifier 真正的價值,往往要等到系統演化出新狀態時才顯現。假設三個月後,工程團隊在 provider 層加了一個 fallback:當主要模型逾時,系統自動切換到次要模型完成回答,workflow_status 因此多了一個新值 degraded_completed。這時候,若沒有先寫好的 classifier 與 fixture,這個新狀態會安靜地落進 is_good_event() 的最後一行判斷式:
return event.workflow_status in GOOD_WORKFLOW_STATUSES
degraded_completed 不在 GOOD_WORKFLOW_STATUSES 集合裡,所以會被判成 bad——這個結果究竟是對是錯,取決於產品要不要把「降級後仍完成」算進使用者可接受的結果。無論哪個答案,重點是:這個決策應該以一則新增的 fixture 加上一行測試斷言的形式被明確記錄下來,而不是讓工程師憑當下的直覺,在合併程式碼時順手把它塞進某個集合裡。
CASES["degraded_but_completed"] = AskEvent(
valid_request=True,
workflow_status="degraded_completed",
response_contract_valid=True,
latency_ms=1_450,
)
# 決策記錄:2026-Q4 產品審查後,降級回答仍視為 bad event,
# 因為使用者體驗的模型能力與主要模型有落差,
# 需要先由 evaluation pipeline 驗證降級模型品質後再重新考慮。
這種「新狀態出現時先補 fixture 再改程式碼」的紀律,正是抵抗第 ① 段提到的「SLI 隨 incident 悄悄變動」的具體做法。當有人在事故後想要「順手把某個失敗狀態挪出分母」,這個改動必須先通過一則新增或修改的 fixture,讓 code review 能夠直接看到「這次改動讓哪個具體情境從 bad 變成 good」,而不是被埋在一行不起眼的 diff 裡。
六個 fixture 涵蓋的情境都離門檻有一段距離——820ms 明顯在 10 秒內、10,001ms 明顯超過。但真正容易讓程式碼與規格認知不一致的地方,往往藏在「剛好等於門檻」的那一筆請求。回頭看 is_good_event() 的判斷式:
if event.latency_ms is None or event.latency_ms > latency_limit_ms:
return False
這裡用的是 >(大於),不是 >=(大於等於),代表一筆延遲剛好 10_000ms 的請求,會通過這一行判斷式繼續往下走,最終仍有機會被判成 good。這個選擇對不對,取決於 spec 裡「10 秒內完成」這句話原本想表達的是「小於 10 秒」還是「小於等於 10 秒」——這種語言上的模糊,恰恰是本文一路強調「文字規格必須落成可測試程式碼」的理由:只要停留在文字階段,沒有人會意識到這裡藏著一個二選一的實作決策;一旦寫成程式碼,這個決策無可迴避,而且會被永久固定下來,直到有人真的寫一個邊界值 fixture 才會重新被檢視。
CASES["exactly_at_latency_boundary"] = AskEvent(
valid_request=True,
workflow_status="completed",
response_contract_valid=True,
latency_ms=10_000,
)
# 決策記錄:10_000ms 剛好等於門檻,目前實作判定為 good
# (> 而非 >=)。若之後要改成「10 秒內」不含 10 秒整,
# 這裡是第一個要更新的 fixture。
這類邊界值測試常被視為「吹毛求疵」而跳過,但對 SLI 而言,它的價值不在於這一筆邊界請求本身有多重要,而在於它強迫團隊把一句口語化的規格(「10 秒內完成」)翻譯成一個明確、無歧義的程式碼行為,並且把這個翻譯結果用一筆可執行的測試永久記錄下來。半年後如果有人想把門檻從 10 秒改成 8 秒,這筆邊界值 fixture 會立刻告訴他:「這裡原本的判斷方式是這樣,你確定新門檻的邊界行為也要一樣嗎?」
前面的 CASES 字典配合 for 迴圈印出結果,對「先討論規則本身合不合理」這個目的已經足夠——這正是它在文章前段刻意保持成一個可以直接貼進 REPL 跑的獨立腳本、不依賴任何測試框架的原因。但一旦這份 classifier 真的要進版控、接受 CI 檢查,逐字印出結果再靠人眼比對就不夠可靠了:人眼比對容易漏看一行、也不會在 pull request 裡自動擋下錯誤的變更。這時候把同一組 fixture 改寫成 pytest.mark.parametrize,讓每一筆案例變成一條獨立、有名字、會在 CI 失敗時精確報出是哪一筆壞掉的斷言,是很自然的下一步:
import pytest
from app.slo_spec import AskEvent, is_good_event, is_sli_eligible
@pytest.mark.parametrize(
"name, event, expected_eligible, expected_good",
[
(
"grounded_answer",
AskEvent(True, "completed", True, 820),
True,
True,
),
(
"honest_unknown",
AskEvent(True, "insufficient_context", True, 640),
True,
True,
),
(
"provider_timeout",
AskEvent(True, "timed_out", False, 10_001),
True,
False,
),
(
"http_200_but_invalid",
AskEvent(True, "completed", False, 510),
True,
False,
),
(
"malformed_payload",
AskEvent(False, "invalid_request", False, 0),
False,
False,
),
(
"system_denied_user",
AskEvent(True, "authorization_error", False, 220, system_rejection=True),
True,
False,
),
(
"exactly_at_latency_boundary",
AskEvent(True, "completed", True, 10_000),
True,
True,
),
],
)
def test_sli_classification(name, event, expected_eligible, expected_good):
assert is_sli_eligible(event) == expected_eligible, name
assert is_good_event(event) == expected_good, name
這個改寫本身不改變任何分類邏輯,純粹是把「規則」從一段會被執行、但失敗時只會印出一堆文字的腳本,變成一組會被 CI 個別追蹤、個別報告失敗原因的斷言。好處在 code review 時特別明顯:如果有人改動 GOOD_WORKFLOW_STATUSES 想加入新狀態,CI 會精確指出「test_sli_classification[某個 fixture 名稱] 失敗」,而不是要 reviewer 自己重新跑一次腳本、肉眼比對哪一行印出的結果跟預期不符。這正是把第 ④ 段「文字規格必須落成可測試程式碼」這句話,再往前推一步:規則落成程式碼還不夠,程式碼還要落成 CI 會主動盯著的斷言,才能真正防止「新狀態悄悄落進錯誤分類」這種第 ④ 段已經討論過的風險。
只有 good / total 還不夠。
每個 SLI 規格至少應有下列欄位。
| 問題 | 範例答案 | 沒寫會發生什麼 |
|---|---|---|
| 使用者旅程是什麼? | 已登入使用者取得政策問答的明確結果 | 把健康檢查或背景任務混進來 |
| 分母是什麼? | valid_request=true 的 /ask completion event |
無效流量或取消行為任意改變數字 |
| 分子是什麼? | 10 秒內的 sli_result=good |
HTTP 200 或空 response 被誤當成功 |
| 資料從哪裡來? | app counter;必要時以 completion log 交叉核對 | 同一件事在兩張 dashboard 有兩個答案 |
| 誰能改規則? | service owner 與產品 owner,變更需 review | incident 後偷偷改分母救數字 |
這五個問題乍看只是把前面幾段的內容整理成表格,但值得說明為什麼剛好是這五個、少一個會出什麼問題。
「使用者旅程是什麼」排在第一位,是因為它決定了後面所有欄位的範圍。如果這個問題沒有先回答清楚,團隊很容易陷入一種常見的失序:先寫好 PromQL 查詢、湊出一個看起來合理的百分比,再回頭幫這個數字編一個「它大概在量什麼」的說法。這個順序一旦顛倒,SLI 就會變成「我們手上剛好有的資料能算出什麼」,而不是「使用者真正在意的旅程需要量測什麼」——前者是資料驅動的假象,後者才是真正以使用者為中心的量測。
「分母是什麼」與「分子是什麼」必須分開列成兩題,而不是合併成一個「good/total 怎麼算」,是因為這兩者常常各自出錯、卻用完全不同的方式出錯。分母錯了,通常是把不該算進來的流量算了進去(例如自動化探測、內部測試流量),讓比例被稀釋或膨脹;分子錯了,通常是把不該算成功的事件算成功(例如 HTTP 200 但 response 是空的)。分開列出來,才能在 review 時逐一檢查兩邊各自的定義是否站得住腳,而不是籠統地問「這個數字對不對」。
「資料從哪裡來」容易被當成技術細節省略,但它其實是「這個 SLI 能不能被信任」的關鍵欄位。第 ⑥ 段會談到,同一個 completion event 理論上可以用 metric counter 算,也可以用 log 查詢算,兩者如果對同一段時間給出不同答案,代表某個環節(通常是 label cardinality 被丟棄、或是多副本聚合方式不一致)出了問題。如果規格裡沒有明確寫「以哪一種資料源為準」,遇到數字對不上時,團隊會花大量時間爭論「該相信哪一份」,而不是去查真正的根因。
「誰能改規則」是最常被視為形式主義、卻在事後最關鍵的一欄。本文第 ⑧ 段會具體講到一起「事後偷改定義來湊達標」的真實案例;那次事件之所以造成信任危機,根本原因就是規則變更沒有經過任何 review、也沒有留下紀錄。把「誰有權改」寫進規格,不是不信任團隊成員,而是確保「規則變了」這件事本身,永遠可以在事後被追溯到是誰、什麼時候、為什麼改的。
另一個容易被省略的欄位是版本。
當 response contract 從 v1 改到 v2,或 provider fallback 新增 degraded_completed,不要偷偷改掉舊 classifier 的意思。
可以在規格中留下:
sli_definition_version: ask-availability-v1
effective_from: 2026-09-21
change_reason: initial Lab definition; no production target declared
review_owner: <team or named role>
版本不是官僚流程。
它讓 incident review 能回答:「這個月的 99.6% 是用哪個定義算出來的?」
抽象地說「要記版本」容易,實際遇到變更時怎麼做,值得走一遍完整流程。假設六個月後,團隊決定把 degraded_completed(上一段提到的降級回答)從 bad event 改判為 good event,因為 evaluation pipeline 已經證明降級模型的答案品質可接受。這個改動至少要同時發生四件事:
1. classifier 程式碼變更
GOOD_WORKFLOW_STATUSES 加入 "degraded_completed"
2. sli_definition_version 從 ask-availability-v1 → ask-availability-v2
(分子的定義變了,就不是同一個 SLI)
3. Prometheus label 帶上新版本號
ask_sli_events_total{..., sli_definition_version="v2", ...}
舊版本的 time series 不會被覆寫,v1 與 v2 在圖表上並存
4. spec 文件的 change log 補一行
2026-Q4:degraded_completed 由 bad 改判 good,
原因:evaluation pipeline 驗證降級模型品質達可接受標準(連結報告)
核准人:<service owner> / <product owner>
第 3 點特別重要,也是最容易被省略的一步。如果只是讓同一個 sli_result label 在同一天悄悄開始把更多事件算成 good,Grafana 上的 availability 曲線會在改版當天出現一個台階式的跳升,但沒有任何標記告訴看圖的人「這一天發生了定義變更,不是系統真的變可靠了」。把版本號做成獨立 label,讓 v1 與 v2 的曲線並存、可以疊圖比較,才能誠實地回答「這次上升是系統變好、還是尺變鬆了」——這也是為什麼前面所有 PromQL 查詢都刻意把 sli_definition_version 寫進 label matcher,而不是只篩 route 跟 sli_result。
把這條 SLI 假想拉到一年後回顧,change log 累積起來會長成這樣一張表——這也是「誰能改規則」這一欄實際運作起來的樣子,而不是只存在文件裡的一句宣示:
| 版本 | 生效日 | 變更內容 | 核准人 | 觸發原因 |
|---|---|---|---|---|
ask-availability-v1 |
2026-09-21 | 初版 Lab 定義,非 production 承諾 | — | 本文示範起點 |
ask-availability-v2 |
2026-Q4 | degraded_completed 由 bad 改判 good |
service owner / product owner | evaluation pipeline 驗證降級模型品質達標 |
ask-availability-v3(假設) |
未來 | 高風險轉真人旅程獨立拆出,不再併入本 SLI 分母 | service owner / product owner / 法遵 | 旅程 C 流量成長,稀釋問題浮現 |
這張表本身就是本文第 ①、⑤ 段一路在強調的「可審查規格」的具體樣貌:任何人不需要去問任何人,只要打開這份 change log,就能重建「這個月的數字是用哪個定義算出來的」,以及「這個定義為什麼會變成現在這樣」。沒有這張表,同樣的知識只會存在於某幾個資深工程師的記憶裡,人一離職,這段歷史就跟著消失。
定義寫清楚了,接下來要把它落地:從 completion event 匯總成 Prometheus counter、算出 window 與 error budget、設計 multiwindow burn-rate alert,再走一輪可重跑的 DIY。下篇(Day 14 下)接著講。
這篇是 Learning SRE for the AI Era 系列的一部分。
Build → Trace → Break → Measure → Evaluate → Recover → Improve.