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;如果猜错,也只损失一小段草稿,而不是让整个结果交给小模型。
二、基本流程是什么
一次推测循环大致如下:
- 根据当前上下文,让小模型生成
k个候选 Token。 - 把原上下文和候选 Token 一起交给大模型并行计算概率。
- 从前往后检查小模型的每个候选是否被大模型接受。
- 接受连续正确的 Token,遇到拒绝时按修正规则采样一个 Token。
- 用新的上下文继续下一轮。
上下文 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 能正确配合,目标硬件能承受两套模型,端到端延迟真的下降。条件不满足时,复杂度可能比收益更大。
我现在不会看到一个加速数字就直接启用它,而会问:接受率是多少?短文本和长文本分别怎样?并发和显存能否承受?输出质量是否与普通目标模型一致?失败时能否回退?
好的推理优化不是把某个模型换成更小的版本,而是在不改变用户得到的结果前提下,减少等待和浪费。让小模型负责猜测,让大模型保留最终判断,这种分工既聪明又克制;但只有经过真实任务、真实硬件和真实尾延迟验证,它才算真正完成了工程价值。