3.5 投机解码(Speculative Decoding)¶
本章你将学到
- 为什么解码(decode)阶段会让算力闲置,以及如何用廉价的草稿(draft)加上目标模型的一次验证(verification)前向,把这部分闲置算力变成额外的 token。
- 接受/拒绝规则,以及输出分布与目标模型完全一致的证明。
- 每一步期望产出的 token 数,如何随接受率(acceptance rate)\(\alpha\) 与草稿长度 \(k\) 变化,以及由此得到的加速比。
- 草稿的几种选择:小模型、n-gram / prompt lookup、Medusa heads、EAGLE、MTP heads;以及树形验证(tree verification)。
- 什么时候投机解码不再有效(大 batch、计算受限的场景)。
为什么重要¶
3.1 章讲过,在小 batch 下,一步解码是带宽受限的:GPU 要花几毫秒把全部权重从 HBM 里读出来,而真正的计算只占其中极小一部分时间。关键在于,一次前向处理 5 个 token 的耗时和处理 1 个 token 几乎一样——两种情况下权重都只读一遍,算术强度仍然远低于拐点(ridge point)。
但解码是串行的:在知道第 \(t+1\) 个 token 之前,没法算第 \(t+2\) 个。投机解码靠猜来打破这个限制。先由一个廉价的机制提出接下来的 \(k\) 个 token,大模型用一次前向把它们全部检查一遍(因为是访存受限,这次前向的耗时和一次普通解码差不多),然后保留最长的正确前缀。如果猜得大多是对的,一次昂贵的前向就能产出好几个 token。只要做得严谨,它只改变速度,绝不改变输出分布。这份"免费的午餐",正是每个生产级推理引擎都支持它、输出很长的推理模型也都依赖它的原因。
直觉¶
想象一位资深作者(目标模型)和一位手脚很快的初级助理(草稿)。助理先敲出接下来的几个词,作者一眼扫过,要么点头("对,对,对——不对,这个词应该是 这个"),要么把第一个错词改掉。对作者来说,"读"比一个词一个词地"写"快得多。如果助理有 80% 左右的概率猜对,作者每扫一眼就能往前推进好几个词。
微妙之处在于采样。贪心解码时,"正确"就是"等于目标模型的 argmax"。但随机采样时并不存在唯一正确的 token,所以我们需要一个概率式的接受检验,使整个过程恰好从目标分布中采样。这个检验本质上是一种拒绝采样(rejection sampling)。
工作原理¶
符号约定¶
- \(p(\cdot)\):目标模型在某个位置上的下一 token 分布(已经过 temperature、top-p 等处理),定义在词表 \(\mathcal{V}\) 上。
- \(q(\cdot)\):草稿在同一位置上的分布。
- \(k\):每一步提出的草稿 token 数("草稿长度"或"lookahead")。
- \(\alpha\):接受率,即一个草稿 token 被接受的概率。
算法(一步)¶
- 起草。 从当前上下文出发,让草稿自回归地采样 \(k\) 个 token:\(\tilde x_1 \sim q_1\),\(\tilde x_2 \sim q_2(\cdot \mid \tilde x_1)\),……,\(\tilde x_k\)。同时记下草稿概率 \(q_i(\tilde x_i)\)。
- 验证。 把目标模型在"上下文 \(+\ \tilde x_1 \dots \tilde x_k\)"上只跑一次。因为目标模型是因果的,这一次前向就给出 \(k+1\) 个分布:\(p_1\)(位置 1,只依赖上下文)、\(p_2\)(依赖 \(\tilde x_1\))、……、\(p_{k+1}\)(依赖全部草稿)。
-
从左到右接受/拒绝。 对 \(i = 1, \dots, k\):抽 \(r \sim \text{Uniform}(0,1)\)。如果 \(r < \min\!\big(1, \tfrac{p_i(\tilde x_i)}{q_i(\tilde x_i)}\big)\),就接受 \(\tilde x_i\) 并继续;否则拒绝:从残差分布(residual distribution)中采样一个替代 token
\[ p_{\text{res}}(x) = \frac{\max\big(0,\ p_i(x) - q_i(x)\big)}{\sum_{y}\max\big(0,\ p_i(y) - q_i(y)\big)}, \]输出它,然后停止(丢弃 \(\tilde x_{i+1},\dots,\tilde x_k\))。 4. 奖励 token(bonus token)。 如果 \(k\) 个草稿全被接受,就再从 \(p_{k+1}\) 采样一个 token——它是验证那次前向顺带送的。
每一步输出 1 到 \(k+1\) 个 token,开销是一次目标模型前向加 \(k\) 次草稿前向。目标模型的 KV cache 需要回滚,丢掉被拒绝的位置(用 paged KV 的话,这只意味着截短序列长度)。
输出分布恰好是 \(p\) 的证明¶
考虑某一个位置,草稿分布为 \(q\),目标分布为 \(p\)。输出的 token \(X\) 要么是被接受的草稿,要么是残差采样的结果。对任意 token \(x\):
第一项等于 \(\min(q(x), p(x))\)。对第二项,总接受概率为 \(\beta = \sum_y \min(q(y), p(y))\),所以 \(P(\text{reject}) = 1 - \beta\)。利用 \(\min(a,b) + \max(0, b - a) = b\) 并对 \(y\) 求和:
所以残差分布的归一化常数恰好是 \(1-\beta\),第二项就等于 \((1-\beta)\cdot \dfrac{\max(0, p(x) - q(x))}{1-\beta} = \max(0, p(x)-q(x))\)。两项相加:
用大白话说:当草稿"多提"了某个 token(\(q > p\)),我们就把多出来的那部分拒掉;当草稿"少提"了(\(q < p\)),残差分布恰好补上缺的那部分概率质量。逐位置地应用这个规则(每个被接受的 token 会作为条件影响下一个位置,和普通解码完全一样),整个生成序列就服从目标模型的分布。对于贪心解码(\(p\) 和 \(q\) 都是 one-hot),规则退化为"当且仅当草稿 token 等于目标的 argmax 时接受,否则输出目标的 argmax"。
顺带一提,某个位置上的接受概率是 \(\beta = \sum_x \min(p(x), q(x)) = 1 - \mathrm{TV}(p, q)\),也就是 1 减去总变差距离(total-variation distance)。草稿的分布越接近目标,接受率就越高。
每步期望 token 数¶
做一个简化假设:每个草稿 token 以概率 \(\alpha\) 被独立接受。第 \(i\) 个草稿要被"走到并接受",当且仅当前 \(1..i\) 个草稿全被接受,概率为 \(\alpha^i\)。被接受的草稿数 \(A\) 满足:对 \(i \le k\),\(P(A \ge i) = \alpha^i\),因此 \(\mathbb{E}[A] = \sum_{i=1}^{k} \alpha^i\)。此外每一步还会恰好多产出一个 token(拒绝时的残差采样,或者全部接受时的奖励 token)。于是
直观地说:草稿完美(\(\alpha \to 1\))时,每步得到 \(k+1\) 个 token;草稿毫无用处(\(\alpha = 0\))时,每步仍然得到 1 个——投机解码每次目标模型前向产出的 token 数,永远不会比普通解码少。
加速比。 设一次目标模型解码步耗时 \(T\),一次草稿步耗时 \(cT\)(即 \(c\) 是草稿与目标的成本比),并假设验证 \(k+1\) 个 token 的耗时约为 \(T\)(在访存受限时成立)。那么
增大 \(k\),草稿开销线性增加,而多得到的 token 收益却按几何级数递减,所以存在一个最优的 \(k\),通常在 3–8 之间。
如何选择草稿¶
| 草稿 | 如何提出候选 | 优点 | 缺点 |
|---|---|---|---|
| 同系列的小模型(例如用 1B 给 70B 起草) | 用自己的 KV cache 自回归采样 | 简单,采样下也无损 | 必须共享 tokenizer;额外占显存;\(c\) 不可忽略 |
| n-gram / prompt lookup | 在 prompt 或已有输出中查找最近几个生成 token,提出其后面跟着的内容 | 无需训练,开销几乎为零 | 只在输出会复制输入时有用(代码修改、RAG、摘要、改写) |
| Medusa heads | 在目标模型最后一层隐状态上接 \(k\) 个额外的 head;第 \(i\) 个 head 直接预测 token \(t+i\) | 只有一个模型,没有额外 KV cache | 各 head 独立预测(不以前面的草稿为条件)→ 靠后位置的接受率更低 |
| EAGLE (1/2/3) | 一个小型(单层)transformer,在目标模型的隐藏特征加 token embedding 上自回归运行 | 以较低成本获得高接受率;EAGLE-2 加入动态树,EAGLE-3 融合低/中/高层特征 | 需要针对每个目标模型训练 |
| MTP heads(DeepSeek-V3 风格) | 在预训练阶段就训练好的多 token 预测(multi-token-prediction)模块 | 随模型自带;接受率高 | 只适用于用 MTP 训练过的模型 |
| Lookahead decoding | 用 Jacobi 式并行迭代生成候选 n-gram,并行验证 | 不需要草稿模型 | 每步计算量更大 |
EAGLE 的核心观察是:预测下一个特征向量(目标模型次顶层的隐状态)比直接预测下一个 token 更容易;而且把"提前一步采样得到的 token"喂给草稿,可以消除采样究竟走了哪条分支所带来的不确定性。
树形验证¶
不只提出一条长度为 \(k\) 的猜测链,而是提出一棵树:比如位置 1 取 top-3 候选,每个候选再各取 top-2 作为位置 2。把树上所有节点展平成一个序列,用一个树注意力掩码(tree attention mask)让目标模型只跑一次:每个节点只能看到上下文和它自己的祖先(看不到兄弟节点)。一次前向就验证了所有路径,保留最长的被接受路径即可。在目标模型前向次数相同的情况下,树能提高期望接受长度,代价是每次前向要验证更多 token。
草稿树:深度 1 取 top-2,每个节点下再取 top-2 展平顺序:[a, b, a1, a2, b1, b2]
树注意力掩码(1 = 可见;
┌─ a ──┬─ a1 每个节点还能看到全部上下文)
上下文 ─────┤ └─ a2 a b a1 a2 b1 b2
└─ b ──┬─ b1 a 1 0 0 0 0 0
└─ b2 b 0 1 0 0 0 0
a1 1 0 1 0 0 0
一次目标模型前向给全部 6 个节点打分; a2 1 0 0 1 0 0
保留通过验证的最长"根到叶"路径 b1 0 1 0 0 1 0
b2 0 1 0 0 0 1
位置编码也需要注意:节点 a1 和 b1 都在深度 2,所以它们拥有相同的位置索引(用于 RoPE),尽管它们在展平序列里是不同的条目。SpecInfer 提出了树形验证,并给出了采样场景下无损的多候选接受规则;Medusa 和 EAGLE-2/3 都使用草稿树(EAGLE-2 会根据草稿模型的置信度动态地塑造树的形状)。
什么时候投机解码反而有害¶
整个论证依赖于"验证 \(k+1\) 个 token 的耗时约等于一步解码",也就是目标模型处于访存受限状态。在 batch 大小为 \(B\) 时,一次验证要处理 \(B(k+1)\) 个 token。3.1 章讲过,在 H100 上,线性层大约在每次前向处理几百个 token 时转为计算受限。一旦 \(B(k+1)\) 越过这个点,验证开销就随 \(k+1\) 近似线性增长,每个被拒绝的 token 都是白白浪费的算力,而这些算力本可以给其他用户用。在大 batch 下,投机解码可能降低吞吐。因此引擎会根据负载调整甚至关闭投机解码;它最适合对延迟敏感、并发为低到中等的服务,以及长的单流生成。
代码¶
下面是在玩具"语言模型"(词表大小为 6 的随机马尔可夫链)上实现的完整投机采样,并通过实验检验:输出分布等于目标分布,实测的每步 token 数与公式估计的量级相符。
import numpy as np
rng = np.random.default_rng(0)
V, k = 6, 4
def random_chain(sharpness): # 下一 token 转移表:P[prev] 是一个分布
logits = rng.standard_normal((V, V)) * sharpness
P = np.exp(logits); return P / P.sum(1, keepdims=True)
P_target = random_chain(2.0)
P_draft = 0.7 * P_target + 0.3 * random_chain(2.0) # 一个不错但不完美的草稿
def speculative_step(prev):
"""从 token `prev` 出发的一步 起草-验证。返回输出的 token 列表。"""
drafts, q_probs, ctx = [], [], prev
for _ in range(k): # 1) 从 q 起草 k 个 token
x = rng.choice(V, p=P_draft[ctx]); drafts.append(x); q_probs.append(P_draft[ctx]); ctx = x
p_probs = [P_target[c] for c in [prev] + drafts] # 2) “一次目标模型前向”:k+1 个分布
out = []
for i, x in enumerate(drafts): # 3) 从左到右接受 / 拒绝
p, q = p_probs[i], q_probs[i]
if rng.random() < min(1.0, p[x] / q[x]):
out.append(x); continue
res = np.maximum(p - q, 0); res /= res.sum() # 残差分布
out.append(rng.choice(V, p=res)); return out
out.append(rng.choice(V, p=p_probs[k])) # 4) 奖励 token
return out
# 从起始 token 0 出发生成大量长度为 3 的序列,比较第 3 个 token 的频率。
n_runs, counts, steps, toks = 40000, np.zeros(V), 0, 0
for _ in range(n_runs):
seq = []
while len(seq) < 3:
new = speculative_step(seq[-1] if seq else 0); steps += 1; toks += len(new); seq += new
counts[seq[2]] += 1
exact = np.linalg.matrix_power(P_target, 3)[0] # 真实的 P(第 3 个 token | 起始 0)
print("speculative:", np.round(counts / n_runs, 3))
print("target :", np.round(exact, 3))
alpha = np.mean([np.minimum(P_target[s], P_draft[s]).sum() for s in range(V)])
print(f"alpha~{alpha:.2f} formula tokens/step={(1 - alpha**(k + 1)) / (1 - alpha):.2f} "
f"measured={toks / steps:.2f}") # 接近但不相等:真实的 alpha 随上下文变化
两行频率在蒙特卡洛误差范围内一致,尽管草稿是另一个不同的分布。你可以试试把 P_draft 改成 random_chain(2.0)(一个毫不相关的草稿):输出分布依然正确,只是接受率下降。
实例演算¶
贪心解码一步的完整追踪。 上下文:"The capital of France is"。草稿提出 \(k = 4\) 个 token:" Paris"、"."、" It"、" is"。目标模型在"上下文加这 4 个 token"上跑一次,返回 5 个位置上各自的 argmax:
| 位置 | 草稿 token | 目标 argmax(以之前所有内容为条件) | 结果 |
|---|---|---|---|
| 1 | " Paris" |
" Paris" |
接受 |
| 2 | "." |
"." |
接受 |
| 3 | " It" |
" The" |
拒绝 → 输出 " The",停止 |
| 4 | " is" |
(未使用) | 丢弃 |
| 5 | — | (未使用) | 没有奖励 token |
这一步的输出是:" Paris"、"."、" The"——一次目标模型前向产出 3 个 token。目标模型为被拒绝的 " It" 和 " is" 写下的 KV 条目会被丢弃(序列长度直接截短);" The" 的 KV 条目会在下一步开头把它作为输入喂进去时写入,下一步就从"…is Paris. The"继续。
设定。 目标:一个 70B 模型,一步解码耗时 \(T\)。草稿:一个 EAGLE 风格的 head,每步耗时 \(c = 0.05\,T\)。实测接受率 \(\alpha = 0.8\)。
\(k = 4\) 时每步 token 数:
加速比: 每一步耗时 \(T(1 + 4 \times 0.05) = 1.2\,T\),所以加速比 \(= 3.36 / 1.2 = 2.8\times\)。
选择 \(k\)(\(\alpha\) 与 \(c\) 同上):
| \(k\) | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 10 |
|---|---|---|---|---|---|---|---|---|---|
| tokens/step | 1.80 | 2.44 | 2.95 | 3.36 | 3.69 | 3.95 | 4.16 | 4.33 | 4.57 |
| speedup | 1.71 | 2.22 | 2.57 | 2.80 | 2.95 | 3.04 | 3.08 | 3.09 | 3.05 |
收益在 \(k \approx 6\)–8 附近趋于平缓,之后开始下降。如果用一个独立的 1B 草稿模型(\(c\) 可能在 0.1–0.2),最优的 \(k\) 会向更小的值移动。
batch 的影响。 在 H100 上,转为计算受限的拐点是每次前向几百个 token(见 3.1 章)。batch 为 8、\(k=4\) 时,验证要处理 40 个 token:仍然是访存受限,所以 2.8× 的估计大致成立。batch 为 128、\(k=4\) 时,要处理 640 个 token:已经计算受限,验证耗时约为普通一步的 \(640/128 = 5\times\),"加速比"变成约 \(3.36 / (5 + 0.2) \approx 0.65\)——变成了减速。(真实的拐点取决于模型、KV 长度和 kernel;请实测。)
实践中的做法(2025–2026)¶
- vLLM、SGLang 和 TensorRT-LLM 支持草稿模型、n-gram/prompt lookup、Medusa、EAGLE(包括 EAGLE-3)以及 MTP。2025 年,EAGLE-3 成为 SGLang/vLLM 中默认的高性能方案,主流开源模型都有现成发布的草稿 head。
- DeepSeek-V3 报告称,其 MTP 模块复用于投机解码时,第二个 token 的接受率约为 85–90%,每秒 token 数约为原来的 1.8 倍。
- 引擎会暴露草稿长度参数(
num_speculative_tokens或类似名称),并在指标里报告接受率 / 平均接受长度;在生产环境中要盯住这些数字,因为接受率取决于领域(代码和结构化输出的接受率远高于创意写作)。 - 投机解码对推理模型在低并发时尤其有价值——一次回答可能长达数万个 token;对"apply edit"这类任务也很有用,因为 prompt lookup 能一次接受很长一段复制的文本。
常见误区¶
- "投机解码是近似的。" 用上面的拒绝规则,它在分布意义上是完全无损的。(有些变体,比如 Medusa 的"typical acceptance",会有意用精确性换速度——要清楚自己启用的是哪一种。)
- "k 越大越好。" 收益按几何级数递减,而草稿开销线性增长;高负载下,多验证的 token 都要消耗真金白银的算力。
- 只在 batch 为 1 时测加速比。 要在你实际服务的整个并发范围内报告加速比;高负载下它可能变成负数。
- 采样参数不一致。 \(p\) 和 \(q\) 必须是处理之后的分布(目标模型同样应用相同的 temperature/top-p 流程);否则无损保证就不成立。
- 逐比特一致的输出。 "分布意义上无损"并不意味着和同一个 seed 下的非投机解码生成完全相同的文本;随机数的消耗方式不同,批量验证也可能让浮点结果略有变化。
本章要点¶
- 解码是访存受限的,所以验证 \(k+1\) 个 token 的耗时约等于一步解码;投机解码把这部分闲置算力花在了"猜测"上。
- 以概率 \(\min(1, p/q)\) 接受草稿 \(\tilde x\);被拒绝时,从归一化后的 \(\max(0, p-q)\) 中重新采样。结果的分布恰好是 \(p\)。
- 每步期望 token 数 \(= (1-\alpha^{k+1})/(1-\alpha)\);加速比 \(\approx\) 该值除以 \((1 + ck)\)。
- 草稿方案是在训练成本与接受率之间做取舍:n-gram(免费)、小模型、Medusa、EAGLE、MTP;树可以在一次前向中验证多个候选。
- 在大 batch 下,验证会变成计算受限,投机解码可能让系统变慢。
自测¶
目标分布 p = (0.6, 0.3, 0.1),草稿分布 q = (0.3, 0.6, 0.1),对应 token (a, b, c)。接受概率是多少?残差分布是什么?
\(\beta = \min(.6,.3) + \min(.3,.6) + \min(.1,.1) = 0.3 + 0.3 + 0.1 = 0.7\)。残差 \(\propto \max(0, p-q) = (0.3, 0, 0)\),也就是总是 token a。验算:\(P(a) = 0.3 + 0.3 \cdot 1 = 0.6\) ✓。
α = 0.6、k = 3 时,平均每次目标模型前向能产出多少个 token?
\((1 - 0.6^4)/(1-0.6) = (1 - 0.1296)/0.4 = 2.18\)。
为什么所有草稿都被接受时,我们能得到一个“奖励”token?
在"上下文 + \(k\) 个草稿"上做的验证前向,同时也产出了第 \(k+1\) 个位置上的目标分布,所以再从中采样一个 token 不需要任何额外开销。
为什么 Medusa heads 在第 3、4 个草稿位置上的接受率通常比 EAGLE 低?
Medusa 的第 \(i\) 个 head 是根据位置 \(t\) 的隐状态来预测 token \(t+i\) 的,它看不到中间那些已起草的 token,因此无法以所选的分支为条件。EAGLE 则是自回归地起草,把每个已起草的 token 和预测出的特征都反馈回去。
你的服务白天大部分时间 batch 为 256,夜里 batch 为 8。你会如何配置投机解码?
负载低时开启(用中等大小的 \(k\)),高负载时关闭或缩小 \(k\),因为那时验证是计算受限的;很多引擎支持动态调整,你也可以按负载做路由。
延伸阅读¶
| 论文 | 为什么读 |
|---|---|
| Fast Inference from Transformers via Speculative Decoding — Leviathan et al., 2022 | 原始算法、无损性证明,以及本章使用的加速比分析。 |
| Accelerating LLM Decoding with Speculative Sampling — Chen et al. (DeepMind), 2023 | 同期独立推导出同样的规则;应用在 Chinchilla-70B 上。 |
| SpecInfer: Tree-based Speculative Inference — Miao et al., 2023 | 用树注意力在一次前向中验证一棵候选树;多候选的无损接受规则。 |
| Medusa: Multiple Decoding Heads — Cai et al., 2024 | 在目标模型上加额外 head 来预测未来 token;树形验证;无需独立的草稿模型。 |
| EAGLE: Speculative Sampling Requires Rethinking Feature Uncertainty — Li et al., 2024 | 特征层面的自回归起草 → 接受率大幅提高。 |
| EAGLE-2: Faster Inference of Language Models with Dynamic Draft Trees — Li et al., 2024 | 由草稿置信度决定、随上下文变化的草稿树。 |
| EAGLE-3: Scaling up Inference Acceleration via Training-Time Test — Li et al., 2025 | 多层特征融合以及对解码过程的训练期模拟;2025 年 SGLang/vLLM 中默认的投机方法。 |
| Break the Sequential Dependency of LLM Inference Using Lookahead Decoding — Fu et al., 2024 | 无需草稿:用 Jacobi 迭代生成 n-gram 并行验证。 |
| Prompt lookup decoding | 从 prompt 中取 n-gram 的最简单草稿方法,附代码。 |
| DeepSeek-V3 Technical Report — DeepSeek-AI, 2024 | 在预训练中训练 MTP 模块,并将其复用为投机草稿。 |