Day 13:推論服務 — 延遲 vs 吞吐的取捨曲線
昨天服務跑起來了。今天回答:它跑得好不好?
面試時很容易被問到的題目,包含「你的服務延遲多少」、「怎麼提升吞吐」及「P99 為什麼重要」?這三題答得好,入職的機率大大提升。
今天要解決的問題
一段夢到的情境對話:
PM:「我們的推薦 API 快嗎?」 工程師:「平均 45 毫秒,很快。」 PM:「可是有客戶抱怨等很久。」 工程師:「他可能遇到極端值,畢竟平均才 45 毫秒。」
雖然工程師沒說錯,是這樣但不是這樣,原因是平均值會被大量的快速請求拉低,而抱怨的人來自那被平均值掩蓋的長尾。
今天要建立的能力是:用百分位說話,並且知道怎麼把它變好。
📊 職缺訊號
「模型部署與推論服務」出現在 49.2% 的 MLOps 職缺中,是第二高的任務訊號。而在生成式 AI 職缺裡,它以「LLM 服務化」的形式出現於 38.7%。這是雙主軸交會的第一個具體地點——Day 26 會用同一組觀念處理 LLM 服務。
一、現象:平均值會騙人

圖 13-1:推論服務的延遲分佈與百分位曲線(10,000 筆模擬請求,固定隨機種子)。左圖顯示平均值 (約 49 ms) 遠低於 P99 (約 219 ms);右圖顯示百分位曲線在 95% 之後陡升。
這張圖的兩個讀法:
左圖:分佈是右偏的——大多數請求很快,但有一條長尾。平均值落在主體,P99 落在長尾。平均值告訴你「典型情況」,P99 告訴你「最不幸的 1% 使用者的體驗」。
右圖:百分位曲線在 P95 之後陡升。這代表訂 SLO Service Level Objective,服務水準目標)時,P95 與 P99 的難度差距很大——從 P95 提升到 P99 的工程成本,通常遠高於從 P50 提升到 P95。
長尾從哪來? 四個常見來源:冷啟動(新 Pod 剛起、模型剛載入)、GC 或記憶體回收停頓、特別大的輸入(長文件、大批次)、排隊(尖峰時請求在佇列等待)。排查長尾要先分清是哪一種,因為解法不同。
二、原理:延遲與吞吐不可兼得
第二張圖是 MLOps 工程師可以多留意的:

圖 13-2:動態批次的取捨曲線(依「單筆 24 ms、每多一筆 +1.15 ms」的模型推算的示範值)。批次從 1 加到 128,吞吐提升近 20 倍,但單筆延遲也從 24 ms 膨脹到 170 ms。
關鍵因素:GPU 擅長平行運算,一次處理 32 筆的時間遠少於分 32 次處理。所以把請求湊成批,總吞吐會大幅提升。代價是先到的請求要等後面的請求湊齊。
這張圖真正的用法,其實不是先看吞吐量,而是反過來從 SLO 往回推:
1. 先訂定 SLO
→ P95 延遲 ≤ 100 ms
2. 從圖上找出仍能滿足 SLO 的最大批次
→ batch = 64
3. 讀出這個設定下的單機吞吐上限
→ 約 664 req/s
4. 再用整體流量需求反推機器數
→ 2,000 req/s ÷ 664 req/s ≈ 3 台
5. 最後預留容錯與尖峰空間
→ 實際部署可規劃 4 台,其中 1 台作為冗餘
先訂 SLO、再回推批次上限、最後算機器數——這條推論路徑就是容量規劃的標準動作,也是面試系統設計題的標準答法。
三、動手:壓測並讀懂結果
3.1 一個具備基本工程素養的推論服務
"""app/main.py —— 模型推論服務的最小完整版。"""
from contextlib import asynccontextmanager
import mlflow.pyfunc
import pandas as pd
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
MODEL = {}
@asynccontextmanager
async def lifespan(app: FastAPI):
# 模型在啟動時載入一次——不要在每次請求時載入(這是新手最常見的效能殺手)
MODEL["m"] = mlflow.pyfunc.load_model("models:/fraud-model@champion")
yield
MODEL.clear()
app = FastAPI(lifespan=lifespan)
class Req(BaseModel):
amount: float = Field(gt=0, description="交易金額")
channel: str = Field(pattern="^(web|app|store|line)$")
age: int = Field(ge=0, le=120)
@app.get("/health")
def health():
"""給 K8s 探針用:模型沒載入完就回 503,避免流量太早進來(Day 12)。"""
if "m" not in MODEL:
raise HTTPException(status_code=503, detail="model not loaded")
return {"status": "ok"}
@app.post("/predict")
def predict(req: Req):
df = pd.DataFrame([req.model_dump()])
score = float(MODEL["m"].predict(df)[0])
return {"score": round(score, 4), "model_alias": "champion"}
三個重點:模型在啟動時載入(不是每次請求)、用 pydantic 驗證輸入(擋掉髒資料,也是文件)、健康檢查反映真實狀態(別無腦回 200)。
3.2 用 locust 壓測
"""locustfile.py —— 逐步加壓,觀察延遲隨負載的變化。"""
from locust import HttpUser, task, between
import random
class ModelUser(HttpUser):
wait_time = between(0.05, 0.2)
@task
def predict(self):
self.client.post("/predict", json={
"amount": round(random.uniform(100, 50000), 2),
"channel": random.choice(["web", "app", "store", "line"]),
"age": random.randint(18, 80),
})
uv add locust
locust -f locustfile.py --host http://localhost:8000 \
--users 200 --spawn-rate 10 --run-time 3m
3.3 怎麼讀壓測結果
看三件事,順序不能亂:
| 觀察 | 意義 | 下一步 |
|---|---|---|
| 延遲隨負載線性上升 | 還沒到瓶頸 | 繼續加壓找出真正的極限 |
| 延遲突然陡升、吞吐不再增加 | 到達飽和點——這就是你的容量 | 記下這個數字,它是擴容的依據 |
| 錯誤率開始上升 | 已經超載 | SLO 應訂在飽和點之前,不是之後 |
⚠️ 壓測最常見的錯誤:壓到自己
用筆電對雲端服務壓測,結果測到的是你家的網路頻寬。壓測端的資源必須遠大於被測端,或至少確認壓測端的 CPU 與網路沒有先飽和。看到「吞吐上不去但被測服務 CPU 只有 30%」時,先懷疑壓測端。
