Speculative Decoding这名字听起来像某种高深莫测的算法黑魔法,但说白了,它干的事情特别朴素:既然大模型一次只能吐一个token,而且还慢得要命,那我能不能先找个便宜的小模型"猜"一串结果,再让大模型一口气把关验货?这几个字背后,是真正意义上把自回归生成的"串行死结"松绑的第一把钥匙。
我最早接触这个方向,是在排查线上推理服务的GPU利用率时发现的。当时某个底座模型的解码速度卡在每秒几十个token,显存带宽被跑得满满当当,但计算单元其实大量闲置。后来看到这一思路的原始论文,才意识到:原来瓶颈根本不在"算得慢",而在"等得久"。这篇文章不聊晦涩的公式推导,就从一个推理优化工程师的角度,把这个开山之作的核心原理、工程落地和踩坑经验一次说清楚。不管你是做模型部署的、搞推理框架的,还是单纯对生成加速感兴趣的,都能从这里拿到一套能直接上手验证的方案。
1. 自回归解码为什么慢?先弄懂真正的病根
1.1 "每次只产一个词"的串行宿命
大模型的文本生成,本质上是一个自回归过程:给定已经生成的前缀,模型预测下一个token的概率分布,从中采样一个token,拼到末尾,再拿新的序列去预测下一个。这个链条是严格串行的,第t个token没算出来,第t+1个token就永远不可能开始。
这一步在理论上无法避免,因为每一步都依赖上一步的输出。但问题在于,每一步的计算开销并没有想象中那么集中在"算"上,而是耗在了"搬数据"上。
用大白话打个比方:你让一位顶级厨师一道菜一道菜地做,每道菜都要重新翻一遍所有食材和厨具。这个厨师本身手艺没问题,但时间全浪费在来回找食材上了。Transformer在单token解码时就是这种状态——模型权重有好几个GB,但为了算出那一个token的概率分布,你得把所有这些权重都从显存里读一遍。
注意,这里的关键不是FLOPs(浮点计算量),而是内存访问量。单token解码时计算密度极低,模型基本处于"内存带宽受限"状态,而非"算力受限"状态。
1.2 计算量与带宽的失衡
我们来看一组大致的数据。一个7B参数的模型,权重在BF16精度下约占14GB显存。解码一个token,需要将这14GB权重全部读取一遍,哪怕只是计算这个token的logits,其实也只涉及很少的矩阵运算。GPU的显存带宽通常在1TB/s到3TB/s之间,粗略估算,单次解码的理论下限就是权重总量除以带宽,7B模型一个token大约要花几毫秒到十几毫秒。
对比一下训练或者预填充阶段:那时候你手里有一整条长序列,比如几千个token,同样的权重读取一次能算出几千个位置的输出,计算密度高,内存访问被充分摊薄。这就是为什么预填充快,而逐token解码慢到让人心焦。
1.3 已有加速手段为什么不够
既然带宽是瓶颈,自然会有人想:多路复用几个请求,把batch size堆上去,带宽不就摊薄了吗?这确实有效,在服务端高并发场景下,通过并发请求共享权重读取成本,系统的整体吞吐量能显著提升。
但问题在于,单条请求的延迟并没有改善。你发一个请求,无论batch多大,属于你这一个序列的token依旧是串行生成的。流式输出的首token延迟之后,每个token之间的间隔依然是那个内存带宽决定的下限。对交互式应用来说,这个"一个字一个字往外蹦"的体验依然不算好。
另一个思路是模型量化或者权重剪枝,把需要搬运的数据体积降下来。这确实是立竿见影的优化手段,但量化的精度损失、剪枝后的能力衰减,在很多场景下都是不想付出的代价。更重要的是,这两种手段改变的是"每次搬运多少数据",没有改变"要搬运多少次"这个结构性问题。
Speculative Decoding的思路完全不同:它用一次"并行验证"替代了K次"串行猜测",让那些闲置的计算单元真正转起来,而不是干等内存把权重送过来。
2. Speculative Decoding 的核心理念:草稿与验证
2.1 让两个模型分工
整个框架最直观的形态是:准备一个大模型(目标模型,通常就是线上服务的那个强模型),再准备一个小模型(草稿模型),让它去"猜"接下来的几个token。
每一轮循环是这样的:
- 小模型先按自回归方式连续生成γ个候选token(γ是超参,常见取4、5、8这类小数字)。
- 把这γ个token拼到原始输入序列后面,交给大模型做一次前向计算。
- 大模型一次算出这个候选序列在每个位置上的概率分布。
- 逐一检查小模型猜的token,是否值得被采纳;不采纳的地方当场纠正。
- 把本轮被采纳的token和纠正后的token全部拼入序列,重新开始下一轮。
最关键的点在第4步。因为大模型一次前向计算,就能同时验证γ个位置,原本需要γ次串行解码的时间,被压缩成"小模型自回归γ次 + 大模型前向1次"。
2.2 拒绝采样如何做到"保分布"
看到这里,你可能立刻会问:小模型的概率分布和大模型完全不同,凭什么是小模型说了算?如果无脑照单全收,生成结果不就是小模型的质量吗?
这就是Speculative Decoding最精妙的地方:它用拒绝采样(rejection sampling)来保证最终的采样分布与目标模型单独解码时完全一致。
逐位置来看这个过程。假设给定前缀 (x_1, ..., x_{t-1}):
- 小模型给出的概率分布是 (q(x)),它实际采样的token是 (x_t),对应概率 (q(x_t))。
- 大模型给出的真实概率分布是 (p(x)),其中 (x_t) 的概率是 (p(x_t))。
- 按概率 (\min(1, p(x_t)/q(x_t))) 决定是否接受这个token。
如果接受,皆大欢喜,token直接采用。如果拒绝,也不只是简单地退回去让大模型重新采,而是从一个"修正分布"中采样替代token:这个分布的本质是 (p(x) - q(x)) 的正部归一化,也就是把那些小模型概率偏低、但大模型认为更该出现的token,按大模型的判断补回来。
这个过程的数学保证非常干净:经过接受/拒绝/修正三步之后,最终每个位置上采到的token分布,与直接用目标模型自回归采样是完全一致的。这意味着你加速2倍也好3倍也罢,得到的不是"近似结果",而是"同一个分布下的另一次采样"。放在需要严格保真度的场景里,这一点无比重要。
我在这里补充一个直觉解释:拒绝采样相当于用大模型的真实分布去"校正"小模型的猜测。猜对了就用,猜错了就修正成符合大模型口径的结果。校正后的整体统计规律,自然回归到了大模型的分布。
2.3 加速倍数从哪里来:一个期望视角
理论分析通常假设草稿模型和真实模型的分布接近,此时每轮接受率很高。设草稿模型生成γ个候选token,大模型一次验证,每轮平均能接受N个token(N≤γ)。那么相比传统方法逐token生成,一个回合省下的时间近似为:
- 传统方式生成N个token需要N次大模型串行解码。
- 投机方式需要草稿模型跑γ步(很快)加大模型跑1步(略快于1次普通解码,因为处理的是短序列)。
如果草稿模型足够小、速度足够快,大模型验证一步的时间约等于普通解码一步的时间,那整体加速比就近似为N。论文中在多个任务上实测,加速2~3倍是很常见的数字。这里面的实际收益多半来自两个因素:草稿模型与目标模型的分布匹配度,以及γ取值是否合适。
3. 草稿模型的选择:决定加速上限的关键变量
3.1 草稿模型从哪里来
草稿模型是整个方案里最"活"的部分,选择空间很大,常见思路有这么几类。
第一类是直接拿现成的小模型。同系列的小尺寸版本往往和大型版本分布接近,比如同源模型的1B级别版本给7B版本当草稿,效果通常不错。不需要额外训练,开箱即用。
第二类是从目标模型蒸馏出来的专用草稿模型。直接用目标模型的输出去微调一个小模型,让它模仿大模型的分布,通常能比随便找一个小模型获得更高的接受率。
第三类是n-gram统计模型或检索式草稿。在某些特定任务上,一个精心构建的n-gram模型作为草稿也能达到不错的接受率,而且速度快到可以忽略不计。这类方案在输入输出高度模式化的场景里尤其值得一试。
3.2 用什么指标衡量草稿模型的好坏
评估草稿模型不能只看它单独生成的质量,核心指标是"与目标模型的分布对齐程度"。
实践中我最看重的指标是平均接受率(acceptance rate):草稿模型生成的token,有多大比例被目标模型接受。接受率越高,一轮循环中"被浪费"的验证算力越少,实际加速比越接近理论上限。
另一个更细的指标是分位置接受率。草稿模型的能力有衰减曲线:一般来说,靠前的候选token接受率高,越往后越低。因为模型越往后越容易"放飞"。所以我习惯在实现里导出"第1个token接受率、第2个token接受率……"这种分布数据,一眼就能看出γ取多少最经济。
还需要留意的是语义层面的对齐。两个模型可能在词汇分布上重合度很高,但在特定领域术语里分歧巨大。比如代码生成场景,草稿模型可能很不擅长预测某个库的API调用,这会让接受率断崖式下跌。所以在正式上线前,最好用目标场景的真实数据测一遍接受率分布,别只看通用benchmark。
3.3 草稿模型要多大才合适
这个问题的答案取决于你的硬件和延迟预算。草稿模型太小,速度快但猜不准;草稿模型太大,猜得准但拖延了验证前的准备时间。从经验上看,草稿模型参数在目标模型的1/10到1/50之间是比较常见的区间。
理论上有个简单的取舍模型:草稿模型的解码时间+大模型的验证时间,必须小于传统大模型直接解码同等token数的时间,否则就白忙活了。设 (T_d) 为草稿模型每token解码时间,(T_t) 为目标模型每token解码时间,γ个候选对应的开销约为 (γT_d + T_t),需要收益 (E[K]T_t) 显著大于前者才划算。所以草稿模型不能慢是底线,但也不是越小越好——太小了接受率低,单轮实际采纳数少,整体收益依然上不去。
一个实用的迭代流程是:先选一个明显偏小的草稿模型跑通流程,看接受率;再逐步调大草稿模型规模,观察整体吞吐量的变化曲线。当吞吐量开始不再明显上升甚至下降时,就到了甜点区。
4. 工程实现的关键细节:让投机真正跑起来
4.1 一次性验证的批量解码
Speculative Decoding的效率基础在于"把γ个位置的验证打包成一次前向计算"。这块的实现细节决定最终能拿到多少加速。
直接的做法是:把草稿模型生成的γ个token补在原始序列后面,构造一个长度为 (L + γ) 的序列,一次前向计算,得到最后γ个位置的全部logits。注意,这里需要把输入序列的attention mask处理成因果掩码,确保每个位置只能看到它之前的token。用一次前向同时算出γ个位置的预测分布,正是相比传统逐token解码的核心优势。
实际操作中有两个优化点值得注意。
第一,KV Cache的复用。在验证这γ个位置时,前面L个前缀的KV Cache在整轮计算中是不变的。如果框架支持,直接复用上一轮的KV Cache,能够避免重复计算前缀的注意力,把验证过程的计算量压到最低。我之前在某个推理框架里折腾过这个问题,最开始的朴素实现每次都全量重算,结果加速效果大打折扣,后来切到KV Cache复用,延迟才真正降下来。
第二,logits整理与采样。大模型一次前向返回的logits shape是 (batch, L+γ, vocabSize),需要正确切出每个位置对应的分布。尤其是γ个位置共享了同一个前缀序列,切分时稍不留神就会错位,最终采样出一串乱码。建议把"取第 (L+i) 个位置的logits"写成独立的小函数,反复核对shape再往下走。
4.2 接受判断与纠错采样的正确姿势
拿到目标模型在每个位置的概率分布 (p(x)) 和草稿模型对应的分布 (q(x)) 之后,逐token的接受判断有一个标准实现路径:
- 对第i个候选token (x_i),计算接受概率 (\alpha = \min(1, p(x_i) / q(x_i)))。
- 抽一个均匀随机数 (u),如果 (u < \alpha),接受该token,继续处理下一个位置。
- 如果某个位置被拒绝,则立即停止检查后续位置。从这个位置开始,从修正分布 (p(x) - q(x)) 的正部归一化结果中重新采样一个token,作为本轮输出的最后一个token,然后整轮结束。
这里有一个坑:很多人在实现时会忽略"被拒绝后重新采样修正分布"这一步,图省事直接让大模型从头采一个token。这样做的结果是整个过程的分布保证被破坏,生成质量虽然可能还能用,但严格来说已经偏离了论文的保证。既然用了这个方法,就要把这个纠错逻辑老老实实写上,否则一旦上游业务方较真"分布一致性",你会很难交代。
4.3 γ怎么取值:一张表说清楚取舍
γ(每轮草稿生成几个token)的选择是个典型的延迟和收益的权衡。取太小,验证的开销占比高,收益不明显;取太大,草稿模型越靠后的token接受率越低,白白浪费时间。
根据多轮实测,我整理过一个参考表格:
| γ取值 | 平均接受率(个人经验区间) | 单轮期望采纳数 | 适用场景 |
|---|---|---|---|
| 2~3 | 70%~90% | 1.8~2.7 | 草稿模型较弱,或对延迟极敏感 |
| 4~5 | 60%~80% | 3.0~4.0 | 大多数通用场景的保守选择 |
| 6~8 | 45%~65% | 3.5~5.0 | 草稿模型质量很高,可冲更高收益 |
这个表只是经验值,不同任务差异很大。真要上线,我建议直接跑一个小批量实验:分别取γ=2、4、6、8,测每个配置下的端到端吞吐量,画出曲线看拐点。而不是对着别人的参数抄作业。
4.4 与采样温度、Top-p的配合
这块容易被忽略,但实际影响很大。目标模型和草稿模型各自的采样参数如果不一致,两边分布的对齐程度会受影响。
道理很简单:大模型在线服务通常带着temperature=0.7、top_p=0.9之类的采样配置,草稿模型如果用的是贪心解码,那它的分布和大模型的真实采样分布之间会有系统偏差,接受率自然不会太好看。所以草稿模型的采样配置应当尽量与目标模型一致,至少temperature要一致。
如果你使用的是多模态模型或者带结构化约束的生成任务(比如JSON Schema约束下的生成),还要额外注意logits处理时的掩码对齐。草稿模型如果不知道约束信息、乱猜了不合法的token,验证阶段就会频繁拒绝,整体收益接近归零。
5. 常见问题与排查技巧实录
5.1 加速比不升反降,问题出在哪
这是最常见的情况。模型能跑通,但端到端延迟比原来还高。先别急着怀疑方案的可行性,从下面几个点排查。
第一,查草稿模型速度。草稿模型如果太慢,γ轮自回归本身就成了新瓶颈。我遇到过用3B模型给13B模型当草稿的情况,接收率高倒是很高,但小模型解码不快,整体收益小得可怜。后来换到几百M的草稿模型,速度才跑起来。
第二,查KV Cache是否真正复用了。有些框架在拼接草稿token时,序列长度变化导致前缀缓存失效,相当于每次验证都在重新计算前缀,这部分开销会把并行验证的优势完全吃掉。
第三,查γ是否匹配接受率。如果接受率只有50%而γ取到8,平均每轮采纳不到4个token,却要付出8步草稿解码加1步大模型验证的开销,还不如传统方式跑4步来得快。简单算一笔账就能定位问题。
5.2 生成结果分布看起来偏了
理论上拒绝采样保证分布一致,但如果你的实现出现结果偏差,几乎肯定是在某一步简化了逻辑。最常见的两个错误:
一个是接受判断后没有再采样修正项。前面说过,这会破坏理论保证。另一个是修正分布的归一化因子写错。修正分布应该对vocab上所有 (p(x) - q(x)) 为正的部分求和做归一,漏掉负值处理或者分母算错,都会导致采样结果悄悄偏移。
排查方法很简单:固定同一个随机种子,对一批prompt分别跑传统解码和投机解码,统计输出token的频率分布、n-gram分布、甚至具体输出的文本,两张分布做对比。如果差异超过噪声范围,就回去检查采样逻辑。理论上是严格等价的,实现层面出了偏差,一定是代码有bug,不存在"差不多就行"的说法。
5.3 长上下文场景的额外开销
当输入序列很长时,投机验证阶段的那次前向计算,序列长度是 (L + γ),注意力计算量会比普通单token解码更大。这种情况下,验证一步的时间可能显著超过普通解码一步,加速比会被压缩。
所以长上下文场景要把γ适当调小,并且优先保证草稿模型的接受率。另外,也可以考虑把验证部分的计算做算子融合,减少kernel launch的额外开销。我在实践中发现,小批量时kernel launch和Python侧调度开销占比很高,把这个优化掉往往能多捞回十几个百分点的延迟收益。
5.4 流式输出场景的兼容性
很多线上应用要求首token之后能持续流式输出。传统解码天然支持流式,而投机解码是"一批一批"出token,产生间歇感。较小的γ会让输出节奏更平滑,但加速效果打折。
一个折中方案是:对首token和开头若干token用传统解码保证快速响应,之后切换投机模式。或者让草稿模型先跑起来,大模型验证完后一次性输出多个token,配合前端做缓冲和分批发消息。实测下来,只要单批输出之间的间隔控制得当,用户几乎感知不到差异。
5.5 显存占用比预期大的问题
投机解码同时加载两个模型,显存开销天然翻倍。某些场景下可以用量化后的草稿模型来压低占用,int8甚至int4版本的草稿模型通常损失可接受。如果显存实在紧张,还可以考虑让草稿模型和目标模型共享部分层,或者用CPU卸载草稿模型——但后者的跨设备传输延迟要仔细评估,往往不划算。
6. 从单点加速到规模化应用的一点体会
我在实际部署中的感受是,Speculative Decoding这套思路最值得称道的地方,不是它那2~3倍的加速数字,而是它提供了一种"不牺牲分布正确性"的加速范式。在生成质量要求严格的生产环境里,这几乎是一个必要条件——因为你不能拿用户反馈去赌一个"看起来差不多"的近似采样。
另外,它的收益是叠加性的。你可以先做权重量化把单步解码压到更短,再套上投机解码把步数减下来,两者互不冲突。配合现在框架里常见的continuous batching、paged attention这些技术,整体推理吞吐能拉开几个身位的差距。我见过一些团队把投机解码和前缀缓存、结构化输出约束组合使用,在代码生成和SQL生成场景里拿到了相当可观的综合收益。
如果你准备在自己的项目里落地,我的建议是从一个简单的两模型方案开始,先把接受率数据导出、把γ的收益曲线画出来,再逐步优化工程细节。这个方向上后续还有树状验证、多草稿并行这类衍生工作,但根基都是这篇文章里"草稿-验证-拒绝采样"这个朴素框架。把这套基本功吃透,后面的路会顺很多。