iT邦幫忙

2026 iThome 鐵人賽

DAY 3
0
AI Engineering

Learning SRE for the AI Era:從 SRE Lab 到 Production AI Reliability系列 第 27 篇

Day 18(上)|LLM Application SLI:把「慢」拆到能修的位置

  • 分享至 

  • xImage
  •  

GitHub:darkstar1227/learning-sre-for-ai-era

結論先說:LLM application 要同時定義 system SLI 與 AI workflow SLI 的事件邊界;只量 API latency 這個總和數字,無法分辨慢在排隊、檢索、生成還是 tool 呼叫。本篇(上)先把這組事件契約定義清楚,下篇(下)接著談實作、告警門檻與 incident 判讀。

① System SLI

先保留傳統訊號:有效請求成功率、end-to-end latency、queue delay、dependency timeout、worker saturation。這些決定服務是不是還接得住流量。

為什麼這五個訊號不能省

這五個訊號各自回答一個 System 層一定要知道的問題,彼此不能互相代替。

有效請求成功率 回答「這次請求最後有沒有走完整條路」——它必須先定義清楚「有效」是什麼意思,因為使用者主動取消的請求、被 rate limit 擋下的請求,跟真正因為系統故障失敗的請求,在分子分母裡的位置完全不同,Day 17 已經談過這件事,這裡不重複。

end-to-end latency 回答「使用者等了多久」,但它是一個總和數字,本身不指向任何可修的位置——這正是 Day 18 全篇要解決的問題:把這個總和拆開。

queue delay 回答一個特別容易被忽略的問題:「請求進系統之後,是先被處理,還是先在排隊?」對傳統 Web API,這個數字通常小到可以忽略;但對 LLM application,queue delay 經常是整條鏈路裡波動最大的一段,因為 GPU 或 model provider 的併發上限遠低於一般 CPU-bound API,稍微多一點流量,隊伍就會迅速拉長。

dependency timeout 回答「外部依賴(資料庫、快取、model provider、tool API)有沒有在合理時間內回應」,它決定了系統該不該 fail fast,還是繼續傻等一個已經沒希望回來的連線。

worker saturation 回答「處理容量是不是已經見底」,通常用進行中的請求數除以可用 worker 數,或用 CPU/記憶體使用率間接推算;它是解釋「為什麼 queue delay 突然變長」的下一層線索,兩者經常一起看。

有個排隊理論的直覺值得記住:Little's Law 說平均排隊中的請求數,等於平均到達速率乘以平均在系統中停留的時間(L = λW)。公式本身不必背,但它的意涵值得記住——當 worker 處理速度沒跟上請求到達速度,隊伍長度不會線性增加,而是隨著使用率逼近 100% 急遽拉長。這解釋了為什麼「CPU 使用率還沒到 100%,延遲卻已經開始爆炸」不是系統壞了,而是排隊系統逼近飽和點時的正常行為——下面用具體數字把「急遽拉長」量化。

worker utilization
  50%  ──────  queue 幾乎不存在
  80%  ──────  queue 開始有感
  90%  ──────  queue 明顯變長
  95%  ──────  queue 可能已經失控
  99%  ──────  queue 幾乎無限延伸

這張圖不是精確模型,只是提醒:worker saturation 不是一條線性刻度,接近滿載時的每一分利用率都比前一分更貴。

把「急遽拉長」換算成實際數字

上面那張圖只講了方向,沒有講量級。排隊理論裡最簡單的一個模型(M/M/1,單一 worker、到達與服務時間都符合指數分佈)給了一個具體公式:平均等待時間 Wq = ρ / (μ × (1 - ρ)),其中 ρ 是使用率、μ 是單一 worker 每秒能處理的請求數。這個公式本身不必背,但把它套進具體數字,會比只看「急遽拉長」這四個字更有說服力。假設一個 worker 平均每秒能處理 2 個請求(μ = 2):

使用率 ρ 平均排隊等待時間(秒) 相對 50% 使用率的倍數
50% 0.50 1x
80% 2.00 4x
90% 4.50 9x
95% 9.50 19x
99% 49.50 99x

使用率從 50% 升到 80%,只上升了 30 個百分點,排隊時間卻變成原本的 4 倍;從 80% 到 95%,只再上升 15 個百分點,排隊時間又翻了將近 5 倍。這正是為什麼前面用「急遽拉長」而不是「稍微變慢」——單看使用率的絕對數字,80% 到 95% 感覺只是「更忙一點」,但排隊時間的實際感受,是從「還好」直接跳到「幾乎打不進去」。

這張表格是簡化模型,真實系統的到達模式、服務時間分布通常都不是乾淨的指數分佈,實際數字會不一樣。但這個模型抓到的核心行為——等待時間隨使用率非線性上升,而且上升的速度本身也在加速——在絕大多數排隊系統裡都成立,不管是 M/M/1、M/M/c(多個 worker)還是更複雜的排隊網路,形狀都類似,只是精確係數不同。這也解釋了為什麼許多團隊會把 worker saturation 的告警門檻設在 70-80% 這個區間,而不是更直覺的 95%:等到 95% 才告警,留給人反應的時間窗口已經被排隊曲線本身壓縮到只剩幾分鐘甚至幾十秒。

把這個門檻落成一條實際的告警規則,大致長這樣:

groups:
  - name: ask-worker-saturation
    rules:
      - alert: AskWorkerSaturationHigh
        expr: |
          sum(rate(ask_worker_busy_seconds_total[5m]))
            / sum(ask_worker_capacity)
          > 0.8
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "/ask worker 使用率超過 80%,持續 5 分鐘"
          description: "依上表換算,使用率超過 80% 後排隊時間會以非線性速度拉長,建議在使用者真正感受到明顯變慢前,先檢查是否需要擴充 worker 或啟動 admission control。"

for: 5m 這個欄位刻意不設成 0——瞬間的使用率尖峰在正常流量下也會偶爾出現,沒有這個緩衝視窗,告警會因為單一分鐘的抖動就響,變成第⑤節「為什麼是 P95,不是平均值」那段提過的「樣本量小、雜訊大」的翻版。門檻設在 80% 而不是更晚的 95%,理由就是上面那張表格算出來的落差:早一點在轉折點附近告警,留給值班工程師的是「還能從容處理」的時間窗口,而不是「幾分鐘內就要決定要不要緊急擴容」的壓力。

常見誤解:System SLI 綠燈就代表使用者沒事

這裡要特別小心一個陷阱:基礎設施健康 ≠ 使用者體驗健康。Kubernetes 社群流傳一個常被引用的教訓,叫做「Deep Health Check 級聯故障」:某支付服務把 readiness probe 設定成順便檢查下游 auth service 是否存活,結果 auth service 故障時 probe 也跟著失敗,所有 pod 被移出 load balancer,整個支付系統瞬間打不通,儘管支付 API 本身的程式碼一行都沒動過。容器還活著、CPU 充足、memory 不爆表,使用者請求卻全部失敗。這個故障的根本原因是混淆了「服務是否能回應」與「依賴是否全部健康」——一個應用程式即使無法連到下游,也應該能返回適當的失敗回應(如「稍後重試」),而非把自己完全移出 load balancer。

這個案例值得多停留一下,因為它精準示範了「量錯訊號」的兩種常見誤區之一:把「我依賴的東西是否健康」誤植進「我自己是否健康」的定義裡。Readiness probe 的正確語意應該只回答「我現在能不能接收流量並嘗試處理它」,而不是「我所有依賴的東西都正常嗎」;後者屬於 dependency SLI 的範疇,應該用獨立的訊號與告警規則處理,而不是拿去決定 pod 生死。把兩者混在一起的直接後果,就是單一依賴故障透過健康檢查機制,被平台自己放大成全站不可用。

Netflix 也公開寫過類似的坑:容器分到足夠的 CPU 核心,在隔離機器上測試表現正常,上了生產叢集後吞吐量卻明顯下滑。原因是排程器把核心分配到不同的 NUMA node,跨 node 記憶體存取比同 node 慢上一截,cache 也跟著互相干擾。CPU 使用率沒有超標,使用者感受到的延遲卻真實升高了。

這是另一種誤區:把「資源使用率」當成「資源是否充足」的唯一判準。CPU 使用率、記憶體使用率這類聚合數字,回答的是「有沒有用滿」,不是「用起來順不順」;NUMA 跨節點存取、CPU cache 汙染、network I/O 排隊,這些都會讓「資源明明夠」的系統跑得比「資源理論上吃緊」的系統還慢。這正是為什麼 System SLI 不能只看資源使用率,還要直接量 latency 與 queue delay 這種貼近使用者體感的指標——資源夠不夠是原因層面的線索,latency 好不好才是結果層面的判準。

這兩個案例提醒的是同一件事:System SLI 的每一個訊號,都要先問「它量的是使用者感受到的結果,還是系統內部的健康狀態」。兩者通常一致,但出現分歧時,應優先確認使用者結果,不要被內部健康指標蓋過。

② AI SLI

再補 workflow 的訊號:TTFT、tokens per second、retrieval hit rate、citation-valid ratio、tool success ratio、insufficient_context 比例與 safety block 比例。後兩項突然上升不一定是故障,可能是文件更新、使用者問題變了,或 guardrail 變嚴;它們需要 investigation,不應一律 paging。

逐一拆開這七個訊號

這七個名字很容易被當成一份待辦清單掃過去,但每一個都值得問「它量的到底是什麼」與「它容易被誤讀成什麼」。

TTFT(time to first token) 量的是「使用者從送出問題到看見系統開始有反應」的等待時間,它是 System SLI 裡沒有對應物的訊號——傳統 Web API 通常沒有「部分回應」的概念,回應要嘛還沒到、要嘛整個到了。串流輸出把這條界線打散:使用者可能在 800ms 看到第一個字,卻要再等 10 秒才看到完整答案。常見誤解是把 TTFT 當成整體體驗的唯一指標;後面第⑤節會用一個對照表說明為什麼 TTFT 快不代表產品體驗好。

tokens per second 量的是拿到第一個 token 之後,答案吐出的速度。它跟 TTFT 是互補而非替代的關係:TTFT 決定「感覺快不快開始」,tokens/sec 決定「讀起來順不順」。這裡有個常見誤解,是把 tokens/sec 直接當成「模型效能」的唯一指標——輸出速度同時受模型大小、batch size、GPU 佔用率、甚至同時段其他租戶的流量影響,單看這個數字掉了,不能立刻斷定是「模型變慢」,還要看是不是 provider 端在做流量調度。

retrieval hit rate 量的是「檢索有沒有找到相關文件」,通常定義成「檢索結果中至少有一筆通過某個相關性門檻」的請求比例。它容易被誤會成「檢索系統健不健康」的訊號,但其實它同時混雜了兩種完全不同的原因:索引真的壞了(technical 問題),或者使用者問的東西本來就沒有文件覆蓋(knowledge 缺口,不是 bug)。這也是為什麼它要跟 insufficient_context 比例放在一起看,而不是單獨解讀。

citation-valid ratio 量的是「答案裡引用的來源,是不是真的存在、真的支持這句話」。這個訊號直接對應 RAG 系統最容易出的錯——答案讀起來言之鑿鑿,引用的段落卻是編造的,或者引用了一段確實存在但講的是別的事情的文件。它需要額外的驗證邏輯(比對引用 ID 是否在檢索結果集合裡),不是模型自己能保證的事。

tool success ratio 量的是「呼叫外部工具(搜尋、資料庫查詢、下單 API)有沒有成功執行」。這裡的陷阱在於「成功執行」與「執行了正確的事」是兩回事:工具呼叫可能語法正確、回傳 200,但引數本身就是錯的(例如把日期算錯、把單位搞混)——這類錯誤要靠工具呼叫的結果驗證,而非只看有沒有丟出 exception。

這正是本文開頭那個庫存代理案例(「清理過期條目」被理解成「刪除任何超過 30 天的東西」)在單一 tool call 層級的縮影,也直接呼應第⑩節提到 Grafana Labs 那套框架裡的 fulfillment 訊號——「有沒有真的完成使用者要求的任務」,不是「呼叫有沒有回傳 200」。把這個檢查落成程式碼,大致長這樣:

from dataclasses import dataclass
from datetime import date


@dataclass
class ToolCallResult:
    syntactically_valid: bool
    semantically_valid: bool
    reason: str | None = None


def validate_delete_entries_call(
    *, cutoff_date: date, request_date: date, matched_count: int, total_count: int
) -> ToolCallResult:
    if cutoff_date > request_date:
        return ToolCallResult(
            syntactically_valid=True,
            semantically_valid=False,
            reason="cutoff_date 晚於今天,計算日期時很可能算反了方向",
        )

    if total_count > 0 and matched_count / total_count > 0.3:
        return ToolCallResult(
            syntactically_valid=True,
            semantically_valid=False,
            reason=f"單次刪除比例達 {matched_count / total_count:.0%},超過安全門檻,需要人工確認",
        )

    return ToolCallResult(syntactically_valid=True, semantically_valid=True)

這兩條規則本身不重要——換一個 tool 就要換一套規則,門檻設幾成也沒有標準答案。真正的重點是拆成兩個獨立欄位:syntactically_valid 回答「格式、型別、必要引數是否正確」,屬於傳統 API 驗證就能處理的範圍;semantically_valid 回答「就算格式全對,這個呼叫是不是使用者真正想要的事」,這一層需要對業務邏輯有假設,假設本身也可能出錯,需要根據場景調整。tool_success_ratio 這個 AI SLI 訊號,量的應該是後者。如果 dashboard 上的 tool_success_ratio 只統計「有沒有拋出 exception」,量到的其實只是語法正確率,很容易被誤讀成「工具呼叫有沒有做對事」。

insufficient_context 比例與safety block 比例放在一起講,是因為它們共享同一種特性:突然上升不一定是故障,可能是文件更新、使用者問題變了,或 guardrail 變嚴;它們需要 investigation,不應一律 paging。但兩者背後的處理邏輯不完全一樣——insufficient_context 上升,第一步通常是查 retrieval index 有沒有變化;safety block 上升,第一步通常是查 policy 設定或使用者輸入分布有沒有變化。把它們當成同一種「品質類訊號」統一丟進一個 ticket 佇列即可,但不要用同一套 runbook 去查,會浪費時間。

safety block 不該只是一個布林值

前面把 safety_blocked 當成 Outcome 五種值之一,寫法上是一個枚舉。

這在最小版本裡沒問題。

但實務上,「被 guardrail 攔下來」這件事,本身也分好幾種嚴重程度,不是只有「擋下」跟「沒擋下」兩種狀態。

一個請求可能只是踩到「語氣稍嫌尖銳」這種軟性規則,也可能是真的在嘗試 prompt injection 或索取敏感資料。

如果兩者在事件契約裡都只記成同一個 safety_blocked,值班工程師看到這個比例上升時,沒有辦法從數字本身分辨「這是使用者在測試系統邊界」還是「這是有人在認真嘗試攻擊」。

比較有用的做法,是讓 safety_blocked 底下再帶一層分類:

分級 觸發情境舉例 建議的第一反應
policy_soft 語氣、格式類的軟性規則 通常不用管,觀察比例趨勢即可
policy_hard 涉及明確禁止的內容類別 定期抽樣確認 policy 設定是否符合預期
injection_suspected 輸入中出現典型 prompt injection 模式 需要安全團隊介入,與 rate limit/來源 IP 一起看
data_exfiltration_suspected 疑似嘗試誘導系統洩漏敏感資料 視同資安事件處理,不只是品質訊號

這張表格的分級不是唯一正解,每個團隊的 policy 定義不同,分級方式也會不同。

重要的是分級這個動作本身:把「品質類的軟性攔截」跟「疑似攻擊」分開記錄,safety block 比例 這個聚合數字才有機會在真正需要叫醒安全團隊時,被單獨挑出來看,而不是被大量無害的軟性攔截稀釋掉。

這也直接呼應第⑩節「該不該告警」的分類表:policy_soft 屬於「需要 investigation 的品質訊號」,injection_suspected、data_exfiltration_suspected 則更接近「立即影響使用者的技術故障」那一類,甚至該直接掛進資安事件的告警管線,而不是品質儀表板——同一個頂層的 safety_blocked outcome,底下卻可能對應到完全不同的處理速度,這正是只用一個布林值會抹平的落差。

落成程式碼,AskEvent 不需要為了這個分級改動 Outcome 這個頂層 literal,只要在 outcome 之外多帶一個獨立欄位:

SafetyTier = Literal[
    "not_applicable",
    "policy_soft",
    "policy_hard",
    "injection_suspected",
    "data_exfiltration_suspected",
]

outcome 只負責回答「這次請求最後落在哪一類」,safety_tier 負責回答「如果落在 safety_blocked,是哪一種嚴重程度」。兩者分開,而不是把嚴重程度硬編進 outcome 本身(例如新增 safety_blocked_hard 這種變體),是因為 outcome 已經身兼 metrics label、log 過濾條件與⑫節驗收清單的檢查對象,值域一直長大會讓每處用到它的地方都要跟著改。安全團隊需要細看 safety_tier,品質儀表板則只要知道 outcome == "safety_blocked" 就夠了。

AI SLI 訊號 回答什麼問題 突然變化最可能的兩種原因
TTFT 系統多快開始回應 queue 壅塞/provider 排隊
tokens/sec 生成過程順不順 provider 流量調度/輸出變長
retrieval hit rate 找不找得到相關文件 索引損壞/使用者問題分布改變
citation-valid ratio 引用是否真實可查證 生成端幻覺/驗證邏輯漏洞
tool success ratio 工具呼叫是否確實達成目的 上游 API 變更/引數推理錯誤
insufficient_context 比例 系統誠實承認不知道的頻率 文件覆蓋不足/新話題出現
safety block 比例 guardrail 攔截的頻率 policy 調整/攻擊性輸入增加

這張表最重要的不是背下每一格,而是記住最右欄那個習慣:任何一個訊號變化,先問「是我的系統壞了,還是外部世界(使用者、文件、policy)變了」,兩者需要完全不同的應對。

技術成功不保證語意成功

但這裡有個深層的陷阱:一個 AI agent 可以返回完全合法的 HTTP 200、格式完美的 JSON response、通過所有 schema validation,同時在語義上完全失敗。例如,一個庫存管理代理被指示「清理過期條目」,它理解成「刪除任何超過 30 天的東西」,結果刪掉了 40% 的有效商品——技術層完全成功,業務層完全災難。傳統監控無法捕捉這類故障,因為沒有 error、沒有 exception、沒有 timeout,只有一個看起來很專業的錯誤答案。這類「semantic failure」只會透過使用者行為(頻繁重試、放棄、投訴)間接洩露,但到那時已經太晚了。

值得停下來想的是,這個庫存代理的每一個技術決策點都「正確」地執行了:它理解了指令、選對了工具、呼叫的參數格式無誤、資料庫也乖乖執行了刪除——問題出在最上游的意圖理解,一個字面上合理但語意上錯誤的解讀,順著整條正確的技術鏈路一路執行到底。這正是為什麼 System SLI 與 AI SLI 必須分開量測而不能互相替代:System SLI 檢查的是「這條鏈路有沒有正確執行」,AI SLI(特別是後面 Day 19 要談的 quality/task success)檢查的是「這條鏈路執行的,是不是使用者真正想要的事」。一個系統可以在前者滿分、後者掛零。

System SLI 綠燈
  ├── HTTP 200
  ├── schema valid
  ├── 沒有 exception
  └── latency 正常
          ↓
     使用者體驗
          ↓
       ???

這張圖刻意留白,因為 System SLI 的箭頭終點,其實無法保證通往「使用者滿意」。中間那段空白,正是 AI SLI 要負責填的部分,也是 Day 19 quality/task success 評估要處理的範圍。

e2e = queue delay + retrieval + TTFT + generation + tool time

這不是永遠精確的等式;它是切 span 的提醒。沒有共同的 request_id 和 trace context,拆解只會變成猜謎。

③ 今日 DIY:定義 span 與 event contract

為一個 /ask 定義下列時間點:request_received、retrieval_done、first_token、generation_done、tool_done、response_sent。計算相鄰差值,並把缺漏狀況寫成 null,不要補零。

{"request_id":"demo-1","ttft_ms":null,"tool_ms":120,"outcome":"completed"}

若沒有 streaming,TTFT 可暫時未量測;誠實的 null 比假裝 0 ms 有用。

這裡先用一行 JSON 而不是完整的 dataclass,是刻意的順序安排:在還沒決定欄位命名、還沒決定 outcome 分類之前,先用最小的資料結構把「有些欄位量不到」這件事逼自己面對一次。等這個最小版本通過第一輪自我檢查(system 與 AI 訊號分開、時間欄位成對出現、缺漏誠實標 null),後面第⑥節才展開成有明確型別、有版本、有測試覆蓋的完整契約。跳過這一步直接寫完整版本,容易把「這個欄位該叫什麼名字」和「這個欄位該不該存在」這兩個決定混在一起做,反而更難收斂。

驗收

  • system 與 AI SLI 分開列出。
  • 每個時間欄位有起訖事件。
  • 缺少量測不被當成 0。
  • 本文沒有取得任何實際 application 指標。

若想更進一步,把這個最小版本跑成一個真的可以執行的專案,本文對應的 Day18/DIY/ 目錄提供完整實作:AskTiming、AskEvent 兩個 dataclass、一支可直接跑的 FastAPI server,以及涵蓋正常、queue 壅塞、無文件可答、provider timeout、非串流四種情境的 demo script。不想跑程式碼的讀者,接下來第⑥、⑦節會直接把這份實作的關鍵片段連同解說一起呈現在文章裡,不需要另外打開專案也能看懂它在驗證什麼。

④ 兩層 SLI 要合看,不是二選一

AI 的觀測不是多幾個 token 圖表,而是把等待與錯誤切到能做決策的位置。接下來把這句話落成可以實作的事件定義。

只挑一層來看,會分別漏掉不同的問題:

只看 System SLI
  API 通、latency 正常、沒有 5xx
  +
  庫存被錯誤地清空 40%
  =
  儀表板全綠,事故仍在發生

只看 AI SLI
  citation-valid ratio 正常、retrieval hit rate 正常
  +
  queue 已經塞爆、worker 全部飽和
  =
  「回答品質」看起來沒事,使用者卻連請求都送不進去

前者是本文開頭已經談過的 semantic failure;後者則是反過來的情況——只盯著品質訊號,會對正在發生的容量問題視而不見,因為 AI SLI 完全沒有涵蓋「請求進不進得來」這件事。這正是為什麼標題要強調「合看,不是二選一」:兩層訊號各自守住一塊系統可能出事的範圍,任何一層單獨拿掉,都會留下一片沒人看的死角。

兩層訊號要怎麼放進同一張 dashboard,而不是兩張互不相干的圖

實務上常見的錯誤配置,是把 System SLI 放在 SRE 團隊的 Grafana,把 AI SLI 放在資料科學團隊的內部筆記本或另一個工具,兩邊各自有自己的告警規則,卻沒有人負責看兩者的交集。這樣的分工在日常運作時看起來沒什麼問題——直到某次 incident 發生時,兩邊同時開會、各自報告「我這邊正常」,卻沒人能回答「所以使用者到底發生了什麼事」。

比較可行的做法,是讓兩層訊號共用同一個 request_id,即使它們存放在不同系統(System SLI 進 Prometheus,AI SLI 的細節進另一個 evaluation pipeline),只要 request_id 能串起來,事故發生時就能從任何一邊出發查到另一邊。這也是為什麼本文第⑥節要花篇幅講事件契約——request_id 不是為了好看而加的欄位,是讓兩層訊號在需要合看的那一刻真的能合起來看的關鍵。沒有這個共同鍵,「合看」永遠只能停留在「兩個人同時打開兩個分頁」的層次,做不到真正的關聯查詢。

從「兩個人同時打開兩個分頁」到「一次查詢串起兩層」,實際會怎麼查

這句話講起來抽象,落成實際查詢動作,大致是這樣的順序。先在 System SLI 那邊(Prometheus)發現異常:

histogram_quantile(
  0.95,
  sum(rate(ask_request_duration_seconds_bucket{route="/ask"}[5m])) by (le, outcome)
)

這條 PromQL 找出 /ask 這條 route 依 outcome 分組的 P95 latency,如果看到 outcome="completed" 這一組的 P95 也在飆,代表不是單純的失敗變多,是連「正常完成」的請求都在變慢——這時候需要往下一層查,而不是只看 error rate。找到異常發生的時間窗口之後,切到 log 系統(Loki)用同一個時間範圍撈出這段期間的完整事件記錄:

{app="ai-workflow"} | json
  | outcome="completed"
  | end_to_end_ms > 5000
  | line_format "{{.request_id}} queue={{.queue_delay_ms}} retrieval={{.retrieval_ms}} gen={{.generation_ms}}"

這條 LogQL 篩出「outcome 正常但 end-to-end 超過 5 秒」的請求,直接把每筆請求的 queue_delay_ms、retrieval_ms、generation_ms 並排印出來——這一步做的事,正是第⑤節那張「區段對照表」在實務上的體現:不是憑印象猜是哪一段變慢,而是把每一段的實際數字攤開來比。如果撈出來的十幾筆 log 裡,queue_delay_ms 都在正常範圍,但 retrieval_ms 系統性地偏高,這時候才有把握說「這次不是 admission 層的問題,去查 retrieval」,而不是兩邊各自开著儀表板猜。

這個查詢順序(先用 metrics 定位「什麼時候、哪一組」異常,再用 log 或 trace 定位「哪一段、哪些請求」異常)本身也呼應第⑧節那句話:「這個分層也讓 incident 調查有合理入口:先用 metrics 找到何時與哪種 outcome 變壞,再以 trace 和 log 找到單一失敗路徑」。兩層 SLI 要合看,最終落地的方式往往就是這種「metrics 定範圍、log 或 trace 定細節」的兩段式查詢,request_id 則是讓第二段查詢的結果,能夠反過來跟第一段的告警對上號的那把鑰匙。

⑤ 先定義「一次回答」到底從哪裡開始、在哪裡結束

Day 17 的 FastAPI SLI 有一個刻意保留的問題:latency_ms 很容易寫,卻不一定說得清楚。

對非串流 API,使用者送出 request,伺服器回傳 JSON,請求結束,邊界相對單純。

對 LLM application,使用者可能先等排隊、再等文件檢索、再等模型吐出第一個 token,然後看著答案一段段出現。

如果後面還有 tool call,畫面可能已經開始輸出,workflow 卻還沒真正完成。

所以,報表上的「latency」一定要附定義。

以下是今天建議固定下來的時間點。

事件 記錄時機 用途 不可偷換成
request_received API 收到請求 使用者端的起點 worker 真正開始執行的時間
queue_started 請求進入 queue 排隊範圍起點 模型開始推論時間
workflow_started worker 取得工作 workflow 範圍起點 HTTP 接收時間
retrieval_started 查詢 knowledge base 前 檢索耗時起點 embedding 建立完成時間
retrieval_finished 得到文件或確定無結果 檢索耗時終點 quality 通過時間
model_started 準備送出 model request provider 等待範圍起點 第一個 token 時間
first_token 應用程式收到或送出第一個可見 token TTFT 終點 response 完成時間
generation_finished 模型輸出完成 generation 終點 tool 執行完成時間
tool_finished 最後一個必要 tool 結束 tool latency 終點 answer 已送到 client
response_sent 最終 response 送出 end-to-end 終點 使用者已看完內容

這些名稱只是範例;團隊可換成自己的 naming convention。

不能換的是語意。

例如把 workflow_started 當成 request_received,會把 queue delay 從報表裡消失。

把 generation_finished 當成 response_sent,又會漏掉 parser、validator 或 tool 的時間。

儀表板只會照定義聚合資料;定義含糊時,圖表就無法回答問題。

三個延遲數字,回答三個不同問題

先不要急著選 P95 或 P99。

先確認每個延遲在回答什麼。

queue_delay_ms
  = workflow_started - request_received

ttft_ms
  = first_token - request_received

generation_ms
  = generation_finished - model_started

end_to_end_ms
  = response_sent - request_received

對串流回應,ttft_ms 通常更接近「使用者什麼時候覺得系統有反應」。

end_to_end_ms 則回答「這件事什麼時候真的收尾」。

兩個數字都需要,但不能互相替代。

假設兩個版本的結果如下:

版本 P95 TTFT P95 end-to-end 使用者可能的感受
A 700 ms 11 s 很快開始講,但答案很長
B 4.8 s 6 s 先沉默很久,之後很快結束

如果產品是聊天介面,版本 A 未必比較糟。

如果產品是後台批次摘要,版本 B 的 TTFT 幾乎沒有意義。

先看使用者旅程,再選 SLI;這正是 Day 16 到 Day 17 一直在做的事。

為什麼是 P95,不是平均值

前面的表格已經用「A 版本 P95 TTFT 700ms」這種寫法,卻還沒解釋為什麼選百分位數,而不是更直覺的平均值。這裡值得停下來說清楚,因為它是整份事件契約選擇「保留分布」而不是「只留一個數字」的根本原因。

平均值有一個致命的性質:它會被少數極端值拉走,卻又同時把這些極端值的存在感抹平。假設 100 個請求裡,99 個花 200ms,1 個花 20 秒,平均值是 (99×200 + 20000) / 100 ≈ 398ms——這個數字比「典型使用者的體驗」(200ms)高出快一倍,卻又遠遠不足以讓人警覺「有人真的等了 20 秒」。平均值同時做壞了兩件事:既沒有代表大多數人的體驗,也沒有揭露少數人的痛苦。

100 個請求:99 個 200ms + 1 個 20000ms

平均值   ≈ 398ms   ← 兩邊都不像
P50      = 200ms   ← 代表典型使用者
P99      = 20000ms ← 代表運氣最差的那個人

百分位數不做這種混合:P50(中位數)誠實地代表「一半使用者的體驗比這個好,一半比這個差」;P95、P99 誠實地代表「最不走運的那一小撮人正在經歷什麼」。選 P95 還是 P99,取決於團隊願意為多少比例的使用者的體驗負責——P95 意味著接受最慢的 5% 暫時不在關注範圍內,P99 把這個範圍收窄到 1%。對一個每天十萬次請求的服務,P99 仍然代表著一千次真實發生、被某個真人經歷過的慢請求,不是理論上的邊角案例。

常見的誤解是以為「P95 已經很嚴格了,那 P99 是不是更好」,於是不假思索把所有指標都换成 P99。這裡的取捨其實跟樣本量與告警穩定性有關:流量越小,P99 的統計雜訊越大——如果一個 route 一分鐘只有 20 個請求,P99 對應的其實是「這 20 個請求裡最慢的那一個」,這個數字每分鐘都可能因為單一離群值而劇烈跳動,反而不適合拿來做告警判準;P95 對應「最慢的那一個」,同樣的樣本量下,用 P95(也就是第 19 名)作為告警依據,會比用 P99(第 20 名,等於直接盯著唯一一個離群值)穩定得多。流量大的 route 才適合用更嚴格的百分位數,這是選擇百分位數時經常被忽略的前提。

儀表板上的 P95,不一定是使用者感受到的 P95

寫到這裡容易有個錯覺:只要把時間點記對、算出 P95,這個數字就可信。

不一定。

Dan Luu 那篇業界反覆引用的〈Some latency measurement pitfalls〉整理過幾組真實數字,落差刺眼。某系統在伺服器端量到的 P99 latency 大約 16 毫秒,但站在 client 端實際量到的 P99 卻高達約 240 毫秒,差了十五倍。差距不是量測誤差,而是請求還要經過 proxy、作業系統網路堆疊、跨機房連線;這些排隊與轉送時間完全不在應用程式自己計時的範圍內。放回今天的情境,server_first_token_ms 量到的是應用程式收到或送出第一個 token 的當下,不是瀏覽器真正把字畫出來的當下。兩者之間還隔著 client 端的 render、可能的 buffering,以及一段網路。

叢集層級的聚合方式也會騙人。假設一百台機器裡只有一台延遲飆高十倍,直接把全部機器的延遲取平均,整體平均只會上升不到一成,真正出問題的那台機器被其他九十九台稀釋掉了。這呼應前面一再強調的「不要把 span 的 duration 全部相加,再拿去和 request duration 比較」。加總與平均都是在壓縮資訊,壓縮就會丟訊號,異常訊號往往丟得最快。

時間解析度是同一類陷阱的另一種樣子。文章舉的例子是一個 cache 服務,以「每分鐘」為單位聚合回報的 P99 latency 只有 0.37 毫秒,但同一段時間如果直接用 client 端逐筆量測,P99 其實逼近 580 毫秒,相差超過三個數量級。只有把統計窗口切細到分鐘以下,才看得到真正發生過的尖峰與級聯效應,用分鐘聚合看,尖峰早被磨平了。

三個陷阱指向同一個教訓:server_first_token_ms 要誠實標成伺服器端量測,不要假裝它是使用者已經看到的時間;跨節點或跨分鐘的聚合要保留分布(histogram、trace),而不是只留一個平均數或一個粗粒度的百分位數。這也是為什麼前面的事件契約要把每一段時間拆成獨立欄位,拆得細,才有機會在事後把這種落差找回來。

把「延遲」拆成可交接的責任

下面這個公式方便診斷,但不是拿來做精確加總的財報:

end_to_end
≈ admission + queue + retrieval + provider_wait
  + generation + tool + validation + transport

各段可能重疊。

例如 retrieval 可以與 prompt template 載入並行;streaming 時 transport 也與 generation 重疊。

因此不要把 span 的 duration 全部相加,再和 request duration 比較後宣布 tracing 壞掉。

比較實用的做法是:每段都有 owner 與下一步。

區段 異常時先看誰 第一個問題
admission / queue API、queue、worker owner 是流量變多,還是 worker 變少?
retrieval search / data owner 文件缺失、索引延遲,還是查詢變慢?
provider wait model provider / platform owner provider 變慢,還是 retry 放大?
generation model / runtime owner 模型、max token、context,哪一項變了?
tool tool owner tool 失敗、慢,還是 policy 拒絕?
validation application owner validator 變嚴,還是真的輸出退化?

這張表最大的價值不是分責任,而是避免值班的人在凌晨看到「P95 變慢」後只能打開十個 dashboard 猜。

retrieval 與 tool 為什麼特別容易在尾端說謊

上面的表格把 retrieval 和 tool 各自列成一個區段,好像它們就是一段單純的等待時間。實際上,這兩段經常不是「打一次就結束」,而是內部再扇出(fan-out)成好幾個平行子請求:retrieval 可能同時查詢多個向量索引 shard 再合併結果;tool 可能同時呼叫多個外部 API(例如同時查天氣與查匯率)再組裝答案。只要是「平行發出多個子請求、等全部或大部分回來才算完成」的架構,就會踩進一個經典但常被低估的機率陷阱。

Google 的 Jeffrey Dean 與 Luiz André Barroso 在 2013 年一篇至今仍被廣泛引用的論文〈The Tail at Scale〉裡,用一個乾淨的算式說明這件事:假設一台伺服器平均回應時間是 10 毫秒,但它的 99th-percentile latency 是 1 秒——也就是每 100 個請求裡有 1 個會慢到 1 秒。如果一個使用者請求只碰到這一台伺服器,1% 的請求會慢;但如果請求要平行收集 100 台這種伺服器的回應(正是 retrieval 平行查多個 shard、tool 平行呼叫多個 API 的典型模式),會有高達 63% 的使用者請求慢過 1 秒——因為只要 100 台裡任何一台踩到那 1%,整個合併後的請求就得等它。就算把單機的慢請求機率壓到萬分之一,一個由 2,000 台這種伺服器組成的服務,仍有接近五分之一的使用者請求會超過 1 秒。

單一 leaf:       99% 機率在 10ms 內完成,1% 機率慢到 1s

扇出到 100 個 leaf、等全部回來:
  只要有 1 個 leaf 踩到那 1%,整體就慢
  P(至少 1 個踩到) = 1 - 0.99^100 ≈ 63%

論文同時引用一組來自真實 Google 服務的量測數字:在邏輯上類似的大型扇出架構裡,單一隨機請求量到的 99th-percentile latency 是 10ms;但「等所有 leaf 都回應完」的 99th-percentile latency 是 140ms;而「只等 95% 的 leaf 回應完,放棄最慢的那 5%」的 99th-percentile latency 是 70ms——換句話說,等待最慢的那 5% leaf,就佔掉了整體 99th-percentile latency 的一半。

這個結論放回今天的情境有兩個意涵。第一,retrieval_ms、tool_ms 若由多個平行子請求合併而成,個別子請求再快,只要數量夠多,整體 P95、P99 都會被最慢的那一小撮系統性拉高——這不是量測方法錯了(那是 Dan Luu 談的問題),而是扇出架構本身的機率結構造成的。第二,若真的需要平行扇出,值得認真考慮「不等最慢的那幾個」:retrieval 設合理的內部逾時、拿到夠多 shard 就先合併;非必要的平行 tool 呼叫也可評估是否要等到全部完成。這類取捨要跟產品需求一起討論,但至少要先知道,扇出本身在數學上就會讓尾端延遲比想像中更常出現。

⑥ 一筆 request 需要哪些欄位?先做事件契約,再做圖

圖表會換,metric 名稱會換,observability vendor 也可能換。

事件契約比較不容易換。

以下資料結構刻意使用 Python 標準函式庫;它不是完整 SDK,只是讓讀者先把欄位語意寫死。

這份事件契約在整條觀測管線裡站在哪個位置,值得先畫出來,免得後面的討論失去座標:

handler 執行過程
  │
  ▼
AskEvent(本節定義的結構)
  │
  ├──→ structured log(JSON,含全部欄位,含 prompt/答案等敏感內容)
  │       → Loki / 任何 log 後端 → 人工搜尋、單筆重建
  │
  ├──→ metrics(只挑低基數欄位:outcome、response_mode、route)
  │       → Prometheus → 告警、儀表板趨勢
  │
  └──→ trace span(時間點映射成 OpenTelemetry attribute)
          → Tempo / Jaeger → 跨服務關聯、瀑布圖

同一份 AskEvent,餵給三個完全不同的後端,各自只取用它需要的那一部分——這正是第⑧節「不要把所有欄位塞進 Prometheus label」背後的架構理由:不是欄位不重要,而是每個後端對「這個欄位適合承載什麼」有不同的物理限制,log 能承受高基數與大內容,metrics 不能。

為什麼用 dataclass,不是 Pydantic

熟悉 FastAPI 生態的讀者,可能會問為什麼不用 Pydantic BaseModel——畢竟 FastAPI 本身大量依賴它做 request/response validation。這裡的選擇是刻意的,不是疏忽。dataclasses 是標準函式庫的一部分,零額外依賴,讀者可以把今天這份契約直接複製進任何專案而不必先 uv add pydantic;它的職責也更單純——只負責「這個結構長什麼樣子」,不像 Pydantic 那樣預設就綁進了驗證、序列化格式、JSON schema 產生等一整套行為。

這不代表 Pydantic 不適合正式的 production 事件契約。當團隊需要對輸入資料做嚴格驗證(例如確保 outcome 真的只能是五個值之一,而不是型別提示層面的建議)、需要自動產生 JSON schema 供其他語言的服務消費,或已經在專案裡大量使用 Pydantic 做其他 model,改用 BaseModel 是合理的下一步。今天用 dataclass 只是為了讓「先把欄位語意定案」這件事,不必先跟任何框架的取捨綁在一起。

from __future__ import annotations

from dataclasses import asdict, dataclass, field
from time import perf_counter
from typing import Literal
from uuid import uuid4


Outcome = Literal[
    "completed",
    "technical_failed",
    "quality_failed",
    "safety_blocked",
    "insufficient_context",
]


@dataclass
class AskTiming:
    request_received: float
    workflow_started: float | None = None
    retrieval_started: float | None = None
    retrieval_finished: float | None = None
    model_started: float | None = None
    first_token: float | None = None
    generation_finished: float | None = None
    tool_finished: float | None = None
    response_sent: float | None = None

    def duration_ms(self, start: str, end: str) -> float | None:
        start_at = getattr(self, start)
        end_at = getattr(self, end)
        if start_at is None or end_at is None:
            return None
        return round((end_at - start_at) * 1000, 2)


@dataclass
class AskEvent:
    request_id: str
    trace_id: str
    workflow_name: str
    workflow_version: str
    model_name: str
    retrieval_index_version: str
    timing: AskTiming
    outcome: Outcome = "completed"
    technical_status: str = "unknown"
    workflow_status: str = "unknown"
    quality_status: str = "unknown"
    safety_status: str = "unknown"
    retrieval_count: int = 0
    tool_count: int = 0
    fallback_triggered: bool = False
    error_class: str | None = None
    notes: list[str] = field(default_factory=list)

    def to_log_record(self) -> dict[str, object]:
        record = asdict(self)
        record["queue_delay_ms"] = self.timing.duration_ms(
            "request_received", "workflow_started"
        )
        record["retrieval_ms"] = self.timing.duration_ms(
            "retrieval_started", "retrieval_finished"
        )
        record["ttft_ms"] = self.timing.duration_ms(
            "request_received", "first_token"
        )
        record["generation_ms"] = self.timing.duration_ms(
            "model_started", "generation_finished"
        )
        record["end_to_end_ms"] = self.timing.duration_ms(
            "request_received", "response_sent"
        )
        return record


def new_ask_event() -> AskEvent:
    now = perf_counter()
    return AskEvent(
        request_id=f"req_{uuid4().hex}",
        trace_id=f"trace_{uuid4().hex}",
        workflow_name="policy_qa",
        workflow_version="2026-09-18",
        model_name="configured-at-deploy-time",
        retrieval_index_version="policy-v1",
        timing=AskTiming(request_received=now),
    )

有幾個欄位值得停下來看。

request_id 關聯 logs、response 與支援工單。

trace_id 關聯每個 span。

兩者可以相同,但分開保留通常比較好:前者是應用程式可讀的 correlation key,後者遵守 tracing 系統的 trace context 格式。

workflow_version、model_name 與 retrieval_index_version 則回答「這次行為屬於哪個版本」。

沒有版本欄位的 latency 圖,容易把 deploy、prompt 改動與資料更新全部混成一條線。

null 不是失敗,也不是零

first_token 不存在時,ttft_ms 應該是 null。

它可能代表:

  • endpoint 不是 streaming;
  • 在取得第一個 token 前就超時;
  • provider 回傳完整內容,應用程式沒有 token callback;
  • instrumentation 尚未完成。

這四種情況不能都寫成 0。

零毫秒會被聚合系統當成極快 request,反而把 percentile 拉低。

也不能一概視為錯誤:非串流工作本來就沒有 TTFT。

正確做法是另外帶一個可判讀的欄位,例如 response_mode: "streaming" | "buffered",或以 workflow schema 區分。

文章的任何「TTFT 改善了 30%」都要同時交代樣本是否只含 streaming request。

若沒交代這個分母,百分比無從判讀。

這份契約會不會跟 OpenTelemetry 衝突?不會,它是前置練習

讀到這裡,熟悉 observability 的讀者可能會想:業界不是已經有 OpenTelemetry Semantic Conventions 這種標準了嗎,為什麼還要自己定義一套 AskEvent?

答案是:兩者處理的問題不完全一樣,自己先想清楚欄位語意,通常比直接套用標準更快看見成效。OpenTelemetry Semantic Conventions 規定的是「span 與 attribute 該叫什麼名字」,它假設你已經知道「這次回答有哪些階段、邊界在哪裡」;但這正是 Day 18 前半段在做的事——連 retrieval_started 該在什麼時候記都還沒共識,直接塞進標準欄位,只是把「定義不清楚」從自家 log 搬到 vendor 的 trace UI,問題並沒有解決。

比較務實的順序是:先用今天這種輕量的 dataclass 把事件語意寫死、跑過幾輪 incident 演練,確認欄位真的能回答值班工程師的問題;等語意穩定了,再把這份契約映射到 OpenTelemetry 的 span/attribute 上(例如 gen_ai.request.model 對應 model_name、一個自訂 span 的 start/end 對應 workflow_started/response_sent),讓資料能被既有的 tracing 後端(Tempo、Jaeger、Honeycomb)接手做視覺化與跨服務關聯。今天的 AskEvent 因此不是要取代 OpenTelemetry,而是它的前置練習:用一份團隊自己看得懂、改得動的最小結構,先把語意定案。

欄位契約要有版本,變更要能追溯

事件契約本身也是一種會演化的介面。今天定義的九個時間點、五種 outcome,隨著 workflow 增加新階段(例如未來加入一個 rerank 步驟),欄位一定會變。如果沒有明確的版本標記,舊資料與新資料混在同一張儀表板上,會出現一批 request 有 rerank_ms 欄位、一批沒有,分析時很容易誤判成「某個時間點之後 rerank 突然消失」,而不是「schema 換了」。

建議的做法很直接:為事件契約本身維護一個版本號(可獨立於 workflow_version),每次欄位新增、改名或語意變更就往上加一版;儀表板與告警規則可以明確過濾特定 schema 版本,或至少在圖表註記標出「此區間 schema 有變更」。這聽起來像多餘的儀式,卻能省下未來某次深夜 incident 排查時,先花十分鐘搞清楚「這批資料的欄位為什麼比較少」的時間。

這份契約還留了一個洞:token 數與 cost 通常不在這九個時間點裡

AskEvent 目前的九個時間點與五種 outcome,回答的是「這次回答快不快」「有沒有走完」,卻完全沒有回答第三個同樣重要的問題:「這次回答貴不貴」。這不是疏漏,是刻意延後——但延後太久,會漏掉一種本文④段已經預告過的儀表板全綠假象:latency 正常、outcome 是 completed、schema 也 valid,token 用量卻已經悄悄超出預算。

問題出在 token 數量本身的不均勻。同一個 workflow、同樣的 model_name、同樣落在 P50 latency 附近的兩次請求,prompt token 數可能差好幾倍——如果 retrieval 那一步因為 query 剛好命中大量文件、或使用者的對話歷史特別長,灌進 prompt 的 context 可能是平常的三到五倍。多數 model provider 的公開定價都把輸出(completion)token 訂得比輸入(prompt)token 貴上不只一截,這代表同一個 latency 數字,背後可能對應著完全不同量級的成本,而 latency 本身完全看不出這件事——一次「很快就回完」的請求,可能剛好是因為輸出很短,卻不代表它便宜;一次 retrieval 抓進大量文件的請求,即使模型也很快吐完,prompt 那一段的 token 帳單可能已經是平常的好幾倍。

延伸 AskEvent,把這個維度接進來,可以是這樣:

@dataclass
class AskCost:
    prompt_tokens: int | None = None
    completion_tokens: int | None = None
    cached_tokens: int = 0
    estimated_cost_usd: float | None = None

    def total_tokens(self) -> int | None:
        if self.prompt_tokens is None or self.completion_tokens is None:
            return None
        return self.prompt_tokens + self.completion_tokens

這裡刻意把 AskCost 獨立成另一個 dataclass,而不是直接塞進 AskEvent 的欄位列表,理由跟第⑥節開頭「為什麼用 dataclass 不是 Pydantic」是同一種考量:時間、結果、成本是三種不同的關注點,混在同一個扁平結構裡,日後想單獨對某一類欄位做驗證或版本管理時,會綁在一起沒辦法拆開。cached_tokens 特別值得獨立記錄——多數 provider 對「命中 prompt cache」的 token 給予顯著折扣,如果不單獨追蹤這個數字,estimated_cost_usd 的計算會系統性高估,事後也無法回答「這次省下的錢是因為 cache 命中,還是因為輸出變短」。

用一組假設數字把這個落差具體攤開來看。假設某次回答的 prompt_tokens 是 4000,其中 3000 是命中 cache 的部分、只有 1000 是全新輸入;completion_tokens 是 500。如果計算成本時忽略 cached_tokens 這個欄位,直接把整段 4000 個 prompt token 都當成全額計價:

忽略 cache 折扣:
  4000 × 全額單價 + 500 × 輸出單價 = 高估後的成本

正確算法(分開計價):
  1000 × 全額單價 + 3000 × cache 折扣單價 + 500 × 輸出單價 = 實際成本

差距不是幾個百分點的誤差,而是隨著 cache 命中率越高、差距越大——如果一個 workflow 大量重複使用同一段系統提示或固定參考文件(本文情境裡的 policy 文件正是典型案例),cached_tokens 佔 prompt_tokens 的比例可能長期維持在六到七成以上,忽略折扣算出的成本會系統性地比實際帳單高出一大截,容易讓團隊誤判「這個 workflow 太貴、要砍功能」,錯的其實是估算方法。這正是為什麼要把 cached_tokens 單獨拉出來當一個欄位,而不是讓它隱藏在 prompt_tokens 這個總數裡——一旦合併進總數,這筆折扣就永遠沒有機會在事後被找回來。

estimated_cost_usd 標成 estimated,不是隨便選的字。它通常由 token 數乘上當下生效的定價表算出來,而定價表本身會隨供應商調整、隨模型版本切換而改變——如果沒有把定價表版本也記下來(可以掛在 model_name 或前面提到的 schema 版本旁邊),事後想解釋「為什麼上個月的平均成本跟這個月對不起來」,會發現一半的差異來自用量、一半來自定價表換了,兩者混在一張圖裡無法拆開,跟本文一路強調「不要把不同來源的變化混進同一條線」是同一個教訓。

這個維度該進 metrics、log 還是 trace?回到⑧節那張表

前面⑧節那張「資料放哪裡」的表格裡,其實已經留了一行給這個維度:「token 數、cost estimate → counter / histogram / trace → 需明確單位與抽樣策略」,只是當時沒有展開細節,現在可以補上。

prompt_tokens、completion_tokens 這種每筆請求都會有、值域是連續數字的欄位,適合用 histogram 觀察分布(跟 latency 一樣,平均值會騙人——少數超大 context 的請求可以把平均值拉高,卻不代表典型請求都很貴);estimated_cost_usd 若要看「這個服務這個月燒了多少錢」,比較適合用 counter 累加,搭配 outcome、model_route 這種低基數 label 分組,而不是每筆都畫一條新線。

from prometheus_client import Counter, Histogram

ASK_PROMPT_TOKENS = Histogram(
    "ask_prompt_tokens",
    "Prompt token count per /ask request",
    labelnames=["model_route"],
    buckets=(256, 512, 1024, 2048, 4096, 8192, 16384),
)

ASK_ESTIMATED_COST_USD = Counter(
    "ask_estimated_cost_usd_total",
    "Cumulative estimated cost for /ask requests",
    labelnames=["model_route", "outcome"],
)


def record_cost_metrics(record: dict[str, object]) -> None:
    prompt_tokens = record.get("prompt_tokens")
    if prompt_tokens is not None:
        ASK_PROMPT_TOKENS.labels(model_route=record.get("model_route", "unknown")).observe(prompt_tokens)

    estimated_cost_usd = record.get("estimated_cost_usd")
    if estimated_cost_usd is not None:
        ASK_ESTIMATED_COST_USD.labels(
            model_route=record.get("model_route", "unknown"),
            outcome=record["outcome"],
        ).inc(estimated_cost_usd)

這裡刻意讓 estimated_cost_usd 沒有值時直接 return,跟前面 record_metrics() 對 end_to_end_ms 的處理邏輯一模一樣——量不到就不上報,不要用 0 假裝成本是零,否則平均成本會被系統性低估,跟前面「null 不是失敗,也不是零」的教訓完全同一種形狀,只是換了一個欄位。

這個維度接進來之後,判讀事故的框架也要多問一個問題:latency 正常、outcome 是 completed,estimated_cost_usd 卻明顯升高,可能代表 retrieval 悄悄灌入更多 context、對話歷史變長、或 prompt 改動讓模型輸出變得囉唆——這些都不是 technical failure,卻一樣需要有人去查:不需要 page,但要被排進日常檢視清單,等帳單月結才發現通常已經來不及。

下集預告

事件契約定義好,不代表工程已經完工。下篇(Day 18 下)會把這份 AskEvent 契約真的接進 FastAPI,處理 Prometheus label 的基數陷阱、決定哪些訊號該告警,以及 incident 發生時怎麼靠這組欄位縮小排查範圍。


這篇是 Learning SRE for the AI Era 系列的一部分。
Build → Trace → Break → Measure → Evaluate → Recover → Improve.


上一篇
Day 17(下)|好的 SLI 與 FastAPI SLO:量得到不代表該量
下一篇
Day 18(下)|LLM Application SLI:把「慢」拆到能修的位置
系列文
Learning SRE for the AI Era:從 SRE Lab 到 Production AI Reliability 共 44 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言