iT邦幫忙

2026 iThome 鐵人賽

DAY 13
0

昨天服務跑起來了。今天回答:它跑得好不好?

面試時很容易被問到的題目,包含「你的服務延遲多少」、「怎麼提升吞吐」及「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%」時,先懷疑壓測端。

四、取捨:優化順序與部署形態

優化的正確順序(由投報率高到低):

  1. 先確認不是蠢問題——模型每次請求都重新載入?沒用連線池?用了同步阻塞的函式庫?這類問題的改善幅度是數倍,且改起來只要幾行。
  2. 加大批次(在 SLO 容許範圍內)——如圖 13-2,這是免費的數倍吞吐。
  3. 模型最佳化——ONNX/TensorRT 圖最佳化、量化。但務必驗證數值一致性,量化可能改變預測結果。
  4. 水平擴充——最後才加機器。加機器是花錢解決問題,前三項是花腦解決問題。

部署形態的選擇

形態 適合 例子
批次推論 無即時需求、量大、可預先算 每日凌晨算全客戶風險分數
即時 API 需要當下回應 交易當下的詐欺判斷
串流 事件驅動、持續處理 IoT 感測異常偵測
邊緣/端側 網路受限、隱私要求 產線瑕疵檢測、手機端模型

推論伺服器要不要用專用的? FastAPI 手刻與 Triton/TorchServe 這類專用伺服器的分界線是:

用 FastAPI 手刻 用專用推論伺服器
模型少、邏輯簡單 多模型、需要版本管理與熱更新
前後處理複雜、需要自訂邏輯 需要動態批次、模型組合(ensemble)
團隊熟 Python 生態 需要壓榨 GPU 效能到極限
延遲需求寬鬆 高吞吐、低延遲的嚴苛要求

建議是先手刻。因為手刻讓你完全掌握發生什麼事,而專用伺服器的設定檔在你還不理解瓶頸在哪時,只會變成另一個黑盒子。等能判讀出「我的瓶頸在動態批次的湊批延遲」時,再換過去。

💡 一個常被忽略的選項:能批次就別即時

如果團隊為了「即時性」把每日只查詢一次的分數做成即時 API,結果扛著沒必要的 SLO 壓力與成本。先問清楚:使用者真的需要「當下」算出來,還是只要「查得到」就好? 後者用批次推論預先算好寫進資料庫,成本與複雜度都低一個量級。


今日小結

  • 平均值會騙人:延遲分佈右偏,平均值反映典型情況,P99 反映最不幸 1% 使用者的體驗。長尾來自冷啟動、GC、大 payload、排隊四種來源。
  • 延遲與吞吐不可兼得。正確的推論路徑是:先訂 SLO → 回推批次上限 → 算出機器數
  • 服務的三個基本功:模型啟動時載入、pydantic 驗證輸入、健康檢查反映真實狀態。
  • 壓測看三件事:線性上升(未飽和)、陡升(飽和點=你的容量)、錯誤率上升(超載)。注意別壓到自己
  • 優化順序:先排除蠢問題 → 加大批次 → 模型最佳化 → 最後才加機器。
  • 能用批次就別做即時——先確認使用者是否真的需要「當下」。

限制與提醒

  • 本篇未深入探討變長輸入(Variable Input Length)對 LLM 的影響:批次吞吐曲線主要基於傳統結構化模型或固定維度特徵;在生成式 AI(LLM)中,Prompt 與 Output Token 長度高度浮動,單純靜態 Batching 會造成嚴重的 Padding 浪費,需額外依賴 Continuous Batching / PagedAttention(如 vLLM)才能精確衡量。

  • 忽略了「動態流量抖動」與「冷啟動彈性」的延遲衝擊:容量推算採用了理想的線性均勻流量假設,但在真實生產環境中,流量常呈現突發波峰(Burst),單靠回推批次上限可能會在冷啟動擴容未完成前直接打垮佇列。

  • 模型量化(Quantization)的精度損失與回歸成本被輕描淡寫:文中雖提到要驗證數值一致性,但在高風險場景(如反洗錢、金融風控)中,量化帶來的微小分佈漂移往往需要龐大的 Golden Set 回歸測試成本,並非單純的效能切換。

明天預告

服務跑得又快又穩,但——它算得對嗎? 明天談這個系列的重要主題:監控與漂移。用時間軸說明模型「無聲壞掉」的那 14 天發生了什麼,以及為什麼縮短這 14 天,就是這份工作的全部價值。

延伸閱讀


上一篇
Day 12:容器與 Kubernetes — GPU 排程與那些會讓 Pod 重啟的坑
下一篇
Day 14:監控與漂移 — 模型無聲壞掉的那 14 天
系列文
從 4,343 筆職缺到 AI Engineer:MLOps × GenAI Engineering 雙主軸實戰14
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言