vLLM 推理内核深度解析

第12章 投机解码:以小博大

作者 杨艺韬 · 11,311 字 · 发布于 · 更新于

"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
  • 学会推导加速比闭式公式:speedup=1−αk+1(1−α)(1+c⋅k)\text{speedup} = \frac{1 - \alpha^{k+1}}{(1-\alpha)(1 + c \cdot k)} 及其指导意义
  • 理解"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 分布为 p(x)p(x),草稿模型的分布为 q(x)q(x)。

对每个候选位置:

  1. 草稿阶段:从 qq 中采样一个 token x∗∼qx^* \sim q
  2. 验证阶段:大模型计算 p(x∗)p(x^*)
  3. 接受/拒绝:
    • 如果 p(x∗)≥q(x∗)p(x^*) \geq q(x^*)(大模型比草稿更看好这个 token),无条件接受
    • 否则,以概率 p(x∗)q(x∗)\frac{p(x^*)}{q(x^*)} 接受
    • 拒绝时,从修正分布 p′(x)=max⁡(0,p(x)−q(x))∑ymax⁡(0,p(y)−q(y))p'(x) = \frac{\max(0, p(x) - q(x))}{\sum_y \max(0, p(y) - q(y))} 中重新采样

12.2.2 数学证明(接受的 token 分布 = pp)

考虑任一 token xx 被最终采纳为输出的概率:

Pr⁡(output=x)=q(x)⋅min⁡(1,p(x)q(x))⏟作为候选被接受+(1−A)⋅p′(x)⏟候选被拒后重采样得到\Pr(\text{output} = x) = \underbrace{q(x) \cdot \min\left(1, \frac{p(x)}{q(x)}\right)}_{\text{作为候选被接受}} + \underbrace{(1 - A) \cdot p'(x)}_{\text{候选被拒后重采样得到}}

其中 A=∑ymin⁡(p(y),q(y))A = \sum_y \min(p(y), q(y)) 是平均接受概率。

化简第一项:q(x)⋅min⁡(1,p(x)q(x))=min⁡(p(x),q(x))q(x) \cdot \min\left(1, \frac{p(x)}{q(x)}\right) = \min(p(x), q(x))

化简第二项:

(1−A)⋅p′(x)=(1−∑ymin⁡(p,q))⋅max⁡(0,p(x)−q(x))∑ymax⁡(0,p(y)−q(y))(1 - A) \cdot p'(x) = \left(1 - \sum_y \min(p, q)\right) \cdot \frac{\max(0, p(x) - q(x))}{\sum_y \max(0, p(y) - q(y))}

注意 ∑ymax⁡(0,p(y)−q(y))=1−∑ymin⁡(p(y),q(y))=1−A\sum_y \max(0, p(y) - q(y)) = 1 - \sum_y \min(p(y), q(y)) = 1 - A,所以第二项 = max⁡(0,p(x)−q(x))\max(0, p(x) - q(x))。

合起来:

Pr⁡(output=x)=min⁡(p(x),q(x))+max⁡(0,p(x)−q(x))\Pr(\text{output} = x) = \min(p(x), q(x)) + \max(0, p(x) - q(x))
  • 如果 p(x)≥q(x)p(x) \geq q(x):= q(x)+(p(x)−q(x))=p(x)q(x) + (p(x) - q(x)) = p(x) ✓
  • 如果 p(x)<q(x)p(x) < q(x):= p(x)+0=p(x)p(x) + 0 = p(x) ✓

两种情况下都等于 p(x)p(x)。QED.

12.2.3 直觉解释

小模型和大模型的概率分布如果"大致一致",小模型提议的候选 token 通常会被接受(因为 p(x)≥q(x)p(x) \geq q(x) 的概率高)。只有当小模型"错判"(给了一个大模型不看好的 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 视为确定性提议(qq 在 draft token 上为 1)——n-gram 和取 argmax 的 EAGLE 本来就是确定性的,所以仍是精确的,没有做"为了性能而精度打折"的简化。例外在采样参数:拒绝采样只对目标分布施加温度与 top-k / top-p,不含 min_p 和惩罚项(见 §12.3.5)。

12.2.4 贪心采样(temperature=0)的退化形式

当 temperature=0\text{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)

三个容易被忽略的实现事实:

  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)。
  2. num_speculative_tokens == 1 有专门的 early exit——只猜一个 token 时,第一次 forward 出来的结果直接 reshape 返回,循环一次都不跑。
  3. 进入循环前把 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

四个真实实现细节值得展开——它们决定了这个模块能不能在生产流量下真的省出延迟:

  1. KMP 而不是朴素 O(N·M) 搜索。对 4K context、max_n=8 的 pattern,朴素搜索最坏是 32K 次比较;KMP 通过 lps 数组记住"已匹配前缀里有多少能当作新起点",全程 O(N+M) — 约 4K 次。看起来小,但 decode 每一步都跑、累积放大。KMP 在 LLM 场景的 pattern 长度较小(<=8),lps 数组也只有 8 元素——实际性能差距主要来自"命中时不退回重扫"。

  2. Numba JIT @jit(nopython=True) 的 nopython 模式是关键——nopython=True 强制 Numba 生成不调用 Python C API 的机器码。没这个参数可能退化到 object-mode,循环仍会受 Python 解释器影响。源码选择 JIT,是因为 n-gram proposer 可能在每个 decode 步都运行。

  3. 构造时预热(self.propose(np.zeros(1024, dtype=np.int32)))——Numba JIT 首次调用需要编译。源码在 __init__ 里主动跑一次 propose,目的就是把首次编译成本从第一条真实请求上移走。

  4. 长 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 接受率与期望接受长度

设每个位置被接受的独立概率为 α\alpha(接受率),num_speculative_tokens=k。期望接受的 token 数:

E[accepted]=∑i=0k−1αi⋅(1−α)⋅i+αk⋅k=1−αk+11−α−1E[\text{accepted}] = \sum_{i=0}^{k-1} \alpha^i \cdot (1 - \alpha) \cdot i + \alpha^k \cdot k = \frac{1 - \alpha^{k+1}}{1 - \alpha} - 1

加上那 1 个 bonus token,每 step 净产出:

E[tokens per step]=1−αk+11−αE[\text{tokens per step}] = \frac{1 - \alpha^{k+1}}{1 - \alpha}

12.5.2 时间成本

设 verify 一次 forward 的时间为 TvT_v,draft 的相对开销为 c=Td/Tvc = T_d / T_v。这里的 cc 必须来自压测,不能按 proposer 名字固定。完整 draft model、EAGLE、n-gram 在不同模型和不同 batch 下的 cc 都会变。

每 step 总时间:

Tstep=Tv⋅(1+c⋅k)T_{\text{step}} = T_v \cdot (1 + c \cdot k)

12.5.3 加速比

原始 decode 每 step 产出 1 token,时间 TvT_v(简化:忽略 verify 比原 decode 稍多的那一点)。所以:

speedup=E[tokens per step]Tstep/Tv=1−αk+1(1−α)(1+c⋅k)\text{speedup} = \frac{E[\text{tokens per step}]}{T_{\text{step}} / T_v} = \frac{1 - \alpha^{k+1}}{(1 - \alpha)(1 + c \cdot k)}

这就是 Leviathan et al. 论文的定理 3.8(原文记 kk 为 γ\gamma),前提同样是各位置接受相互独立、同分布为 α\alpha。

12.5.4 数值计算

下面这张表是代入公式的假设值示例,不是 vLLM 官方 benchmark,也不是本专栏实测。它的用途是展示 α\alpha 和 cc 如何影响最优 k:

假设场景 α c k=3 k=5 k=7 上界 11−α\frac{1}{1-\alpha} 观察
高接受率、低 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 反而更慢

(这几个数是把 α\alpha、cc、kk 代进上面公式直接算出来的,读者可以自己验算。)

几个洞察:

  • 加速比有硬上界 11−α\frac{1}{1-\alpha}——这是 c=0c=0、k→∞k\to\infty 的极限。接受率 0.25 时,哪怕 draft 完全免费也只有 1.33×;想要 4× 就必须先把接受率做到 0.75
  • c>0c > 0 时一定存在最优 k,而且往往比想象的小——分子随 kk 指数收敛到上界,分母却随 kk 线性增长。上表第二、第四行的最优 k 都是 3 甚至更小,继续加 k 是净亏
  • cc 决定"最优 k"的位置——cc 越小,最优 k 越大;cc 大到一定程度,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 里已经没有了,用它写的告警规则要改。

计算实时接受率:α=accepteddraft tokens\alpha = \frac{\text{accepted}}{\text{draft tokens}}。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 目录规模更小的三条根因——

  1. 没有独立的 worker 类——V0 有 SpecDecodeWorker(1324 行)+ MultiStepWorker(416)两个专门的 worker,再配 BatchExpansionTop1Scorer(505)/ MQAScorer(159)两种打分器;V1 把投机解码变成 GPUModelRunner.execute_model 里的一段分支——本步采样后顺手生成 draft,交回调度器,下一步随普通 token 一起排进主 forward 的 batch,不需要单独的执行器
  2. 没有独立 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
  3. 没有 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 概率时按 max⁡(0,p−q)\max(0, p-q) 采样,v0.8.5 实际走的无 draft 概率分支则把 draft token 的 pp 临时置 0 再采样(相当于 qq 为 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 路径、模型文件和配置层能力
  • 加速比闭式公式:1−αk+1(1−α)(1+c⋅k)\frac{1-\alpha^{k+1}}{(1-\alpha)(1+c \cdot k)},硬上界是 11−α\frac{1}{1-\alpha};只要 draft 有成本(c>0c>0),最优 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