让大模型快 8 倍:从投机解码到 DDTree 的完整原理
Published:
本文从零开始,带你理解 LLM 推理加速的核心思路,读完之后你会明白:大模型为什么慢、投机解码如何加速、为什么加速后输出质量完全不变,以及 DDTree 这篇 2026 年的新论文究竟做了什么创新。
1. 大模型推理为什么慢?
你每次跟 ChatGPT 或 Claude 对话,它生成文字的方式其实非常朴素——每次只生成一个 token(词片段),然后把这个 token 加进上下文,再生成下一个,如此往复。
这种方式叫做自回归解码(Autoregressive Decoding)。它的问题在于天然的串行性:第 $n$ 个 token 必须等第 $n-1$ 个 token 生成完才能开始,完全无法并行。
更麻烦的是,现代大模型动辄数百亿参数,每生成一个 token 就要做一次完整的前向传播,把所有参数都过一遍。而 GPU 最擅长的恰恰是大批量并行计算——生成单个 token 这件事,对 GPU 来说几乎是一种浪费,大量算力处于闲置状态。
用一个类比:这就好比你雇了一个有 100 条生产线的工厂,却每次只让它生产一个零件,然后等零件出来之后再决定下一个生产什么。工厂的产能大部分都在空转。
那有没有办法,让大模型一次性并行生成多个 token,又不影响输出质量?
这就是投机解码(Speculative Decoding)的出发点。
2. 投机解码:用小模型”猜”,用大模型”验”
投机解码的核心思想简单到令人惊喜:
用一个轻量的草稿模型(Draft Model) 先快速生成多个候选 token,再让大的目标模型(Target Model) 一次性并行验证这些候选 token,接受正确的,拒绝错误的。
为什么这能加速?关键在于一个不对称性:大模型验证多个 token 和验证单个 token,所需要的计算量几乎相同。这是因为验证本质上是一次 prefill(并行处理整个序列),而不是逐个自回归生成。
具体流程是这样的:
- 草稿阶段:草稿模型连续生成 $k$ 个 token(比如 4 个),速度很快
- 验证阶段:目标模型把这 $k$ 个候选 token 一次性并行处理,输出对每个位置的概率判断
- 接受/拒绝:从左到右逐个判断,接受的 token 保留,遇到第一个被拒绝的 token 就停止,后面的全部丢弃
- 下一轮:从被拒绝的位置重新开始草稿
如果草稿模型猜对了 3 个 token,那这一轮相当于大模型用一次前向传播的时间,产出了 3 个高质量 token,而不是通常的 1 个。加速比可以非常显著。
草稿模型的 $q(x)$ 究竟是什么?
一个常见的细节困惑:草稿模型给每个 token 的概率 $q(x)$,到底指什么?
- Temperature = 0(贪心):草稿直接取 argmax 那个 token,$q(x)$ 就是该 token 在 softmax 后对应的那个最大概率值
- Temperature > 0(采样):按分布采样一个 token,$q(x)$ 就是采到的那个 token 对应的概率值
- DFlash 这类块扩散草稿:一次 forward pass 直接输出每个位置的完整边际分布,$q(x)$ 就是该分布在对应 token 上的概率值
无论哪种情况,$q(x)$ 都是”草稿模型对它自己选出的那个 token 所赋予的概率”,而不是整个分布。这一点在后面理解接受/拒绝规则时非常重要。
3. 最关键的问题:输出质量真的不变吗?
这是整个方法最精妙的地方,也是很多人最困惑的地方。
答案是:严格不变,数学上可以证明。
关键在于接受/拒绝的规则设计。对于草稿模型提出的某个 token $x$:
- 草稿模型给它的概率是 $q(x)$
- 目标模型给它的概率是 $p(x)$
以概率 $\min!\left(1,\, \frac{p(x)}{q(x)}\right)$ 接受这个 token。
直觉很简单:如果目标模型认为这个 token 的概率至少和草稿模型一样高($p \geq q$),就无条件接受;如果目标模型认为它概率更低($p < q$),就按比例拒绝,避免这个 token 被过度采样。
被拒绝时怎么办? 不是直接放弃,而是从一个”残差分布”里重新采样:
\[p'(x) = \frac{\max(0,\; p(x) - q(x))}{1 - \beta}, \quad \text{其中 } \beta = \sum_y \min(p(y), q(y))\]这个残差分布的含义是:草稿模型过度提名了某些 token($q > p$),那些 token 残差为 0,不再有机会;而那些被草稿欠缺代表的 token($p > q$),按亏欠量分配权重,作为补偿。
一个具体的数字例子
抽象公式不好懂,换一个只有 5 个 token 的小词表来算一遍。假设目标分布 $p$ 和草稿分布 $q$ 如下,草稿模型抽出来的 token 是 “cat”:
| Token | $p(x)$ | $q(x)$ | $\min(p,q)$ | $\max(0,\, p-q)$ |
|---|---|---|---|---|
| cat | 0.50 | 0.70 | 0.50 | 0 |
| dog | 0.20 | 0.10 | 0.10 | 0.10 |
| sat | 0.15 | 0.05 | 0.05 | 0.10 |
| on | 0.10 | 0.10 | 0.10 | 0 |
| mat | 0.05 | 0.05 | 0.05 | 0 |
第一步:判断是否接受 “cat”。因为 $p(\text{cat})/q(\text{cat}) = 0.50/0.70 \approx 0.71 < 1$,以 71% 的概率接受,29% 的概率拒绝。
第二步:一旦被拒绝,从残差分布 $p’$ 重新采样。先算 $\beta = \sum \min(p, q) = 0.50 + 0.10 + 0.05 + 0.10 + 0.05 = 0.80$,因此 $1 - \beta = 0.20$。把 $\max(0,\, p-q)$ 这一列每个元素除以 $0.20$:
| Token | 残差概率 $p’(x)$ |
|---|---|
| cat | 0 / 0.20 = 0 |
| dog | 0.10 / 0.20 = 0.50 |
| sat | 0.10 / 0.20 = 0.50 |
| on | 0 / 0.20 = 0 |
| mat | 0 / 0.20 = 0 |
于是:被拒绝后,以 50/50 的概率从 “dog” 和 “sat” 中补采一个。
为什么残差分布不是”把 cat 去掉再归一化”?
这里最容易踩的坑:很多人第一反应是”既然 cat 被拒了,就把 cat 从 $p$ 里划掉,剩下的 ${0.20, 0.15, 0.10, 0.05}$ 归一化一下(和是 0.50),按这个分布采一个就好”。
但那样算出来 “on” 会得到 $0.10/0.50 = 0.20$ 的概率。而残差分布给 “on” 的概率是 0。差别在哪?
关键是 “on” 满足 $p(\text{on}) = q(\text{on}) = 0.10$——草稿模型已经给 “on” 分配了完全正确比例的概率质量,不多不少。再在补采里给它机会,就会让 “on” 在最终输出分布里超出 0.10。
残差分布只补偿那些被草稿欠缺代表的 token($p > q$),而不是盲目地把剩下的 $p$ 归一化。这才是最终边际分布严格等于 $p$ 的关键。
为什么整体输出分布仍然等于 $p$?
把直接接受和补采的贡献加起来:
\[P_{\text{out}}(x) = \underbrace{\min(p(x),\, q(x))}_{\text{直接接受贡献}} + \underbrace{\max(0,\, p(x) - q(x))}_{\text{补采贡献}} = p(x)\]这个等式对任意 $p, q$ 都严格成立,无需任何假设。草稿模型质量只影响速度(草稿越准,$\beta$ 越大,补采越少),永远不影响输出正确性。
这种技术叫做拒绝采样(Rejection Sampling),是概率论里的经典工具,被投机解码巧妙地应用到了 LLM 推理加速上。
4. 级联丢弃:为什么第一个错了,后面全废
这里有个重要细节需要理解。
草稿模型生成 token 序列时,每个 token 都是条件于前面的 token 生成的。比如草稿模型生成的是 “The cat sat on the mat”,其中 “sat” 是在”已知前两个 token 是 The, cat”的条件下预测的;”on” 是在”已知前三个是 The, cat, sat”的条件下预测的。
一旦 “cat” 被目标模型拒绝,目标模型会在那个位置补采一个不同的 token,比如 “dog”。此时,原草稿里的 “sat” 是基于错误前缀 “The, cat” 生成的 $q(\text{sat} \mid \text{The, cat})$,但真实需要的条件概率是 $q(\text{sat} \mid \text{The, dog})$——这是两个完全不同的分布,原来的 $q$ 值对新上下文毫无参考价值。
因此:第 $i$ 个位置被拒绝,位置 $i+1$ 及之后的所有草稿 token 必须全部丢弃,不再验证。
这也意味着:
- 草稿模型的质量极其重要——第一个 token 就被拒绝,整轮草稿全部白费
- 优化的核心指标是期望接受长度(expected accepted length):拒绝发生得越晚,这一轮能白捡的 token 越多
- 从 $\beta = \sum_x \min(p(x), q(x))$ 也能看出:当 $q \equiv p$ 时 $\beta = 1$,每个 token 都被接受;当 $q$ 与 $p$ 完全不重叠时 $\beta \to 0$,几乎每个 token 都被拒,速度反而比纯自回归还慢(多跑了一次草稿模型)
正是因此,专门为投机解码训练的草稿模型(比如 EAGLE、DFlash)要比通用小模型效果好得多:它们被明确训练成”输出分布尽可能像目标模型”,而不仅仅是”在自己的训练集上 loss 最小”。
5. DFlash:块扩散草稿模型
传统的草稿模型和目标模型一样,也是自回归的——要生成 4 个草稿 token,就要跑 4 次前向传播。自回归草稿的问题是:虽然模型小,但”串行”这个结构性瓶颈一点没变。EAGLE、EAGLE-2、EAGLE-3 这类主流草稿模型都是自回归派系的优化(更轻的 head、共享 hidden state 等),它们在单步上做得很快,但仍然要跑 $k$ 次 forward 才能出 $k$ 个草稿。
DFlash 走了完全不同的一条路:块扩散(Block Diffusion)。它基于 Arriola 等人在 ICLR 2025 提出的 BD3-LM(Block Discrete Denoising Diffusion Language Model)范式,并针对投机解码场景做了专门适配。
5.1 核心范式:块之间自回归,块内扩散
BD3-LM 的一句话总结:块与块之间保持自回归,块内部用离散扩散一次性并行预测所有位置。
具体来说:
- 把要生成的长序列切成若干固定长度 $L_b$ 的块(例如 $L_b = 4, 8, 16$)
- 块与块之间是严格的因果顺序:第 $j$ 块的生成必须在第 $j-1$ 块完成之后进行
- 块内部的 $L_b$ 个位置通过离散扩散一次性联合预测
这种混合结构解决了纯 diffusion 语言模型的两大痛点:固定长度限制和无法做 KV cache。因为块之间还是自回归,已经生成好的块就可以像普通 LLM 一样把 K/V 缓存下来,后续块只需要在缓存之上再做 attention——这和目标模型的 KV cache 使用完全兼容。
5.2 训练目标:块内的 masked diffusion ELBO
训练的时候,对每一个训练样本:
- 采一个噪声水平 $t \in [0, 1]$
- 在当前块的 $L_b$ 个位置上,独立地以概率 $t$ 把每个 token 替换成特殊的
[MASK]token - 保留前面所有完整的块(上下文),只在当前块的位置上加噪
- 模型的任务是从被 mask 的块中恢复原始 token
损失函数本质上是离散 diffusion 的 ELBO,形式上可以理解成一个加权的交叉熵:
\[\mathcal{L}_{\text{BD}} = \mathbb{E}_{t, x}\left[\sum_{i \in \text{当前块}} \mathbb{1}[x_i^{(t)} = \text{MASK}] \cdot \frac{1}{t} \cdot \log p_\theta(x_i \mid x^{(t)}, x_{<\text{block}})\right]\]几个要点:
- 只在被 mask 的位置计算 loss(未被 mask 的位置模型只是看到了答案)
- $1/t$ 是重要性权重,把不同噪声水平的贡献归一化
- 模型同时看到了”前面已完成的块”和”当前块的部分观测”,需要学会利用这两者一起做预测
5.3 架构:块因果 attention(Block-Causal Attention)
BD3-LM 用的还是标准 Transformer,但 attention mask 变了:
- 同一块内:全 attention(每个位置可以看到块内所有位置,包括被 mask 的)
- 跨块:因果 attention(当前块可以看前面所有块,反之不行)
画出来像这样:
块1 块2 块3
块1 [■ ■ ■][□ □ □][□ □ □]
块2 [■ ■ ■][■ ■ ■][□ □ □]
块3 [■ ■ ■][■ ■ ■][■ ■ ■]
(■ 可见,□ 不可见;每块 3 个位置)
这个结构让 KV cache 成为可能:块 1 算完之后,它的 K/V 就固定了;块 2 生成时复用块 1 的缓存,只需要新计算块 2 这 $L_b$ 个位置的 K/V;块 3 再叠加。跟自回归 LLM 的 KV cache 行为一致。
5.4 采样:T 步去噪一次得到一个块
给定前面的块做上下文,生成当前块的流程是:
- 把当前块的所有 $L_b$ 个位置初始化为
[MASK] - 进行 $T$ 步去噪:每一步,模型看一眼当前的”部分 mask 状态”,对每个仍然是
[MASK]的位置输出一个 softmax 分布,按某种策略(如置信度 top-k 或随机)选一部分位置把 mask 替换成采样到的 token - 直到所有位置都被填上,当前块完成
关键:最后一步去噪得到的 $L_b$ 个 softmax 分布,就是 DDTree 要用的”每个位置的概率分布”。也就是说,DFlash 在块生成的最终一步 forward pass 里,同时暴露了块内每个位置的完整后验分布——这正是后面 DDTree 能用它构树的原因。
BD3-LM 原论文在一般生成时用 $T = 5000$ 步(质量优先),但在 draft 场景下完全不需要这么多——DFlash 把 $T$ 降到很小(论文级别通常 $T \in {1, 2, 4}$ 甚至 single-step),用”一次去噪直接输出块”的方式保证草稿延迟最低。
5.5 块大小的权衡
块大小 $L_b$ 是 DFlash 最关键的超参数:
| 块大小 | 草稿模型调用次数 | 单次输出 token 数 | 被接受长度期望 |
|---|---|---|---|
| 小($L_b=4$) | 多 | 4 | 短 |
| 中($L_b=8$) | 中 | 8 | 中 |
| 大($L_b=16$) | 少 | 16 | 可能长也可能第一个就被拒 |
BD3-LM 论文显示,$L_b$ 越大,困惑度恶化越明显(diffusion 建模 16 个位置比建模 4 个位置难得多);但 $L_b$ 越小,草稿模型要被调用更多次才能”推进”相同距离。投机解码里通常选 $L_b \in {4, 8}$,在单次出 token 数和接受长度之间取平衡。
5.6 DFlash 针对投机解码的特化
把 block diffusion 作为草稿模型,DFlash 相比原始 BD3-LM 做了几项适配:
- 目标模型蒸馏:训练时不只最大化 block diffusion ELBO,还加一项”草稿输出分布尽可能贴近目标模型”的 KL 损失,让 $\beta = \sum \min(p, q)$ 尽可能大
- 极少去噪步数:从 5000 步降到 1–4 步,在 draft 阶段用”近似一步到位”换延迟
- 输出带温度的完整分布:不像 BD3-LM 需要最终离散 token,DFlash 保留最后一步 softmax 的完整分布以供 DDTree 这类下游使用
- 共享 tokenizer 和 context:和目标模型使用同一份 tokenizer,KV cache 可以部分对齐复用
5.7 一次 forward pass 实际输出什么?
有了上面这些背景,具体看 DFlash 一次前向传播(假设 $L_b = 16$,$T = 1$)的输出:
输入:已经生成的 prefix tokens + 16 个 [MASK] 占位符
│
▼(一次 block-causal transformer forward pass)
│
输出:16 个 softmax 分布(每个分布大小 = 词表大小)
位置 1 的分布:cat=0.70, dog=0.20, sat=0.08, ...
位置 2 的分布:sat=0.60, lay=0.30, on=0.08, ...
位置 3 的分布:on=0.65, at=0.20, the=0.12, ...
...
位置 16 的分布:...
然后 DFlash 从每个位置的分布里各采样一个 token,拼成草稿序列,交给目标模型验证。
这比自回归草稿快很多——不管块有多长,草稿生成只需要一次前向传播。DFlash 在实测中已经超越了 EAGLE-3 等强大的自回归草稿模型,成为投机解码的领先方案。
但 DFlash 有一个明显的浪费:每个位置的概率分布包含丰富的候选信息,却只用了最高概率的一个 token。
比如位置 1,cat 概率 0.70,dog 概率 0.20。DFlash 只采了 cat,dog 的可能性完全被忽略了。如果目标模型拒绝了 cat,这一轮就白费了,而其实 dog 也是一个很有希望的候选。
能不能把这些信息都利用起来?
6. DDTree:从”一条路”到”一棵树”
DDTree(Diffusion Draft Tree) 就是这个问题的答案。
核心思路极其直觉:既然 DFlash 一次 forward pass 已经给出了每个位置的完整分布,为什么不用这些分布构建多条候选路径,组成一棵树,让目标模型一次性验证整棵树?
6.1 草稿树长什么样?
树的根节点是上一轮结尾的 token,然后从每个位置的分布里选出 top-K 个候选,展开成多条分支:
root(…The)
/ \
cat (0.70) dog (0.20)
/ \ \
sat(0.42) lay(0.21) sat(0.12)
/
on(0.27)
每条从根到叶子的路径,就是一个完整的草稿序列。目标模型可以同时验证所有路径,找出其中最长的被接受前缀。
6.2 节点预算 B:速度与收益的权衡
树里的节点越多,覆盖的候选路径越丰富,被接受的 token 越多。但同时,目标模型验证时需要处理更多节点,开销也更大。
DDTree 用一个节点预算 $B$ 来控制树的大小——$B$ 就是整棵树里最多允许几个节点槽。在 $B$ 范围内尽可能选出最有价值的节点。论文实验表明,最优预算大约是 $B = 512$。
6.3 怎么在预算内选出最优树?Best-First Heap
每条路径的联合概率 = 各位置边际概率之积(因为 DFlash 输出的是独立的按位置分布):
\[P(\text{cat} \to \text{sat} \to \text{on}) = 0.70 \times 0.60 \times 0.65 \approx 0.27\]DDTree 用一个最优先堆(Best-First Heap) 来贪心构建最优树:
- 初始把根节点的所有子节点候选放入堆,按概率排序
- 每次弹出概率最高的叶子节点,将其子节点(下一位置的 top-K)推入堆
- 重复,直到节点总数达到预算 $B$
整个过程完全基于 DFlash 同一次 forward pass 的输出,不需要再调草稿模型,时间复杂度仅 $O(B \log(B \cdot K))$,极快。可以证明,这个算法在预算 $B$ 内构建出的树,能最大化期望被接受的 token 数量。
6.4 Ancestor-only Attention Mask:一次并行验证整棵树
树构建好之后,把所有节点按 BFS/DFS 顺序拉平成一维序列,输入目标模型做 prefill。但需要一个特殊的 attention mask:每个节点只能 attend 到它的祖先节点,不能看到兄弟或表亲节点。
这保证了每条路径的验证是独立正确的——就好像每条路径单独 prefill,但通过共享公共祖先的 KV(兄弟路径共享同一份祖先的 KV,只存一份),实际上一次 forward pass 就完成了所有路径的验证,效率极高。
kernel 实现的坑:这种任意稀疏的 attention mask,标准 FlashAttention 并不原生支持,而且树结构每一轮都在变化,无法提前编译。实践上通常用 Triton 实现的 FlashAttention 变体,先把 mask 预处理为 block 格式,然后在 kernel 里跳过全零 block 的计算——用稀疏性换效率。这是 DDTree 工程落地的关键之一。
6.5 贪心走树:一个具体例子
验证完成后,贪心沿最长接受路径走树:从根出发,每一步看当前节点的哪些孩子被目标模型接受了,如果接受了就往下走一层,遇到第一个被拒绝的节点就停止,把该路径上所有接受的 token 一次性提交。
举个例子,假设树如下:
root
/ \
cat ✓ dog ✗
/ \
sat ✓ lay ✗
/
on ✗
走树过程:从 root 出发 → “cat” 被接受,进入 cat → “sat” 被接受,进入 sat → “on” 被拒绝,停止。本轮提交 cat → sat(2 个 token),并在 “on” 的位置让目标模型额外采一个 bonus token 作为下一轮草稿的起点。被拒绝节点的子树(比如 “dog” 下面挂着的那一整条分支)完全丢弃。
6.6 KV Cache 怎么回收?
这是树形投机解码最优雅的地方——根本不需要显式”删除”。
验证本质是一次 prefill,prefill 结束后只把接受路径上的节点 KV 追加到主序列的 KV Cache 末尾。拒绝节点的 KV 值虽然在 prefill 里算过,但从未被持久化到 KV Cache 里,它们只是 attention 计算的中间张量,离开 kernel 就自动废弃,无需任何显式释放。
6.7 Batch 推理的挑战
单请求的 DDTree 已经比较清楚,但工业落地必然要支持多请求并发。方案是把多个请求的树节点 concat 成一个长序列做一次 prefill,请求之间用 attention mask 完全隔离——A 请求的任何节点看不到 B 请求的任何节点。
DDTree 论文的实现聚焦单请求,真正生产级的多请求 batch 需要类似 vLLM 的调度层配合树形 attention kernel,包括不同请求之间 KV Cache 的 paged 管理、不同请求树预算的动态分配等。这是当前投机解码生产落地最主要的工程难点。
7. 效果:全面超越 DFlash
DDTree 在 60 个数据集-模型-温度组合上,全部优于原始 DFlash。以 Qwen3-8B 在 temperature=0 时为例:
| 数据集 | DFlash | DDTree |
|---|---|---|
| MATH-500 | 5.56× | 7.52× |
| HumanEval | 4.84× | 6.90× |
| GSM8K | 4.78× | 6.75× |
| AIME’24 | — | 7.3× |
| AIME’25 | — | 7.2× |
更大的 30B 代码模型上,HumanEval 加速比达到 8.22×,接近自回归解码速度的 8 倍。
而且 DDTree 是完全无损的——所有 token 都经过目标模型验证,输出分布与原始自回归解码在数学上完全等价。论文代码基于 HuggingFace Transformers 开源实现,结果可复现。
8. 总结:一张图理解全套系统
[自回归解码的困境]
每次只生成 1 个 token → GPU 闲置 → 延迟高
[投机解码的思路]
草稿模型快速提出候选 → 目标模型并行验证 → 数学上等价于原始分布
[接受/拒绝规则]
以 min(1, p/q) 的概率接受草稿 token
被拒绝时从残差分布 p'(x) 补采 → 无论如何输出都等于 p(x)
[DFlash 的创新]
块扩散:一次 forward pass 输出整块的按位置分布
比自回归草稿快,但每个位置只用了一个 token
[DDTree 的创新]
利用 DFlash 的完整分布,在节点预算 B 内用 Best-First Heap 构建最优草稿树
目标模型用 ancestor-only attention mask 一次验证整棵树
贪心走树取最长接受路径 → 每轮接受更多 token → 速度提升 35%+
投机解码的整个故事,本质上是一次关于”如何把 GPU 的并行能力用足”的精妙设计。从朴素的串行解码,到草稿-验证的并行框架,再到树形结构对信息的充分利用,每一步都在回答同一个问题:怎样让大模型在不降低质量的前提下,尽可能快地生成文字。
参考论文:Ringel & Romano, “Accelerating Speculative Decoding with Block Diffusion Draft Trees”, arXiv 2604.12989, 2026.