jiangwei.me
Back to Home

常见的投机解码方法 & vLLM 的实现

llm·vllm·inference·speculative-decoding

本文包含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 的核心思路

在正常的自回归解码中,每生成一个 token 就要跑一次前向传播。而每次前向传播都要把整个模型权重从显存搬到计算单元,但 batch=1 时实际计算量很小——GPU 大部分时间都花在等数据搬运上(memory-bound),计算单元反而在空转。

Speculative Decoding 的核心想法非常直觉:

  1. Draft(起草):用一个廉价的方式快速猜出 K 个候选 token(draft tokens)
  2. Verify(验证):把这 K 个 token 一起喂给目标大模型,一次前向传播并行验证
  3. Accept/Reject(接受/拒绝):通过 rejection sampling 决定接受前几个 token,拒绝后面的

如果平均能接受 m 个 token,那等效于每一步前向传播生成了 m+1 个 token(如果全部接受还能多拿一个 bonus token),吞吐量直接提升 m 倍。

关键保证:Speculative Decoding 在理论上是无损的(lossless)。经过 rejection sampling 之后,输出的分布和不用投机解码完全一致。这不是近似加速,是精确等价。

原始论文:Speculative Decoding 由两篇独立论文在 2022-2023 年同时提出:


二、vLLM 的投机解码方案全景图

vLLM 目前支持的投机解码方案可以分为三大类:

类别方案是否需要额外模型核心特点
无模型方案N-Gram, N-Gram GPU, Suffix Decoding零额外开销,即插即用
轻量 Draft HeadEAGLE, 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_fusioncoordinate_descent_tuningtriton.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 的三个核心区别

  1. 匹配范围更广:N-Gram 只在当前请求的 prompt + 已生成内容中匹配。Suffix Decoding 会缓存之前所有请求的生成结果,构建一个全局的后缀树(suffix tree)。这意味着即使当前 prompt 里没有匹配的模式,也可能从历史请求中找到有用的模式。

  2. 基于频率的概率估计:不是简单地取匹配片段后面的 token,而是统计后缀树中所有匹配路径的 token 频率,选择出现频率最高的作为候选。这让它的提案质量更高。

  3. 动态投机长度: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

相关论文

一句话概括:用一个轻量 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 内部完成的——比如 Eagle3LlamaForCausalLMEagle3DeepseekV2ForCausalLM 各自实现了融合逻辑,把多层隐藏状态拼接或投影到 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_ratioshc_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 的核心区别

EAGLEMedusa
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_headoriginal_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_headconfig.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 方案。原理非常直白:

  1. 目标模型是 Qwen3-8B,draft 模型是 Qwen3-0.6B
  2. 0.6B 先跑 K 次前向传播,生成 K 个 token
  3. 8B 把这 K 个 token 一起喂进去,跑一次前向传播验证
  4. 通过 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.2deepseek_mtp.py包括 DeepSeek V3、V3.2
DeepSeek V4deepseek_mtp.py独立的 DeepSeekV4MTPModel 架构
MiMomimo_mtp.py
MiMo V2mimo_v2_mtp.py包括 MiMoV2、MiMoV2Omni、MiMoV2Flash 三个变体
GLM-4 MoEglm4_moe_mtp.py
GLM-4 MoE Liteglm4_moe_lite_mtp.py
GLM OCRglm_ocr_mtp.py
Ernieernie_mtp.py包括 Ernie 4.5 MoE
Nemotron-Hnemotron_h_mtp.py包括 Nemotron-H Puzzle 和 VLM 变体
Qwen3 Nextqwen3_next_mtp.py
Qwen3.5qwen3_5_mtp.py包括 Qwen3.5 Dense 和 MoE 两个架构
Intern-S2 Previewqwen3_5_mtp.py被映射到 Qwen3.5 MTP 路径
Exaone MoEexaone_moe_mtp.py
Exaone 4.5exaone4_5_mtp.py
LongCat Flashlongcat_flash_mtp.py
OpenPangu Ultra MoEopenpangu_mtp.py
Step 3.5/3.7step3p5_mtp.py有独立的 proposer(见下文)
HY V3hy_v3_mtp.py
Gemma 4gemma4_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

关键设计点:

  1. enorm / hnorm / eh_proj 三件套(见 vLLM deepseek_mtp.pyDeepSeekMultiTokenPredictorLayer.__init__):把”输入 embedding”和”上一轮 hidden state”分别 RMSNorm 后拼接,再线性投影回 hidden_size。这一步是 DeepSeek V3 MTP 区别于 Medusa/EAGLE 的关键——它不是只消费 hidden state,而是把”上一轮的预测结果 embedding”也作为输入,让 MTP 层能感知到序列的当前位置。

  2. mtp_block 直接复用主模型的 DeepseekV2DecoderLayer:这意味着 MTP 层用的是标准的 MLA + MoE,不是简化版本。代价是单层 MTP 就包含约 10.6B 参数,整个 MTP 模块约 14B(11.5B 独有 + 2.5B 共享)。

  3. 共享的 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 权重,其他层自动共享。

  4. 支持多个 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
  • 同时支持 step3p5step3p7 两种模型类型

特别说明——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 argmaxgumbel_block_argmax)——给每个 token 的 logit 加上一个 Gumbel 噪声,然后取 argmax。这种方式在数学上等价于从 softmax 分布中采样,但数值稳定性更好,且天然支持 block-level 并行归约。

此外还支持 fp64 Gumbeluse_fp64_gumbel),用双精度浮点数来生成 Gumbel 噪声,进一步提高采样精度。

7.4 Triton Kernel 流水线

整个 rejection sampling 过程由四个 Triton kernel 串联完成:

  1. 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
  2. Rejection Kernel:逐位置执行接受/拒绝测试,遇到第一个拒绝就停止。Greedy 模式下直接比较 argmax;概率模式下做 log p(x) > log(u) + log q(x) 测试;synthetic 模式下和预设 rate 比较
  3. 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)) 形式计算
  4. 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,不需要特殊处理。

© 2026 jiangwei.me. All rights reserved.