常见的投机解码方法 & vLLM 的实现
本文包含AI辅助生成内容。
GitHub: https://github.com/vllm-project/vllm
项目版本: v0.22.1rc0(来自 git tag)
Git Commit: 3becc5db4
代码行数: ~295,403 行
语言: Python + CUDA/C++
常见的投机解码方法 & vLLM 的实现
大模型推理是在一个token一个token往外蹦,每次只生成一个token,然后自回归地生成接下来的一个token,每个token都要模型前向一次,太慢了。Speculative Decoding(投机解码)是目前最有效的加速手段之一——用小模型或轻量结构”猜”出多个token,再让大模型一次性验证,猜对了就白赚,猜错了也不亏。vLLM 作为最主流的大模型推理框架,已经实现了10+种投机解码方案。本文解析vLLM目前已经实现的投机解码方案。
目录
- 一、先搞清楚 Speculative Decoding 的核心思路
- 二、vLLM 的投机解码方案全景图
- 三、无模型方案:生成的文本里自带答案
- 四、轻量 Draft Head:站在巨人的肩膀上
- 五、独立模型方案
- 六、特殊用途方案
- 七、验证机制:Rejection Sampling 是怎么工作的
- 八、整体架构:从调度到执行
一、先搞清楚 Speculative Decoding 的核心思路
在正常的自回归解码中,每生成一个 token 就要跑一次前向传播。而每次前向传播都要把整个模型权重从显存搬到计算单元,但 batch=1 时实际计算量很小——GPU 大部分时间都花在等数据搬运上(memory-bound),计算单元反而在空转。
Speculative Decoding 的核心想法非常直觉:
- Draft(起草):用一个廉价的方式快速猜出 K 个候选 token(draft tokens)
- Verify(验证):把这 K 个 token 一起喂给目标大模型,一次前向传播并行验证
- Accept/Reject(接受/拒绝):通过 rejection sampling 决定接受前几个 token,拒绝后面的
如果平均能接受 m 个 token,那等效于每一步前向传播生成了 m+1 个 token(如果全部接受还能多拿一个 bonus token),吞吐量直接提升 m 倍。
关键保证:Speculative Decoding 在理论上是无损的(lossless)。经过 rejection sampling 之后,输出的分布和不用投机解码完全一致。这不是近似加速,是精确等价。
原始论文:Speculative Decoding 由两篇独立论文在 2022-2023 年同时提出:
- Fast Inference from Transformers via Speculative Decoding(Leviathan et al., 2022 年 11 月,ICML 2023 Oral)
- Accelerating Large Language Model Decoding with Speculative Sampling(Chen et al., DeepMind, 2023 年 2 月)
二、vLLM 的投机解码方案全景图
vLLM 目前支持的投机解码方案可以分为三大类:
| 类别 | 方案 | 是否需要额外模型 | 核心特点 |
|---|---|---|---|
| 无模型方案 | N-Gram, N-Gram GPU, Suffix Decoding | 否 | 零额外开销,即插即用 |
| 轻量 Draft Head | EAGLE, EAGLE3, Medusa, MLP Speculator, DFlash | 是(轻量级) | 利用目标模型的隐藏状态,接受率高 |
| 独立 Draft 模型 | Draft Model, PARD, MTP | 是(独立模型或内置层) | 最灵活或最原生 |
此外还有两个特殊用途的方案:
- Extract Hidden States:不真正做投机,而是提取中间层隐藏状态用于训练或蒸馏
- Custom Class:用户自定义 proposer 的扩展接口
下面逐一深入讲解。
三、无模型方案:已生成的文本里自带答案
3.1 N-Gram(CPU 版)
无正式论文,方法由 João Gante(Hugging Face)在 2024 年 1 月的 Twitter/X 讨论 中提出并推广。
简单说就是在当前prompt + 已生成的token序列中,找到和当前结尾最长匹配的 n-gram 片段,把匹配片段后面的 token 当作候选。
原理:
假设你正在生成一段代码,前面已经出现过 def forward(self, x):,而现在又在写类似的函数签名。N-Gram Proposer 会在已有的 token 序列中搜索和当前结尾最匹配的片段(比如 def forward(self,),找到后就把那个片段后面的 token(x):)提出来作为 draft tokens。
具体实现上,它从最大的 n-gram 窗口开始匹配(由 prompt_lookup_max 控制),如果找不到就逐步缩小窗口(直到 prompt_lookup_min),找到匹配后提取后续 num_speculative_tokens 个 token。
实现细节:
- 运行在CPU上,使用Numba JIT编译加速
- 支持批量处理:当 batch 中总 token 数超过阈值(8192)时有并行处理框架,但当前实际只启用了 1 个 Numba 线程(代码中有 TODO 注释,等 TP 并行支持后再提高到 8 线程)
- 搜索算法类似 KMP 字符串匹配
- 不需要任何额外的模型或权重
适用场景:
- 生成内容中有大量重复模式的场景(代码生成、模板化文本、翻译中的常见短语)
- 不想引入任何额外模型开销的轻量部署
- 低 QPS 下的延迟优化
3.2 N-Gram GPU 版
一句话概括:和 CPU 版思路一样,但把整个匹配过程搬到了 GPU 上,并且匹配策略有细微差别。
为什么要做 GPU 版:CPU 版的 N-Gram Proposer 虽然不需要额外模型,但每次 propose 都需要把 token IDs 从 GPU 拷贝到 CPU,做完匹配再传回去。这个 CPU-GPU 同步在高吞吐场景下会成为瓶颈。
和 CPU 版的细微差别:
- GPU 版在匹配时找的是最早出现的位置(earliest match),即在整个序列中第一次出现该 n-gram 的位置,然后从所有 n-gram 尺寸中选最长的那个
- CPU 版的搜索策略更接近”找到任意一个匹配就行”
实现细节:
- 核心匹配逻辑封装在
NgramGPUKernel中,使用@support_torch_compile()装饰器做编译优化 - 使用 PyTorch 的
unfold(O(1) 的滑动窗口视图)+argmax等算子在 GPU 上向量化完成 n-gram 匹配 - 编译配置开启了
aggressive_fusion、coordinate_descent_tuning、triton.autotune_pointwise等高级优化 - 完全避免了 CPU-GPU 同步,所有操作都在 GPU 上完成
- 支持增量更新 token_ids 和 sequence lengths,避免全量拷贝
适用场景:和 CPU 版相同,但在高 QPS 或大 batch 场景下性能更好。
3.3 Suffix Decoding(后缀解码)
提出:Suffix Decoding: A Model-Free Approach to Accelerating Large Language Model Inference(Liu et al., Snowflake, 2024 年 11 月)
一句话概括:N-Gram 的”全面升级版”——不仅匹配 prompt,还匹配历史生成的内容,并且用频率统计来选 token,投机长度还是动态的。
和 N-Gram 的三个核心区别:
-
匹配范围更广:N-Gram 只在当前请求的 prompt + 已生成内容中匹配。Suffix Decoding 会缓存之前所有请求的生成结果,构建一个全局的后缀树(suffix tree)。这意味着即使当前 prompt 里没有匹配的模式,也可能从历史请求中找到有用的模式。
-
基于频率的概率估计:不是简单地取匹配片段后面的 token,而是统计后缀树中所有匹配路径的 token 频率,选择出现频率最高的作为候选。这让它的提案质量更高。
-
动态投机长度:N-Gram 每次固定提出
num_speculative_tokens个 token。Suffix Decoding 会根据匹配置信度动态调整——匹配越深、频率越高就多猜几个,反之少猜。这直接提高了接受率。
实现细节:
- 依赖 Snowflake 开源的
arctic-inference库 - 通过
suffix_decoding_max_tree_depth控制后缀树最大深度(默认 24) suffix_decoding_min_token_prob控制最小 token 概率阈值(默认 0.1),低于此概率就不再继续投机suffix_decoding_max_spec_factor控制投机长度与匹配长度的比例上限
适用场景:
- 高重复性任务:代码编辑、self-reflection 的 agent 循环、RL rollout
- 多个请求之间有大量相似输出的场景(比如同一类问题的多次回答)
具体工作示例:
假设服务端先后收到了 3 个请求并已经处理完前两个(已生成的内容被加入全局后缀树),第 3 个请求生成到一半时的状态如下:
[全局后缀树] (来自历史请求)
├── 之前请求 A 输出: "def fibonacci(n):\n if n <= 1: return n\n return fibonacci(n-1) + fibonacci(n-2)"
├── 之前请求 B 输出: "def factorial(n):\n if n <= 1: return 1\n return n * factorial(n-1)"
└── 之前请求 C 输出: "def add(a, b):\n return a + b\ndef subtract(a, b):\n return a - b"
[当前请求 prompt + 已生成部分]
"def merge_sorted_lists(a, b):\n result = []\n i = j = 0\n "
↑ 已经生成了 4 行,模型下一步要决定做什么
Suffix Decoding 看到最近 max_tree_depth 个 token(默认 24),发现结尾 " "(4 个空格 + 缩进)匹配之前 A/B 请求中 " if n"、" return"、 result = [] 这类高频前缀。SuffixDecodingProposer 会调用 suffix_cache.speculate(),按 token 频率排序候选续写并返回 draft.token_ids。
候选续写(按频率排序):
"result = []\n "(出现 1 次:来自请求 C 末尾)—— 概率估计 > 0.1,通过阈值"return "(出现 2 次:来自 A 和 B)—— 频率高"if "(出现 2 次:来自 A 和 B)—— 频率高
频率最高的 "return " 被选为 draft tokens 的开头,然后继续向后匹配(“return a + b” 出现 1 次、“return n * ” 出现 1 次),到频率不足以支持继续投机(< min_token_prob=0.1)时停止。最终本次投机可能输出:
draft_tokens = ["return", " a", " +", " b"] # 动态长度 4,受 max_spec_factor * prefix_match_length 约束
然后大模型一次性验证这 4 个 token。可以看到同样的”4 个空格缩进”模式在代码生成中是超高频信号,Suffix Decoding 几乎不花成本就猜中了。
完整配置(包含所有 4 个 suffix-decoding 专属参数):
vllm serve Qwen/Qwen3-8B \
--speculative-config '{
"method": "suffix",
"suffix_decoding_max_tree_depth": 24,
"suffix_decoding_max_cached_requests": 10000,
"suffix_decoding_max_spec_factor": 1.0,
"suffix_decoding_min_token_prob": 0.1
}'
上述 4 个参数的默认值分别是 24 / 10000 / 1.0 / 0.1。如果不指定
num_speculative_tokens,vLLM 会自动把它设为suffix_decoding_max_tree_depth。 使用前需要先pip install arctic-inference,否则会因为缺少该依赖而无法启动 Suffix Decoding。
四、轻量 Draft Head:站在巨人的肩膀上
这一类方案的共同特点是:不需要一个完整的独立语言模型来做 draft,而是在目标大模型之上嫁接一个轻量级的”头”(head),利用目标模型本身的隐藏状态来预测后续 token。
这意味着:
- Draft 模型的参数量通常只有目标模型的 1%~5%
- 共享目标模型的 embedding 和 lm_head(词表映射层)
- 不需要独立的完整前向传播,只需在目标模型跑完之后”顺手”跑一下 draft head
4.1 EAGLE / EAGLE3
相关论文:
- EAGLE: Speculative Sampling Requires Rethinking Feature Uncertainty(Li et al., 2024 年 1 月,ICML 2024)
- EAGLE-2: Faster Inference of Language Models with Dynamic Draft Trees(Li et al., 2024 年 6 月,EMNLP 2024)
- EAGLE-3: Scaling up Inference Acceleration of Large Language Models via Training-Time Test(Li et al., 2025 年 3 月,NeurIPS 2025)
一句话概括:用一个轻量 transformer head 读取目标模型的隐藏状态来自回归地生成 draft tokens,是目前社区采用最广泛的投机解码方案之一。
EAGLE(v1)
EAGLE 的全称是 Extrapolation Algorithm for Greater Language-model Efficiency(直译:面向语言模型效率提升的外推算法)。它的 draft head 结构大致是:
目标模型最后一层隐藏状态 + 上一步 draft token 的 embedding
↓
[线性投影层]
↓
[一层 Transformer(带自己的 attention 和 KV cache)]
↓
[共享目标模型的 lm_head]
↓
draft token 的 logits
关键点:
- Draft head 有自己独立的一层 attention,有自己的 KV cache
- 但 embedding 层和 lm_head 层完全共享目标模型
- 自回归生成:每生成一个 draft token,就把它的 embedding 和下一步的隐藏状态喂回去,生成下一个
- 参数量通常只有目标模型的 1-2%
vLLM 支持多个模型家族的 EAGLE head:Llama、DeepSeek、Cohere、MiniCPM、Mistral、Llama 4、Mistral Large 3 等。
EAGLE3(v2 升级版)
EAGLE3 在 EAGLE 的基础上做了一个关键改进:不仅使用目标模型最后一层的隐藏状态,还额外引入中间层的隐藏状态。
为什么这很重要?最后一层隐藏状态主要编码了”下一个 token 是什么”的信息,但中间层隐藏状态包含了更丰富的语法结构、语义关系等信息。把这些信息融合进来,draft head 对目标模型行为的”理解”更深,接受率自然更高。
具体实现上,EAGLE3 通过 eagle_aux_hidden_state_layer_ids 配置参数指定要提取哪些中间层的隐藏状态。隐藏状态的融合(combine_hidden_states)是在 draft model 内部完成的——比如 Eagle3LlamaForCausalLM 和 Eagle3DeepseekV2ForCausalLM 各自实现了融合逻辑,把多层隐藏状态拼接或投影到 draft head 期望的维度。
DeepSeek V4 的特殊处理:DeepSeek V4 的 MTP 层消费的是目标模型的 pre-hc_head residual stream,其 shape 是 (T, hc_mult * hidden_size),维度被放大了 hc_mult 倍。vLLM 在检测到 draft model config 中有 compress_ratios 和 hc_mult 属性时,会自动扩大隐藏状态 buffer 的尺寸来适配。
配置示例(EAGLE):
vllm serve meta-llama/Meta-Llama-3-8B-Instruct \
-tp 4 \
--speculative-config '{"method": "eagle", "model": "yuhuili/EAGLE-LLaMA3-Instruct-8B", "draft_tensor_parallel_size": 1, "num_speculative_tokens": 2}'
配置示例(EAGLE3):
vllm serve meta-llama/Meta-Llama-3-8B-Instruct \
-tp 2 \
--speculative-config '{"method": "eagle3", "model": "RedHatAI/Llama-3.1-8B-Instruct-speculator.eagle3", "draft_tensor_parallel_size": 2, "num_speculative_tokens": 2}'
适用场景:
- 通用场景下延迟优化和吞吐优化的首选方案
- 低 QPS 下高增益,高 QPS 下也有中到高增益
- 有预训练好的 EAGLE head 可用时强烈推荐
4.2 Medusa
论文:Medusa: Simple LLM Inference Acceleration Framework with Multiple Decoding Heads(Tianle Cai et al., 2024 年 1 月)
一句话概括:在目标模型最后一层隐藏状态上并行跑多个轻量 MLP head,每个 head 独立预测不同位置的下一个 token。
和 EAGLE 的核心区别:
| EAGLE | Medusa | |
|---|---|---|
| Draft 方式 | 自回归(一个接一个生成) | 并行(所有 head 同时生成) |
| Draft 模型结构 | 带 attention 的 transformer | 多个独立 MLP head |
| KV Cache | 需要独立的 KV cache | 不需要 |
| 前向传播次数 | K 次(K = num_speculative_tokens) | 1 次 |
Medusa 的结构非常简洁。每个 head 内部是一个 ResidualBlock——由多层 Linear + SiLU + 残差连接 组成:
目标模型最后一层隐藏状态
↓
┌────┼────┐
↓ ↓ ↓
Head0 Head1 Head2 (每个 head = 多层 Linear+SiLU+Residual)
↓ ↓ ↓
pos+1 pos+2 pos+3 (每个 head 预测不同未来位置的 token)
每个 head 只做一次前向传播,通过 argmax 直接选出该位置最可能的 token。所有 head 并行执行,一次前向传播就得到所有 draft tokens。
值得注意的实现细节:
- Token Map(缩减词表):Medusa 支持一个可选的
token_map机制——只保留训练数据中最高频的 k 个 token,把 draft 的词表从完整词表缩减到 k 个。这直接降低了 logits 计算和采样的开销,同时对接受率影响很小。如果 checkpoint 中包含token_map且 config 设置了truncated_vocab_size < vocab_size,就会自动启用。 - LM Head 两种模式:可以所有 head 共享一个
lm_head(original_lm_head=True),也可以每个 head 有独立的lm_head(默认模式)。共享模式更省显存,独立模式理论上更灵活。
vLLM 中的 Medusa 模型结构:
class ResidualBlock(nn.Module):
"""
Medusa 头部的基本组成单元:一个"残差 MLP 块"。
每个 Medusa head 内部就是若干层 ResidualBlock 堆叠而成的 MLP。
"""
def __init__(self, config: VllmConfig, hidden_size: int, num_layers: int) -> None:
super().__init__()
# 堆叠 num_layers 个 Linear 层。
# hidden_size 始终不变(输入和输出维度一致),
# 这保证可以做残差连接:x = x + act(layer(x))
self.layers = nn.ModuleList(
[
nn.Linear(
hidden_size,
hidden_size,
bias=getattr(config, "medusa_fc_bias", False),
# bias 默认 False。原始 Medusa 仓库的 Linear 不带 bias
)
for _ in range(num_layers)
]
)
self.act = nn.SiLU() # SiLU 激活函数(即 Swish)
def forward(self, x: torch.Tensor) -> torch.Tensor:
# 残差 + 激活:x = x + SiLU(Linear(x))
for layer in self.layers:
x = x + self.act(layer(x))
return x
class Medusa(nn.Module):
"""Medusa draft 模型。论文:https://arxiv.org/abs/2401.10774"""
def __init__(self, *, vllm_config: VllmConfig, prefix: str = "") -> None:
# 拿 Medusa 自己的 HF config(不是目标模型的 config)
config = vllm_config.speculative_config.draft_model_config.hf_config
super().__init__()
self.config = config
# 【核心】构造 num_heads 个并行的 Medusa head
# 每个 head 都是一个独立的 ResidualBlock 堆叠
# num_heads 对应"想一次投机几个未来 token"
self.blocks = nn.ModuleList(
[
ResidualBlock(
config=config,
hidden_size=self.config.hidden_size,
num_layers=self.config.num_hidden_layers,
# 注意:这里的 num_hidden_layers 不是目标模型的层数,
# 而是每个 Medusa head 内部 MLP 的层数(一般 1-3 层)
)
for _ in range(self.config.num_heads)
]
)
# 词表大小
self.orig_vocab_size = config.vocab_size
self.truncated_vocab_size = config.truncated_vocab_size
# 【LM Head 的两种模式】
if getattr(config, "original_lm_head", False):
# 模式 A:所有 head 共享一个 lm_head(显存更省)
self.lm_head = ParallelLMHead(
self.truncated_vocab_size,
config.hidden_size,
prefix=maybe_prefix(prefix, "lm_head"),
)
self.lm_heads = [self.lm_head for _ in range(self.config.num_heads)]
else:
# 模式 B(默认):每个 head 独立的 lm_head(更灵活但显存开销 num_heads 倍)
self.lm_heads = nn.ModuleList(
[
ParallelLMHead(
config.vocab_size,
config.hidden_size,
prefix=maybe_prefix(prefix, f"lm_heads.{i}"),
)
for i in range(self.config.num_heads)
]
)
# LogitsProcessor 负责 hidden state → logits 的统一处理
logit_scale = getattr(config, "logit_scale", 1.0)
self.logits_processor = LogitsProcessor(
config.vocab_size, self.truncated_vocab_size, logit_scale
)
def forward(self, hidden_states: torch.Tensor) -> list[torch.Tensor]:
# 输入:目标模型最后一层的 hidden_states,shape = [batch, seq, hidden_size]
# 输出:每个 head 各自输出的 hidden_states 列表
return [block(hidden_states) for block in self.blocks]
模型结构小结:
| 论文概念 | vLLM 代码中的对应 |
|---|---|
num_heads(同时投机的 token 数) | self.blocks 的长度 |
| Medusa head 的 MLP 深度 | ResidualBlock.layers 的数量 |
| 共享 vs 独立 lm_head | config.original_lm_head 开关 |
优势:
- 极低的 draft 延迟(一次前向传播搞定)
- 不需要额外的 KV cache
- 结构简单,训练成本低
劣势:
- 各 head 之间完全独立,没有建模 token 之间的依赖关系
- 位置越远的 head 接受率越低(预测第 5 个 token 比预测第 1 个难得多)
适用场景:
- 对 draft 延迟极度敏感的场景
- 投机深度不需要太大(2-4 个 token)的场景
4.3 MLP Speculator
论文:Accelerating Production LLMs with Combined Token/Embedding Speculators(IBM Research, 2024 年 4 月)
一句话概括:和 Medusa 思路类似,但用级联的 MLP 层代替独立的 head,在保持轻量的同时建模了 token 间的依赖关系。
和 Medusa 的区别:
Medusa 的每个 head 完全独立,互相不知道对方预测了什么。MLP Speculator 则用级联结构:第 2 步的预测会用到第 1 步的预测结果,第 3 步用到前两步的结果,以此类推。
目标模型隐藏状态
↓
[MLP Layer 1 + L2 Norm] → 预测 pos+1
↓ (拼接 pos+1 的 embedding)
[MLP Layer 2 + L2 Norm] → 预测 pos+2
↓ (拼接 pos+1, pos+2 的 embedding)
[MLP Layer 3 + L2 Norm] → 预测 pos+3
注意这里用的是 L2 normalization(不是标准的 LayerNorm),带有 learned scale 和 shift 参数。L2 norm 的好处是计算更轻量,且在 embedding 空间上效果不错。
实现细节:
- 有独立的
emb_dim(embedding 维度)和inner_dim(内部隐藏维度),两者可以不同 - 支持 weight tying:跨 stage 共享投影层权重(第一步用独立的投影,后续步骤共享同一个投影层),进一步减少参数量
- 支持
scale_input选项,对输入做 learned scaling - 共享目标模型的 embedding 层(
VocabParallelEmbedding),但 lm_head 是独立的
这让它在保持轻量的同时,理论上比 Medusa 有更好的预测质量。和 IBM Research 提出的方案一脉相承。
适用场景:
- 有兼容的 MLP Speculator 模型可用时
- 想要比 Medusa 更好的 token 间依赖建模,但不想用 EAGLE 那么重的结构
4.4 DFlash
论文:DFlash: Block Diffusion for Flash Speculative Decoding(Chen et al., 2026 年 2 月,ICML 2026)
一句话概括:用 block diffusion 模型在一次前向传播中并行生成所有 draft tokens,是目前 vLLM 中唯一原生支持”并行起草”(parallel drafting)的轻量 head 方案。
核心思路:
传统的 EAGLE 是”自回归式起草”——draft head 需要一步步生成 K 个 token。DFlash 走的是另一条路:它把目标模型的隐藏状态投影为 cross-attention 的 Key 和 Value,然后用一组特殊的 query token(一个”bonus token” + K-1 个 mask token)在一次前向传播中同时生成所有 K 个 draft tokens。
目标模型隐藏状态 → 投影为 K, V
↓
Mask tokens + Bonus token → 投影为 Q
↓
[Cross-Attention 层]
↓
K 个 draft tokens(并行输出)
关键特点:
parallel_drafting=True:所有 draft token 在一次前向传播中生成- 支持两种注意力模式:通过
dflash_config.causal配置项切换。默认使用非因果注意力(non-causal attention),因为 mask tokens 之间没有因果关系;但也支持 causal 模式 - 多层隐藏状态融合:和 EAGLE3 走的是同一条代码路径(
combine_hidden_states),DFlash 也会利用目标模型的多层隐藏状态来生成更高质量的 draft tokens - Context 和 Query 分离:DFlash 把 context(目标模型的隐藏状态)和 query(bonus + mask tokens)的 positions、slot_mapping 分开管理,context 的 KV 直接预计算后写入 cache,query 走正常的 attention 路径
- 目前支持 Qwen3 和 Qwen3.5(包括多模态模型)的 DFlash 模型
适用场景:
- 需要极低 draft 延迟的场景
- 相比自回归 draft head,更适合投机深度较大的场景
五、独立模型方案
5.1 Draft Model(草稿模型)
基于原始 Speculative Decoding 论文(见第一节),是最经典的实现方式。
一句话概括:用一个同词表的小模型(比如同系列的 0.6B)自回归地生成 K 个候选 token,再让大模型验证。
这是最”教科书”的 Speculative Decoding 方案。原理非常直白:
- 目标模型是 Qwen3-8B,draft 模型是 Qwen3-0.6B
- 0.6B 先跑 K 次前向传播,生成 K 个 token
- 8B 把这 K 个 token 一起喂进去,跑一次前向传播验证
- 通过 rejection sampling 决定接受几个
优势:
- 最灵活:任何同词表的模型都可以当 draft model
- Draft 模型本身就是一个完整的 LM,生成质量有保障
- 概念简单,容易理解
劣势:
- Draft 模型需要自己跑 K 次前向传播,延迟不低
- 需要额外的显存来加载 draft 模型
- Draft 模型和目标模型的分布差异越大,接受率越低
硬约束:draft_tensor_parallel_size 必须等于 tensor_parallel_size。vLLM 会直接报错拒绝不一致的配置——因为不同 TP size 会导致 torch compile cache 在不同 rank 之间冲突(rank 0 的 cache 会被其他 rank 覆盖)。
配置示例:
vllm serve Qwen/Qwen3-8B \
--speculative-config '{"method": "draft_model", "model": "Qwen/Qwen3-0.6B", "num_speculative_tokens": 5}'
5.2 PARD(Parallel Draft Models,并行草稿模型)
论文:PARD: Accelerating LLM Inference with Low-Cost PARallel Draft Model Adaptation(An et al., AMD, 2025 年 4 月)
一句话概括:Draft Model 的并行升级版——draft 模型经过特殊训练,可以在一次前向传播中并行生成所有 draft tokens,而不是自回归地一个个来。
和传统 Draft Model 的区别:
传统 Draft Model 需要跑 K 次前向传播来生成 K 个 token。PARD 的 draft 模型在训练时就引入了 mask token 机制,学会了在一次前向传播中同时预测所有位置的 token。
传统 Draft Model: token_1 → token_2 → token_3 → ... → token_K (K 次前向传播)
PARD: [mask, mask, ..., mask] → [token_1, token_2, ..., token_K] (1 次前向传播)
这意味着 draft 阶段的延迟从 O(K) 降到了 O(1),是一个质的飞跃。
配置示例:
vllm serve Qwen/Qwen3-8B \
--speculative-config '{"method": "draft_model", "model": "amd/PARD-Qwen3-0.6B", "num_speculative_tokens": 12, "parallel_drafting": true}'
注意:需要使用专门训练过的 PARD 模型(如 AMD 发布的 amd/PARD-Qwen3-0.6B),普通模型不能直接开启 parallel_drafting。
5.3 MTP(Multi-Token Prediction,多 token 预测)
MTP 不是一种独立的投机解码方法论文,而是越来越多模型架构(DeepSeek V3、MiMo、Qwen3.5 等)内置的多 token 预测能力。每个模型的 MTP 设计各有不同,详见各模型的技术报告。
一句话概括:很多新一代大模型在训练时就内置了多 token 预测能力(MTP 层),vLLM 直接利用这些内置层作为 draft 模型,不需要任何外部模型。
这是最”原生”的投机解码方案。越来越多的模型架构在设计时就考虑了多 token 预测:模型内部附带一个或多个 MTP 层,这些层在训练时和主模型一起训练,学会了预测未来第 1、第 2、第 N 个 token。
vLLM 目前已经支持了大量模型的 MTP 投机解码:
| 模型家族 | MTP 模型文件 | 备注 |
|---|---|---|
| DeepSeek V3/V3.2 | deepseek_mtp.py | 包括 DeepSeek V3、V3.2 |
| DeepSeek V4 | deepseek_mtp.py | 独立的 DeepSeekV4MTPModel 架构 |
| MiMo | mimo_mtp.py | |
| MiMo V2 | mimo_v2_mtp.py | 包括 MiMoV2、MiMoV2Omni、MiMoV2Flash 三个变体 |
| GLM-4 MoE | glm4_moe_mtp.py | |
| GLM-4 MoE Lite | glm4_moe_lite_mtp.py | |
| GLM OCR | glm_ocr_mtp.py | |
| Ernie | ernie_mtp.py | 包括 Ernie 4.5 MoE |
| Nemotron-H | nemotron_h_mtp.py | 包括 Nemotron-H Puzzle 和 VLM 变体 |
| Qwen3 Next | qwen3_next_mtp.py | |
| Qwen3.5 | qwen3_5_mtp.py | 包括 Qwen3.5 Dense 和 MoE 两个架构 |
| Intern-S2 Preview | qwen3_5_mtp.py | 被映射到 Qwen3.5 MTP 路径 |
| Exaone MoE | exaone_moe_mtp.py | |
| Exaone 4.5 | exaone4_5_mtp.py | |
| LongCat Flash | longcat_flash_mtp.py | |
| OpenPangu Ultra MoE | openpangu_mtp.py | |
| Step 3.5/3.7 | step3p5_mtp.py | 有独立的 proposer(见下文) |
| HY V3 | hy_v3_mtp.py | |
| Gemma 4 | gemma4_mtp.py | 有独立的 proposer(见下文) |
以 DeepSeek V3 为例:MTP 模块的内部结构
参考:DeepSeek-V3 Technical Report §2.2 Multi-Token Prediction(arXiv 2412.19437v2,2025-02-18)
DeepSeek V3 是 MTP 方案最经典也最完整的实现,整个仓库的 deepseek_mtp.py 都围绕它的设计来写。
为什么 DeepSeek V3 要做 MTP:
DeepSeek-V3 技术报告原文的定位是 MTP 是训练目标而非推理加速手段——“we have observed to enhance the overall performance on evaluation benchmarks”。但因为这些 MTP 层在训练时和主模型联合优化过,它们天然适合在推理时作为”草稿器”使用。这种”训练用 MTP 提升质量 + 推理复用 MTP 做投机”的二合一设计后来被 Qwen3.5、MiMo 等大量模型沿用。
DeepSeek V3 MTP 模块的内部结构:
vLLM 里对应的类是 DeepSeekMultiTokenPredictorLayer,单层结构(config.num_nextn_predict_layers 默认 = 1):
[输入:上一轮主模型的 hidden state h_t]
+
[输入:下一个 token 的 embedding e_{t+1}]
↓
┌─────────────────────┴─────────────────────┐
↓ ↓
RMSNorm(e_{t+1}) RMSNorm(h_t)
(enorm,input 端) (hnorm,hidden 端)
↓ ↓
└────────────── cat(dim=-1) ──────────────┘
↓
Linear(2*hidden → hidden)
(eh_proj 投影)
↓
DeepseekV2DecoderLayer
(标准的 MLA + MoE transformer 层)
↓
hidden state
↓
RMSNorm(shared_head.norm)
↓
ParallelLMHead
(与主模型共享的输出投影)
↓
下一个 token 的 logits
关键设计点:
-
enorm/hnorm/eh_proj三件套(见 vLLMdeepseek_mtp.py中DeepSeekMultiTokenPredictorLayer.__init__):把”输入 embedding”和”上一轮 hidden state”分别 RMSNorm 后拼接,再线性投影回hidden_size。这一步是 DeepSeek V3 MTP 区别于 Medusa/EAGLE 的关键——它不是只消费 hidden state,而是把”上一轮的预测结果 embedding”也作为输入,让 MTP 层能感知到序列的当前位置。 -
mtp_block直接复用主模型的DeepseekV2DecoderLayer:这意味着 MTP 层用的是标准的 MLA + MoE,不是简化版本。代价是单层 MTP 就包含约 10.6B 参数,整个 MTP 模块约 14B(11.5B 独有 + 2.5B 共享)。 -
共享的
shared_head:MTP 层的输出头是RMSNorm + ParallelLMHead,其中ParallelLMHead与主模型完全共享权重。vLLM 在加载权重时显式判断:“According to DeepSeek-V3 Technical Report, MTP modules shares embedding layer. We only load the first weights.”
即多个 MTP 层之间也只加载第一份 embedding 权重,其他层自动共享。
-
支持多个 MTP 层:
num_nextn_predict_layers控制数量,默认为 1(即只投机 1 个 token)。多个 MTP 层时由spec_step_idx % num_mtp_layers决定用哪一层:MTP 层 0: 预测第 t+1 个 token (正常推理) MTP 层 1: 预测第 t+2 个 token (需要 MTP 层 0 的输出) MTP 层 2: 预测第 t+3 个 token (需要 MTP 层 1 的输出) ...每一层读上一层的 hidden state + 自己的 draft token embedding,串起来形成 chain。
DeepSeek V3 投机解码的完整一轮推理(基于 vLLM + DeepSeek V3.1 官方推荐的 num_speculative_tokens=1 配置):
时间步 t:
Step 1: 主模型跑一次 decode 前向,得到 h_t(输出第 t 个 token 的 hidden state)
Step 2: 取出主模型预测的 token t,写入输出
Step 3: MTP 层用 h_t + embedding(token t) → 预测 draft token t+1
┌──────────────────────────────────────────┐
│ input: h_t (来自主模型 step 1) │
│ e_{t+1} (来自主模型 step 2) │
│ flow: enorm(e_{t+1}) || hnorm(h_t) │
│ → eh_proj → MTP decoder → head │
│ output: logits → argmax → draft token │
└──────────────────────────────────────────┘
Step 4: 把 draft token t+1 喂回主模型,连同 step 1 的 h_t 一起做一次前向验证
Step 5: 主模型对 t+1 位置的预测 vs draft token:
- 一致:接受,继续 step 6
- 不一致:从主模型的残差分布重采样 t+1,丢弃 draft token
Step 6: 如果接受,整个 forward 等价于"一次 decode 产出 2 个 token"
实测 DeepSeek V3 的 MTP 接受率在 85%-90% 之间(Baihai_IDP 技术解析 引用 DeepSeek 官方数据),在标准推理 benchmark 上能拿到 ~1.8× 加速。
DeepSeek V3.2 的特殊之处:
vLLM 通过 hasattr(config, "index_topk") 判断 V3.2。V3.2 在 MTP 模块里加了一个 topk_tokens 大小的 indexer 缓冲区,用于支持 DSA(DeepSeek Sparse Attention)这种”先粗筛再精读”的注意力模式。投机解码时这个 indexer 会被 MTP 层消费,shape 取决于 config.index_topk 的大小。
配置示例(DeepSeek V3 投机解码):
vllm serve deepseek-ai/DeepSeek-V3 \
--tensor-parallel-size 8 \
--speculative-config '{"method": "mtp", "num_speculative_tokens": 1}'
注意:
num_speculative_tokens=1不是写错,是 DeepSeek V3 官方推荐的设置——num_nextn_predict_layers=1的 MTP 模块本来就只能一次投机 1 个 token(再大也得不到第 2 个 draft)。如果用num_speculative_tokens=2,vLLM 会在两个 draft step 里反复调用同一个 MTP 层(每 step 都把上一次预测的 token 当作输入),效果不如 native 的 multi-layer MTP。
核心优势:
- 不需要额外的 draft 模型,MTP 层是目标模型自带的
- MTP 层和主模型共享 KV cache,额外显存开销极小
- MTP 层在训练时和主模型联合优化,接受率通常很高
配置示例:
vllm serve XiaomiMiMo/MiMo-7B-Base \
--speculative-config '{"method": "mtp", "num_speculative_tokens": 1}'
特别说明——Step 3.5/3.7 的独立 Proposer:
Step 3.5 和 Step 3.7 虽然在 MTP 表格中,但 vLLM 为它们实现了独立的 Step3p5MTPProposer(继承自 EagleProposer),有几个独特之处:
- Per-layer draft-step selection:每个 MTP 层通过
spec_step_idx参数知道自己负责预测第几个未来 token,并且每个 MTP 层有自己独立的 lm_head 权重(不共享目标模型的 lm_head) - 多 KV cache group 支持:不同的 draft 层可能属于不同的 KV cache 组,每个组有自己独立的 block table 和 slot mapping。
Step3p5MTPProposer维护了 per-group 的 slot mapping buffer,在每一步 draft 迭代中为每个 group 独立计算 slot mapping - 同时支持
step3p5和step3p7两种模型类型
特别说明——Gemma 4 Assistant 模型:
Gemma 4 的 assistant checkpoint 走的也是 MTP 路径,但 vLLM 为它实现了完全独立的 Gemma4Proposer,设计上有大量独特之处:
constant_draft_positions = True:所有 draft step 都从同一个位置(目标模型最后一个 token 的位置)进行预测,位置不会像 EAGLE 那样逐步递增。这是因为 Gemma 4 的 assistant 层通过 cross-model KV sharing 直接读取目标模型的 KV cache,不需要自己”推进”位置- 跨模型 KV 共享:每个 draft decoder 层的 attention 直接共享目标模型对应层的 KV cache。具体来说,draft 的 sliding attention 层共享目标模型最后一个 sliding attention 层的 KV,draft 的 full attention 层共享目标模型最后一个 full attention 层的 KV。这意味着 draft 层几乎不需要自己的 KV cache
- 异构 head 维度:Gemma 4 的 sliding attention 使用
head_dim=256,full attention 使用head_dim=512。这种异构设计需要强制使用 TRITON_ATTN 后端(FLASH_ATTN 不支持这种混合 head dim 的 KV 共享) - Centroids-based masked embedding:Gemma 4 使用一种特殊的
MaskedEmbedding层来做 token 选择(get_top_tokens),并且为不同 batch size 预捕获了 CUDA graph(sizes: 1, 2, 4, 8, 16, 32, 64)来加速这个操作 - 独立的 lm_head:draft model 的 lm_head 维度是 draft hidden_size(如 256),和目标模型的 backbone hidden_size(如 1536)完全不同,因此不共享 lm_head
vllm serve google/gemma-4-E2B-it \
--speculative-config '{"method":"mtp","model":"gg-hf-am/gemma-4-E2B-it-assistant","num_speculative_tokens":1}'
六、特殊用途方案
6.1 Extract Hidden States(隐藏状态提取)
这不是一个真正的投机解码方案,而是一个”借用”投机解码基础设施来做隐藏状态提取的工具。
它的工作方式是:把目标模型中间层的隐藏状态”假装”当作 draft token 来处理,实际上每一步都直接接受(100% 接受率),真正的目的是把中间层激活值保存到磁盘。
实现细节:ExtractHiddenStatesProposer 内部有一个 cache-only attention 层(代码中 assert 了只有 1 个 attention 层)。这个层不做真正的 attention 计算,只是把传入的隐藏状态作为 KV 写入 cache。这样做的好处是可以利用 vLLM 已有的 KV cache 管理机制来存储和传输隐藏状态。它还支持 CUDA graph 和 data parallel,并且 num_speculative_tokens 被限制为 1。
用途:
- 训练 EAGLE 风格的 draft head(需要目标模型的隐藏状态作为训练数据)
- 知识蒸馏
- 模型内部分析
输出格式:每个请求产生一个 .safetensors 文件,包含 hidden_states(shape: [num_tokens, num_extracted_layers, hidden_size])和 token_ids(shape: [num_tokens])。
6.2 Custom Class(自定义 Proposer)
vLLM 提供了扩展接口,允许用户实现自己的 proposer 类:
vllm serve <your-model> \
--speculative-config '{"method": "custom_class", "model": "my_module.MyCustomProposer"}'
自定义类需要接受 VllmConfig 作为构造参数,并实现 propose 方法。这为研究和定制化场景提供了完全的灵活性。
七、验证机制:Rejection Sampling 是怎么工作的
7.0 例子
Rejection Sampling是投机解码用来保证输出分布与目标模型完全一致的数学技巧:草稿模型说A,目标模型说B,我们既要尽量接受草稿(省时间),又要保证最终输出数学上严格符合目标模型的分布。
假设目标模型对下一 token 的概率是:
p(target) = {"the": 0.5, "a": 0.3, "an": 0.15, "this": 0.04, "that": 0.01}
草稿模型给出的概率是:
q(draft) = {"the": 0.7, "a": 0.2, "an": 0.05, "this": 0.03, "that": 0.02}
草稿说”the”(argmax = “the”),但目标更平均(“the” 只占 0.5)——草稿过度自信了。
怎么决定接不接受草稿的 “the”?
Leviathan 等人 2022 年的论文给出了一个精妙的 coin-flip 方案:
接受条件: 随机数 u < p("the") / q("the")
= 随机数 u < 0.5 / 0.7
= 随机数 u < 0.714
也就是说,有 71.4% 的概率接受 “the”——介于”草稿的过度自信(0.7)“和”目标的真实概率(0.5)“之间。剩下 28.6% 拒绝的情况怎么办?从剩下的可能性里重采样(具体公式是 max(p - q, 0) 归一化后采样):
拒绝后的残差分布:
p'("a") = max(0.3 - 0.2, 0) = 0.10
p'("an") = max(0.15 - 0.05, 0) = 0.10
p'("this") = max(0.04 - 0.03, 0) = 0.01
p'("that") = max(0.01 - 0.02, 0) = 0 ← 草稿比目标更自信,所以 "that" 概率被截到 0
...
归一化后从 {a, an, this, ...} 中按比例采样
这套机制的精妙之处:
你可以严格证明(Leviathan et al., 2022):当 p 和 q 是任意两个兼容的分布时, “按 p/q 接受,否则从 max(p-q, 0) 重新采样” 这个过程的输出,与直接从 p 采样完全等价。 也就是说,投机解码 K 个 token 数学上无损(lossless)——和没有投机时一字一句逐个采样结果一致。
这就是 vLLM 投机解码能够”无损加速”的根本原因。
7.0.1 简化到 Greedy 模式
vLLM 的默认模式是 draft_sample_method="greedy",对应草稿模型”过度自信到极致”——只选 1 个 token,q(draft_token)=1,其他都是 0。
这时 p/q 简化成:
- 对 draft_token:
p(draft_token) / 1 = p(draft_token)→ 接受概率 = 目标模型给 draft_token 的概率 - 其他 token:
p(other) / 0 = ∞→ 总被接受(但草稿没选这些,所以无所谓)
实践中 vLLM 直接比较 argmax:目标模型对同一位置的 argmax 和 draft token 一致就接受,否则拒绝。这个简化是精确的(不是近似),因为在温度 0 / top-1 设定下,p(argmax) > p(any other) 必然成立。
7.0.2 Bonus Token 机制
如果 K 个 draft tokens 全部被接受,说明草稿模型在 K 个位置上全猜对了。这种情况下:
- 主模型对第 K+1 个位置的预测其实也已经算出来了(没浪费)
- 把它当作”额外奖励”也输出
- 每步最多 K+1 个 token
这让”接受 K 个 token”的情况产生 K+1 的吞吐量贡献,配合”接受 0~K-1 个 token”的概率,整体加速比才能稳定在 ~E[接受长度] 附近。
7.0.3 为什么 vLLM 不用更简单的”argmax 匹配 + 拒绝”?
简单接受方案 = “argmax 匹配就接受,否则从目标分布重采样”,看起来够用。但数学上不能保证输出分布严格等于目标分布——它只在大数极限下接近。换句话说:单个 prompt 的输出可能有偏。
vLLM 实现的”残差采样 max(p-q, 0)“是 Leviathan 2022 严格证明过的方案:对每个 prompt,输出 token 的分布与目标模型独立采样完全一致(lossless guarantee)。
7.1 Standard 模式(标准拒绝采样)
这是 vLLM 默认的模式,对应上面 7.0.1 的 greedy 简化。draft_sample_method="greedy" 时:
- 接受条件:目标模型的 argmax == draft token
- 拒绝时:从目标分布的残差(即”目标分布把 draft token 概率置零”)中用 Gumbel argmax 重采样
- 不需要存储完整 draft logits
draft_sample_method="probabilistic" 时:
- 接受条件:
u < p(x) / q(x) - 拒绝时:从
max(p - q, 0)归一化后重采样 - 需要额外存储完整的 draft logits(shape:
[max_num_reqs, num_speculative_steps, vocab_size]),在大词表模型(如 128K 词表)上内存开销显著
7.2 Synthetic 模式(合成接受率)
这是一种不需要 draft logits 的简化模式。它以一个预设的衰减概率来接受 draft tokens。有两种配置方式:
synthetic_acceptance_rates:直接指定每个位置的无条件接受率(必须单调非递增)synthetic_acceptance_length:指定目标平均接受长度(在[1, num_speculative_tokens + 1]范围内),内部会自动转换为最小方差的 per-position rates
适合 draft 模型不提供完整 logits 的场景。
7.3 Gumbel Argmax 采样
在 rejection sampling 的”重新采样”步骤中(拒绝位置或 bonus token),vLLM 并不是简单地做 multinomial sampling,而是使用 Gumbel argmax(gumbel_block_argmax)——给每个 token 的 logit 加上一个 Gumbel 噪声,然后取 argmax。这种方式在数学上等价于从 softmax 分布中采样,但数值稳定性更好,且天然支持 block-level 并行归约。
此外还支持 fp64 Gumbel(use_fp64_gumbel),用双精度浮点数来生成 Gumbel 噪声,进一步提高采样精度。
7.4 Triton Kernel 流水线
整个 rejection sampling 过程由四个 Triton kernel 串联完成:
- Block Stats Kernel:计算 target 和 draft logits 的 block 级统计量(max、argmax、sum-of-exp),用于数值稳定的 log-sum-exp。对于 greedy 采样(temperature=0),只需要 target 的 max/argmax;对于概率采样,还需要计算 sum-of-exp 来得到 log-sum-exp
- Rejection Kernel:逐位置执行接受/拒绝测试,遇到第一个拒绝就停止。Greedy 模式下直接比较 argmax;概率模式下做
log p(x) > log(u) + log q(x)测试;synthetic 模式下和预设 rate 比较 - Resample Kernel:在拒绝位置(或 bonus 位置)计算残差 logits,然后用 Gumbel argmax 重新采样。对于 one-hot draft(greedy),残差就是把 draft token 的概率置零;对于概率 draft,残差是
log(max(exp(log_p) - exp(log_q), 0)),用更稳定的a + log(1 - exp(b-a))形式计算 - Insert Kernel:把重新采样的 token 插入输出序列
八、整体架构:从调度到执行
vLLM 的投机解码涉及三个核心组件的协作:
┌──────────────────────────────────────────────────────────────┐
│ Scheduler(调度器) │
│ - 决定哪些请求参与投机解码 │
│ - 跟踪每个请求的 speculative token 数量 │
│ - 把 draft tokens 当作普通 token 一样调度 │
└───────────────────────────┬──────────────────────────────────┘
│ SchedulerOutput
▼
┌──────────────────────────────────────────────────────────────┐
│ GPU Model Runner(模型执行器) │
│ │
│ Step 1: 目标模型前向传播(prefill 或 decode) │
│ Step 2: 提取目标模型的隐藏状态和 token IDs │
│ Step 3: 调用 drafter.propose() 生成 draft tokens │
│ Step 4: 把 draft tokens 反馈给调度器,进入下一步 │
│ Step 5: 目标模型对 draft tokens 做验证前向传播 │
│ Step 6: 调用 rejection_sampler() 做接受/拒绝判定 │
│ │
└──────────────────────────────────────────────────────────────┘
v1 架构的一个关键设计:调度器把 prompt tokens 和 output tokens 统一对待(不区分 prefill 和 decode),这让投机解码可以无缝集成——draft tokens 在调度器看来就是普通的 tokens,不需要特殊处理。