昨天把模型壓小(量化、蒸餾、剪枝),解決的是「跑得起、跑得省」;今天要接著談,把壓好的模型高效地「端出去服務」——推理服務層(inference serving)的系統最佳化。同一張顯示卡、同一份權重,換一套服務引擎,吞吐量可以差 3 到 20 倍。原因不在模型本身,而在你怎麼排程請求、怎麼管記憶體、怎麼把 GPU 餵飽。這一層的主角是 vLLM、TensorRT-LLM、SGLang、TGI 這些框架,而它們共同對付的,是自迴歸生成天生的兩個結構性痛點:記憶體與批次。
📖 學
自迴歸生成的兩階段:prefill 與 decode
一次 LLM 推論其實分成性質截然不同的兩段。Prefill(預填充):把你輸入的整段 prompt 一次餵進模型,平行算出所有位置的表示、產生第一個 token。這一步要處理很多 token,是運算受限(compute-bound),GPU 的矩陣乘法單元忙得很。Decode(解碼):接著一個一個往下吐 token,每次只處理「上一個 token」這一個位置,把整份權重從顯示記憶體搬進運算單元、只算一個 token 就丟掉。這一步運算量小、搬權重量大,是記憶體頻寬受限(memory-bound),GPU 運算單元其實大半在發呆等資料。
理解這個分裂很關鍵:prefill 決定你的首 token 延遲(TTFT),decode 決定每 token 時間(TPOT)。長 prompt 拖慢 TTFT,長輸出拖慢總時間。兩階段的資源特性相反,也是後面所有最佳化技巧的出發點——decode 階段 GPU 閒著,正是「塞更多請求進來一起算」有搞頭的根本原因。
KV cache:為什麼它是記憶體的頭號吃貨
自迴歸生成每吐一個新 token,注意力機制都要回頭看前面所有 token 的 key 與 value 向量。若每步都重算,成本是平方級。KV cache 就是把每個 token 算過的 K、V 存起來,下一步直接取用,把重算變成查表——用記憶體換運算。
代價是這份 cache 會隨序列變長而線性膨脹,而且大得驚人。它的大小約等於「2(K 和 V)× 層數 × 注意力頭數 × 每頭維度 × 序列長度 × batch 大小 × 每值位元組」。以一個 13B 級模型、單一序列跑到數千 token,KV cache 就能吃掉好幾 GB;要同時服務幾十路請求,KV cache 往往比權重還占記憶體。所以推理服務的記憶體天花板,常常不是模型權重,而是 KV cache——它才是決定「你這張卡能同時服務幾個人」的真正瓶頸。
PagedAttention 與 vLLM:用作業系統的分頁管 KV cache
傳統做法會替每個請求預留一整段連續的顯示記憶體來裝 KV cache,而且要按「可能的最大長度」保留。這會造成兩種浪費:內部碎片(保留了 2048 格,實際只用 500,其餘閒置)與外部碎片(記憶體被切得零碎,湊不出下一段連續空間)。研究團隊量測發現,樸素方案真正有效利用的 KV 記憶體可能低到兩三成,其餘全被碎片吃掉。
vLLM 的 PagedAttention 直接借用作業系統「虛擬記憶體 + 分頁」的老智慧:把 KV cache 切成固定大小的區塊(block),邏輯上連續、物理上可以散落在記憶體各處,用一張「區塊表」做映射。要多少配多少、用完即還,幾乎消滅碎片,KV 記憶體利用率逼近滿載——省下來的空間就能換成更大的批次、更高的吞吐。附帶好處是共享:多個請求若有共同前綴(例如同一段系統提示),可以共用同一批實體區塊,這正是 prefix caching 的基礎。這套設計讓 vLLM 相較樸素的 HuggingFace 推論迴圈,吞吐能高出一個數量級。
連續批次(continuous batching):別讓短請求等長請求
傳統靜態批次把一批請求湊齊、一起送進去,然後等整批都生成完才收工、才換下一批。問題是每個請求的輸出長度天差地別:一個回 20 字、一個回 2000 字,短的早就做完了,卻得空等最長那一個,GPU 上一堆位置在陪跑空轉。
連續批次(又叫 in-flight batching、iteration-level scheduling)改成「以每一步 iteration 為單位」重新編排:哪個序列這一步生成完了就立刻離場、把空出來的位置讓給排隊中的新請求即時補上。GPU 每一輪 forward 都盡量保持滿載,不再有「陪最長那個跑」的浪費。這一招對真實線上流量(請求長度高度不均)效果極大——Anyscale 的基準測試顯示,連續批次搭配 KV 記憶體最佳化,吞吐相較靜態批次最高可達約 23 倍,且因為新請求能即時插入,p50 延遲反而下降。它和 PagedAttention 是絕配:連續批次要頻繁增減序列,正需要 PagedAttention 那種彈性、零碎片的記憶體管理來支撐。
推測解碼(speculative decoding):讓小模型先猜、大模型來驗
Decode 是 memory-bound,GPU 運算單元大半閒置——推測解碼就是要把這些閒置算力壓榨出來。做法:找一個又小又快的 draft model 先一口氣猜出接下來 k 個 token,再把這 k 個 token 一次性丟給大模型平行驗證(一次 forward 就能同時檢查多個位置,因為算力本來就有餘)。驗證時逐一比對:猜中的直接採納,猜錯的地方截斷、由大模型接手產生正確 token。
關鍵是它在數學上保證輸出分佈與大模型單獨生成完全一致,不是近似、不掉品質,純粹是加速。省下的是「本來要跑 k 次大模型 forward,現在合成一次驗證」。原始論文在 T5-XXL 上實測到約 2 到 3 倍加速。變體很多:自我推測(用模型自己的淺層當 draft)、Medusa(加多個預測頭)、EAGLE 等。效果取決於 draft 的接受率——draft 越像大模型、猜得越準,加速越多;猜得爛反而因為多做驗證而變慢。
關鍵指標與取捨:吞吐與延遲不可兼得
服務層有三個必須分清楚的數字。TTFT(Time To First Token,首 token 延遲):送出請求到吐出第一個字的時間,主要由 prefill 與排隊等待決定,是互動體驗(「它有沒有在動」)的關鍵。TPOT(Time Per Output Token,每 token 時間):進入 decode 後平均每個字的間隔,決定「打字速度」。Throughput(吞吐):整個系統每秒能服務的總 token 數,決定成本效率。
最反直覺、也最該記住的一條:吞吐與延遲是對立的。你可以把批次(batch size)開很大,一次塞爆滿的請求,GPU 使用率拉高、總吞吐漂亮——但每個「個人」的 TPOT 會變慢,因為大家在搶同一份算力與記憶體頻寬。反過來,想給單一用戶最低延遲,就得縮小批次、讓 GPU「專心伺候」,但整體吞吐與每 token 成本會惡化。所以沒有「最佳設定」,只有「對準你 SLO 的設定」:離線批次任務把批次開大追吞吐;即時聊天則盯緊 TTFT/TPOT、限制批次,並靠 chunked prefill 之類技巧讓長 prompt 不要卡住其他人的 decode。
量化與平行:服務端的兩根支柱
量化在推理端的角色(承昨日):昨天談量化是為了「塞得下、跑得省」;在服務層它還多一層意義——decode 是 memory-bound,權重位元數越少,每步從顯示記憶體搬權重越快,TPOT 直接改善,而且省下的顯示記憶體可以全部撥給 KV cache、換更大批次。所以量化不只是壓縮,它同時是延遲與吞吐的最佳化手段。近年也有把 KV cache 本身量化(FP8/INT8)的做法,直接把記憶體頭號吃貨也一起瘦身。
平行策略:單卡裝不下時要跨卡。**張量平行(tensor parallelism)**把每一層的矩陣切開、分散到多張卡上同時算,靠高速互連(如 NVLink)在每層之間交換結果,能降低單次延遲,但對頻寬敏感、跨機器就吃虧。**管線平行(pipeline parallelism)**把不同的層分給不同的卡,像產線接力,通訊量小、適合跨機器,但要小心「氣泡(bubble)」造成的空等。實務上大模型常混用:機器內張量平行、機器間管線平行。加上前面提過的 prefix caching——把共用的系統提示、few-shot 範例、多輪對話的歷史前綴算過就快取,新請求命中就跳過重算,對 RAG 與長系統提示的場景省時明顯。
破除迷思
迷思一:「延遲高?換更大的卡就好。」 大多數 decode 場景是 memory-bound,換更強的運算卡若記憶體頻寬沒同步提升,TPOT 幾乎不動;而且真正卡住吞吐的往往是 KV cache 記憶體與排程策略,不是算力。先換對「服務引擎」與「批次策略」,常比先換硬體划算得多。迷思二:「延遲與吞吐可以同時最佳化。」 如上,兩者在批次大小上直接對立,只能依 SLO 取捨。迷思三:「換上 vLLM 就自動最快。」 框架給你工具,但 batch 上限、KV cache 分配比例、是否開推測解碼、平行怎麼切,都要對著你的流量與硬體調;預設值很少是你的最佳解。
🧠 記
- 推論分兩階段:prefill 是 compute-bound、決定 TTFT;decode 是 memory-bound、決定 TPOT——所有最佳化都從這個分裂出發。
- KV cache 用記憶體換運算(避免重算注意力),但隨序列與批次線性膨脹,常比權重還吃記憶體,是「能同時服務幾個人」的真正瓶頸。
- PagedAttention 用作業系統分頁的思路把 KV cache 切成區塊、物理可不連續,幾乎消滅碎片、逼近滿載利用,還能讓共用前綴的請求共享區塊。
- 連續批次以每步 iteration 重排請求(做完即離場、排隊即補上),避免短請求陪長請求空轉,吞吐相較靜態批次最高約 23 倍。
- 推測解碼讓小 draft model 先猜 k 個 token、大模型一次平行驗證,輸出分佈與大模型完全一致(不掉品質),加速取決於接受率。
- 吞吐與延遲是對立的:批次開大衝吞吐但 TPOT 變慢;要低延遲就縮批次犧牲吞吐。沒有最佳設定,只有對準 SLO 的設定。
✍️ 實踐
用同一個模型,量出「批次大小 → 吞吐/延遲」的取捨曲線。 挑一個開源模型,用 vLLM 起一個服務,再拿它內建的壓測工具(vllm bench serve 或社群的 benchmark script)在固定 GPU 上掃不同併發數:記錄每個併發下的 TTFT、TPOT(或 p50/p99)與總 throughput。把結果畫成一張圖,你會親眼看到吞吐隨併發上升、但 TPOT 也跟著惡化的那條曲線,以及 GPU 使用率飽和的轉折點。這比讀十篇文章更能建立「服務層在權衡什麼」的直覺。
做一次「服務引擎 A/B」對照。 拿同一個量化好的模型與同一組代表性請求(混合長短輸入輸出,貼近你真實流量),分別用 HuggingFace 樸素 generate 迴圈與 vLLM 各跑一輪,比較吞吐與延遲。接著在 vLLM 端只改一個旋鈕試試看:提高 KV cache 記憶體占比(gpu_memory_utilization)、或開啟推測解碼掛一個小 draft model,再測一次。你會很具體地感受到「同一張卡、同一份權重,光是服務層調校就能差幾倍」——這正是這一層的價值所在。
🔗 延伸學習
- vLLM: Easy, Fast, and Cheap LLM Serving with PagedAttention — vLLM 官方部落格
- Efficient Memory Management for Large Language Model Serving with PagedAttention — arXiv 2309.06180
- Fast Inference from Transformers via Speculative Decoding — arXiv 2211.17192
- How continuous batching enables 23x throughput in LLM inference — Anyscale Blog
💬 問 AI
想替自己的服務選對引擎與參數時,先讓 AI 依你的流量特性與 SLO 排出「引擎選型 + 調校清單 + 壓測方法」,再動手:
我要把「[模型名稱與參數量,例如 Llama 3 8B、已 INT4 量化]」部署成線上推理服務,
硬體是「[GPU 型號與張數/顯示記憶體]」,流量特性是「[例如即時聊天、平均輸入 800 token、輸出 300 token、尖峰併發 50]」,
SLO 是「[例如 TTFT < 500ms、p99 TPOT < 50ms]」。
請幫我:
1. 在 vLLM / TensorRT-LLM / SGLang / TGI 之間,依我的流量與硬體建議選型與理由;
2. 列出關鍵調校旋鈕(KV cache 記憶體占比、最大批次、chunked prefill、是否開推測解碼與 draft model 選擇、是否開 prefix caching),並說明各自對 TTFT / TPOT / 吞吐的影響方向;
3. 判斷我該用張量平行還是管線平行(或不需要),理由是什麼;
4. 給我一份壓測計畫:要掃哪些併發、量哪些指標、怎麼判讀吞吐與延遲的取捨曲線、飽和點怎麼找;
5. 指出這個情境最容易踩的雷。