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(绿色,加序列依赖)在不同草稿位置上的条件接受率做了对比:

位置条件 acceptance rate 对比

蓝色曲线非常典型:第 1 个位置还行,第 4、5 个位置明显往下掉。而绿色的 DSpark 曲线平坦甚至略升——原因就是它给 block 内部做了序列依赖,让每个位置”看”到了前面已经采样的 token。

3. Semi-Autoregressive 架构:并行打草稿 + 顺序轻修正

DSpark 的 drafter 是两阶段结构,整个架构和 decode 循环如下:

DSpark 整体架构和 decoding 循环

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 带来的额外延迟几乎看不见(最右面板)。

proposal length 和 latency overhead

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

drafter 深度对 acceptance length 的影响

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 的完整流程截图如下,主体就是上面这套贪心:

Hardware-Aware Prefix Scheduler 伪代码

上面那段逻辑,翻译成 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 前沿对比:

Throughput vs TPS:DeepSeek V4 线上表现

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

再看 load-adaptive 的效果:

负载自适应吞吐和 verification budget

并发请求数上涨时,DSpark 的验证预算(下、折叠线)自动从 ~5 收缩到 ~3.5,始终把系统吞吐保在最优位置;相比之下 MTP-1 永远固定在 2,任凭负载变化。

7. Offline Benchmark:换个模型也成立

生产流量好说,但有人会问:是不是只有 DeepSeek 自家模型调得好?论文在 Qwen3-4B/8B/14B 和 Gemma4-12B 上也做了完整对照:

主结果:acceptance length 对比

以 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 推理效率和参数高效微调,和本文的工程背景有直接关联。

动手学AutoML书籍封面

Flag Counter