結論先說:平均 latency 能掩蓋少數非常慢的請求;互動服務要同時看分布、P50、P95 或 P99,才能看見排隊和下游抖動。
九十五個請求各 100 ms,五個各 10 秒,平均約 595 ms。看起來勉強可以,實際上有五個使用者已經以為網路壞掉。percentile 不是魔法,但它比平均值更接近「最慢那群人正在等多久」。
Google SRE Book 用過一個經典例子:「如果一個網路服務的平均 latency 是 100 ms,每秒 1000 個請求,那 1% 的請求很容易就需要 5 秒。」簡單算術跟現實體驗差了一大截。再往下想,在分散式系統中,當使用者的請求需要觸及 100 個後端服務時,某個後端的 P99 會直接變成使用者感知的中位數(median),服務層疊得越多,尾端延遲越容易被放大。
可以想像一個五層微服務架構的線上購物平台(catalog、pricing、inventory、reviews、recommendations),每個服務的平均延遲都在 80ms 以下,乍看非常健康。產品頁面平行呼叫這五個服務彙整資料,dashboard 上顯示的中位數延遲穩定在 120ms。但使用者的抱怨卻越來越多,反映頁面經常「卡頓」。工程師研究了好一陣子才發現,團隊只關注了 P50 與平均值,完全忽略了 P99。等他們把 P99 分布匯出後,才看清全貌:每個服務的 P99 落在 40–125ms 之間。在五個服務的平行呼叫中,整體端到端 P99 不是簡單相加(5×100ms≠500ms),而是「五個慢請求同時發生的機率」。粗估下來,約 2–3% 的使用者會同時遇到至少一個服務落在 P99 級別的延遲,導致頁面載入從平均的 120ms 膨脹到 800–1200ms。團隊一直在優化單一服務的平均值,卻沒看見 fanout 架構會把各層的尾端延遲「相乘」——這也是為什麼改善單一服務的平均值無法解決使用者的抱怨。
轉折點在於團隊開始把 P50 跟 P99 並排放在 dashboard 上看,而不是只留一條平均值的線。落差立刻現形:P50 依然穩定在 120ms 附近,P99 卻在 800-1200ms 之間跳動——多數請求沒事,少數請求的體驗完全走樣。問題也從「為什麼平均值已經很漂亮,使用者還在抱怨」變成「哪幾個服務在拖後腿」。從「看一條線」變成「看整個分布」,是排查尾端延遲最基本、也最容易被忽略的第一步,本文接下來會反覆用到這個切換。
先把直覺講清楚,再進技術細節。一間餐廳,一百個客人裡九十五個吃得很滿意,老闆看報表滿意度 95%,覺得應該值得慶祝。但剩下的五個客人等了整整一小時才上菜——他們不會因為「隔壁桌很快」就覺得自己被善待,只會記得自己等了一小時。
線上服務也是同一種邏輯。平均值站在老闆的角度:整體資源花費多少、系統整體是否健康;percentile 站在客人的角度:我這一次,落在哪個等待區間。這兩個角度都對,但回答的是不同問題。監控系統如果只裝了老闆視角的儀表板,值班工程師會一直以為「一切都好」,直到客訴湧進來,才發現儀表板從來沒有問過「最慢的那一群人過得如何」——這就是為什麼互動服務的 latency 監控,從第一天就不該只有一條平均值的線。
在一段明確時間窗內,把有效請求由快到慢排序,P95 是其中 95% 不超過的值。要寫清楚 window、request 類型與資料來源;把 background job、streaming response 和互動 API 混算,分位數一樣會說謊。
把「排序取第 95% 位置」講白話一點:100 筆請求由快到慢排好隊,P95 大致是排在第 95 個人那裡的數字,代表這批樣本裡 95% 的請求都比它快(或一樣快)。但「大致」兩個字藏著陷阱——排名要落在哪一筆,業界至少有兩種常見算法:
nearest-rank:直接取排序後第 ceil(0.95 × N) 筆的值,簡單但在樣本數少時跳動明顯
linear interpolation:在第 95% 對應的位置附近,用相鄰兩筆做線性內插,數字更平滑
這聽起來像是統計課本裡的枝微末節,但在實務上,它直接決定了你能不能拿別人系統的 P95 跟自己的 P95 做有意義的比較。
用一組實際數字感受這個差異:假設 10 筆已排序的延遲樣本是 100, 105, 110, 120, 130, 150, 200, 400, 800, 2000(單位 ms)。
nearest-rank:
ceil(0.95 × 10) = ceil(9.5) = 10 → 取第 10 筆 = 2000ms
linear interpolation(如 numpy 預設的 linear 方法):
rank = 0.95 × (10 − 1) = 8.55
介於第 9 筆(800ms)與第 10 筆(2000ms)之間
P95 ≈ 800 + 0.55 × (2000 − 800) = 800 + 660 = 1460ms
同一份 10 筆資料,nearest-rank 給出 2000ms,linear interpolation 給出約 1460ms——差了超過 500ms。
樣本數越少,這種算法差異造成的落差越明顯;樣本數夠大時(例如上千筆),不同算法算出來的 P95 通常會非常接近。
同一份資料丟進 Grafana、Datadog、手寫的 numpy.percentile(),三個工具可能給出三個略有差異的數字,原因往往不是誰算錯了,而是內插方法不同。這不影響「看見尾端」這件事的方向,但如果要把某個百分位數字寫進 SLA 或拿去跟另一個系統比較,先確認雙方用的是同一種計算方式,否則兩個「P95」其實不是同一種東西。
更根本的一點是:percentile 是對「已經發生的樣本」做的描述性統計,不是對未來的保證。「這一小時的 P95 是 800ms」說的是過去這一小時裡已完成的請求裡 95% 落在 800ms 以下;它不承諾下一分鐘的請求也會如此——流量模式、下游健康度、甚至 GC 時機都可能在下一刻改變分布。把 P95 直接當成「95% 的使用者都會得到這個等級的服務」的承諾,是把描述性統計誤讀成了服務等級協議(SLA)。真正的 SLA 要另外定義觀測窗、更新頻率與違約條件,percentile 只是餵給那份 SLA 的原始材料之一。
Google SRE Book 講監控哲學時舉過一個更極端的例子:一個系統奇數秒服務 200 req/sec、偶數秒完全服務 0 req/sec,平均下來是 100 req/sec,看起來像穩定的中等流量。真實情況是它每秒都在「全速」與「停擺」間切換,瞬間負載是平均值的兩倍,一半時間根本沒在做事——對容量規劃、佇列深度的意義,跟「穩定的 100 req/sec」完全不同。
同樣的邏輯套進 latency:一半請求 20ms、一半 2000ms,平均會落在 1000ms 附近,但沒有任何一筆請求真的落在 1000ms 附近,這個數字誰的體驗都不代表。平均值對極端值「不夠敏感又太敏感」——會被拉走,卻又不夠敏感到能被單獨看出來——這正是它在監控上最危險的地方。這也是為什麼 Prometheus 的 Histogram 型別存在:它記的是「每個桶裡各有多少筆」,把分布的形狀保留下來,而不是壓扁成一個數字(本文 ⑥ 段會動手做這件事)。
寫文章時常把這幾個詞隨口互換,但它們不是同一件事。
mean(平均值):把所有數字加起來除以個數,容易被極端值拉走。
median(中位數):把數字排序後取最中間那一個,其實就是 P50,不容易被極端值拉走。
percentile(百分位數):把「取中間」推廣成「取任意位置」,P50 是 median 的另一種說法,P95、P99 是往尾端移動的版本。
用一組數字具體感受差異:
樣本:10, 12, 11, 13, 10, 12, 11, 9, 10, 500
mean = (10+12+11+13+10+12+11+9+10+500) / 10 = 59.8
median = 排序後第 5、6 名取平均 = (11+11) / 2 = 11
P95 = 排序後第 9.5 名附近 = 接近 13(依內插方法略有不同)
一個離群值(500)就能把 mean 從「大約 11」拉到「將近 60」。
median 完全不受影響,因為它只看排序後的位置,不看數值本身有多極端。
這也是為什麼很多監控實務會建議:quick health check 用 median 或 P50,觀察尾端才用 P95、P99——用不同的統計量分工,而不是全部塞進同一條線。
把以下概念加入你自己的測試 endpoint;不要在未知 production endpoint 加 sleep。
import time
def simulated_work(request_number: int) -> None:
if request_number % 20 == 0:
time.sleep(2)
送出至少 20 個受控請求,記錄每筆 latency,離線排序後比較 mean、P50、P95。若只跑 20 筆,percentile 粗糙是預期的;重點是看見同一份資料能同時有漂亮平均值和難看的尾端。
這裡故意用 request_number % 20 == 0 這種固定週期,而不是 random.random() < 0.05——長期比例一樣是 5%,但固定週期能重跑:你可以先預測「這次應該有 1 筆慢請求」,再驗證量測鏈路有沒有抓到它,抓到的數量不對就知道是量測出了問題,而不是運氣不好。這是故障注入的一個基本原則:先讓故障可預測,才能驗證偵測機制;隨機化留到你已經信任偵測機制之後,用來測試不可預期條件下系統會不會被誤導。
5% 這個數字本身也只是為了讓例子好算、好觀察,不是業界基準——多少比例的慢請求算異常,取決於你的 request class 與使用者容忍度(呼應 Day14 定義 SLO 時強調的「使用者能接受的等待是多久」)。今天的練習目的是讓你看得見 5% 慢請求對平均值和 P95 的影響幅度,警戒線該設在哪,得回到你自己的 SLO spec 去定義。
第一個坑是把「注入延遲的耗時」和「呼叫端量測的耗時」混在一起算。如果你是用 shell script 跑迴圈送 20 次 curl,記得量測目標是伺服器回應每一筆的 latency(例如從 response body 或伺服器端 log 讀出來),而不是整個迴圈跑完的總時間——後者混進了你自己 shell 迴圈的開銷、DNS 快取效應、以及 curl 進程啟動的成本,這些雜訊會讓「5% 慢請求」的訊號變得不清楚。
為了讓「量測目標」這句話更具體,實際去看要記錄哪一個時間點:如果用 shell script 呼叫 curl,curl 本身可以用 -w 參數印出伺服器實際回應的時間分解(例如 time_starttransfer),這比整個迴圈的 wall-clock 時間更接近伺服器端真正的處理耗時,值得先熟悉這個小技巧,再開始收集資料。
第二個坑是樣本數太少導致 P95 本身失去意義。20 筆資料的第 95 百分位,用最直觀的算法大概落在排序後第 19 筆附近——這代表你的「P95」實際上高度依賴那唯一一筆是不是剛好踩到慢請求。如果 20 筆裡剛好 0 筆是慢的,P95 會看起來完全正常;如果剛好 2 筆是慢的,P95 可能直接跳到 2 秒。這不是程式錯誤,而是小樣本 percentile 天生的抖動——這也是為什麼 ⑤ 段會特別強調「先確認你量到的是分布」,樣本數不夠時,percentile 本身就是一個不穩定的量測工具。
第三個坑是忘記把「注入延遲」跟「原本業務邏輯的耗時」分開看。如果你的測試 endpoint 本身還會做其他事情(例如呼叫真的 LLM API),慢請求的耗時會是「業務邏輯耗時 + 注入的 2 秒延遲」,而不是單純的 2 秒。這在解讀結果時容易誤判——你可能以為是注入延遲造成了尾端,但其實原本業務邏輯本身的變異就已經不小。乾淨的作法是先在一個什麼都不做、只回傳固定字串的 endpoint 上跑這個實驗,確認你能看懂注入延遲的訊號長什麼樣子,再套到有真實業務邏輯的 endpoint 上。
前言那個五層微服務的例子講了結論,這裡把算術補完整。假設每個後端獨立運作,各自的 P99 代表「這個服務有 1% 的請求落在慢速區間」。當一次使用者請求需要平行呼叫 n 個這樣的後端,只要其中任何一個踩到自己的 P99,使用者感受到的就是慢請求。用機率算「至少一個服務變慢」的機率:
P(至少一個服務落在自己的 P99 級距)
= 1 − P(全部 n 個服務都沒有落在 P99 級距)
= 1 − (0.99)^n
n = 5 時,1 − 0.99^5 ≈ 4.9%;n = 20 時,1 − 0.99^20 ≈ 18.2%;n = 100 時,1 − 0.99^100 ≈ 63.4%。
把這三個數字對照回開頭的五層微服務案例:n=5 算出來的 4.9%,跟文中「2-3% 使用者同時撞上至少一個服務 P99」的估算量級一致(真實情境裡各服務的 P99 機率不完全是獨立的 1%,也不會剛好都是 99%,實際比例會因相關性與各服務個別的可靠度而上下浮動,但整體量級吻合)。
這個對照的重點不是要讀者去驗算小數點後幾位是否精確,而是讓抽象的機率公式,跟前面讀過的具體案例對上號——公式不是憑空出現的,它就是在解釋為什麼那個案例會長成那個樣子。這正是《The Tail at Scale》裡那句話的算術依據:「當使用者的請求需要觸及 100 個後端服務時,某個後端的 P99 會直接變成使用者感知的中位數(median)」——不是誇飾,100 個後端時,幾乎三分之二的使用者請求都會撞到至少一個 P99 級的延遲。服務層疊得越多、平行呼叫的 fanout 越寬,這個機率爬升得越快,而且是非線性的。
用一張簡單的曲線感受這個非線性:
fanout 數量(n) "至少一個服務踩到自己 P99" 的機率
1 1%
5 4.9%
10 9.6%
20 18.2%
50 39.5%
100 63.4%
從 1 到 10,機率只成長了不到 10 個百分點;從 20 到 100,機率卻暴增超過 45 個百分點。
這條曲線一開始爬得慢、後段爬得極快,正是微服務架構越拆越細、依賴越串越多時,尾端延遲問題會突然「變得很嚴重」而不是「緩慢變差」的數學原因。這也是為什麼優化單一服務的「平均值」對使用者體驗幫助有限:使用者的體驗由「最慢的那個環節」決定,不是由「大部分環節的平均表現」決定。
Google 論文「The Tail at Scale」(Dean & Barroso, 2013)提出的「hedged requests」技術,示範了工業級系統怎麼應對 tail latency:前面的請求還沒回應時(例如等到某個 percentile 的時間點,而不是一開始就重複發送),同時把同一請求送去另一個副本,取先到的那個回應、取消還沒回來的那個。把「賽跑」這個比喻再拆開一點:它不是同時鳴槍讓兩個選手從頭跑到尾,而是讓第一個選手先跑,只有在他跑得比預期慢的時候,才臨時加派第二個選手上場,誰先抵達終點就用誰的成績,還在跑的那個直接被叫停。
這個「先等、再補打」的設計,是整個技巧的關鍵,如果一開始就無條件雙打,成本會直接翻倍,而不是論文裡的 2%。
這個技術之所以有效,是因為尾端延遲的成因大多是 stragglers——不是後端整體變慢,而是少數請求撞上短暫的資源競爭、GC 暫停或網路波動,屬於偶發、局部、彼此獨立的事件。既然是獨立事件,同一份請求打到兩個副本,兩者「同時」踩到慢速的機率遠低於任一副本單獨踩到的機率,這也是為什麼只需要多打一小部分流量(實驗中約多 2% 的後端負荷),就能把 BigTable 查詢的 P99 從 1,800 ms 壓到 74 ms。尾端延遲不是「無可奈何只能接受的特性」,而是值得投資優化的工程問題;但它的前提假設是「重複發送是冪等、安全、不會產生副作用」,對會寫入資料或呼叫外部 API 扣款的 AI workflow step,直接照搬 hedged requests 之前要先確認這件事。
Discord 後來把歷史訊息儲存從 Cassandra(Java,P99 浮動在 40–125 ms)換成 ScyllaDB(C++,P99 穩定在 15 ms),發現垃圾回收暫停才是藏在底下的尾端延遲根源,全端都得盯著。
以上兩個案例都是「事後找到根因、事後優化」。Netflix 的做法更進一步:Chaos Engineering 工具家族裡的 Latency Monkey,專門在生產環境的 RESTful 通訊層為部分請求人為增加回應時間,然後觀察上游服務是否按預期反應——是否正確 timeout、正確切到 fallback,還是乾脆卡死無限等待。常態化跑過幾輪之後,Netflix 發現許多服務的 timeout 設定過寬或根本沒設,導致下游一個暫時的慢請求會被逐層放大成整條呼叫鏈的體感變慢。這呼應了本文 ② 段的 DIY 精神:平時 dashboard 沒有出現尾端延遲,不代表系統真的扛得住,得像 Netflix 一樣把「故意變慢」當成第一等公民的測試項目,而不只是等它自然發生才臨場應對。
在往下講之前,要把 hedged requests 的成本講清楚,否則容易誤以為「多打一份請求」是萬靈丹。第一,資源成本:論文裡只多花約 2% 額外負荷,是因為觸發條件是「等到某個 percentile 才補打」,門檻設太低會讓額外負荷遠超過 2%,甚至自己造成下游過載——跟 ⑧ 段講的「retry 風暴」是同一種機制,差別只在 hedge 是預先設計、有節制的重複。第二,正確性成本:後端操作若不是冪等的(例如扣款、寫入訂單),同時打兩份就是在冒重複執行的風險,除非上層有 idempotency key 兜底。第三,複雜度成本:取消還沒回來的那份請求,在真實網路裡不是「立刻停止」,下游仍要承擔那份工作量,只是結果被丟棄。三個成本合起來說明:這不是隨手加的優化,而是要先確認操作是否唯讀、下游能不能撐住多出來的負荷、取消訊號是否真的省得下工作量。
把 ③ 段出現的幾個案例攤開來看,可以發現它們其實對應到尾端延遲問題的三個不同切入點:
| 策略 | 代表案例 | 解決的問題 | 代價 |
|---|---|---|---|
| hedged requests(賽跑) | Google BigTable | 偶發、獨立的 stragglers | 額外負荷、需要冪等性 |
| 換底層技術(消除根因) | Discord:Cassandra → ScyllaDB | 系統性、可重現的 GC pause | 遷移成本、重寫存儲層 |
| 主動注入故障(驗證韌性) | Netflix Latency Monkey | 「不知道系統會怎麼反應」 | 需要在生產環境冒風險(但受控) |
這三種策略不是互斥的,而是分屬不同階段:Netflix 的做法幫你「發現」問題在哪一層,Discord 的做法是「移除」已知的系統性根因,Google 的做法是在「根因暫時無法移除」時,用工程手段把偶發的慢請求變成使用者感受不到的慢請求。三者的共同點是,沒有一個依賴「盯著 dashboard 手動反應」——它們都是把應對尾端延遲的邏輯,寫進系統或流程裡,讓它在下一次事件發生時自動生效。
很多團隊面對 P99 的態度是「這就是分布式系統的代價,沒辦法」,但前面三個案例剛好反駁了這個直覺:Google、Discord、Netflix 都沒有把「尾端延遲」當成運氣問題,而是當成可以拆解、可以量測、可以投入工程資源改善的具體目標。這對 AI workflow 尤其重要,因為 AI 系統的尾端延遲來源比傳統系統更多——不只有網路和硬碟,還有 model provider 排隊、token 生成速度、retrieval 冷快取、tool call 逾時。每一種來源都值得先量出來,再決定要不要投資改善,而不是一開始就放棄。
一句「這個 API 很慢」通常不夠拿來排障。
同一個 /ask 請求,可能在不同地方耗掉時間:
client click
↓
client → gateway network
↓
queue wait
↓
FastAPI validation
↓
retrieval / vector search
↓
prompt assembly
↓
model time to first token
↓
token generation / tool call
↓
parser and validator
↓
first byte or completed response
把所有時間只存成 latency_ms,就像只知道帳單變高、卻沒看消費明細。
先替每個指標寫出它回答的問題:
| 指標 | 回答的問題 | 常見誤讀 |
|---|---|---|
| end-to-end latency | 使用者從送出到拿到完整結果等多久? | 以為它能指出根因 |
| server latency | 服務端收到請求後處理多久? | 忽略 client、gateway 與 queue |
| queue delay | 工作開始前等了多久? | 把排隊誤當模型變慢 |
| retrieval latency | 文件搜尋花多久? | 以為 retrieval 成功就代表答案正確 |
| TTFT | 串流回覆第一個 token 多快出現? | 把第一個 token 當完成答案 |
| generation latency | 產生剩餘輸出花多久? | 忽略輸出長度改變 |
| tool latency | 外部工具是否拖慢 workflow? | 忽略 tool 是否真的產生價值 |
排隊理論有一個直覺但常被低估的結論:系統使用率越接近滿載,等待時間不是線性增加,而是急遽拉長。
用一個簡化的比喻理解:一條有一個收銀台的隊伍,平均每分鐘進來的顧客數如果遠低於收銀台每分鐘能服務的人數,隊伍幾乎不會排長,等待時間穩定而短。但當進來的顧客數量接近收銀台的服務能力上限時,只要有那麼幾個顧客結帳比較久,後面所有人的等待時間都會被連鎖拖長,而且拖長的幅度會越來越誇張,不是穩定增加一點點。
使用率 50% → 平均等待時間:短,且穩定
使用率 80% → 平均等待時間:明顯變長,尾端開始拉開
使用率 95% → 平均等待時間:任何一點波動都會讓隊伍瞬間爆炸性變長
使用率 99% → 幾乎必然有人要等到天荒地老
這個現象解釋了為什麼很多系統在「日常流量」下 P95、P99 表現得體面,一旦遇到流量尖峰(例如促銷活動、新聞事件帶來的使用者暴增),P99 不是溫和上升,而是斷崖式惡化。
它也解釋了 ⑦ 段那個假想值班場景裡「worker saturation」被放在 dashboard 最後一列的原因:使用率是一個領先指標,在它逼近臨界值之前先看到,比等到 P99 已經爆炸才去查資源使用率更有意義。
對 AI workflow 而言,這個臨界值往往不是 CPU 或記憶體,而是 GPU 佇列深度、或 provider 端的併發配額——這些資源一旦逼近上限,尾端延遲惡化的速度會比傳統 Web 服務更快,因為每個請求佔用資源的時間(token 生成通常是秒級)遠比傳統 API 的毫秒級請求長得多,佇列效應被放大。
把 ④ 段開頭那張「client click 到 completed response」的架構圖,換成一次具體請求的時間戳,會更容易理解每一段各自在耗掉多少時間。以下是一個依本文架構假想出來的範例,不是真實系統擷取的 trace。
t = 0ms client click,使用者按下送出
t = 15ms 請求離開瀏覽器,開始網路傳輸
t = 40ms gateway 收到請求
t = 45ms FastAPI validation 完成,進入 workflow
t = 46ms 排進 worker queue(此刻 queue 尚無積壓)
t = 48ms worker 取出這筆請求,queue delay 只有 2ms
t = 90ms retrieval / vector search 完成
t = 130ms prompt assembly 完成,送出給模型
t = 310ms model TTFT,第一個 token 抵達
t = 1,050ms token generation 結束
t = 1,060ms parser 與 validator 通過
t = 1,065ms response 送回 client,client 端算完成
這一筆請求的 end-to-end latency 是 1,065ms,看起來相當健康。
拆開來看,占最大宗的是 model 的 token generation(約 740ms),其次是 TTFT 本身(180ms,從 130ms 到 310ms)。
retrieval 和 queue 幾乎可以忽略不計。
如果只存一個 latency_ms = 1065,你完全看不出這個結構;下次如果同一個請求變慢到 3,000ms,你不會知道是 retrieval 突然變慢、還是 provider 排隊變長、還是模型輸出變得更長。
有了這張拆解時間軸打底,才看得懂為什麼下面要把 TTFT 單獨拉出來講。
串流 LLM 服務尤其要拆開看。
使用者看到第一個 token,會覺得系統開始回應;這是 TTFT 的價值。
但若第一個 token 在 400 ms 出現,完整答案在 45 秒後才結束,任務仍可能失敗。
TTFT 快
不代表
End-to-end 快
不代表
Task success
反過來,短答案的 end-to-end latency 很漂亮,也不一定代表模型快。
它可能只是少產生了 500 個 token。
因此比較 prompt 或 model 版本時,至少要同時保留輸入 token、輸出 token、是否使用 tool、回覆類型與 workflow status。
否則「新版快了」有時只是新版回答得更短,甚至漏答了。
TTFT 之所以值得單獨量測,背後有一個心理學上的原因:使用者對「系統有沒有在回應」的判斷,主要來自第一次收到反饋的時間點,而這個判斷不是均勻分布的,而是分成幾個明顯的門檻。大致上:在 100ms 內拿到反饋,感覺像是「瞬間」發生;100ms 到 1 秒之間,使用者會察覺到延遲,但還在「這是系統正常運作的一部分」的範圍內;超過 1 秒,使用者開始意識到自己在等待,注意力從任務轉移到「等待」這件事本身;超過 10 秒,相當比例的使用者會直接放棄,切分頁、關視窗,或是覺得系統壞了。
這組門檻解釋了一個乍看反直覺的現象:一個總耗時 4 秒、但 TTFT 只要 200ms 的串流回應,使用者體感通常會好過一個總耗時 2 秒、但 TTFT 要 1.5 秒的非串流回應——即使後者的「總時間」數字明明比較小。原因是使用者在 200ms 就已經開始讀到文字,注意力被逐步餵入的內容佔住,體感上是「系統一直在動」;而 TTFT 1.5 秒的那個版本,使用者前 1.5 秒面對的是一片空白,這段空白會被感知放大,即使最終總時間比較短。這也是為什麼許多 LLM 應用把串流輸出當成預設選項,不是為了炫技,而是把「使用者開始感知回應」的時間點,從 end-to-end latency 拉到 TTFT——用一個通常小一個數量級的數字,去對應使用者真正在意的體驗。
但這個技巧有前提:TTFT 快只解決「使用者是否覺得系統活著」,不解決「使用者是否拿到正確或完整的答案」。這正是本文 ③ 那句話要強調的順序——TTFT 快 不代表 End-to-end 快 不代表 Task success——把這條鏈接完整:一個系統可以在體感上「感覺很快」,同時在事實上「沒有把事情做完」,這兩件事分別要用 TTFT 和 task-level evaluation(Day 22 之後的主題)來量,不能用同一個數字回答兩個問題。
傳統 API 的尾端延遲,成因通常是網路抖動、GC pause、資源競爭,這些在③段已經講過。
AI 推論還多了一個傳統系統少見的成因:GPU 的批次處理(batching)。
為了把昂貴的 GPU 用滿,多數 model serving 框架不會「一個請求進來就立刻算」,而是先等一小段時間,把幾個請求湊成一批,一起送進 GPU 運算。
request A 抵達 t=0ms
request B 抵達 t=8ms
request C 抵達 t=15ms
↓ batching window 通常設在 10-50ms 之間
批次送入 GPU(t≈20ms),三個請求「同時」開始運算
這個機制對平均吞吐量是好事:GPU 一次處理一批,單位時間能服務的請求數遠高於一個一個算。
但對個別請求的延遲,它是一個隱藏的變因:同樣的請求,如果不巧抵達在一個批次「剛好送出」之後,得等到下一個 batching window 才會被撿起來,平白多等了幾十毫秒。
流量越高,batching window 通常會動態調整(更多請求時縮短等待,湊批更快),但如果流量突然尖峰、GPU 本身已經在滿載運算前一批,新請求得排隊等 GPU 空出來,这就是 TTFT 從 100ms 級距跳到秒級的另一個常見原因——不是網路慢,也不是 provider 掛了,是你的請求剛好卡在別人的批次後面。
這也是為什麼同一個 provider、同一個 model,尖峰時段的 TTFT P95 經常比離峰時段高出好幾倍:不是模型變笨了,是佇列和批次調度在尖峰期間承受了更大壓力。
TTFT 本身也有自己的變因,不是模型速度的直接映射。它受 prompt 長度(更長的 prompt 需要更久的初始 forward pass)、模型大小、批次處理策略(provider 端把多個請求湊在一起跑,你的請求要先排進某一批)、以及優先權佇列(付費層級或流量控制策略)影響。這代表在流量正常的情況下 TTFT 可能穩定落在 100–300ms,一旦進入流量尖峰,provider 端的排隊時間拉長,TTFT 可能暴增到 3–5 秒——服務本身沒有故障,只是「輪到你」的等待變長了。這也是為什麼 ⑧ 段會提到「context 越大不等於答案越可靠」:更長的 prompt 除了增加 token 成本,也會直接拉長 TTFT,是一個同時影響體驗與成本的槓桿,值得在設計 retrieval 策略時一併考慮。
先不要急著宣布 production SLO。
在 Lab 中,可以把互動式政策問答暫時寫成下列假設:
request class: interactive_policy_qa
valid request: schema valid, authenticated, not cancelled before service receives it
completion: response contract passes parser and validator
latency boundary: server-side end-to-end duration
target: P95 <= 3 seconds in a 1-hour Lab window
exclusions: offline batch jobs; client-side network time; cancelled-before-start requests
3 seconds 是練習用假設,不是對使用者的承諾——它只是落在④段那張 TTFT 心理門檻裡「使用者開始主動等待」與「使用者可能放棄」之間的一個保守值,方便在 Lab 裡練習寫 spec、設 bucket、畫 dashboard,不是任何產業標準。它的用處是迫使你講清楚:量的是誰、從哪一刻開始、在哪一刻結束,哪些流量不該混進去——這個逼問的過程,往往比拿到的文件本身更有價值。逼自己回答「哪些流量該被排除」,常常會發現團隊裡不同的人對「一次成功的請求」根本有不同的定義(使用者中途取消算不算失敗?),這種分歧如果不趁寫 contract 時攤開討論,會一路潛伏到某次事故覆盤時才爆出來。如果未來產品改成長篇報告生成,這個目標很可能不再合理;變更時要改 spec、測試、dashboard 說明和 alert,而不是只把 Grafana 門檻調大。
把這份 contract 的每一行對照它想避免的具體錯誤:request class 避免把 /health、/ask、/admin/report 混進同一條 P95(⑤段的母體污染問題);valid request 避免把根本沒送達伺服器的請求也算進分母;completion 避免誤把 server 端「完成」當成使用者「拿到」;latency boundary 明確選了 server-side end-to-end,client 端網路時間不在責任範圍內;exclusions 避免 batch job 或已取消的請求悄悄拉低(或拉高)達標率。每一行都是一個曾經真實發生過的混淆,被提前寫成規則——這也是為什麼 Day14 說 SLO spec 是「先把問題問清楚」的工具,不是拿來填表格的形式主義。
假設我們有 20 筆有效請求。
其中 19 筆是 100 ms,1 筆刻意睡 2 秒。
19 × 100 ms = 1,900 ms
1 × 2,000 ms = 2,000 ms
total = 3,900 ms
mean = 195 ms
195 ms 看起來很健康。
可是那一位慢請求的使用者,等的是 2 秒,不是 195 ms。
把慢請求增加到 5 筆,平均會提高;但真正的危險在於 tail 的形狀開始變厚。
15 × 100 ms = 1,500 ms
5 × 2,000 ms = 10,000 ms
total = 11,500 ms
mean = 575 ms
平均值終於變醜了,可是它仍沒有告訴你是「每個人都等 575 ms」,還是「四分之一的人等 2 秒」。
這兩種使用者體驗、容量問題與修復方式都不一樣。
上面兩組算術都讓慢請求固定是 2 秒,方便手算;真實世界裡「慢請求」本身通常也有自己的分布。假設 20 筆請求裡,15 筆落在 100ms,3 筆落在 1,500ms,2 筆落在 8,000ms:
15 × 100 ms = 1,500 ms
3 × 1,500 ms = 4,500 ms
2 × 8,000 ms = 16,000 ms
total = 22,000 ms
mean = 1,100 ms
mean 更醜了,但真正說故事的是三群人分別在等什麼:多數人等 100ms、少數人等 1.5 秒、極少數人等 8 秒。這種多層次分布在真實系統裡很常見——一層正常流量、一層踩到快取失效或輕微排隊、一層踩到真正的資源耗盡或下游逾時。只用 P95 一個數字,可能剛好切在兩層之間,看不出這是兩種不同成因、需要不同修法的問題,這也是為什麼成熟的監控實務除了 P50/P95/P99,通常還會保留完整的 histogram bucket 分布圖。
把 mean、P50、P95、P99、max 想成五支不同焦段的鏡頭,沒有哪一支是「正確答案」。廣角鏡頭(mean)適合看整體構圖,看不清楚邊緣細節;長焦鏡頭(P99、max)適合看邊緣細節,看不到整體構圖;中焦鏡頭(P50)適合看典型使用者的日常體驗。值班時該用哪支鏡頭,取決於你這次想回答的問題:「這次改版對整體資源成本有沒有幫助」該用廣角鏡頭,「有沒有使用者正在受苦」該換上長焦鏡頭。max 這支鏡頭最極端,只拍下整個時間窗裡唯一一筆最慢的樣本,適合抓「是不是存在一個完全失控的離群值」,不適合描述任何族群的日常體驗。一個健康的 dashboard,通常同時擺著這五支鏡頭的畫面,而不是替每個人選定唯一一支「正確」的鏡頭。
| 指標 | 適合觀察 | 不適合單獨回答 |
|---|---|---|
| mean | 總體資源時間、粗略趨勢 | 最慢使用者的體驗 |
| P50 | 典型請求的體驗 | 少數嚴重卡住的請求 |
| P95 | 顯著慢的使用者是否變多 | 最極端的尾端 |
| P99 | 長尾、級聯與偶發停頓 | 低流量下的穩定結論 |
| max | 是否存在離群個案 | 常態使用者體驗 |
P99 不是越高階越好——若一個小 Lab 一小時只有 30 筆請求,P99 幾乎等於在凝視一筆資料,此時保留原始測試樣本、顯示 event count,往往比在 dashboard 畫一條看似精準的 P99 線更誠實。
假設你的服務有 10 台實例,各自算出自己的 P95,接著把 10 個 P95 數字加起來除以 10,得到一個「平均的 P95」,這個數字是錯的,而且錯得很隱蔽。percentile 是一個非線性統計量,不像總數或平均值那樣可以直接跨群體合併。想像其中 9 台實例的 P95 是 100ms(流量健康),1 台實例因為某個 pod 卡在資源競爭裡,P95 飆到 5000ms;如果 10 台的流量並不平均(例如那台出問題的實例因為某種路由原因只吃到很少流量),跨實例平均算出來的「P95 的平均值」可能只有 590ms 左右,看起來只是小幅度上升,完全掩蓋了「有一台實例完全在報廢」的事實。
正確的做法是把所有實例的原始 latency 樣本(或它們回報給 Prometheus 的 histogram bucket 計數)先合併,再對合併後的整體分布重新計算一次 P95——這正是 ⑥ 段 PromQL 範例裡 sum by (le) (...) 這一步在做的事:先把各實例、各時間點的 bucket 計數加總,讓 histogram_quantile() 是對整個母體的分布做估算,而不是對「已經算好的 percentile 們」再取一次平均。這也是為什麼「先確定你的 event 母體是什麼」(本節開頭那句話)如此重要:母體如果選錯,不是數字會有一點誤差,而是整張圖問的問題已經悄悄變了。
有個常見的直覺誤解,覺得 P99 是在講「最慢的 1% 使用者」,但更精確的說法是「1% 的(有效)請求」。同一個使用者如果在同一段時間內發出多次請求,其中一次恰好落在慢速區間,這個使用者的體驗會被算進 P99,但他在其他請求裡的體驗完全正常,並不代表他是「系統裡最不幸的那 1% 的人」。反過來,一個高頻使用的重度使用者,只要發出的請求數量夠多,落入 P99 級距的機率也會累積得比輕度使用者高——即使系統本身完全公平,也可能讓重度使用者「主觀上感覺自己比較常遇到卡頓」。這不是統計錯誤,而是提醒你:percentile 描述的是請求層級的分布,不是使用者層級的體驗分布;如果你真正關心的是「有多少比例的使用者,至少遇過一次慢請求」,那是另一個要單獨定義、單獨計算的指標,不能直接從 request-level 的 P99 推論出來。
每一種混法拆開來看都不是刻意犯的錯,而是「先求快,之後再拆」的順手之作,只是那個「之後」經常沒有真的發生。下列混法很常見,也很容易讓圖表變成謎語:
把 /health、/ask、/admin/report 放進同一個 histogram
把成功、timeout、client cancellation 放進同一個 latency 分布
把 streaming 的 first byte 和 non-streaming 的 complete response 當同一個結束點
把 batch job 與互動式 request 混在同一條 P95
比較前先固定一件事:這張圖的「一筆 event」是什麼?對本系列的 /ask,第一版可以選擇「server 完整產出符合 response contract 的結果,或明確結束失敗」作為 event。若要量 TTFT,請建立另一個明確命名的 metric,不要把兩個時間點塞進同一個 duration_seconds。
命名本身也是一種文件:ask_request_duration_seconds 已經在告訴任何看到它的人,這是 /ask 這條路由的耗時分布。如果之後要新增 TTFT,取一個一樣明確的名字,例如 ask_ttft_seconds,而不是硬塞進同一個 histogram、多加一個 label 去區分——那會讓同一個 histogram 混著兩種不同量級的數字,任何查詢都得先小心篩選正確的 label。一個 metric、一個明確的意義,是避免⑤段「母體污染」最簡單也最容易被忽略的方法。
以下是給讀者自行放進 Day16 DIY 專案的教材。
本文沒有建立 Day16/DIY、沒有安裝套件、沒有啟動 server,也沒有執行任何測試。
請依自己的 Day2 Lab 套件版本和專案結構調整 import。
在動手之前,先講清楚接下來六個步驟合起來要驗證的具體論點是什麼,這樣即使讀者不打算真的跑一次,也能看懂這段程式碼的意義,而不是把它當成又一份「範例程式碼」略過。
這六個步驟合起來,要驗證的是①段那句「平均值能掩蓋少數非常慢的請求」在真實 FastAPI 服務裡是不是成立、以及⑤段那句「percentile 需要一致的母體」在實際操作上要怎麼落地。具體來說:
步驟 1(設計 label) 驗證:⑥段前言「Prometheus 適合聚合,不適合存個別識別資訊」
步驟 2(計時邊界) 驗證:④段「一句『這個 API 很慢』通常不夠拿來排障」——先框出 server 這段
步驟 3(延遲開關) 驗證:②段「故意製造 5% 慢請求」——在真實 server 而非離線腳本重現
步驟 4(送出流量) 驗證:⑤段「先確認你量到的是分布」——用固定比例而非隨機流量
步驟 5(PromQL 算 P95)驗證:①段「平均值和 P95 是不同的問題」——同一份資料算出兩種數字
步驟 6(trace/log 追根因)驗證:④段「哪一段慢」——從一張 P95 圖鑽回具體 workflow step
如果讀者只想抓重點而不逐行跑,讀完這張對照表,再挑一兩個步驟看程式碼細節,也能掌握這段實作想證明什麼。
Prometheus 很適合拿來聚合 request 類型、workflow 狀態與固定路由。
它不適合存每一個 request 的識別資訊。
這個分工可以用一句話記住:Prometheus 回答「多少」和「多快」的聚合問題,trace 和 log 回答「這一筆」的具體問題。
兩者不是互相競爭的工具,而是分別站在放大鏡的兩端:Prometheus 讓你看見趨勢與異常的存在,trace 和 log 讓你鑽進去看清楚異常長什麼樣子。
把這兩端搞混,常見的失敗模式有兩種:一種是把所有東西塞進 Prometheus label,妄想單靠一個系統回答所有問題,結果撞上⑥段前面提過的高基數問題;另一種是完全不用 histogram,只靠 log 逐筆分析,每次要看趨勢都得寫一段查詢腳本掃過所有 log,既慢又容易漏掉沒被特別記錄下來的異常。
from prometheus_client import Histogram
ask_request_duration_seconds = Histogram(
"ask_request_duration_seconds",
"Server-side end-to-end duration for /ask requests",
labelnames=("route", "workflow_status", "model_route"),
buckets=(0.1, 0.25, 0.5, 1, 2, 3, 5, 10, 30),
)
這裡的 bucket 要服務於你想問的問題。
如果 Lab latency target 是 P95 小於 3 秒,2、3、5 秒附近就值得有 bucket;若只放 1 和 10,你會知道它在兩者之間,卻不知道是否剛好違反 3 秒目標。
label 值必須是低基數且可枚舉。
可以:route=/ask、workflow_status=completed、model_route=primary
不可以:request_id=req_9f…、user_id=…、prompt=完整問題、exception=完整錯誤
request_id、trace_id、prompt 版本與具體例外內容,應放在 structured log 或 trace,讓你從 P95 尖峰再往下鑽。
label 和 metric 名稱合起來,決定未來能不能查、怎麼查、查起來會不會拖垮監控系統——設計得好,六個月後接手的人也能一眼看懂;設計得不好,只剩一堆猜不透的數字。
Prometheus 內部,每一組不同的 label 值組合都會被存成一條獨立的時序資料(time series)。route、workflow_status、model_route 三個 label 若分別有 3、4、2 種可能值,總共只有 3 × 4 × 2 = 24 條——完全可控。但如果不小心把 request_id 塞進 label,每來一筆新請求就多生出一條全新的 series,一天十萬筆請求就多出十萬條,而且幾乎永遠不會再被更新。Prometheus 得為每一條 series 保留記憶體 metadata,這種「high cardinality」問題是實務上真實會拖垮記憶體、拖慢查詢速度的常見原因,而且往往不是立刻爆炸,而是隨時間累積才被發現。這也是為什麼步驟 1 一開始就要求「先設計有限的 metric labels」——不是風格偏好,是替一個會隨規模惡化的問題提前把關。
以下範例刻意不使用 decorator 魔法。
顯式的 perf_counter() 能讓讀者看到開始與停止位置;如果你的 contract 不同,先改邊界,再談指標。
from time import perf_counter
from fastapi import FastAPI, HTTPException
from app.metrics import ask_request_duration_seconds
from app.workflow import run_policy_workflow
app = FastAPI()
@app.post("/ask")
async def ask(question: str) -> dict:
started_at = perf_counter()
workflow_status = "failed"
model_route = "primary"
try:
result = await run_policy_workflow(question)
workflow_status = result.workflow_status
model_route = result.model_route
if workflow_status != "completed":
raise HTTPException(status_code=503, detail="workflow not completed")
return {"answer": result.answer, "sources": result.sources}
finally:
elapsed_seconds = perf_counter() - started_at
ask_request_duration_seconds.labels(
route="/ask",
workflow_status=workflow_status,
model_route=model_route,
).observe(elapsed_seconds)
這一版是「server 收到 route handler 控制權,到 handler 離開」的時間,不包含瀏覽器排隊、DNS、TLS、反向 proxy 前的網路時間,也不保證 client 收到最後一個 byte——這不是缺點,是指標的範圍,寫出範圍之後才能在另一層補 client journey telemetry,不會誤以為兩者是同一件事。把這個範圍對照④段那張十段式時間軸圖:
client click
↓ ← 這一段:client journey telemetry(本文沒有實作,是未來的補充項)
client → gateway network
↓
queue wait
↓ ┐
FastAPI validation │
↓ │
retrieval / vector search│ ← 這整段:ask_request_duration_seconds 涵蓋的範圍
↓ │ (從 handler 拿到控制權,到 handler return)
prompt assembly │
↓ │
model TTFT │
↓ │
token generation │
↓ │
parser and validator │
↓ ┘
first byte or completed response
這個範圍的選擇不是隨意的,而是刻意對齊「應用程式能控制、能負責的部分」——client 端的網路狀況、瀏覽器渲染速度,不在應用程式的控制範圍內,把它們混進同一個 histogram,只會讓這個指標同時回答兩個團隊(前端與後端)各自負責的不同問題,失去它原本該有的診斷力。
不要根據隨機數在 Lab 裡製造故障。
隨機會讓 demo 刺激,卻讓失敗難以重跑。
可以傳入一個只限本地測試的延遲設定:
import asyncio
async def inject_delay_if_requested(delay_ms: int | None) -> None:
if delay_ms is None:
return
if not 0 <= delay_ms <= 10_000:
raise ValueError("delay_ms must be between 0 and 10000 in this Lab")
await asyncio.sleep(delay_ms / 1_000)
在自己的測試 route 或 workflow fixture 呼叫它:
@app.post("/ask")
async def ask(question: str, test_delay_ms: int | None = None) -> dict:
started_at = perf_counter()
workflow_status = "failed"
model_route = "primary"
try:
await inject_delay_if_requested(test_delay_ms)
result = await run_policy_workflow(question)
workflow_status = result.workflow_status
model_route = result.model_route
return {"answer": result.answer, "sources": result.sources}
finally:
ask_request_duration_seconds.labels(
route="/ask",
workflow_status=workflow_status,
model_route=model_route,
).observe(perf_counter() - started_at)
這個 query parameter 只能存在於受控的本地 Lab 或明確隔離的測試入口——若把「任意 sleep」帶進 production public API,就不是 chaos experiment,而是發給使用者一張免費的等待券。這個開關特意設計成「參數化、有上限、有明確錯誤訊息」,而不是隨口寫一個 if random.random() < 0.05: time.sleep(2) 藏在程式碼深處:後者一旦忘記移除,會變成日後排查效能問題時沒人會想到要懷疑的地雷;test_delay_ms 這個參數名稱本身就是自我說明,0 <= delay_ms <= 10_000 的邊界檢查則防止有人不小心傳入天文數字,把測試請求變成永遠不會回應的掛起連線。
啟動方式依你的 DIY README 為準。
若 Day16 DIY 沿用 uvicorn,可從 Day16/DIY 使用類似指令:
uv run uvicorn app.main:app --reload
接著在另一個 terminal 對本機 endpoint 發 20 次請求。
前 19 次不帶延遲,最後 1 次帶 2,000 ms;請依自己的 request schema 補上 body 或認證。
curl -sS -X POST 'http://127.0.0.1:8000/ask?question=remote-work-policy'
curl -sS -X POST 'http://127.0.0.1:8000/ask?question=remote-work-policy&test_delay_ms=2000'
重複指令時,不要把 shell loop 的執行時間直接當服務 latency。
真正要看的資料是 histogram observation 或 API 所寫出的結構化 log。
請紀錄測試時間、請求數、慢請求數、delay 值與是否有其他背景程序。
這些小細節會決定下一次你看到不同數字時,能否判斷是程式變了還是測試條件變了。
在查 PromQL 之前,先確認資料真的進得去 Prometheus。
沿用 Day2 Lab 的 prometheus.yml,幫這個服務加一個 job:
scrape_configs:
- job_name: "sre-lab-api"
scrape_interval: 15s
static_configs:
- targets: ["api:8000"]
metrics_path: /metrics
scrape_interval 設 15 秒,代表 Prometheus 每 15 秒才抓一次資料。
這個數字要記在腦中:如果你剛送出測試流量就立刻去查圖,很可能資料點還沒被抓進來。
確認 target 是否成功,可以先看 Prometheus 自己的 /targets 頁面:
curl -s http://localhost:9090/api/v1/targets | grep -A 3 "sre-lab-api"
如果 health 欄位不是 up,往下的所有 PromQL 查詢都只會是空的圖,不代表你的程式碼有問題。
若 histogram 已被 Prometheus scrape,下面 query 可算 /ask 的 P95:
histogram_quantile(
0.95,
sum by (le) (
rate(ask_request_duration_seconds_bucket{route="/ask"}[5m])
)
)
若想分開看 workflow outcome:
histogram_quantile(
0.95,
sum by (le, workflow_status) (
rate(ask_request_duration_seconds_bucket{route="/ask"}[5m])
)
)
搭配 request rate 或 count:
sum(rate(ask_request_duration_seconds_count{route="/ask"}[5m]))
histogram_quantile() 是根據 bucket 估算分位數,不是保存每一筆原始延遲後再精確排序,這也是 bucket 邊界要先設計的原因。Lab 的 20 筆樣本可能因 scrape timing、rate window 或桶內插值而呈現和手算不同的數字,這是量測模型的限制——常見的錯誤反應是手算 P95 是 118ms、PromQL 查出來 95ms,於是不斷調 bucket 邊界或 rate window 直到兩個數字對上,但你不是在讓量測更準確,而是在「校準」一個本來就不該完全一致的東西:手算是精確排序,PromQL 是桶估算,落差是方法論本身決定的。正確的反應是先確認落差量級是否合理(例如落在相鄰兩個 bucket 邊界之間),合理就接受它,把心力留給真正該調整的地方:bucket 邊界該不該涵蓋你關心的門檻值(例如 SLO 定的 3 秒)。
一張 P95 圖可以告訴你何時變慢,不能告訴你哪個 workflow step 變慢。
每個請求完成時,可寫一筆固定結構的事件:
{
"event": "ask.completed",
"request_id": "req_local_017",
"trace_id": "trace_local_017",
"workflow_status": "completed",
"model_route": "primary",
"retrieval_ms": 42,
"ttft_ms": 180,
"generation_ms": 820,
"tool_ms": 0,
"end_to_end_ms": 1046,
"prompt_version": "policy-qa-v1"
}
範例中的 ID 是示意值。
真正的 request ID 不可拿來當 Prometheus label;它的工作是讓你從 dashboard 的時間範圍進到 Loki 或 trace backend,定位那幾筆 tail request。
若 end_to_end_ms 升高而 retrieval_ms 穩定、ttft_ms 卻升高,優先查 provider queue、model route 或 prompt size。
若 ttft_ms 穩定、generation_ms 上升,則看輸出 token、tool loop 或串流中斷重試。
這些都是調查假設,不是自動結論;要用 trace 和同時間的 dependency telemetry 驗證。
把 retrieval_ms、ttft_ms、generation_ms、tool_ms、end_to_end_ms 分別記錄,而不是只記一個 latency_ms,成本是多存幾個欄位,換來的價值遠不只是「看起來比較詳細」。
它讓你能在不重新部署、不重新跑一次請求的前提下,直接用既有資料回答「這次變慢,卡在哪一段」——這是 diagnosability(可診斷性)和單純的 observability(可觀測性)之間的差異:後者告訴你發生了什麼,前者讓你不必再多問一輪就能定位原因。
它也讓你能做時間序列上的相關性分析:例如畫出 retrieval_ms 和 end_to_end_ms 隨時間的曲線,如果兩條線幾乎同步起伏,說明 retrieval 是主導端到端延遲的主因;如果 retrieval_ms 平穩、只有 end_to_end_ms 在跳,說明問題出在別的階段。
這種相關性分析,只有在你把每一段都存成獨立欄位時才做得到;只存一個總數,你永遠只能停在「變慢了」這個層次,無法再往下問一句「為什麼」。
prompt_version 這個欄位看似跟延遲無關,放在這裡是為了避免④段和⑨段都提過的陷阱:一旦某次 latency 異常剛好對應到 prompt 或 model 版本的變更,你需要立刻知道,而不是事後翻部署紀錄慢慢比對。
如果讀者真的把這段程式碼放進自己的 Day16/DIY 專案動手跑,這裡列出幾個大概率會遇到、容易誤判成「我哪裡寫錯了」的現象。
現象一:前 19 次請求的 latency 不是整齊的一個數字。 即使沒有刻意加 sleep,FastAPI 第一次請求通常比後續慢一些——直譯器 import 快取、連線池建立、記憶體頁面載入的成本,都會讓「第一筆請求」自然比「第十筆」慢。這是真實系統裡幾乎必然存在的「冷啟動」效應,呼應⑤段「小樣本 percentile 天生抖動」。
現象二:histogram_quantile() 算出的 P95,跟手算排序的 P95 不會完全一樣。 這是「bucket 估算」而非「精確排序」的必然結果。判斷方式:先確認 bucket 邊界(buckets=(0.1, 0.25, 0.5, 1, 2, 3, 5, 10, 30))有沒有涵蓋你注入延遲的區間(本文注入 2 秒,剛好卡在 1 和 2 之間),落在兩個 bucket 之間就會有內插誤差——不是誰算錯,是兩種方法本來就在回答「大約」而非「精確」。
現象三:本地起 Prometheus scrape 後立刻查 Grafana,經常看不到最新的慢請求。 這是 scrape interval 與 rate(...[5m]) 時間窗的組合效應:資料點可能還沒被抓到,抓到了也會被過去 5 分鐘的資料平滑掉。監控系統呈現的「現在」,通常落後真實的「現在」幾十秒到幾分鐘,值班時不能拿著剛部署完的畫面就斷定「沒問題」。
現象四:如果測試 endpoint 前面有 nginx、gateway 等中介層,本機量到的 latency 會系統性地比使用者實際體驗到的短。 這呼應④段那張時間軸圖——client → gateway network 這段不在 server-side latency 裡;別把本地數字直接拿去回答「使用者實際等多久」。
percentile 算清楚、histogram 也埋好了,接下來要處理「值班的人怎麼在半夜三點看懂這張圖」:dashboard 該放什麼、AI workflow 的尾端延遲怎麼在多次呼叫裡被「相乘」放大,以及那幾個最容易把漂亮平均值誤判成系統健康的陷阱。下篇(Day 16 下)接著講。
這篇是 Learning SRE for the AI Era 系列的一部分。
Build → Trace → Break → Measure → Evaluate → Recover → Improve.