vLLM 推理内核深度解析
第12章 投机解码:以小博大
"It is better to be approximately right than precisely wrong."
—— Warren Buffett,1993 年致伯克希尔·哈撒韦股东信
不过投机解码是个例外:拒绝采样让它在「近似」的外表下,输出分布与原始 decode 完全相同。
本章要点
- 理解 LLM Decode 的 memory-bound 倾向:低并发 decode 常被权重读取、KV 访问和调度开销限制,TensorCore 不一定吃满
- 掌握投机解码的数学等价性——拒绝采样证明了输出分布和原始 decode 完全相同,不是近似
- 读懂当前 V1 直接初始化的两类 proposer:n-gram 与 EAGLE/EAGLE3;同时知道 draft model、Medusa、MLP speculator、MTP 在更广 vLLM 代码里的位置
- 看懂
num_speculative_tokens=k时,一次 forward 到底塞了什么进 GPU:k+1 个 position、cu_seqlens_q 的混合构造、为什么不需要专门的 attention mask - 学会推导加速比闭式公式: 及其指导意义
- 理解"batch 大了投机解码反而变慢"这个反直觉现象:draft cost 不再可忽略、budget 抢占 + decode 请求变多
- 识别 n-gram 容易命中的高重复场景(代码生成、模板化摘要、长文改写)与命中不稳定的场景(开放聊天、创意写作)
- 拿到 V1 spec decode 的配置模板与调优建议(v0.8.5 中须显式设
VLLM_USE_V1=1,否则 ngram / EAGLE 回退 V0)
12.1 自回归枷锁:memory-bound 的根本代价
要理解投机解码为什么能工作,得先理解它在解决什么——LLM Decode 是被内存带宽而不是算力拖慢的。
12.1.1 一次 decode step 的成本结构
低并发 decode 的核心问题是:每一步通常只生成很少的新 token,但仍要走完整模型前向。对大模型来说,权重读取、KV Cache 访问、attention 状态组织、kernel 调度和采样输出都会进入临界路径。具体多少毫秒不能在正文里写成常数,因为它随模型大小、dtype、量化方式、GPU 型号、batch、上下文长度、CUDA Graph、FlashAttention 后端和服务端队列状态变化。
更稳妥的拆法是:
| 成本项 | 为什么存在 | 投机解码能否缓解 |
|---|---|---|
| 模型权重读取 | 每步都要让目标模型计算下一个分布 | 可以通过一次 verify 覆盖多个候选位置来摊薄 |
| KV Cache 访问 | attention 需要历史 K/V | 不能消除,只能减少目标模型 step 数 |
| draft 生成 | 额外 proposer 要先猜 token | 会新增成本,收益取决于接受率 |
| 拒绝采样 | 需要保证输出分布仍等价于目标模型 | 必需成本,不能省 |
| 采样与停止处理 | 每步都要采样、detokenize、检查终止条件 | step 数少了会间接受益 |
因此本章不把某个模型在某张卡上的演示数字写成普遍事实。投机解码的正确判断方式是:在你的模型、你的 prompt 分布、你的并发下,实测 num_draft_tokens、num_accepted_tokens、TTFT、TPOT 和吞吐变化。
12.1.2 Batch 能救一点,但不多
提高 batch size 可以分摊部分目标模型前向成本,但它不是投机解码的替代品。batch 变大后,GPU 利用率更高,单 token 的摊销成本可能下降;同时排队时间、KV 压力、调度预算和请求间长度差异也会上升。低并发在线对话追求的是单请求 TPOT,离线批处理追求的是总 tokens/s,这两个目标不是一回事。
12.1.3 投机解码的直觉:反正要读一遍权重,顺便多验几个 token
核心洞察:既然目标模型一次 verify 可以同时计算多个连续位置的 logits,就让便宜的 proposer 先猜一串候选 token,再让目标模型一次性验证。若前几个候选被接受,就减少了目标模型自回归 step 数;若候选经常被拒绝,draft 和验证的额外工作反而会拖慢系统。
graph TB
subgraph "传统 Decode (baseline)"
T1[每步生成 1 token] --> T2[目标模型一次 forward]
T2 --> T3[产出 1 个 token]
end
subgraph "投机解码 k=5"
S1[Draft 提议 5 个候选] --> S2[大模型 1 次 forward 验证 5 个位置]
S2 --> S3{接受 n 个}
S3 -->|接受前缀| S4[本 step 产出多个 token]
S4 --> S5[目标模型 step 数减少]
S5 --> S6[TPOT 可能下降]
end
style T3 fill:#dc2626,color:#fff,stroke:none
style S6 fill:#10b981,color:#333,stroke:none
这就是"以小博大"——用一个廉价的 draft(小模型 / 预测头 / n-gram)产生候选,用大模型一次 forward 并行验证。如果 draft 猜得准,加速立竿见影。
12.2 拒绝采样:为什么投机解码是精确的而不是近似的
一个常见的误解:投机解码是"用小模型近似大模型"。错。投机解码产出的 token 分布与直接用大模型采样完全一致。这是通过一个叫"拒绝采样(Rejection Sampling / Speculative Sampling)"的精妙算法保证的。
12.2.1 算法原理
设大模型的 token 分布为 ,草稿模型的分布为 。
对每个候选位置:
- 草稿阶段:从 中采样一个 token
- 验证阶段:大模型计算
- 接受/拒绝:
- 如果 (大模型比草稿更看好这个 token),无条件接受
- 否则,以概率 接受
- 拒绝时,从修正分布 中重新采样
12.2.2 数学证明(接受的 token 分布 = )
考虑任一 token 被最终采纳为输出的概率:
其中 是平均接受概率。
化简第一项:
化简第二项:
注意 ,所以第二项 = 。
合起来:
- 如果 := ✓
- 如果 := ✓
两种情况下都等于 。QED.
12.2.3 直觉解释
小模型和大模型的概率分布如果"大致一致",小模型提议的候选 token 通常会被接受(因为 的概率高)。只有当小模型"错判"(给了一个大模型不看好的 token 高概率),才会被拒绝——被拒绝后从"大模型独有的倾向分布"中重采样,保证整体分布等于大模型。
这套保持分布不变的算法由 DeepMind 的 Chen et al. (arXiv:2302.01318) 和 Google 的 Leviathan et al. (arXiv:2211.17192) 各自独立提出,2023 年成为 LLM 推理优化的显学。vLLM V1 的 RejectionSampler 在类注释里写明严格按 arXiv:2211.17192 实现;v0.8.5 中调用方给它的 draft_probs 恒为 None(vllm/v1/worker/gpu_model_runner.py:1139-1145),即把 draft 视为确定性提议( 在 draft token 上为 1)——n-gram 和取 argmax 的 EAGLE 本来就是确定性的,所以仍是精确的,没有做"为了性能而精度打折"的简化。例外在采样参数:拒绝采样只对目标分布施加温度与 top-k / top-p,不含 min_p 和惩罚项(见 §12.3.5)。
12.2.4 贪心采样(temperature=0)的退化形式
当 ,分布退化为 one-hot(所有概率集中在 argmax token)。拒绝采样变成简单的相等比较:
draft_token == target_token → 接受
draft_token != target_token → 拒绝,用 target 的 argmax
接受率就是"两个模型在当前上下文下恰好选同一个 argmax 的概率"。这种 case 的接受率通常比随机采样更高(Leviathan et al. 在 T5 实验中观察到 temp=0 时 α 和加速都更高),但也更依赖模型相似度。
12.3 num_speculative_tokens=k 一次 forward 里发生了什么
弄清楚数学之后,我们看工程——给定 k=5,一次 spec decode step 实际上 GPU 里跑的是什么。
12.3.1 Draft 阶段(5 个串行 step)
如果用 Draft Model(一个独立的小 LLM),5 次自回归 forward:
step-draft-1: input = [last_target_token], output = draft_token_1
step-draft-2: input = [draft_token_1], output = draft_token_2
step-draft-3: input = [draft_token_2], output = draft_token_3
step-draft-4: input = [draft_token_3], output = draft_token_4
step-draft-5: input = [draft_token_4], output = draft_token_5
小模型 draft 的成本取决于 draft 模型大小、并行度和是否与目标模型共享资源。它不是免费路径,必须把额外显存和额外 forward 纳入压测。
如果用 EAGLE / MTP(共享大模型 hidden state 的预测头),5 次预测头 forward:
hidden_0 (from target) → head(hidden_0) → draft_token_1 + hidden_1
hidden_1 → head(hidden_1) → draft_token_2 + hidden_2
hidden_2 → head(hidden_2) → draft_token_3 + hidden_3
...
EAGLE/MTP 这类预测头通常比完整 draft 模型轻,但具体成本仍取决于实现、模型结构和批大小。不要把某个 benchmark 的数字写成通用结论。
如果用 n-gram,候选来自已有上下文的 token 匹配,不需要额外 GPU 模型;但 CPU 上的模式匹配、JIT 预热和上下文长度仍会影响开销。
12.3.2 Verify 阶段:大模型一次 forward 5+1 个 token
Verify 阶段把 5 个 draft token 追加到输入序列,调用大模型 forward。大模型会输出6 个位置的 logits:
- 位置 0(last_target_token 之后):验证 draft_token_1
- 位置 1(draft_token_1 之后):验证 draft_token_2
- 位置 2(draft_token_2 之后):验证 draft_token_3
- 位置 3(draft_token_3 之后):验证 draft_token_4
- 位置 4(draft_token_4 之后):验证 draft_token_5
- 位置 5(draft_token_5 之后):bonus token
第 5 位的 logits 是"如果 draft 全接受,顺便给你生成的下一个 token"。这是 spec decode 的免费午餐——无论前面接受几个,本步总会多得 1 个目标模型给出的 token:全接受时是 bonus token,否则是第一个被拒位置重采样出的 token(源码称 recovered token)。
12.3.3 cu_seqlens 的构造
第 11 章讲过的 varlen 混合批协议在这里被原样复用——spec decode 对 attention backend 来说,无非是"每个 decode 请求的 Q 长度从 1 变成 k+1":
# 假设 batch 里 3 个 decode 请求都做 spec decode with k=5
# 每个请求贡献 6 个 Q 位置(上一步采样出的 1 个 token + 5 个 draft)
query.shape = [3 * 6, num_heads, head_dim] = [18, H, D]
cu_seqlens_q = attn_metadata.query_start_loc # [0, 6, 12, 18]
# K/V 侧不用累积和:vLLM 传的是每请求的 KV 长度本身
seqused_k = attn_metadata.seq_lens # 例如 [2005, 1505, 3005]
block_table = attn_metadata.block_table # 每请求的 paged KV 物理块号
注意 K/V 侧传的是 seqused_k(每请求的 K/V 长度)加上 block_table(见 vllm/v1/attention/backends/flash_attn.py:563-592),不传 K 侧的累积和——因为 KV 住在 PagedAttention 的块里,本来就不是一个按请求首尾相接的连续张量,偏移量式的累积和用不上。
Attention mask 还有一个 subtle 细节——k 个 draft token 之间必须保持因果(draft_token_2 能看 draft_token_1,但不能看 draft_token_3~5)。vLLM 在调用 flash_attn_varlen_func 时固定传 causal=True,varlen kernel 对每个请求按「Q 段末尾对齐 KV 末尾」应用因果掩码:第 i 个 Q 位置能看到全部历史加上它之前的 draft,正好就是需要的语义——不需要为 spec decode 单独构造 mask。
12.3.4 接受 n 个 token 后的状态更新
Verify 返回 6 个位置的 logits 后,拒绝采样逐个检查 5 个 draft token:
if accepted all 5: next step starts at position + 6 (5 accepted + 1 bonus)
if accepted 3: next step starts at position + 4 (3 accepted + 1 resampled)
if accepted 0: next step starts at position + 1 (0 accepted + 1 resampled)
关键是 KV Cache 的处理。先纠正一个常见误解:写进目标模型 KV Cache 的不是 draft 阶段,而是 verify 那一次 forward——它把这一步调度到的全部 k+1 个位置的 K/V 都算好并写进了块里。所以如果只接受了 3 个:
- 前 3 个位置的 KV 是正确的(大模型验证通过,且它们的前缀都对)
- 第 4 个位置:draft 被拒,从大模型重采样出一个新 token。这个位置的 K/V 是按 draft_4 算的,与实际采纳的 token 不一致,必须作废
- 第 5 个 draft token 的 KV 同样作废——它建立在错误的 draft_4 之上
V1 不做任何物理擦除,只是把进度指针往回拨。Scheduler.update_from_output(vllm/v1/core/sched/scheduler.py:663-665)里就一行:
num_tokens_rejected = (len(scheduled_spec_token_ids) + 1 -
len(generated_token_ids))
request.num_computed_tokens -= num_tokens_rejected
被拒位置对应的 KV 槽位还留着旧数据,但因为 num_computed_tokens 退回去了,下一步会重新调度这些位置、用正确的 token 再写一遍——旧值被覆盖,永远不会被读到。同一段代码顺带记下 num_draft_tokens 和 num_accepted_tokens = len(generated_token_ids) - 1(减掉本步必有的那 1 个目标模型 token,bonus 或 recovered),这就是 §12.7.5 里 Prometheus 接受率指标的数据源。
这个设计再次体现了第 11 章 §11.4.2 的扁平哲学:进度用一个整数表达,回滚就是一次减法。
12.3.5 当前 V1 主路径怎么串起来
把上面的概念落到本地源码,V1 主路径可以按四段读。
第一段是初始化。gpu_model_runner.py 在构造时检查 self.speculative_config。如果存在配置,就把 use_spec_decode 设为 True;最后一个 pipeline rank 上,method == "ngram" 创建 NgramProposer,use_eagle() 创建 EagleProposer,并创建 RejectionSampler。这就是为什么 §12.4 反复强调:当前 V1 直接接入的是 n-gram 与 EAGLE/EAGLE3,而不是配置层认识的所有 speculative method。
第二段是生成 draft,发生在 execute_model 末尾、本步采样之后,产出的 draft 经 ModelRunnerOutput.spec_token_ids 交回调度器,下一步才被排进 batch 验证。n-gram 走 generate_draft_token_ids()(vllm/v1/worker/gpu_model_runner.py:1290),逐请求检查几个跳过条件:本轮没有 sampled token 就跳过;is_spec_decode_supported() 判定不支持就跳过;追加 sampled token 后已经到 max_model_len 就跳过;proposer 返回 None 或空结果也跳过。最终返回的是 list[list[int]],也就是每个请求各自的 draft token 列表——同一个 batch 里可以有些请求走 spec、有些不走。EAGLE 不走这个函数,而是对整批直接调一次 self.drafter.propose(...)(:1265-1275),每个请求都拿到 num_speculative_tokens 个 draft。
那个 is_spec_decode_supported 只有 18 行(vllm/v1/spec_decode/utils.py),但它列出了 v0.8.5 里 spec decode 的三条采样能力禁区:用了 min_p 的请求不支持、用了 frequency / presence / repetition 任一惩罚项的不支持、要 logprobs 的不支持。生产上如果发现"开了 n-gram 但 draft 计数几乎不涨",第一件事就是检查请求里是不是带了这些参数——它们会让这些请求静默退回普通 decode。注意这道检查全树只在 n-gram 分支里调用(vllm/v1/worker/gpu_model_runner.py:1307);EAGLE 分支不做这三项过滤,带这些参数的请求照样投机,而拒绝采样并不施加 min_p 和惩罚项(按 v0.8.5 源码判断、未实跑)。
第三段是把 ragged draft 变成张量。调度器把各请求的 draft 连同普通 token 写进本步输入,GPUModelRunner._calc_spec_decode_metadata()(vllm/v1/worker/gpu_model_runner.py:769)再按每请求的 draft 数算出扁平的 draft_token_ids、num_draft_tokens、前缀和 cu_num_draft_tokens,以及 target / bonus 两组 logits 下标,装进 SpecDecodeMetadata。这和第 11 章 chunked prefill 讨论的 varlen 元数据思想一致:真正麻烦的不是 token 值,而是每个请求 draft 长度不同。
第四段是接受/拒绝。目标模型 forward 后,rejection_sampler.py 根据 target logits、draft token 和采样参数做接受判断(draft 概率这个参数在 v0.8.5 恒传 None,见 §12.2.3)。它还要处理贪心退化路径、top-k/top-p 约束、seed 随机数、按位置接受统计等细节。因此 V1 里最大的 spec decode 相关单文件不是 proposer,而是 vllm/v1/sample/rejection_sampler.py。
12.4 Proposer 谱系:V1 主路径与广义方法
v0.8.5 源码里,V1 worker 直接初始化的 proposer 只有两类:NgramProposer 和 EagleProposer。vllm/v1/worker/gpu_model_runner.py:166-182 的逻辑一目了然:
self.use_spec_decode = False
self.use_aux_hidden_state_outputs = False
if self.speculative_config:
self.use_spec_decode = True
if get_pp_group().is_last_rank:
if self.speculative_config.method == "ngram":
self.drafter = NgramProposer(self.vllm_config)
elif self.speculative_config.use_eagle():
self.drafter = EagleProposer(self.vllm_config, self.device)
if self.speculative_config.method == "eagle3":
self.use_aux_hidden_state_outputs = True
else:
raise ValueError("Unknown speculative decoding method: "
f"{self.speculative_config.method}")
self.rejection_sampler = RejectionSampler()
两个细节值得注意:drafter 只在最后一个 pipeline rank 上创建(get_pp_group().is_last_rank,因为只有它拿得到最终 hidden state 和 logits);除 ngram / eagle / eagle3 外的任何 method 在这里直接 raise ValueError。事实上根本走不到这个 raise——vllm/engine/arg_utils.py:1423-1442 在选引擎时就已经把非这三种的 method 判为"V1 不支持",走 _raise_or_fallback 回退到 V0。而且即便是这三种,未显式设 VLLM_USE_V1=1 时也被当作实验特性回退 V0(vllm/engine/arg_utils.py:1484-1490,EAGLE 那条注释写的是 "Eagle is under development");设了 VLLM_USE_V1=1 才会走到这里。更广的 vLLM 代码仍保留 V0 vllm/spec_decode/、draft model、Medusa、MLP speculator,以及 model executor 里的 deepseek_mtp.py 等文件,所以读源码时要区分"配置层认识的 method"、"V0 遗留 worker"和"V1 当前主路径"。
graph TB
Prop[Spec Decode Proposer]
Prop --> NG["n-gram<br/>V1 直接支持"]
Prop --> EG["EAGLE / EAGLE3<br/>V1 直接支持"]
Prop --> DM["Draft Model<br/>配置层/V0 路径相关"]
Prop --> MTP["MTP / Medusa / MLP<br/>模型文件或 V0 路径相关"]
NG --> NGD["从上下文匹配候选<br/>收益依赖重复性"]
EG --> EGD["使用 EAGLE draft 模型<br/>需要对应权重与配置"]
DM --> DMD["独立小模型 draft<br/>额外显存与 forward 成本"]
MTP --> MTPD["模型原生或专门头<br/>是否可用取决于当前路径"]
style NG fill:#f59e0b,color:#333,stroke:none
style EG fill:#10b981,color:#333,stroke:none
style DM fill:#2563eb,color:#fff,stroke:none
style MTP fill:#7c3aed,color:#fff,stroke:none
12.4.1 Draft Model
最经典的形态——用一个小得多的同系列模型做 draft。例如 Llama-3-8B-Instruct 给 Llama-3-70B-Instruct 做 draft。
下面这段是概念示意,不对应 v0.8.5 任何文件——DraftModelProposer 类和它的 propose 方法都是为讲清流程现编的名字;真实的 V0 实现把这套流程拆在 MultiStepWorker 和 draft runner 两边,接口名与这里对不上,别照着去源码里搜。
# 概念示意,非 v0.8.5 源码
class DraftModelProposer:
def __init__(self, draft_model_config, ...):
# 加载 draft 模型到 GPU(占一部分显存)
self.draft_runner = GPUModelRunner(draft_model_config, ...)
def propose(self, target_ids, num_speculative_tokens, ...):
# 自回归跑 k 步 draft
draft_ids = []
current_input = target_ids[-1:]
for _ in range(num_speculative_tokens):
logits = self.draft_runner.forward(current_input, past_kv=...)
draft_token = sample(logits, temperature=draft_temp)
draft_ids.append(draft_token)
current_input = draft_token.unsqueeze(0)
return draft_ids
vllm/v1/spec_decode/ 下没有 draft model proposer,V0 的对应实现在 vllm/spec_decode/multi_step_worker.py(416 行)+ draft_model_runner.py(335 行)。
优点:
- 同系列小模型 tokenizer 一致、分布相近,接受率往往不错
- 现成的 checkpoint 可直接用(如 Llama 3.x 系列的 1B(3.2)/8B/70B)
缺点:
- 显存占用和加载时间取决于 draft 模型大小、dtype、量化方式和并行度
- 每个 draft step 都是额外计算,不能只看目标模型 verify 的收益
- 长序列 draft 时,小模型自己也要做 KV Cache 管理
12.4.2 EAGLE / EAGLE3
EAGLE (arXiv:2401.15077) 的洞察:在特征层(目标模型 LM head 之前那一层的 hidden state)上做自回归,比直接在 token 层做容易得多;再把提前一步的 token 序列一并喂进去,消除采样带来的不确定性。于是一个很轻的草稿头(v0.8.5 的 llama_eagle.py 按草稿权重 config 的层数堆 LlamaDecoderLayer,llama_eagle3.py 固定单层)就能从 hidden state 里解码出后续 token。propose() 开头把 input_ids 整体左移一位、再配上目标模型的 hidden state(vllm/v1/spec_decode/eagle.py:63-69),正是这个设计。
vllm/v1/spec_decode/eagle.py(318 行)的 propose() 签名直接说明了它需要什么(第 41-58 行):target_token_ids、target_positions、target_hidden_states、target_slot_mapping、next_token_ids、cu_num_tokens、block_table、sampling_metadata——全部来自目标模型这一步已经算出来的东西,不需要另跑一遍大模型。
主循环的骨架是这样(vllm/v1/spec_decode/eagle.py:99-168,节选):
sample_hidden_states = hidden_states_logits[last_token_indices]
logits = self.model.compute_logits(sample_hidden_states, None)
draft_token_ids = logits.argmax(dim=-1)
# Early exit if there is only one draft token to be generated.
if self.num_speculative_tokens == 1:
return draft_token_ids.view(-1, 1)
draft_token_ids_list = [draft_token_ids]
for _ in range(self.num_speculative_tokens - 1):
input_ids = draft_token_ids_list[-1]
positions += 1
# ... 更新 attn_metadata、跑一次 draft 模型 ...
logits = self.model.compute_logits(hidden_states_logits, None)
draft_token_ids = logits.argmax(dim=-1)
draft_token_ids_list.append(draft_token_ids)
# [batch_size, num_speculative_tokens]
return torch.stack(draft_token_ids_list, dim=1)
三个容易被忽略的实现事实:
- draft 阶段是纯贪心——用的是
logits.argmax(dim=-1),不是按sampling_metadata采样。文件里其实写好了按温度采样并返回 draft 概率的compute_probs_and_sample_next_token,但注释说明它暂未启用、"always use argmax"(vllm/v1/spec_decode/eagle.py:252-255),原因是 draft 概率张量还没想好怎么管理。确定性的 argmax draft 配合draft_probs=None,拒绝采样仍然精确(§12.2.3)。 num_speculative_tokens == 1有专门的 early exit——只猜一个 token 时,第一次 forward 出来的结果直接 reshape 返回,循环一次都不跑。- 进入循环前把
attn_metadata.max_query_len设为 1(vllm/v1/spec_decode/eagle.py:113-115)——第二步开始,每个请求只喂一个 token,draft 模型自己也走 decode 形态。
EAGLE3(arXiv:2503.01840)在 EAGLE 基础上改变了 draft 模型结构,用多层特征融合取代只用顶层特征(需要目标模型多层的辅助 hidden state),所以 V1 会在 method == "eagle3" 时把 use_aux_hidden_state_outputs 打开。接受率和收益必须按具体 EAGLE 权重、目标模型和任务压测,不能只靠方法名判断。
v0.8.5 V1 EAGLE 的完成度要心里有数:load_model 只认 Llama 结构的草稿头(EagleLlamaForCausalLM / Eagle3LlamaForCausalLM),并自注 "This does not handle with distributed inference"(vllm/v1/spec_decode/eagle.py:221);draft forward 直接构造 FlashAttention 后端的元数据(vllm/v1/spec_decode/eagle.py:77)、不走 CUDA Graph。另外它牵动前缀缓存:启用 EAGLE 且有命中时,KVCacheManager.get_computed_blocks 会丢掉最后一个命中块重算,以拿到草稿头需要的 hidden state(vllm/v1/core/kv_cache_manager.py:140-145,见第 10 章)。
优点:
- 相比完整 draft 模型,EAGLE 路径通常更轻
- V1 主路径有明确的
EagleProposer实现 - 不需要额外的 tokenizer;EAGLE 草稿头借用目标模型的
lm_head,EAGLE3 权重里没有 embedding 时借用目标模型的embed_tokens(vllm/v1/spec_decode/eagle.py:242-249)
缺点:
- 需要针对特定 target 模型预训练 EAGLE head——不是所有模型都有现成 checkpoint
- 需要和目标模型匹配的 EAGLE/EAGLE3 权重;跨模型乱配会直接伤接受率
12.4.3 MTP (Multi-Token Prediction)
DeepSeek-V3 采用的训练目标(技术报告 §2.2)——模型本身就带 MTP 模块,训练时除了下一个 token 还要预测更远的 token。与 Medusa 那种并行的多个头不同,DeepSeek-V3 的 MTP 模块是串行的:第 d 个模块吃第 d-1 个深度的表示和下一个 token 的 embedding,并共享主模型的 embedding 与输出头;V3 设深度 D=1,即只多预测 1 个 token。推理时这些模块可以直接当草稿用:
# 概念示意,非 v0.8.5 源码
def propose_mtp(h_main, next_token, mtp_modules):
h, tok, draft_tokens = h_main, next_token, []
for m in mtp_modules: # DeepSeek-V3 只训练了 1 个(D=1)
h = m(h, embed(tok)) # 上一深度的表示 + 下一个 token 的 embedding
tok = shared_lm_head(h).argmax(-1)
draft_tokens.append(tok)
return draft_tokens
优点:
- 预测头和主模型是同一模型设计的一部分,理论上比外接小模型更容易对齐
- 不需要另选一个独立 draft checkpoint
缺点:
- 只有原生支持 MTP 的模型能用
- 不能跨模型适配(不像 Draft Model 那样你可以自选一对模型)
v0.8.5 源码里可以看到 vllm/model_executor/models/deepseek_mtp.py,但 V1 GPUModelRunner 的 drafter 初始化分支只直接接受 n-gram 和 EAGLE/EAGLE3。阅读时不要把"模型文件存在"等同于"V1 spec decode proposer 已把它作为独立方法启用"。
12.4.4 n-gram
最简单但在对的场景下惊人有效。原理:在已生成的上下文里找重复模式。
打开 vllm/v1/spec_decode/ngram_proposer.py,实现不是朴素的嵌套循环——而是 KMP(Knuth-Morris-Pratt)字符串匹配 + Numba JIT。131 行代码完整实现了线性时间模式匹配:
# vllm/v1/spec_decode/ngram_proposer.py:10(节选,略去类型注解、docstring、load_model 与 assert)
class NgramProposer:
def __init__(self, vllm_config):
self.min_n = vllm_config.speculative_config.prompt_lookup_min
self.max_n = vllm_config.speculative_config.prompt_lookup_max
self.k = vllm_config.speculative_config.num_speculative_tokens
self.max_model_len = vllm_config.model_config.max_model_len
# Trigger Numba JIT compilation for N-gram proposer.
# This usually takes less than 1 second.
self.propose(np.zeros(1024, dtype=np.int32)) # ← 构造时预热
def propose(self, context_token_ids):
k = min(self.k, self.max_model_len - context_token_ids.shape[0])
if k <= 0:
return None
for n in range(self.max_n, self.min_n - 1, -1): # 先试长 n-gram
result = _find_subarray_kmp(context_token_ids, n, k)
if result is not None:
return result
return None
@jit(nopython=True)
def _kmp_lps_array(pattern):
"""Build the lps (longest proper prefix which is also suffix) array."""
lps = np.zeros(len(pattern), dtype=np.int32)
prev_lps = 0
i = 1
while i < len(pattern):
if pattern[i] == pattern[prev_lps]:
prev_lps += 1
lps[i] = prev_lps
i += 1
else:
if prev_lps != 0:
prev_lps = lps[prev_lps - 1]
else:
lps[i] = 0
i += 1
return lps
@jit(nopython=True)
def _find_subarray_kmp(context_token_ids, n, k):
pattern = context_token_ids[-n:] # 末尾 n 个 token 做 pattern
lps = _kmp_lps_array(pattern)
i, j = 0, 0
while i < context_token_ids.shape[0] - n:
if context_token_ids[i] == pattern[j]:
i += 1; j += 1
if j == n:
return context_token_ids[i:i + k] # 返回匹配后的 k 个 token
else:
if j != 0:
j = lps[j - 1] # KMP 的精髓:跳过已匹配前缀
else:
i += 1
return None
四个真实实现细节值得展开——它们决定了这个模块能不能在生产流量下真的省出延迟:
-
KMP 而不是朴素 O(N·M) 搜索。对 4K context、
max_n=8的 pattern,朴素搜索最坏是 32K 次比较;KMP 通过lps数组记住"已匹配前缀里有多少能当作新起点",全程 O(N+M) — 约 4K 次。看起来小,但 decode 每一步都跑、累积放大。KMP 在 LLM 场景的 pattern 长度较小(<=8),lps 数组也只有 8 元素——实际性能差距主要来自"命中时不退回重扫"。 -
Numba JIT
@jit(nopython=True)的nopython模式是关键——nopython=True强制 Numba 生成不调用 Python C API 的机器码。没这个参数可能退化到 object-mode,循环仍会受 Python 解释器影响。源码选择 JIT,是因为 n-gram proposer 可能在每个 decode 步都运行。 -
构造时预热(
self.propose(np.zeros(1024, dtype=np.int32)))——Numba JIT 首次调用需要编译。源码在__init__里主动跑一次 propose,目的就是把首次编译成本从第一条真实请求上移走。 -
长 n-gram 优先回退(line 62
range(self.max_n, self.min_n - 1, -1))——先试max_n(如 §12.7.4 示例的 8)的模式匹配,没找到逐级降到min_n(两者都不填时SpeculativeConfig默认均为 5,vllm/config.py:2316-2320)。理由:长模式匹配成功的信号更强(8 个连续 token 重复几乎确定是模板化输出),短模式命中多但假阳性也多。先试长的能让"真正的高接受率命中"优先返回——绕过短 pattern 的干扰。
示例直接来自源码 docstring(line 46-54):
context_token_ids = [1,2,3,4,2,3], min_n=2, max_n=3, k=4
- 最后 3 个 token [4,2,3] 找不到匹配
- 最后 2 个 token [2,3] 在前 4 个 [1,2,3,4] 里找到
- 返回 [2,3] 之后的 token,即 [4,2,3],只有 3 个(因为 context 只剩这么多)
注意返回的可以短于 k——源码注释明示"如果匹配后剩余 token 不足 k 个,返回全部剩余"。这种"尽力而为"语义对调度器是友好的——总比返回 None 让 proposer 失效要强。
场景契合度:代码生成、模板化摘要、长文改写里更容易出现重复片段,n-gram 更可能命中;开放聊天和创意写作的后续分布更分散,n-gram 可能经常返回 None 或低质量候选。这也是为什么 n-gram 是"低额外成本但场景敏感"的选项。
典型场景是代码生成:
# 已生成的 context(示意,实际按 token 而非字符匹配)
"def foo(a, b): return a + b\ndef bar(a, b): return a + "
# 末尾的 "return a + " 在前文出现过
# KMP 找到那一处,把紧随其后的 "b\n..." 原样抄来当 draft
# 目标模型确实要写 "b\n" 时,这几个 draft 会被整段接受;
# 若新函数写的是 "x + y",第一个 draft 就会被拒——n-gram 只会照抄
优点:
- draft 生成零 GPU 开销(纯 CPU + Numba JIT)
- 零显存占用
- 实现简单,几十行代码
- 在高重复性任务上更可能命中有用候选
缺点:
- 创意写作、开放对话等场景命中率和接受率通常更不稳定
- 依赖 context 长度(越长找到匹配概率越高)
12.4.5 选择建议
graph LR
Q[选 Proposer?] --> A{任务类型}
A -->|代码 / 摘要 / 改写| NG[n-gram<br/>无额外模型]
A -->|对话 / 通用| B{target 模型}
B -->|DeepSeek-V3/R1| MTP[MTP<br/>原生内置,v0.8.5 仅 V0]
B -->|其他| C{VRAM 宽裕?}
C -->|有| DM[Draft Model<br/>同系列小模型,v0.8.5 仅 V0]
C -->|紧张| EG[EAGLE<br/>轻量折中]
style NG fill:#f59e0b,color:#333,stroke:none
style MTP fill:#7c3aed,color:#fff,stroke:none
style DM fill:#2563eb,color:#fff,stroke:none
style EG fill:#10b981,color:#333,stroke:none
12.5 加速比的闭式公式
推一下 spec decode 的理论加速比。
12.5.1 接受率与期望接受长度
设每个位置被接受的独立概率为 (接受率),num_speculative_tokens=k。期望接受的 token 数:
加上那 1 个 bonus token,每 step 净产出:
12.5.2 时间成本
设 verify 一次 forward 的时间为 ,draft 的相对开销为 。这里的 必须来自压测,不能按 proposer 名字固定。完整 draft model、EAGLE、n-gram 在不同模型和不同 batch 下的 都会变。
每 step 总时间:
12.5.3 加速比
原始 decode 每 step 产出 1 token,时间 (简化:忽略 verify 比原 decode 稍多的那一点)。所以:
这就是 Leviathan et al. 论文的定理 3.8(原文记 为 ),前提同样是各位置接受相互独立、同分布为 。
12.5.4 数值计算
下面这张表是代入公式的假设值示例,不是 vLLM 官方 benchmark,也不是本专栏实测。它的用途是展示 和 如何影响最优 k:
| 假设场景 | α | c | k=3 | k=5 | k=7 | 上界 | 观察 |
|---|---|---|---|---|---|---|---|
| 高接受率、低 draft 成本 | 0.75 | 0.05 | 2.38× | 2.63× | 2.67× | 4.00× | k=5 之后基本饱和 |
| 中等接受率、中等成本 | 0.55 | 0.20 | 1.26× | 1.08× | 0.92× | 2.22× | 最优 k 很小,k=7 已经亏 |
| 低接受率、零成本 | 0.25 | 0.00 | 1.33× | 1.33× | 1.33× | 1.33× | 接受率本身就是天花板 |
| 高接受率、高 draft 成本 | 0.75 | 0.40 | 1.24× | 1.10× | 0.95× | 4.00× | draft 成本吃光收益,k=7 反而更慢 |
(这几个数是把 、、 代进上面公式直接算出来的,读者可以自己验算。)
几个洞察:
- 加速比有硬上界 ——这是 、 的极限。接受率 0.25 时,哪怕 draft 完全免费也只有 1.33×;想要 4× 就必须先把接受率做到 0.75
- 时一定存在最优 k,而且往往比想象的小——分子随 指数收敛到上界,分母却随 线性增长。上表第二、第四行的最优 k 都是 3 甚至更小,继续加 k 是净亏
- 决定"最优 k"的位置—— 越小,最优 k 越大; 大到一定程度,spec decode 整体就变成负优化(表中第四行 k=7 已经掉到 1 以下)
实际生产中不要按表抄 k。正确流程是固定目标模型和流量,扫描 num_speculative_tokens,同时看接受率、TPOT、TTFT、总吞吐、KV 占用和错误率。只要 k 增大后接受率下降、draft 成本上升或队列变长,就应该停在更小的 k。
顺带一个源码级的硬上限:vllm/v1/sample/rejection_sampler.py:20 定义了 MAX_SPEC_LEN = 32,注释说明这是单个请求一步内允许的最大 draft token 数。按上面的分析,实际最优 k 通常远小于这个数——32 是个安全阀,不是推荐值。
12.6 为什么"batch 大了投机解码反而变慢"
这是一个经常让工程师困惑的现象。原因是高并发下三个变化:
12.6.1 Draft cost 不再可忽略
低并发下目标模型 step 数少是主要矛盾,draft 的额外工作可能被收益覆盖;高并发下 GPU、KV 和调度预算更紧,draft 的额外 forward、额外 token 位置和拒绝后的浪费都更明显。是否变慢取决于实际 batch,而不是某个固定阈值。
12.6.2 Verify 的 k 倍 Q 占用 budget
一个 running 请求纯 decode 时 num_scheduled_tokens=1;开了 num_speculative_tokens=5 后,num_tokens_with_spec 让它一步要 6 个 token 位置。同一个 max_num_batched_tokens=4096 的预算,原本理论上能覆盖 4096 个 decode 请求,现在只剩 4096 // 6 ≈ 682 个。
(实际并发还同时受 max_num_seqs 限制,通常远小于这两个数——但比例关系是准的。)
换句话说,spec decode 把每请求的 budget 消费放大了 k+1 倍。在中高并发下,这个放大效应让 batch 压不下来,吞吐反而降。
12.6.3 拒绝采样的部分浪费
被拒绝的 draft token 会带来无效验证工作。接受率越低,浪费越大;k 越大,单步可能浪费的位置也越多。这部分成本在高并发场景会更容易暴露。
12.6.4 经验法则
v0.8.5 的 SamplingParams 没有 use_spec_decode 字段;投机解码是 engine 级 --speculative-config / SpeculativeConfig 能力。配置层有 disable_by_batch_size,但它只被 V0 的 SpecDecodeWorker 读取:运行队列长度 ≥ 阈值时,把本步各请求的投机长度置 0,且这些请求此后不再投机(vllm/spec_decode/spec_decode_worker.py:553-571);V1 全目录不读这个字段。也就是说,"按负载动态收缩"在 v0.8.5 只是 V0 的配置层能力,不是每个请求随手传一个 SamplingParams(use_spec_decode=False),V1 下要靠部署侧自己控制并发。
12.7 生产配置模板与调优
12.7.1 先记住:v0.8.5 只有一个 CLI 入口
先纠正一个到处流传的过时写法。v0.8.5 的 EngineArgs 里,投机解码只暴露一个命令行参数:
--speculative-config # 一个 JSON,对应 SpeculativeConfig
--speculative-model、--num-speculative-tokens、--speculative-draft-tensor-parallel-size 这些老的独立 flag 已经不在 add_cli_args 里了(vllm/engine/arg_utils.py:744-752 只注册了 --speculative-config)。所有字段——method、model、num_speculative_tokens、draft_tensor_parallel_size、disable_by_batch_size、prompt_lookup_min / prompt_lookup_max——都写进那个 JSON 里(字段定义在 vllm/config.py:2149-2243)。
还要记住上一节的硬约束:V1 引擎只认 ngram / eagle / eagle3 三种 method。写别的 method(包括独立 draft model)会触发 _raise_or_fallback,要么报错要么回退到 V0 引擎——不是"配上就能用"。即便是这三种,v0.8.5 也只把它们当实验特性:不设 VLLM_USE_V1=1 就回退 V0(vllm/engine/arg_utils.py:1484-1490),所以下面想走 V1 的模板都显式带上了这个环境变量。
12.7.2 单用户对话(低并发,追求 TPOT):EAGLE
VLLM_USE_V1=1 vllm serve meta-llama/Llama-3.1-70B-Instruct \
--speculative-config '{"method": "eagle", "model": "<对应的 EAGLE head 权重>", "num_speculative_tokens": 5}' \
--max-num-seqs 16 \
--gpu-memory-utilization 0.92
这类配置的目标是降低低并发 TPOT。EAGLE head 必须是针对这个 target 模型训练的权重,随便配一个只会把接受率打到地板。收益和显存占用必须用自己的模型实测;不要把示例命令理解成某个固定倍数的收益。70B 通常要多卡 TP,而 v0.8.5 的 EagleProposer.load_model 自注未处理分布式推理(vllm/v1/spec_decode/eagle.py:221),上多卡前先小规模验证(按源码判断、未实跑)。
12.7.3 DeepSeek MTP:v0.8.5 的 V1 路径还接不上
网上常见这样的写法:
# 注意:在 v0.8.5 上这会让引擎回退到 V0(设了 VLLM_USE_V1=1 则直接报错)
vllm serve deepseek-ai/DeepSeek-V3 \
--speculative-config '{"method": "deepseek_mtp", "num_speculative_tokens": 3}' \
--max-num-seqs 64
DeepSeek MTP 属于模型原生多 token 预测路线,v0.8.5 里确实有 vllm/model_executor/models/deepseek_mtp.py(266 行)这个模型实现。但 arg_utils.py 的 V1 支持判定只放行 ngram / eagle / eagle3,deepseek_mtp 会命中 "Other speculative decoding methods are not supported yet" 分支(vllm/engine/arg_utils.py:1439)。"模型文件存在" ≠ "V1 spec decode 能用它"——这是读 vLLM 源码时最容易踩的一类坑。回退后由 V0 的 SpecDecodeWorker + MultiStepWorker 跑 MTP 草稿(vllm/spec_decode/spec_decode_worker.py:202-204);目标模型是 deepseek_v3 时,SpeculativeConfig 在未给 model 的情况下会自动拿目标模型自身当草稿来源(vllm/config.py:2295-2299,按源码判断、未实跑)。想在 V1 上用,请确认你所在版本的 arg_utils.py 是否已经把它加进白名单。
12.7.4 代码生成工作流(n-gram)
VLLM_USE_V1=1 vllm serve meta-llama/Meta-Llama-3-70B-Instruct \
--speculative-config '{"method": "ngram", "num_speculative_tokens": 5, "prompt_lookup_min": 3, "prompt_lookup_max": 8}' \
--max-num-seqs 32
n-gram 不加载额外 GPU 模型,适合先用低额外成本验证重复性任务有没有收益。代码场景经常值得试,但接受率和提速仍要用 spec_decode_* 指标验证。
12.7.5 调优关键指标
vLLM 暴露 spec decode 的专用 Prometheus 指标:
vllm:spec_decode_num_drafts_total # 累计发起 draft 的次数(不是 token 数)
vllm:spec_decode_num_draft_tokens_total # 累计提议的 draft token 数
vllm:spec_decode_num_accepted_tokens_total # 累计被接受的 draft token 数
vllm:spec_decode_num_accepted_tokens_per_pos # 带 position 标签,看第几个位置开始掉接受率
四个 Counter 都定义在 vllm/v1/spec_decode/metrics.py:118-153。注意第四个在源码里注册名不带 _total,但 prometheus_client 导出 Counter 时会统一补上,实际抓到的是 vllm:spec_decode_num_accepted_tokens_per_pos_total。另外,V0 时代的 vllm:spec_decode_num_emitted_tokens_total 在 V1 里已经没有了,用它写的告警规则要改。
计算实时接受率:。num_accepted_tokens_per_pos 尤其有用——它按位置分桶,能直接看出"第几个 draft token 之后接受率崩掉",从而反推该把 num_speculative_tokens 定在几。如果接受率长期偏低、TPOT 没降反升或队列变长,就考虑降 k、换 proposer、调低并发下才启用,或直接关闭 spec decode。
12.8 投机解码的未来
Spec Decode 这个方向从 2022 年以来持续演进。可以关注的方向包括:
- 模型协同设计——像 MTP 这样"训练时就考虑 spec"的模型会越来越多
- Tree-based 投机——一次 draft 多个分支(tree),验证时挑最好的那支;Medusa 和 EAGLE 已用静态树,EAGLE-2 改为按草稿置信度动态建树(v0.8.5 的 V1 只有单链 draft)
- 层次化投机——draft 嵌套 draft(小小模型 + 小模型 + 大模型),像 CPU cache 的 L1/L2/L3
- 跨请求共享 draft——同 prompt 前缀的多个请求共用同一段 draft
- 硬件协同——draft 上 CPU / NPU 异构加速,verify 上 GPU
vLLM 的配置层和模型实现层保留了多种 speculative decoding 路线,但能否进入 V1 主路径,要看 GPUModelRunner、SpeculativeConfig 校验和对应 proposer 是否已接好。读新 PR 时优先看三处:config.py 是否识别 method,gpu_model_runner.py 是否初始化 proposer,vllm/v1/sample/rejection_sampler.py 是否支持对应接受逻辑。
12.9 V1 vs V0 投机解码模块的真实尺寸:从 4320 行简化到 692 行
vLLM 在 V1 重写了整个投机解码子系统,目录规模缩减约 6.2 倍——当然功能面也小得多(V1 只有 ngram 与 EAGLE):
| 模块 | 文件数 | 行 | 备注 |
|---|---|---|---|
V1 vllm/v1/spec_decode/ |
6 | 692 | eagle.py 318 + metrics.py 164 + ngram_proposer.py 131 + metadata.py 61 + utils.py 18 + __init__.py 0 |
V0 vllm/spec_decode/ |
16 | 4320 | spec_decode_worker.py 1324 + batch_expansion.py 505 + multi_step_worker.py 416 + draft_model_runner.py 335 + util.py 276 + top1_proposer.py 274 + 其余 10 个文件 |
| 拒绝采样(V1 在 sample/) | 1 | 631 | vllm/v1/sample/rejection_sampler.py——本章 §12.2 算法的实际实现 |
| 模型实现 model_executor/models/ | 6 | 1312 | eagle 247 / deepseek_mtp 266 / llama_eagle3 232 / medusa 210 / mlp_speculator 205 / llama_eagle 152 |
V1 目录规模更小的三条根因——
- 没有独立的 worker 类——V0 有
SpecDecodeWorker(1324 行)+MultiStepWorker(416)两个专门的 worker,再配BatchExpansionTop1Scorer(505)/MQAScorer(159)两种打分器;V1 把投机解码变成GPUModelRunner.execute_model里的一段分支——本步采样后顺手生成 draft,交回调度器,下一步随普通 token 一起排进主 forward 的 batch,不需要单独的执行器 - 没有独立
BatchExpansion文件——V0 的 batch_expansion.py 505 行专门处理"如何把 k 个 draft token 展开成 verifier 的 input":把每个多 query 位置的序列拆成若干单 query 序列,docstring 自承 "strictly less efficient than MQA scoring"(vllm/spec_decode/batch_expansion.py:34)。V0 另有直接喂多 query 位置的MQAScorer,但只在 FlashAttention 后端、eager 模式且草稿max_model_len不小于目标时才启用,否则退回 batch expansion(vllm/spec_decode/spec_decode_worker.py:225-246)——默认不开 eager,所以 V0 默认走的其实是 batch expansion。V1 天然就是 MQA 式:把 draft token、num_draft_tokens、cu_num_draft_tokens等信息放进SpecDecodeMetadata,再交给主 runner 组织 target forward - 没有 proposer 间的通用基类——V0 的
proposer_worker_base.py(58 行)+top1_proposer.py(274 行)是为了让 ngram / draft model / MLP speculator / Medusa 走统一的 proposer 接口;V1 的两个 proposer(NgramProposer/EagleProposer)各写各的propose(),签名完全不同(一个吃单请求的 numpy token 数组,一个吃整批 GPU 张量),没有共同基类,由 runner 按 method 分支、用isinstance断言分别调用;SpecDecodeMetadata这个 61 行的 dataclass 只在 runner 与拒绝采样器之间流转,proposer 并不接触它
rejection_sampler.py 631 行是 §12.2 算法的核心——比 eagle.py(318 行)大近一倍,是这条链路上最大的单文件。它要处理的事情确实比 proposer 多:
- 贪心退化路径——只要 batch 里有贪心请求就调一个专门的 Triton kernel
rejection_greedy_sample_kernel(调用处vllm/v1/sample/rejection_sampler.py:174-192,定义在:433),全贪心时到此直接返回;混合 batch 里贪心请求用温度哨兵值GREEDY_TEMPERATURE = -1标记,两个 kernel 各自跳过对方的请求 - top-k / top-p 约束——
compute_probs里要先把 batch 级的top_k/top_p用expand_batch_to_tokens展开到 token 级,再调apply_top_k_top_p(vllm/v1/sample/rejection_sampler.py:273-291) - 可复现随机数——
generate_uniform_probs支持传入 per-request 的generators,让带 seed 的请求结果可复现 - 批量接受/拒绝 + 修正分布重采样——每个请求一个 kernel program 顺序判定它的 k 个 draft token;被拒位置的 recovered token 由
sample_recovered_tokens_kernel预先算好:有 draft 概率时按 采样,v0.8.5 实际走的无 draft 概率分支则把 draft token 的 临时置 0 再采样(相当于 为 one-hot),并用指数分布随机数做argmax(prob / q),省掉了归一化
贪心和随机走的是两条完全不同的 kernel 路径——这也解释了为什么这个文件比两个 proposer 加起来还大。
注意 metrics.py 在 spec_decode/ 而不是 v1/metrics/——这是因为 spec decode 指标和算法状态强耦合:草稿次数、草稿 token 数、接受 token 数、按位置接受矩阵,都来自 spec decode 自己的统计对象。放在同目录里,改 proposer 或接受逻辑时更容易同步更新指标。
12.10 本章小结
投机解码是打破 LLM decode memory-bound 枷锁的关键武器:
- 动因:低并发 decode 容易受 memory-bound 和 step-by-step 自回归限制;一次 verify 覆盖多个候选位置可以减少目标模型 step 数
- 数学等价:拒绝采样证明输出分布 = 原始大模型 decode,不是近似,是精确
- 一次 forward 内部:k+1 个 Q 位置、varlen cu_seqlens、causal mask within draft span
- Proposer 谱系:当前 V1 主路径直接支持 n-gram 与 EAGLE/EAGLE3;draft model、MTP、Medusa、MLP speculator 要区分 V0 路径、模型文件和配置层能力
- 加速比闭式公式:,硬上界是 ;只要 draft 有成本(),最优 k 就存在且往往很小
- 反直觉现象:并发升高后,draft 成本、预算放大和拒绝浪费可能让 spec decode 变慢
- 实战配置:v0.8.5 只有
--speculative-config这一个 CLI 入口,且 V1 只认ngram/eagle/eagle3,还须显式设VLLM_USE_V1=1,否则回退 V0;n-gram 路径上带min_p、惩罚项或 logprobs 的请求会被静默跳过 spec decode
源码导航(实测 vllm v0.8.5 布局)
- V1 投机解码主目录:
vllm/v1/spec_decode/(6 文件 692 行)——eagle.py 318/metrics.py 164/ngram_proposer.py 131/metadata.py 61/utils.py 18/__init__.py 0- 没有独立
interface.py/draft_model_proposer.py/deepseek_mtp.py——两个 proposer 没有公共接口,各自的propose()签名不同,由gpu_model_runner.py按 method 分支调用;metadata.py的SpecDecodeMetadata供 runner 与拒绝采样器使用- 拒绝采样:
vllm/v1/sample/rejection_sampler.py(631 行——本章核心算法主体在这里、几乎是 spec_decode/ 全部 6 文件之和)- 模型实现(在 model_executor/models/):
eagle.py 247/llama_eagle.py 152/llama_eagle3.py 232/medusa.py 210/deepseek_mtp.py 266/mlp_speculator.py 205——6 文件 1312 行- V0 遗留
vllm/spec_decode/4320 行(spec_decode_worker.py 1324 / batch_expansion.py 505)——理解 V1 简化幅度的对比基准- Metrics:
vllm/v1/spec_decode/metrics.py(不在 v1/metrics/)论文
- Leviathan et al., "Fast Inference from Transformers via Speculative Decoding", ICML 2023 (arXiv:2211.17192)
- Chen et al., "Accelerating Large Language Model Decoding with Speculative Sampling", 2023 (arXiv:2302.01318)
- Cai et al., "Medusa: Simple LLM Inference Acceleration Framework with Multiple Decoding Heads", 2024 (arXiv:2401.10774)
- Li et al., "EAGLE: Speculative Sampling Requires Rethinking Feature Uncertainty", ICML 2024 (arXiv:2401.15077)
- Li et al., "EAGLE-2: Faster Inference of Language Models with Dynamic Draft Trees", 2024 (arXiv:2406.16858)
- Li et al., "EAGLE-3: Scaling up Inference Acceleration of Large Language Models via Training-Time Test", 2025 (arXiv:2503.01840)
- DeepSeek-AI, "DeepSeek-V3 Technical Report", 2024 (arXiv:2412.19437) — MTP section