logo

Speculative Decoding:用小模型加速大模型

Published on

Speculative Decoding:用小模型加速大模型

大语言模型生成文字时,通常是一个 Token 一个 Token 地来。每生成一个 Token,就要把结果放回上下文,再计算下一个 Token。GPU 很强,但这个过程有一个天然限制:后一个 Token 依赖前一个 Token,很多计算不能直接并行。

我第一次看到 Speculative Decoding 时觉得它像一个很朴素的想法:既然大模型每一步都很慢,就让一个小模型先连续猜几步,再让大模型一次检查这些猜测。猜对了就一次前进好几个 Token,猜错了从错误位置重新接管。

真正漂亮的地方在于,只要验证和接受规则设计正确,最终生成结果仍然可以保持目标大模型的分布,而不是偷偷把输出变成小模型的风格。它不是简单的“用小模型替代大模型”,而是让小模型承担草稿,大模型保留最终决定权。

一、为什么自回归生成慢

生成阶段可以抽象成:

已有上下文 → 大模型 → 下一个 Token
                   加入上下文再算一次

即使每一步使用 KV Cache,模型仍然要逐步执行 Decode。长回答需要很多轮,单步计算往往没有把 GPU 的并行能力充分利用起来。

Speculative Decoding 引入一个更小、更快的 Draft Model:

Draft Model:一次猜 k 个 Token
Target Model:一次并行验证这 k 个 Token

如果大部分猜测都对,大模型一次验证就能确认多个 Token;如果猜错,也只损失一小段草稿,而不是让整个结果交给小模型。

二、基本流程是什么

一次推测循环大致如下:

  1. 根据当前上下文,让小模型生成 k 个候选 Token。
  2. 把原上下文和候选 Token 一起交给大模型并行计算概率。
  3. 从前往后检查小模型的每个候选是否被大模型接受。
  4. 接受连续正确的 Token,遇到拒绝时按修正规则采样一个 Token。
  5. 用新的上下文继续下一轮。
上下文 C
小模型猜测 y1, y2, y3, y4
大模型并行验证 p(y|C)
接受 y1, y2,拒绝 y3
从修正分布采样新的 y3

关键在“拒绝后怎么采样”。如果简单地丢弃后面所有候选,通常仍能工作,但未必保持目标分布;严格实现会根据目标模型和草稿模型的概率做接受-拒绝采样,让结果在理论上等价于直接从目标模型生成。

三、小模型不需要和大模型一样聪明

Draft Model 的任务不是独立完成回答,而是尽可能快速猜出大模型也会选择的 Token。因此它可以更小、更快,甚至使用量化版本。它最重要的指标不是单独的 benchmark,而是对目标模型的预测重合程度。

type DraftProfile = {
  name: string
  parameters: number
  tokensPerSecond: number
  acceptanceRate: number
  compatibleTokenizer: boolean
}

Tokenizer 兼容很重要。两个模型如果切分方式不同,候选 Token 很难直接对齐;模型架构、词表和特殊 Token 也需要满足推理框架要求。

Draft Model 太弱,接受率低,大模型仍然要频繁接管;Draft Model 太大,生成草稿本身就花掉很多时间。真正的最优点取决于硬件、上下文、任务和服务并发。

四、速度收益取决于接受率

可以用一个粗略模型理解收益:

每轮平均推进 Token 数
≈ 1 + 接受的草稿 Token 数

如果每次猜 5 个,平均接受 4 个,大模型一次验证就能推进约 5 个 Token;如果平均只接受 1 个,额外运行小模型和验证逻辑可能不划算。

收益还要扣除:

  • 小模型生成草稿的时间;
  • 大模型验证批次的计算时间;
  • 两个模型之间的同步和内存开销;
  • 不同设备之间的数据传输;
  • 服务并发增加后的排队时间。

因此不能只看理论接受率,要测首 Token、每秒生成 Token、P95 延迟和单位成本。短回答可能还没进入稳定收益区间就结束了,长文本和重复性强的任务更容易获得好处。

五、为什么结果可以保持目标模型分布

直观地说,大模型仍然拥有最终否决权。小模型提出候选,大模型提供目标概率;当候选不符合目标分布时,通过接受-拒绝采样修正,而不是盲目接受。

伪代码可以表示成:

for token, draft_prob, target_prob in zip(
    draft_tokens, draft_probs, target_probs
):
    accept_prob = min(1.0, target_prob / max(draft_prob, eps))
    if random() <= accept_prob:
        accepted.append(token)
    else:
        accepted.append(sample_correction(target_prob, draft_prob))
        break

真实实现会处理完整词表、温度、Top-p、数值稳定性和批处理,但核心关系就是目标概率和草稿概率的比较。温度、采样策略不一致时,接受率和分布都会变化。

六、KV Cache 管理决定能不能跑得快

推测解码同时涉及两个模型的 Cache。草稿模型先生成候选,大模型验证后,只有被接受的前缀可以保留;被拒绝的分支必须回滚,不能把错误候选继续留在 Cache 里。

当前 Cache
  ├─ 接受的 Token → 保留
  └─ 被拒绝的 Token → 回滚

Cache 管理不严谨,会出现结果错误、显存持续增长或后续 Token 与实际上下文不一致。生产引擎需要对 Cache 版本、候选长度和回滚位置有明确表示,而不是依赖“通常只会错一两个”的假设。

七、不同任务的收益不同

适合 Speculative Decoding 的场景通常具有:

  • 目标模型和草稿模型词表兼容;
  • 输出有较强局部规律;
  • 生成长度较长;
  • 草稿模型和目标模型分布相近;
  • 两个模型能高效放在同一设备或高速互联设备上。

代码补全、模板化文本、翻译和格式化生成,通常比需要长链推理、频繁改变方向的任务更容易获得高接受率。推理模型在思路快速变化时,小模型草稿可能经常被拒绝。

八、工程上常见的失败原因

1. 草稿模型太慢

如果小模型没有足够快,草稿阶段抵消了验证节省的时间。要测真实端到端,而不是只看参数量。

2. Tokenizer 不兼容

候选无法直接对齐,框架可能回退到普通生成。

3. 采样参数不一致

温度和 Top-p 差异会降低接受率,也可能让结果比较失去意义。

4. 并发下显存不足

两个模型、两套 Cache 和中间缓冲区同时存在,单请求能跑不代表高并发能跑。

5. 只测平均值

某些问题接受率很高,另一些问题很低。要按任务、长度和语言切片,否则平均数会掩盖失败场景。

九、上线前怎么评估

先用普通目标模型作为质量基线,再比较开启和关闭推测解码的结果:

type SpeculativeReport = {
  taskSet: string
  draftModel: string
  speculationLength: number
  acceptanceRate: number
  firstTokenMs: number
  decodeTokensPerSecond: number
  p95LatencyMs: number
  outputEquivalent: boolean
  peakMemoryMb: number
}

质量检查不能省。理论上正确的实现应保持目标分布,但工程实现中的采样、精度、Cache 和后端差异仍可能产生问题。对结构化输出、代码和高风险任务,要做严格回归。

总结:让小模型猜,让大模型负责

Speculative Decoding 给我的启发是,模型协作不一定意味着把任务简单地切成“难题给大模型,简单题给小模型”。还可以让小模型在大模型旁边做预测,把大模型最慢的串行等待变成一次批量验证。

它的收益建立在几个条件上:草稿模型足够快,预测足够接近,Tokenizer 和 Cache 能正确配合,目标硬件能承受两套模型,端到端延迟真的下降。条件不满足时,复杂度可能比收益更大。

我现在不会看到一个加速数字就直接启用它,而会问:接受率是多少?短文本和长文本分别怎样?并发和显存能否承受?输出质量是否与普通目标模型一致?失败时能否回退?

好的推理优化不是把某个模型换成更小的版本,而是在不改变用户得到的结果前提下,减少等待和浪费。让小模型负责猜测,让大模型保留最终判断,这种分工既聪明又克制;但只有经过真实任务、真实硬件和真实尾延迟验证,它才算真正完成了工程价值。

🤪 您也可以编辑此页: