DSpark:DeepSeek 线上跑的投机解码,半自回归 + 置信度调度怎么做到的
DSpark:DeepSeek 线上跑的投机解码,半自回归 + 置信度调度怎么做到的
原文:DSpark: Confidence-Scheduled Speculative Decoding with Semi-Autoregressive Generation
1. 前言:先把背景交代清楚
你有没有想过这样一个问题:为什么大模型”算”得那么快,但生成一段话,却要一个字一个字往外蹦?
先说结论。因为 LLM 的生成是自回归的(autoregressive decoding)——每生成一个 token,都要把完整的网络从头到尾 forward 一遍,相当于把模型的所有参数从显存里读一遍。而 decode 阶段 token 之间严格串行,上一步没算完,下一步没法开始,所以显卡算力再强,也只能干等”读一遍参数”的时间:搬一遍权重,换回来一个字。
这个瓶颈在系统领域有个专门的说法:decode 阶段是 memory-bound(内存带宽受限),不是 compute-bound。翻译成大白话就是——生成一个 token 需要的计算量其实很小,真正卡你的是把全部参数读出来这件事;而显存带宽这些年涨得比算力慢多了,所以模型越做越大、算力越来越猛,生成速度却始终被拉在后头。
在生产环境里,这个瓶颈会直接变成账单:decode 的吞吐 = 实打实的运营成本。DeepSeek 的 API 每天服务着海量请求,多快、多久、能扛多少并发,都是钱。所以”怎么让解码变快”是 LLM 推理优化里最核心的课题之一,而其中最主流的一路解法,就是今天要聊的投机解码(Speculative Decoding)。
投机解码说人话就是:
让小模型(draft model)先打草稿,一口气把后面好几个 token 猜出来;再让大模型批量”验收”这串草稿,验收通过的 token 直接全部收下。
乍一听像个作弊技巧——草稿不靠谱怎么办?其实它的数学是完备的:大模型在验收时会并行为每个草稿 token 算概率,从第一个被拒绝的位置开始,把后面的草稿全部作废,并用大模型自己采样的结果替换。这套 rejection sampling 机制保证了输出分布和大模型逐字自回归生成时完全一致——也就是说,投机解码提速是”免费”的,模型不会因为打草稿而”变笨”。
概念说清楚了,但落地没那么简单:每一轮解码的花销其实是两笔账,打草稿的时间 $T_\text{draft}$,加上大模型验收的时间 $T_\text{verify}$,换来的收益是这轮平均被接受的 token 数,记为 $\tau$(acceptance length)。所以平均每生成一个 token 的延迟是:
\[L = \frac{T_\text{draft} + T_\text{verify}}{\tau}\]这个公式看着不起眼,但它把整个投机解码家族的设计空间都压缩到一行了:想让 $L$ 变小,只有两条路——要么压分子(打草稿更快、验收更便宜),要么拉分母(每轮多验收几个 token)。顺着这两条路,现有工作分成两派,各有各的坑:
- AR drafter(逐 token 串行打草稿,如 EAGLE-3):打草稿同样是一个个 token 生成的,质量高,当然 $\tau$ 很漂亮;但打草稿的时间随草稿长度 $\gamma$ 线性上涨,草稿越长越慢,所以只能被迫用小的 $\gamma$ 来维持动量。
- Parallel drafter(并行整块打草稿,如 DFlash):一次 forward 直接并行算出整个 block 的草稿,$T_\text{draft}$ 基本不随 $\gamma$ 涨,延迟极低;但代价是 block 里靠后的每个 token 都是”盲猜”——它们根本没见过 block 里前面的 token 长什么样,只能按全局 context 猜,所以越靠后的位置 acceptance rate 掉得越狠。
这个”block 越靠后越不靠谱”的现象,论文里有名字,叫 suffix decay,理解它等于理解这篇论文的一半。
现在主角登场。DSpark 是 DeepSeek 部署在自己 V4 生产系统上的实际方案,它想做的事一句话讲完:把 parallel drafter 的低延迟保留下来,但往 block 内部补上序列依赖,把 suffix decay 治好;同时不是所有草稿 token 都值得拿去大模型验收——多加一个置信度打分器,动态决定每轮验证几个。这两点看着都不难,难的是它在 DeepSeek V4 的线上系统真实跑过、用生产流量测过。下面拆开来讲。
2. Suffix Decay:并行 Drafter 的病根在哪
先把病根看清楚。假设要草拟一个 block [x₁, x₂, x₃, x₄],并行 drafter 一次 forward 同时预测这 4 个位置:
x₁ = argmax P(x₁ | context) ← 只依赖完整 context,还好
x₂ = argmax P(x₂ | context) ← 不知道 x₁ 是什么!
x₃ = argmax P(x₃ | context) ← 不知道 x₁, x₂ 是什么!
x₄ = argmax P(x₄ | context) ← 完全盲猜 x₁ ~ x₃
问题就在这:每个位置只知道全局 context,不知道 block 内部”前面已经说出来的是什么”。举例:假设模型在这个位置有两种等大概率的续写,"of course" 和 "no problem" 的语境。并行 drafter 对每个位置独立取最高概率,完全可能拼出 "of problem" 或者 "no course"——每个位置单独看都对,组合起来语句不通,大模型一验收就被否。
下图对 DFlash(蓝色,纯并行)和 DSpark(绿色,加序列依赖)在不同草稿位置上的条件接受率做了对比:

蓝色曲线非常典型:第 1 个位置还行,第 4、5 个位置明显往下掉。而绿色的 DSpark 曲线平坦甚至略升——原因就是它给 block 内部做了序列依赖,让每个位置”看”到了前面已经采样的 token。
3. Semi-Autoregressive 架构:并行打草稿 + 顺序轻修正
DSpark 的 drafter 是两阶段结构,整个架构和 decode 循环如下:

3.1 Parallel Stage:并行骨干先跑一遍
第一阶段直接采用类似 DFlash 的 parallel backbone,一次 forward 并行算出整个 block 的 hidden states 和 base logits:
输入:anchor token x₀ + γ 个 <mask>
一次 forward → hidden_states: h₁, ..., hᵧ
base logits: U₁, ..., Uᵧ [γ, V]
为什么要个 anchor?因为上一轮大模型验收完之后,必然留下一个已确认的 token(图中把它记为 D,作为下一轮草稿的锚点)——它给 drafter 提供”从我这往后可能是”的全局位置信息。这一步 $T_\text{draft}$ 极低,不随草稿长度增长。
3.2 Sequential Stage:轻量序列头修正
第二阶段是一个很轻的模块,从左到右逐给每个位置的 logits 加一个”转移修正” $B_k$,让第 $k$ 个 token 的预测真正依赖前面已经采样出来的 token:
\[p_k(v \mid x_0, x_{<k}) = \frac{\exp(U_k(v) + B_k(x_0, x_{<k}, v))}{\sum_{u \in \mathcal{V}} \exp(U_k(u) + B_k(x_0, x_{<k}, u))}\]$U_k$是并行阶段给的 base logit(已经感知全局 context),$B_k$ 是对 block 内依赖的修正,两种实现:
Markov Head(默认)——最简,只依赖前一个 token,低秩分解 $B = W_1 W_2$:
class MarkovHead(nn.Module):
def __init__(self, vocab_size, rank=256):
super().__init__()
self.W1 = nn.Embedding(vocab_size, rank) # 上一个 token -> rank 维向量
self.W2 = nn.Linear(rank, vocab_size, bias=False) # rank -> V
def forward(self, prev_token_id: torch.Tensor) -> torch.Tensor:
# prev_token_id: [batch]
embed = self.W1(prev_token_id) # [batch, rank]
return self.W2(embed) # [batch, V] ← 转移偏置 B(x_{k-1}, ·)
RNN Head——带一个跨位置的 recurrent state $s_k$,能看到整个 prefix 历史:
class RNNHead(nn.Module):
def __init__(self, vocab_size, backbone_dim, rank=256):
super().__init__()
self.W1 = nn.Embedding(vocab_size, rank)
# 单个 linear 拆成 gate / candidate / output 三部分
self.Wgco = nn.Linear(2 * rank + backbone_dim, 3 * rank, bias=False)
self.W2 = nn.Linear(rank, vocab_size, bias=False)
def step(self, s_prev, prev_token_id, h_k):
# s_prev: [batch, rank],h_k: [batch, backbone_dim]
z = torch.cat([s_prev, self.W1(prev_token_id), h_k], dim=-1) # [batch, 2r+d]
gates = self.Wgco(z).chunk(3, dim=-1) # gate, cand, out 各 [batch, rank]
g = torch.sigmoid(gates[0])
s_k = g * s_prev + (1 - g) * torch.tanh(gates[1]) # GRU-like 更新
B_k = self.W2(torch.tanh(gates[2])) # [batch, V]
return s_k, B_k
整个 sequential stage 的推理是个串行的循环,伪代码理解:
def sequential_stage(base_logits, hidden_states, seq_head, anchor_id):
# base_logits: [batch, γ, V] ← 来自 parallel stage
# hidden_states: [batch, γ, d] ← 来自 parallel stage
B, gamma, V = base_logits.shape
draft_tokens = []
prev_id = anchor_id # 第一个"前序 token"就是 anchor
s = torch.zeros(B, seq_head.rank)
for k in range(gamma):
s, bias = seq_head.step(s, prev_id, hidden_states[:, k, :])
logits_k = base_logits[:, k, :] + bias # 并行 logit + 转移修正
x_k = torch.multinomial(logits_k.softmax(-1), 1).squeeze(-1)
draft_tokens.append(x_k)
prev_id = x_k # 下一步就看到当前采样结果
return torch.stack(draft_tokens, dim=1) # [batch, γ]
虽然这个循环是串行的,但 seq head 实在太轻(embedding 查表 + 低秩矩阵乘),每步计算量远小于并行骨干,所以 $T_\text{sequential} \ll T_\text{parallel}$,总延迟仍然由并行阶段主导,这一点论文里也是实测过的:如下图,草稿长度从 2 拉到 8,sequential head 带来的额外延迟几乎看不见(最右面板)。

而序列依赖带来的收益也立竿见影——下图对比 drafter 深度对 acceptance length 的影响,2 层的 DSpark 已经超过了 5 层的 DFlash,说明花最便宜的成本把”block 内依赖”补上,比无脑加深并行骨干划算得多:

4. Confidence-Scheduled Verification:不是所有草稿都值得验收
个阶段解决了”草稿质量”的问题,但还有一个独立的问题没人管——验证浪费。
投机解码送进大模型验证的 token 越多,一旦某个位置被拒、后面全部作废,前面的验证预算就白花了。如果草稿质量本来就差,每次送 8 个去验收、平均只打回 2 个,剩下 6 个 token 验证纯属浪费。更要命的是在高并发系统里:每个白白验证的 token 都占着 target model 的 batch capacity,而这部分 capacity 本该用来服务其他用户的请求。 所以关键是要做到”择优验证”——即先估计草稿 token 能不能被接住,再决定送几个。
4.1 Confidence Head:预测”我这份草稿被接受的概率”
DSpark 在 sequential stage 之外,加了一个 confidence head,为每个草稿 token 输出 $c_k \in (0,1)$:在有前面的 token 全被接受的条件下,第 $k$ 个 token 也会被接受的概率:
\[c_k = \sigma(w^\top [h_k; W_1[x_{k-1}]])\]监督信号不是简单地”这个 token 对没对”,而是 draft 和 target 两个分布之间的 TV 距离:
\[c_k^* = 1 - \frac{1}{2} \|p_k^d - p_k^t\|_1\]这里的 $p_k^d$ 是 drafter 在第 $k$ 个位置给出的分布,$p_k^t$ 是目标模型在同一位置的分布,$|\cdot|_1$ 是 L1 范数(逐元素取绝对差求和)。为什么要 TV 距离当监督信号?因为投机解码的 rejection sampling 机制保证:单步接受概率恰好等于 $1 - \frac{1}{2}|p^d - p^t|_1$——两个分布越接近,TV 距离越小,被接受的概率越高。所以模型学会的是”我的草稿和真正的大模型有多像”,而不是”我猜的 token 对不对”。
这两件事的区别很关键:accuracy 高不等于 acceptance rate 高。就算你 top-1 猜对了,但如果整个分布和大模型偏差很大,rejection sampling 依然可能拒绝你。很多人在跑投机解码时会忽略这层。
class ConfidenceHead(nn.Module):
def __init__(self, backbone_dim, markov_rank):
super().__init__()
self.w = nn.Linear(backbone_dim + markov_rank, 1, bias=False)
def forward(self, h_k, prev_embed):
# h_k: [batch, d], prev_embed: [batch, rank]
return torch.sigmoid(self.w(torch.cat([h_k, prev_embed], dim=-1))).squeeze(-1)
训练时用 BCE loss:
\[\mathcal{L}_\text{conf} = -\sum_{k=1}^\gamma w_k \left[ c_k^* \log c_k + (1-c_k^*) \log(1 - c_k) \right]\]4.2 Sequential Temperature Scaling:给置信度”校准”
神经网络输出的概率天然贴向 0/1 两边(overconfident),直接把 $c_k$ 当作接受概率用,误差会很大。DSpark 用了一个挺工程化的手段——Sequential Temperature Scaling(STS):prefix survival probability 是各位置置信度的连乘 $\prod_{i \le k} c_i$,所以校准也是从左到右逐位置来的,每个位置用一维 grid search,在固定前面已校准位置的前提下,找一个让该位置 ECE 最小的温度参数。
这还是个 order-preserving 的变换:它不改变各个草稿之间质量高低的相对排序,只是把绝对概率尺子校正对,让后面的调度器算得实。
4.3 Hardware-Aware Prefix Scheduler:把”该验证几个”变成调度问题
有了校准后的 $c_k$,一条请求(记为 $r$)在第 $j$ 处的 prefix survival probability 就是:
\[a_{r,j} = \prod_{i \le j} c_{r,i}\]含义是”前 $j$ 个草稿全部被接受的概率”,天然单调递减——这给贪心算法提供了支撑。
现在的问题是,一批请求 $r=1..R$ 在排队,该给每个请求塞多少 token 去验证?DSpark 的答案是把这当成一个在整个 batch 上的吞吐优化:
\[\Theta = \tau \cdot \text{SPS}(B)\]其中 $B = \sum_r (1 + \ell_r)$ 是送进 target model 的 token 总数,$\text{SPS}(B)$ 是引擎在 batch size $B$ 下的每步步数(提前 profile 好的函数)。实际里 $\text{SPS}(B)$ 随着 batch 变大单调下降(batch 越大,每一步的耗时越长),$\tau$ 却随着每个请求的 $\ell_r$ 拉长而变大(多验收几轮多收益)——所以两头有个最优值。而 $a_{r,j}$ 的单调性又能保证贪心成立:把所有候选 (request, position) 按 $a_{r,j}$ 从大到小排序,依次塞进验证 batch,每塞一个算一次当前吞吐,吞吐不再变大就停。论文里 Algorithm 1 的完整流程截图如下,主体就是上面这套贪心:

上面那段逻辑,翻译成 Python 大约是这样:
def hardware_aware_scheduler(confidence_scores, sps_table, R, gamma):
# confidence_scores: [R, gamma]
# sps_table: dict {batch_size -> steps_per_sec}
# 计算每个请求每个位置的 survival probability
a = torch.cumprod(confidence_scores, dim=1) # [R, gamma]
# 所有候选 (request, position) 按 a 从大到小排序
candidates = [(a[r, j].item(), r, j)
for r in range(R) for j in range(gamma) if a[r, j] > 0]
candidates.sort(reverse=True)
lengths = [0] * R # 初始每个请求验证长度为 0
B = R # 初始 batch size(每个请求至少发 anchor)
tau_expected = R
best_theta = tau_expected * sps_table[B]
best_lengths = lengths.copy()
for a_val, r, j in candidates:
if j != lengths[r]: # prefix 约束,必须按顺序扩展
continue
lengths[r] += 1
B += 1
tau_expected += a_val
theta = tau_expected * sps_table.get(B, 0)
if theta > best_theta:
best_theta = theta
best_lengths = lengths.copy()
else:
break # 单调性保证可以提前终止
return best_lengths
这个设计的妙处在于:系统负载高时 $\text{SPS}(B)$ 往下掉得快,scheduler 就自动收紧各请求的验证长度;负载低时再放开,让每请求验证更多 token。 相比之下,虽然 DeepSeek 之前的生产 baseline(MTP-1)也是”多 token 预测”一家人,但每轮固定验证 2 个 token,没有任何弹性。
置信度也真的有用。如下图为 confidence threshold 的扫参结果——随着阈值提高,acceptance rate 从 ~77% 上升到 92.5%,代价是每步平均接受的 token 略降,因为”会被拒的草稿”提前被筛掉了:

5. 训练目标:三项损失
DSpark 的 drafter 加起来一起训练,损失由三项组成(每一项都带位置衰减权重 $w_k = e^{-(k-1)/\gamma}$,越靠前的 token 权重越大——因为位置靠前一旦出错,浪费的更多):
\[\mathcal{L} = \alpha_{ce} \mathcal{L}_{ce} + \alpha_{tv} \mathcal{L}_{tv} + \alpha_{conf} \mathcal{L}_{conf}\]| 项 | 作用 | 权重 |
|---|---|---|
| $\mathcal{L}_{ce}$ | 交叉熵,让 drafter 逐 token 预测正确 | 0.1 |
| $\mathcal{L}_{tv}$ | TV 距离,直接最小化 draft/target 分布差异 | 0.9 |
| $\mathcal{L}_{conf}$ | 置信度 head,BCE | 1.0 |
$\mathcal{L}_{tv}$ 权重是最高的,有意的设计:因为 acceptance rate 与 TV 距离直接挂钩,最小化 TV 比单纯猜对 token 标准化更直接地提升加速比。
6. 线上部署结果(DeepSeek V4)
这篇论文最有说服力的部分在真实生产数据,前面讲的所有设计都跑在 DeepSeek V4 的线上系统里。
下图是 throughput(系统总吞吐)和 TPS(单用户生成速度)的 Pareto 前沿对比:

与 MTP-1(production baseline)比,DSpark 把 Pareto 曲线整体向右上凸:相同 TPS 下系统吞吐提升 51%~52%,相同吞吐下每用户 TPS 提升 57%~60%,在低负载高 TPS 区间提升高达 +85%。
再看 load-adaptive 的效果:

并发请求数上涨时,DSpark 的验证预算(下、折叠线)自动从 ~5 收缩到 ~3.5,始终把系统吞吐保在最优位置;相比之下 MTP-1 永远固定在 2,任凭负载变化。
7. Offline Benchmark:换个模型也成立
生产流量好说,但有人会问:是不是只有 DeepSeek 自家模型调得好?论文在 Qwen3-4B/8B/14B 和 Gemma4-12B 上也做了完整对照:

以 Qwen3-8B + GSM8K 为例:Eagle3 是 5.30,DFlash 是 5.33,DSpark 达 6.17。而 Chat 类任务(MT-Bench、Alpaca)提升更刺眼——也符合直觉,链上对话需要的”语境连贯性”越多,序列依赖建模就越值钱。
8. 个人 Take
DSpark 有两处我觉得设计得很干净的地方:
一是 semi-autoregressive 架构中的分工:重骨干做粗预测,轻 head 做小修正。这个”把重活留给并行、把依赖补丁下放给一个几乎免费的顺序头”的范式,本身可搬走,不一定要配投机解码这一整套。
二是 confidence-scheduled verification 的视角转换:它把投机解码从”单请求 drafting”兜底扩大到了“多请求 batch 调度”的框架。在真正的生产系统里,一个 draft token 的边际价值不是固定的——它取决于当前 batch size 和系统负载。把”草稿值不值得送”和”送多少进去”这两个以前被拆开考虑的问题放在一起联合优化,这才是投机解码在生产环境里真正发挥价值的地方。
最后聊点感受。投机解码过去常被吐槽”理论全对、工程没法用”,但问题往往不在草稿模型本身,而是栽在”验收浪费”和”调度僵化”这两个工程细节上。DSpark 能把这些细节做到生产级,并拿出真实线上数据,说服力就不是一般 benchmark 能量化的了。这类思路对做 LLM 推理的朋友,值得读一遍原文。
如果这篇文章涉及的 LLM 推理效率优化你想系统深入,可以看看我之前出版的《动手学 AutoML:从 NAS 到大语言模型优化实战》,书里有专章讲 LLM 推理效率和参数高效微调,和本文的工程背景有直接关联。
