要回答「這個 AI 推論服務能不能上線」,我需要一個比「最多幾個 client」更硬的答案。連線數只描述「同時能掛幾條連線」,不告訴你「在使用者能接受的延遲下,每秒真的服務得動幾個成功請求」。這兩件事在一個會排隊、會被 KV cache 撐爆、會冷啟動的 LLM 服務上,差距可以很大。
所以我把容量問題改寫成一句可驗收的話,並做成一個能跑的 skill。這篇講那句話怎麼定義、一個 LLM 服務至少要看哪五個維度,以及我拿一顆 26B 模型從 c1 掃到 c64 的實測成績,長什麼樣。
為什麼「最多幾個 client」不是容量答案
容量的正式定義,我用這一句:
在指定 workload 與流量模型下,仍同時滿足成功率、TTFT/TPOT/E2E(或語音的 RTF)、且沒有 OOM/restart 的最大 offered load,以及對應的 SLO-qualified goodput。
拆開看兩個關鍵詞。
offered load 是你「送進去」的速率,不是服務「接得住」的速率。壓測工具送 20 req/s,服務只消化得動 12 req/s,那多出來的 8 會排隊、延遲爆掉。如果你只盯著「client 數」,會誤以為連線開得越多容量越大,實際上超過某個點之後,多開的連線只是讓每個請求都變慢。
goodput 是「成功且全部延遲條件都達標」的請求速率。這跟 completed throughput(單純完成的請求速率)是兩個數字:一個請求可以「回 200、但慢到使用者早就關掉分頁」,它算 completed,不算 good。容量報告要報的是 goodput,不是 completed。
所以「能撐幾個 client」只回答了連線上限這一小塊。正式容量,要用符合全部 SLO 的 goodput 來回答。
壓測 LLM 要量哪些指標
一般 HTTP 壓測量「平均延遲 + QPS」就夠了。串流式的 LLM 不行,因為使用者體感被拆成兩段:第一個字多久出來,以及後面的字吐多快。我固定量這四個:
| 指標 | 定義 | 為什麼重要 |
|---|---|---|
| TTFT | 從原訂到達時間,到第一個非空 token/chunk | 使用者盯著空白畫面等多久 |
| TPOT | 第一個 token 之後,平均每個 output token 的時間 | 生成過程順不順、會不會一頓一頓 |
| E2E | 整個請求從到達到最後一個 token | 整體體感 |
| token throughput | input/output token 每秒吞吐 | 跨請求聚合的真實產能 |
usage;服務不回 usage 時,對應的 token SLO 就標「證據不足」,不硬湊。
還有一個串流專屬的指標 inter_chunk_latency(chunk 間抖動)。注意一個 chunk 不一定等於一個 token,所以它跟 TPOT 是兩件事,別混用。
一個 LLM 服務要看哪幾個維度
別只盯「第一個字多快」。一個模型服務至少五個維度一起看,缺一個都會誤判:首字很快,但逐字卡頓一樣難用;延遲全過,但掉 5% 請求就是不能上線。
下面這張表把五個維度講成白話,最後一欄放我這次拿 gamma-26b(一顆 26B 模型、vLLM 服務、OpenAI 相容 SSE)跑 concurrency sweep(c1 → c64)的實測成績。SLO 契約設 P99 TTFT < 3s、P95 E2E < 30s、成功率 100%。
| 維度 | 指標 | 白話 | 為什麼在乎 | gamma-26b 實測 |
|---|---|---|---|---|
| 首字快不快 | TTFT | 送出去到吐第一個字多久 | 體感卡不卡——聊天/串流最敏感,用戶盯著游標轉圈 | c64 起 P99 跳 3.3s(唯一先破線) |
| 吐字順不順 | TPOT | 第一個字之後,每個字之間隔多久 | 讀起來順不順——首字再快,逐字卡頓一樣難用 | 全程 ≤ 0.075s,沒破門檻 |
| 整句多久吐完 | E2E latency | 從送出到最後一個字 | 一個完整回答要等多久——批處理/API 在乎這個,不在乎首字 | c1 5.8s → c64 20.0s,沒破 30s |
| 單位時間服務多少 | throughput/goodput | 每秒吐幾個 token、每秒完成幾個達標請求 | 同時能扛幾個人——運維排容量、算成本靠這個 | token/s 51 → 814;goodput 0.25 → 3.99 |
| 穩不穩 | 成功率/SLO 達成率 | 會不會掉請求、會不會超時 | 能不能上線——前四個再漂亮,掉 5% 請求就是不合格 | 0 fail;SLO c48 前 100%、c64 98.7% |
所以這顆服務的容量瓶頸不是「吐得慢」,是「並發一高、第一個字要等」。這結論只有五個維度一起攤開才看得出來:單看 throughput 一路漲到 814 token/s 會以為還很閒,單看成功率 98.7% 會以為只是零星掉件,要把 TTFT P99 這條線疊上去,才知道「c64 是這份 SLO 下的容量邊界,卡在首字延遲」。這就是為什麼容量報告不能只報一個大數字。
HTTP 2xx 為什麼不等於合格
這是最容易讓報告變好看、卻站不住腳的地方:把 HTTP 2xx 當成「這個請求 OK」。
2xx 只代表服務沒在最外層掛掉。一個回 200、但 E2E 20 秒的請求,對一個 SLO 訂 P95 E2E < 5s 的服務來說是失敗的。goodput 只計「同時符合所有 request-level SLO」的請求,一條都不能少算。這條規則的作用,就是擋住「成功率 99%、但一半請求慢到沒人要用」這種漂亮的假數據。
延遲統計本身也有一個經典陷阱,叫 coordinated omission。閉環壓測(closed-loop)送完一個請求才送下一個,服務一慢,壓測自己就跟著慢下來、少送了請求,於是最該被記錄的那些「塞車時段的高延遲」反而沒被送進去測量,分位數被系統性低估。
我的做法是:要驗真實 server capacity,用開環(open-loop)+ poisson 到達,而且延遲從「原訂到達時間」起算,把壓測工具自己的排隊 delay 也算進去。這樣壅塞藏不住。可重現的 regression 才用 constant + 固定 seed;找最大吞吐才用 closed-loop 掃 concurrency。
穩定性要什麼證據
「零 OOM、零 restart」是很多容量驗收的硬條件。但這條特別容易被輕輕放過 —— 跑完沒看到爆炸,就寫「穩定性 PASS」。
沒有計數器就不算證據。要主張零 restart,得有 before/after 的累積計數:壓測前記一次 restart_count / oom_count / gpu_xid_count,壓測後再記一次,取 delta。health 用 exit code 判。沒有這組 probe,對應項標 UNKNOWN / 證據不足,不能標 PASS。
還有一個看似無害的動作也要擋:缺資料時把數字補成 0。「這台沒接 GPU counter」跟「這台 GPU 錯誤數是 0」是兩件完全不同的事,前者是不知道,後者是知道且為零。缺探針就留白,不補零 —— 資源指標(GPU/CPU/RAM/KV cache/queue)也適用同一條誠實規則。
這套怎麼實際跑起來
流程是一條線:定義 workload 與 SLO 契約 → 選流量模型 → dry-run 煙霧測試 → 逐級加 offered rate 找失敗邊界 → 產出單檔 HTML 報告做 round-trip 驗收。dry-run 會先遮蔽認證 header,確認 payload、response parser、SSE/WS message type、stability probe 都取得到資料,再開始加負載。
煙霧測試長這樣(endpoint 換成你自己的):
python3 scripts/driver_http.py \
--url "http://your-server:8000/v1/chat/completions" \
--cases cases.json \
--payload-template '{"model":"$model","messages":[{"role":"user","content":"$prompt"}],"stream":true}' \
--adapter openai_sse \
--clients 1 --rounds 1 \
--output smoke.json
換一顆 reasoning 模型,量測方法要跟著調
上面 gamma-26b 是標準 completion,指標很乾淨。但同一套 driver 我後來拿去測另一顆會先做內部推理的 reasoning 模型,馬上踩到兩個坑,剛好說明為什麼容量測試不能套一個固定模板了事。
上面這張是那顆 reasoning 模型的一次 smoke 摘要,有兩個現象值得記:
第一,reasoning 模型的 TTFT 會抖得很兇。 同一次跑、兩個 prompt,一個 TTFT 0.9 秒,另一個 85 秒。差距不是網路,是 reasoning 模型在吐正式答案前,會先用一段內部推理(SSE 的 delta.reasoning 欄位)想過一輪,想多久這一輪就佔多久,而 TTFT 量的是「第一個非空輸出」。如果你的 driver 只認 delta.content、不認 reasoning 欄位,會把整段推理期間誤判成「零輸出」,TTFT 直接量歪。
第二,max_tokens 是 reasoning 跟答案一起分的預算。 那次兩個請求的 output 都剛好卡在 256 token —— 因為 256 這個上限被「推理過程 + 最終答案」一起吃掉了。對 reasoning 模型設 output 上限,要把推理的 token 開銷算進去,不然答案會在推理燒完額度後被硬切斷。
同一套壓測工具、換一顆模型,前面 gamma-26b 那張乾淨的 sweep 表就變成這副抖動樣。這不是模型的缺點,是量測方法必須跟著模型型態調的證據。
抓輸出樣本這件事,是刻意加的
壓測工具的本分是回答「多快、多穩」,天生不管「答得好不好」—— 那是語意評測的地盤。但有一個現實問題:要比「同一題,A 模型跟 B 模型各答什麼」,得趁兩顆模型都還在線的那個窗口一次抓齊,模型一卸載,同題對照就永遠做不成了。
所以我讓 driver 在跑效能壓測的同一次,順手保留前 N 個成功請求的完整輸出文本,對齊成並排對照表。邊界劃得很清楚:skill 只負責「趁窗口把證據抓下來」,不負責評分。 誰答得好由語意評測工具判,壓測工具不越界當裁判。這條邊界是刻意的,混在一起會讓兩邊都做不乾淨。
限制與邊界
這套工具有幾條我自己會先講清楚的界線:
- 跨硬體推估只能當粗估。 可以用記憶體頻寬比對 decode-heavy workload 做參考,但量化格式、KV cache、batching、context length、tensor parallel、互連全都會改變結果。要當採購驗收,目標硬體得用同一份 workload 實測,推估結論一律標「推估非實測」。
- 它不評正確率、不評幻覺、不評語意品質。 這些是 regression / eval 的範圍,不要指望壓測工具給你「哪個模型答得對」。
- 極高 QPS 時,先確認壓測端自己沒先飽和。 Python stdlib 的 driver 適合內部容量驗證,真要打到很高的 offered rate,先看 load generator 的 CPU、網路跟 queue delay,必要時換分散式壓測端。