vLLM Intro Part 3 — 連續批次與排程器 — 決定誰在這一輪前進一格

大多數人調 LLM 推論效能的方式,是把 batch size 調大,看到吞吐上升就停手。 真正的答案是:在連續批次的世界裡「batch size」根本不是一個固定值——它是排程器每一輪重新決定的結果,而排程器真正在分配的不是「幾個請求」,是「這一輪的 token 預算怎麼切」。 看不懂那份預算表,你調的每一個參數都是在賭。


前言

Part 2 把 KV 記憶體管好了:分頁、共享、快取、量化。但記憶體只是配額

這一篇處理的是分配:每一次 GPU forward 之前,有一個元件要回答「這一輪跑哪些請求、每個請求推進幾個 token」。它叫 Scheduler,住在 vllm/v1/core/sched/scheduler.py,是整個 vLLM 裡最短但最關鍵的一段程式碼——你所有的延遲數字都是它的輸出

本篇的目標:讀完之後,你看到 P99 ITL 抖動能立刻猜出是哪個參數的問題,看到 GPU 利用率低能說出是 CPU bound 還是 batch 湊不起來,並且知道投機解碼在你的負載下是賺還是賠。


一、核心問題:靜態批次的氣泡

先看清楚被連續批次取代的東西。

1.1 靜態批次為什麼浪費

靜態批次(static batching):湊滿 4 個請求一起跑,跑完整批才換下一批

時間 →   t0    t1    t2    t3    t4    t5    t6    t7    t8
       ┌─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┐
req A  │ P   │ D   │ D   │ END │░░░░░│░░░░░│░░░░░│░░░░░│░░░░░│ 3 個 token
req B  │ P   │ D   │ D   │ D   │ D   │ D   │ D   │ D   │ END │ 8 個 token
req C  │ P   │ D   │ END │░░░░░│░░░░░│░░░░░│░░░░░│░░░░░│░░░░░│ 2 個 token
req D  │ P   │ D   │ D   │ D   │ END │░░░░░│░░░░░│░░░░░│░░░░░│ 4 個 token
       └─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┘
                                  ↑
                        ░░░ = 氣泡:GPU 在算 padding,純浪費
                        req E 在門外排隊,但要等 t8 之後才能進來

有效算力 = 17 / (4×9) = 47%

兩個獨立的損失:

  1. Padding 浪費:短序列結束後,它在 batch 裡的位置變成 padding,GPU 照算不誤。生成長度分布越發散,浪費越大。真實流量的輸出長度常呈重尾分布(多數 50 token,少數 2000 token),47% 已經是樂觀估計
  2. 排隊延遲:新請求必須等整批結束。P99 TTFT 直接等於「最長那個請求的總生成時間」。

1.2 連續批次:以 iteration 為單位

連續批次(continuous batching):每一輪重新決定 batch 組成

時間 →   t0    t1    t2    t3    t4    t5    t6    t7    t8
       ┌─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┐
req A  │ P   │ D   │ D   │ END │     │     │     │     │     │
req B  │ P   │ D   │ D   │ D   │ D   │ D   │ D   │ D   │ END │
req C  │ P   │ D   │ END │     │     │     │     │     │     │
req D  │ P   │ D   │ D   │ D   │ END │     │     │     │     │
req E  │     │     │ P   │ D   │ D   │ D   │ D   │ END │     │  ← C 一走就補上
req F  │     │     │     │ P   │ D   │ D   │ D   │ D   │ END │  ← A 一走就補上
       └─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┘
         槽位永遠是滿的,沒有 padding 氣泡
有效算力 ≈ 90%+

機制只有一句話:每一個 forward step 結束後,排程器重新檢查所有請求的狀態,完成的移出、等待的移入。 沒有「批」這個概念,只有「這一輪」。

這件事之所以可行,完全建立在 Part 2 的分頁機制上——序列可以隨時進出,是因為它們的 KV 不需要連續空間;新請求能立刻插入,是因為配 block 是 O(1) 的。PagedAttention 是連續批次的前提條件,兩者是同一套設計的兩面。


二、三個演進階段

╔═══════════════════════════════════════════════════╗

║ Phase 1:靜態批次 —— 湊滿才跑 ║

╚═══════════════════════════════════════════════════╝

┌─────────────┐   湊滿 N 個或等 T 毫秒   ┌──────────────┐
│ 請求佇列    │ ───────────────────────▶ │ model.generate│
└─────────────┘                          │ (整批跑到完)  │
                                         └──────────────┘
  • 可接受的捷徑:程式碼 30 行,用 HF Transformers 就能寫完。
  • 成本:GPU 有效利用率 30–50%;P99 TTFT = 最長請求的完整生成時間(可能是 30 秒)。
  • 解決了什麼:比逐一處理好——至少攤提了權重搬運。
  • 還沒解決什麼:氣泡、排隊延遲、以及最痛的一點——你無法同時滿足吞吐與延遲,只能二選一。

╔═══════════════════════════════════════════════════╗

║ Phase 2:連續批次 + 雙軌排程(V0 模式) ║

╚═══════════════════════════════════════════════════╝

┌──────────┐        ┌─────────────────────────────────────┐
│ waiting  │───────▶│ Scheduler(每 iteration 跑一次)     │
│ queue    │        │  IF 有 waiting 且記憶體夠            │
└──────────┘        │     → 組一個「prefill batch」        │
┌──────────┐        │  ELSE                                │
│ running  │◀──────▶│     → 組一個「decode batch」         │
│ queue    │        │  兩者互斥,一輪只能是其中一種         │
└──────────┘        └─────────────────────────────────────┘
┌──────────┐
│ swapped  │  記憶體不足時被換到 CPU 的請求
└──────────┘
  • 新增元件:三個佇列、搶佔機制(recompute / swap 兩種策略)、每 iteration 的重新排程迴圈。
  • 複雜度 delta:從「一個函式」變成「一個狀態機」。prefill 與 decode 兩套路徑各自有 batch 組裝、記憶體檢查、kernel 選擇邏輯,大量重複程式碼
  • 效果 delta:GPU 利用率 30% → 70–80%;P99 TTFT 從「等整批」變成「等一輪」。
  • 解決了什麼:氣泡與排隊。
  • 還沒解決什麼prefill 與 decode 互斥造成的抖動。一個 32K token 的 prompt 進來,那一輪全部拿去做 prefill,所有正在 decode 的使用者卡住 800ms——ITL 出現一根巨大的尖刺。這就是著名的 “prefill 打斷 decode” 問題。

╔═══════════════════════════════════════════════════╗

║ Phase 3:統一 token 預算(V1 模式) ║

╚═══════════════════════════════════════════════════╝

┌──────────────────────────────────────────────────────────────┐
│  Scheduler(V1):不分 prefill/decode,只分 token 預算         │
│                                                               │
│  token_budget = max_num_batched_tokens   (例如 8192)          │
│                                                               │
│  輸出一份字典:                                                │
│  {                                                            │
│    "req_A": 1,      ← decode 中,推進 1 個 token               │
│    "req_B": 1,      ← decode 中                               │
│    "req_C": 1,      ← decode 中                               │
│    ...(60 個 decode 請求,共 60 token)                       │
│    "req_X": 4096,   ← prefill 中,這輪只做 4096 個(chunked)  │
│    "req_Y": 4036,   ← prefill 中,吃掉剩下的預算               │
│  }                  總和 = 8192 ✓                             │
└──────────────────────────────────────────────────────────────┘
                            │
                            ▼  一次 forward,全部一起跑
              ┌──────────────────────────────────┐
              │ 60 個 decode token + 8132 個      │
              │ prefill token 在同一個 batch 裡   │
              └──────────────────────────────────┘
  • 新增元件:其實是移除元件。沒有 prefill batch / decode batch 的分支、沒有 swapped queue(V1 移除 CPU swap)、沒有依模型特性決定是否開 chunked prefill 的啟發式。
  • 複雜度 delta下降。官方 V1 指南說明新排程器「以一個 per-request 的 token 預算字典消除了 prefill/decode 的嚴格分界」,讓 chunked prefill、prefix caching、投機解碼可以無縫疊加——因為它們在這個模型下都只是「某個請求這輪要幾個 token」的不同算法。
  • 效果 delta:P99 ITL 的尖刺大幅收斂;CPU 開銷下降(少了一整套分支邏輯)。
  • 解決了什麼:prefill 打斷 decode。
  • 還沒解決什麼單輪內的公平性仍是 FCFS(或 priority)。一個超長 prompt 仍會在多輪內持續吃掉大部分預算;「concurrent partial prefills」(同時推進多個 prefill 的細緻配額)在官方功能表上仍標記為進行中。

這就是為什麼 V1 值得整個重寫。 不是為了效能數字,是為了讓「加一個新功能」不再需要改兩條路徑。


三、V1 排程器:一輪裡發生什麼

拆解 Scheduler.schedule() 的邏輯骨架。

每個 iteration:

┌─ 初始化 ──────────────────────────────────────────────┐
│  token_budget = max_num_batched_tokens                 │
│  scheduled = {}                                        │
└────────────────────────────────────────────────────────┘
            │
┌─ 步驟 1:先服務 running(decode 優先) ────────────────┐
│  for req in running:                                   │
│     需要的 token 數 = 1(或 1+k,若開投機解碼)         │
│     需要新 block 嗎?(token 數 % block_size == 0)     │
│       ├─ 要,但 KV 池沒空位                             │
│       │    → 【搶佔】從 running 尾端踢出最晚來的請求     │
│       │      釋放它的 block,把它放回 waiting 隊首       │
│       │      重試本請求                                 │
│       └─ 有空位 → 配置                                 │
│     scheduled[req] = 1;  token_budget -= 1             │
└────────────────────────────────────────────────────────┘
            │
┌─ 步驟 2:用剩餘預算服務 waiting(prefill) ────────────┐
│  while token_budget > 0 and waiting:                   │
│     req = waiting[0]        ← FCFS,或依 priority 排序  │
│     ① 查前綴快取 → 得到 num_computed_tokens             │
│     ② 還要算的 = len(prompt) − num_computed_tokens      │
│     ③ 這輪實際做 = min(還要算的, token_budget)          │
│           ← 【chunked prefill 就是這一行】              │
│     ④ 向 KVCacheManager 要 block;要不到就 break        │
│     ⑤ scheduled[req] = 這輪實際做                       │
│        token_budget -= 這輪實際做                       │
│        若沒做完 → 它留在「partial prefill」狀態,下輪續 │
│     ⑥ 檢查 len(running) < max_num_seqs,否則 break      │
└────────────────────────────────────────────────────────┘
            │
            ▼
      SchedulerOutput → Executor → GPUModelRunner.forward()

四個值得標出來的細節:

細節一:decode 優先於 prefill。 步驟 1 先跑完才輪到步驟 2。理由是已經在生成的使用者正在等,而還在排隊的使用者還沒開始等。這個順序讓 ITL 比 TTFT 更受保護——符合「串流體驗中,卡住比慢開始更難受」的直覺。

細節二:搶佔發生在步驟 1 的中間,不是事後補救。 排程器不會先組好 batch 再發現 OOM,而是邊配置邊檢查,配不到就當場搶佔。被搶佔的一定是 running 佇列尾端(最晚加入的),這讓早到的請求不會被反覆搶佔而餓死。

細節三:兩個獨立的上限。 token_budgetmax_num_batched_tokens)限制「這輪算多少 token」,max_num_seqs 限制「同時有多少請求在飛」。前者控制單輪 forward 的時長,後者控制 batch 的寬度。兩個都會成為瓶頸,且症狀不同——第八節有對照表。

細節四:partial prefill 的狀態就在 num_computed_tokens 這個數字裡。 沒有額外的狀態機。一個請求 prefill 到一半,它就只是「一個 num_computed_tokens=4096 但 prompt 長度 12288 的請求」,下一輪照樣走步驟 2 的邏輯。這種「用一個數字表達狀態」的設計,是 V1 比 V0 乾淨的根本原因。

3.1 排程策略:FCFS vs Priority

1# 預設
2--scheduling-policy fcfs
3
4# 優先序(數字小 = 優先高,同優先序內仍 FCFS)
5--scheduling-policy priority

priority 模式下,請求可帶 priority 參數。適用場景:同一個叢集同時服務互動式聊天(低延遲要求)與批次任務(可等)。把批次任務設低優先序,它們會自動填滿互動流量的空隙,且尖峰時第一個被搶佔。

注意:priority 只影響「誰先進 running」與「誰先被搶佔」,不影響已在 running 中的請求每輪拿到的 token 數(decode 都是 1 個)。它不是一個 QoS 的完整解法。


四、Chunked Prefill:那個最該懂的旋鈕

max_num_batched_tokens 是 vLLM 裡影響延遲曲線最大的單一參數。

4.1 它在做什麼

情境:一個 32,768 token 的 prompt 進來,同時有 60 個使用者正在 decode

【沒有 chunked prefill】
  iteration N:   32,768 個 prefill token,0 個 decode
                 forward 耗時 ≈ 800 ms
                 → 60 個使用者的 ITL 這一輪變成 800 ms(平常 15 ms)
                 → P99 ITL 圖表上一根 50 倍高的尖刺

【有 chunked prefill,max_num_batched_tokens = 8192】
  iteration N:   60 decode + 8,132 prefill   → ≈ 200 ms
  iteration N+1: 60 decode + 8,132 prefill   → ≈ 200 ms
  iteration N+2: 60 decode + 8,132 prefill   → ≈ 200 ms
  iteration N+3: 60 decode + 8,192 prefill   → ≈ 200 ms(做完)
                 → 每輪 ITL 200 ms,尖刺高度降為 1/4
                 → 該請求的 TTFT 從 800ms 變成 800ms(總量沒變,但分散了)

【max_num_batched_tokens = 2048】
  需要 16 輪,每輪 ≈ 55 ms
                 → ITL 尖刺幾乎看不出來
                 → 但該請求的 TTFT 變成 16 × 55 = 880 ms(略增,因為每輪有固定開銷)
                 → 且 decode 的攤提變差:每輪只有 60 個 decode token 卻要付一次完整權重搬運

核心取捨一句話max_num_batched_tokens 大 → TTFT 好、吞吐好、ITL 差;小 → ITL 好、TTFT 與吞吐差。

官方調參文件給的方向很明確:

設定官方描述適用
2048較佳的 inter-token latency互動式聊天,ITL 是主要 SLO
8192+較佳的 TTFT 與吞吐一般平衡點,V1 的典型預設量級
> 8192(大 GPU)「為取得最佳吞吐,在大 GPU 上設為 > 8192」批次處理、離線推論、長 prompt RAG

4.2 為什麼不能無限大

三個天花板:

  1. 記憶體max_num_batched_tokens 直接決定 profiling 時量到的峰值 activation。設 65536 會讓 activation 吃掉好幾 GB,這些記憶體是從 KV 池扣的——你可能因為把預算設太大而讓併發數下降
  2. CUDA graph 捕捉:V1 對不同 batch 形狀捕捉 CUDA graph,形狀空間太大會拉長啟動時間與記憶體。
  3. 報酬遞減:prefill 在幾千 token 時就已經 compute-bound 了,再加 token 只是線性增加時間,不再提升 GPU 效率。

4.3 一個實用的起手式

你的 SLO 是什麼?
    │
    ├── 「串流要順」(ITL 主導,如聊天 UI)
    │      → max_num_batched_tokens = 2048~4096
    │        max_num_seqs = 128~256
    │
    ├── 「要快點開始回」(TTFT 主導,如搜尋摘要)
    │      → max_num_batched_tokens = 8192~16384
    │        並考慮 P/D 分離(Part 5)
    │
    └── 「只要總量大」(吞吐主導,如離線批次)
           → max_num_batched_tokens = 16384~32768
             max_num_seqs = 512+
             gpu_memory_utilization = 0.95

設完之後必看vllm:time_to_first_token_secondsvllm:inter_token_latency_seconds 的 P95/P99,而不是平均值。平均值在這裡毫無資訊量。


五、搶佔:記憶體不足時誰倒楣

當 running 中的請求要新 block 而 KV 池空了,排程器搶佔。

搶佔流程(V1):

  running = [A(早), B, C, D, E(晚)]
  A 需要新 block,但 free queue 空了
        │
        ▼
  從 running 尾端取 E
  ├─ 釋放 E 的所有 block(ref_cnt--,放回 free queue)
  ├─ E 的 num_computed_tokens 重設為 0(或前綴快取命中的部分)
  └─ E 插回 waiting 佇列的最前面
        │
        ▼
  A 拿到 block,繼續
        │
        ▼
  之後 E 重新被排程時:整段 prompt + 已生成的 token 全部重算 prefill
  (但前綴快取可能救回大部分 —— 這是兩個機制的協同)

5.1 為什麼 V1 選 recompute 而非 swap

V0 有兩種搶佔策略,V1 只留一種:

選擇              選 Recompute 的理由                不選 Swap 的理由
──────────────────────────────────────────────────────────────────────
Recompute         無 PCIe 傳輸,不佔用頻寬            Swap 到 CPU:
vs Swap to CPU    實作簡單,沒有換頁狀態機             · PCIe 頻寬遠低於 HBM,
                  與前綴快取天然協同(重算時大量命中)  搬 KV 可能比重算還慢
                  GPU 算力相對 PCIe 頻寬「便宜」       · 需要維護 swapped 佇列與
                  V1 已完全移除 swap,相關指標          換入換出的狀態機
                  (num_requests_swapped、            · 佔用 CPU 記憶體且不可控
                   cpu_cache_usage_perc)也一併移除
                  ────────────────────────────────────────────────────
                  翻轉條件:prompt 極長(100K+)且前綴不可快取時,重算成本
                  理論上高於傳輸成本。但 V1 選擇用更通用的 KV offloading
                  connector(Part 5)覆蓋這個場景,而不是回頭做 swap——
                  因為 connector 可以同時服務「跨實例 KV 共享」這個更大的需求。

5.2 搶佔是一個警訊

偶發搶佔正常,持續搶佔代表配置有問題。官方調參文件給的四個方向:

做法效果代價
提高 gpu_memory_utilizationKV 池變大OOM 緩衝變小
降低 max_num_seqs同時在飛的請求變少吞吐下降
降低 max_num_batched_tokens每輪配置需求變小TTFT/吞吐下降
提高 tensor_parallel_size / pipeline_parallel_size每卡分到的 KV 空間變大需要更多卡 + 通訊開銷

還有兩個文件沒列但同樣有效的:開 FP8 KV cache(Part 2)與max_model_len。後者最常被忽略——很多人把 max_model_len 設成模型支援的最大值(128K),但實際 99% 的請求都在 4K 以內,白白讓排程器保守。


六、投機解碼:用算力換 token 步數

Decode 是 memory-bound 的(Part 1),意思是GPU 算力在 decode 時是閒置的。投機解碼把這份閒置算力拿來用。

6.1 機制

一般 decode:一次 forward 產出 1 個 token
  [已生成] ──forward──▶ t1
  [已生成,t1] ──forward──▶ t2
  [已生成,t1,t2] ──forward──▶ t3
  3 個 token = 3 次權重搬運

投機解碼(num_speculative_tokens = 3):
  ┌─ 步驟 1:草稿 ────────────────────────────────┐
  │  用便宜的方法猜 3 個 token:t1', t2', t3'      │
  │  · n-gram:查前文有沒有出現過同樣的 pattern    │
  │  · EAGLE/MTP:一個小小的頭,成本約主模型的 5%  │
  │  · draft model:一個 1B 小模型                 │
  └────────────────────────────────────────────────┘
  ┌─ 步驟 2:驗證(一次 forward)─────────────────┐
  │  把 [已生成, t1', t2', t3'] 一起送進主模型     │
  │  ← 這是 prefill 形狀!算力吃得滿,但只搬一次權重│
  │  得到每個位置的真實機率分布                     │
  └────────────────────────────────────────────────┘
  ┌─ 步驟 3:拒絕取樣 ────────────────────────────┐
  │  逐個比對:t1' 接受、t2' 接受、t3' 拒絕        │
  │  → 採納 t1, t2,並從修正分布重新取樣出 t3      │
  │  → 這一次 forward 產出了 3 個 token            │
  └────────────────────────────────────────────────┘

關鍵保證:vLLM 的拒絕取樣「與目標分布對齊」,也就是理論上無損——輸出分布和不開投機解碼完全一致。這不是近似加速。

6.2 什麼時候賺、什麼時候賠

設 α 為接受率、k 為投機 token 數、c 為草稿成本佔主模型的比例。每次驗證的期望產出 token 數約為:

E[tokens] = (1 − α^(k+1)) / (1 − α)

加速比 ≈ E[tokens] / (1 + c×k)

代幾個數:

方法典型 αkcE[tokens]加速比
n-gram(重複性文本)0.73~02.62.6×
n-gram(一般文本)0.23~01.251.25×
EAGLE / MTP0.7540.13.22.3×
draft model 1B→70B0.650.052.41.9×
draft model 1B→8B0.650.32.40.96×(虧了)

最後一行是重點:草稿模型相對主模型太大,就會賠本。1B 草稿配 8B 主模型,草稿成本佔 30%,加速比跌破 1。

但上表只算了單條序列。真正決定賺賠的是 batch 大小

        低 QPS(batch=4)                高 QPS(batch=128)
    ┌──────────────────────────┐   ┌──────────────────────────┐
    │ GPU 算力大量閒置          │   │ GPU 算力已被 batch 吃滿   │
    │ 投機用掉的是「本來浪費的」│   │ 投機和其他請求搶算力      │
    │ → ITL 明顯下降            │   │ → 總吞吐下降              │
    │ → 淨賺                    │   │ → 淨賠                    │
    └──────────────────────────┘   └──────────────────────────┘

官方文件的說法一致:低 QPS(latency-focused)收益高,尤其 EAGLE 與 MTP;高 QPS(throughput-focused)報酬遞減,而 n-gram / suffix decoding 這類極輕量方法「在尖峰流量時的額外開銷最小」。

6.3 設定

 1# n-gram:零額外模型,適合有大量重複的場景(程式碼補全、文件改寫、結構化輸出)
 2vllm serve <model> \
 3  --speculative-config '{"method":"ngram","num_speculative_tokens":3,
 4                         "prompt_lookup_max":4,"prompt_lookup_min":2}'
 5
 6# EAGLE:需要對應的 EAGLE 頭權重,加速最明顯
 7vllm serve meta-llama/Llama-3.1-8B-Instruct \
 8  --speculative-config '{"method":"eagle","model":"<eagle-head-repo>",
 9                         "num_speculative_tokens":4}'
10
11# draft model:最通用但也最容易賠本
12vllm serve <big-model> \
13  --speculative-config '{"method":"draft_model","model":"<small-model>",
14                         "num_speculative_tokens":5}'

Python 端用 LLM(..., speculative_config={...}),鍵名相同。

上線前必做:用你的真實 trace 跑一次 A/B。官方文件直說「實際收益取決於你的模型家族、流量形態、硬體與取樣設定」。我的建議是把它當成一個在離峰時段開、尖峰時段關的動態設定,而不是一個固定配置。

6.4 一個容易踩的坑

投機解碼會讓每個請求在一輪內要求 1 + k 個 token 的 KV 空間,而不是 1 個。這代表:

  • KV 池的消耗速度變成 (1+k) 倍的尖峰(雖然拒絕後會回收)
  • 搶佔頻率上升
  • max_num_batched_tokens 的有效容量下降

如果你開了投機解碼之後看到搶佔指標飆升,先降 num_speculative_tokens,再考慮調記憶體。


七、把三個機制疊在一起看

V1 統一預算模型的價值,在於這三件事可以同時發生而不需要特殊處理:

某一個 iteration,token_budget = 8192:

┌────────────────────────────────────────────────────────────────┐
│ req_01 … req_50   各 1 token    = 50      ← 純 decode           │
│ req_51 … req_58   各 5 token    = 40      ← decode + 投機(k=4)  │
│ req_59            2048 token              ← chunked prefill 第 3 塊│
│ req_60            4096 token              ← chunked prefill 第 1 塊│
│ req_61            1958 token              ← 前綴命中 90% 後的殘餘 │
│                   ─────────────────                             │
│                   合計 8192 ✓                                    │
└────────────────────────────────────────────────────────────────┘
                            │
                            ▼
        一次 forward,一次權重搬運,推進 8192 個 token 的計算
        其中 90 個是「真正產出使用者可見 token」的

在 V0 的雙軌模型下,req_59~req_61 的 prefill 和 req_01~req_58 的 decode 不可能在同一輪。V1 之所以能,是因為它從頭就不區分這兩者——對排程器來說,它們都只是「某個請求要算 N 個 token」。

這也解釋了為什麼前綴快取、chunked prefill、投機解碼在 V1 全部是預設開啟:它們不是三個互相干擾的功能,而是同一個預算分配問題的三種輸入。


八、調參手冊:症狀 → 診斷 → 處方

這一節是本篇最實用的部分。左邊是你在 Grafana 上看到的,右邊是你該做的。

8.1 症狀對照表

症狀(指標)最可能的原因處方驗證方式
P99 ITL 有週期性尖刺,P50 正常長 prompt 的 prefill 塞爆單輪預算max_num_batched_tokens 到 2048–4096尖刺高度應下降至原本的 1/2–1/4
ITL 整體偏高且平坦batch 太大,每輪 forward 本來就慢max_num_seqs吞吐會下降,確認是否可接受
TTFT 的 P50 就很高佇列在排隊,容量不足vllm:num_requests_waiting;若持續 > 0 則需擴容或降 max_model_lenwaiting 應回到接近 0
TTFT 高但 num_requests_waiting ≈ 0prefill 本身慢(prompt 太長 / 預算太小)max_num_batched_tokensvllm:request_prefill_time_seconds 應下降
吞吐低、GPU 利用率也低CPU bound(tokenize / API server)--api-server-count、確認實體核心 ≥ 2+N、試 VLLM_USE_FASTOKENS=1GPU 利用率應上升
吞吐低但 GPU 利用率高已經跑滿,是硬體極限擴容 / 量化 / 換架構
vllm:kv_cache_usage_perc 長期 > 0.95KV 池不足,頻繁搶佔見第五節四方向 + FP8 KV使用率應降到 0.7–0.9
kv_cache_usage_perc 長期 < 0.3記憶體浪費,batch 湊不起來max_num_seqsmax_num_batched_tokens吞吐應上升
前綴命中率低但 workload 有共同前綴prompt 模板把變動內容放前面;或多副本隨機路由重排 prompt;導入前綴感知路由(Part 5)prefix_cache_hits/queries 應上升
開了投機解碼後吞吐反而降高 QPS 下算力已飽和關掉,或改用 n-gram,或只在離峰開對照 A/B
啟動很久(> 10 分鐘)torch.compile 首次編譯 + 權重下載掛載 ~/.cache/vllm/torch_compile_cache;權重預先放本地第二次啟動應大幅縮短

8.2 參數之間的關係圖

                    ┌─────────────────────────┐
                    │ gpu_memory_utilization  │
                    │        (0.90)           │
                    └───────────┬─────────────┘
                                │ 決定 KV 池大小
                                ▼
              ┌─────────────────────────────────────┐
              │         KV Cache 池                  │
              │   啟動日誌:GPU KV cache size: N     │
              └──────┬───────────────────────┬──────┘
                     │                       │
        限制併發上限 │                       │ 不足時觸發
                     ▼                       ▼
           ┌──────────────────┐      ┌──────────────┐
           │  max_num_seqs    │      │   搶佔       │
           │  (batch 寬度)    │      │ (recompute)  │
           └────────┬─────────┘      └──────────────┘
                    │
                    │  兩者同時限制每輪工作量
                    ▼
           ┌──────────────────────────┐
           │ max_num_batched_tokens   │──▶ 單輪 forward 時長
           │    (token 預算)          │       │
           └──────────────────────────┘       ├──▶ ITL(大則差)
                    │                         └──▶ TTFT(大則好)
                    │ 也決定 profiling 的
                    ▼ 峰值 activation
           ┌──────────────────────────┐
           │ 回頭吃掉 KV 池的空間      │  ← 循環依賴!設太大反而降低併發
           └──────────────────────────┘

           ┌──────────────────────────┐
           │     max_model_len        │──▶ 影響「最壞情況併發估計」
           │  設成 128K 但實際用 4K   │     排程器會過度保守
           └──────────────────────────┘

最後那個循環依賴值得再強調:max_num_batched_tokens 設太大會透過 activation 反過來壓縮 KV 池。這是很多人調參越調越慢的原因——他們只看到「預算變大應該更快」,沒看到併發數悄悄掉了。改完永遠回頭確認啟動日誌的 GPU KV cache size


九、為什麼選 X 不選 Y

選擇                 選 X 的理由                        不選 Y 的理由 / 翻轉條件
──────────────────────────────────────────────────────────────────────────────
連續批次             GPU 利用率 90%+,無 padding 氣泡    靜態批次:實作簡單,
vs 靜態批次          新請求隨時可插入                    無排程器 CPU 開銷
                     ────────────────────────────────────────────────────────
                     翻轉條件:離線批次且所有序列長度一致(如固定長度的 embedding),
                     靜態批次沒有排程開銷反而更快。生成式服務永遠選連續批次。

統一 token 預算      一套邏輯覆蓋 prefill/decode/投機    雙軌制:prefill 與 decode
vs 雙軌 prefill/     新功能不需要改兩條路徑              各自可用不同 kernel 最佳化
   decode 排程       chunked prefill 天然可行            (但 V1 用 backend 選擇
                                                         解決了這一點)
                     ────────────────────────────────────────────────────────
                     翻轉條件:無。V0 已被移除,這題已有定論。

Decode 優先          保護 ITL:正在串流的使用者不卡頓    Prefill 優先:新請求的
vs Prefill 優先                                          TTFT 更好
                     ────────────────────────────────────────────────────────
                     翻轉條件:非串流的批次 API(使用者只等最終結果),此時 ITL
                     無意義,prefill 優先能提高整體吞吐。vLLM 不直接提供此開關,
                     實務上用「調大 max_num_batched_tokens」達成類似效果。

FCFS                 公平、可預測、無飢餓                Priority:可分級 QoS
vs Priority          實作簡單                            但需要上游正確設 priority
                     ────────────────────────────────────────────────────────
                     翻轉條件:同叢集混跑互動流量與批次任務時,priority 是正解。
                     單一類型流量用 FCFS 就好。

Chunked prefill 開   ITL 尖刺收斂,V1 預設               關閉:長 prompt 的 TTFT
vs 關                長短請求混合場景必要                略好(少了分塊的固定開銷)
                     ────────────────────────────────────────────────────────
                     翻轉條件:純批次離線推論、沒有併發 decode 需要保護時,
                     關掉可以省下每塊的排程開銷。線上服務不要關。

投機解碼開           低 QPS 下 ITL 降 2–3×               關閉:高 QPS 下搶算力,
vs 關                無損(拒絕取樣對齊目標分布)         總吞吐下降;額外 KV 消耗
                     ────────────────────────────────────────────────────────
                     翻轉條件:見 6.2。判準是「你的 GPU 算力在 decode 時是否閒置」。
                     用 batch 大小當代理指標:平均 batch < 16 大概率賺,> 64 大概率賠。

n-gram 投機          零額外模型與記憶體                  EAGLE/MTP:加速更高但
vs EAGLE/MTP         尖峰時開銷最小                      需要對應權重、佔記憶體、
                     重複性文本(程式碼/改寫)效果極好    模型支援有限
                     ────────────────────────────────────────────────────────
                     翻轉條件:你的模型有官方 EAGLE3/MTP 頭且是延遲敏感場景 →
                     選 EAGLE/MTP。否則先試 n-gram,成本幾乎為零。

十、系列導航

本篇把「配額」變成了「分配」:連續批次消除氣泡、統一 token 預算讓 prefill 與 decode 共存、chunked prefill 是那個最該懂的旋鈕、搶佔是壓力的洩壓閥、投機解碼把閒置算力換成 token 步數。

到目前為止,所有討論都在一張 GPU 的邊界內。但真實的大模型放不進一張卡,真實的流量也不會停在一台機器。

下一篇跨出這道邊界:四種平行度(TP/PP/DP/EP)各自切開模型的哪一維、通訊量差多少、多機部署時 NCCL 走不走 RDMA 怎麼確認;以及兩個把單卡效率再往上推的方向——量化(AWQ/GPTQ/FP8/INT8 與硬體支援矩陣)與編譯優化(torch.compile 的 piecewise CUDA graph 到底切在哪裡、為什麼要切在 attention 上)。

  • Part 4 — 分散式推論、量化與編譯優化:TP/PP/DP/EP、多機部署、量化矩陣、torch.compile 與 CUDA Graph

Part 2 — PagedAttention 與 KV Cache — 把作業系統的分頁搬進 GPU | Part 4 — 分散式推論、量化與編譯優化 — 讓模型放得下也跑得快 →


本文基於 vLLM main 分支(2026 年 9 月,版本參考 0.19.x)、官方 V1 引擎指南、最佳化與調參文件、投機解碼文件撰寫。排程流程對照 vllm/v1/core/sched/scheduler.py;延遲與加速比數字為量級估算,實際值依模型、硬體與流量形態而異。

Yen

Yen

Yen