最近在优化内部大模型推理服务的时候,业务方反馈最多的就一句话:生成速度太慢了。一个几百字的回复要等好几秒才吐完,流式接口虽然看着是逐字输出,但token到达的节奏依然让人着急。这个场景做LLM应用的朋友应该都不陌生,尤其是线上服务延迟敏感的任务,慢吞吞的自回归解码几乎成了共识痛点。我当时翻了不少加速方案,真正在生产环境里跑通、拿到显著收益的,就是标题里这个Speculative Decoding(推测解码)。它不换模型、不动权重,只是改变了"解码"的方式,却能让端到端生成速度提升一倍以上,某些场景实测可以逼近三倍,也就是300%的量级。这篇文章把我从原理到生产落地的完整路径拆开讲清楚,包括那些文档里不会写的坑。
它的思路核心一句话就能说明白:既然大模型每一步只能吐一个token,那不如让一个非常小的小模型先快速"打草稿",大模型一次性审核多个草稿token。审核比创作快得多,于是整体速度就上去了。听起来是不是有点像团队里先让实习生写初稿、负责人集中审稿?道理是一模一样的。但"审稿"要审得又快又稳,背后有一套完整的数学保障和工程细节,绝不是简单拼两个模型就能用。
1. 大模型生成慢的根因:算力没吃满,带宽卡住了"串行"
1.1 一个token一次前向:自回归解码的硬约束
要理解推测解码为什么有用,得先看清楚大模型生成token到底慢在哪。所有主流大模型都是自回归结构:给定已生成的token序列,预测下一个token的概率分布,然后从中采样一个token,追加到序列尾部,再重复这个过程。每一步之间是严格串行的,因为第t+1个token的预测依赖第t个token的生成结果,这个依赖关系绕不开。
这个循环在推理框架里对应一个prefill阶段加一个完整的generation loop。prefill阶段把用户的输入prompt一次性处理完,生成KV缓存;之后每来一个token,只需做一次前向计算,把新token对应的KV追加进缓存。单看一次前向,计算量比起prefill小得多,但问题是这个前向要跑很多次。生成100个token就是100次串行前向,生成500个token就是500次。每次前向的延迟累加在一起,体感自然就慢了。
1.2 Memory Bound:读权重比算权重更耗时
那单次前向为什么也要好几毫秒?这里有一个反直觉的结论:大模型推理的瓶颈通常不是计算,而是显存带宽。我做一个粗略估算你就明白了。以7B模型为例,FP16精度下的权重约14GB,每次前向都要把这14GB权重从显存读一遍。按主流A系列显卡2TB/s左右的显存带宽算,光读一次权重就得7毫秒左右。
而这一步真正需要的计算量呢?一个token的前向FLOPs大约等于两倍的模型参数量乘以序列长度相关的系数,粗略算下来7B模型也就十几GFLOPs,显卡的FP16算力动辄几百TFLOPS,算过去只有零点零几毫秒。也就是说,计算资源利用率连1%都不到,时间全花在"把权重搬出来"这件事上了。这就像让一个团队每天做一道数学题,但规则要求每次做题前必须把那本一千页的参考书从头翻一遍。计算本身不是瓶颈,翻书才是。
1.3 破解方向:让一次前向产出多个token
既然单次前向的权重读取成本是固定的,那最直接的想法就是:能不能让一次前向多干点活?如果一次前向能同时预测出5个位置的概率分布,生成5个token,那权重读取成本就被摊薄到五分之一。这个思路就是推测解码的本质:用一次大模型前向,验证多个候选位置。
剩下的问题是,谁来预测这些候选token?大模型自己没法一次写多个token,这是自回归结构决定的。但如果有个足够小的模型,用极低的成本快速生成一段"草稿",大模型看到草稿后一次性判断"这里对不对、那里改哪里",那整个过程的串行延迟就从"大模型单次前向延迟 × 待生成token数"变成了"小模型生成草稿的延迟 + 大模型单次前向延迟 × 草稿批次数量"。只要小模型够小、接受率够高,整体收益就会非常可观。
2. 推测解码原理:小模型打草稿,大模型一次审完
2.1 三条角色:草稿模型、目标模型、接受规则
先看推测解码的完整流程。整个系统包含两个模型:一个目标是我们要加速的大模型,叫目标模型;另一个是陪跑的小模型,叫草稿模型。小模型的任务是在每个解码步先快速生成γ个候选token(γ通常取3到5,论文里一般叫gamma,有些框架里写作lookahead或num_draft_tokens),把这段草稿拼到当前序列后面。然后大模型做一次前向,拿到这γ个位置上所有token的概率分布。最后按规则从第一个位置开始逐个判定是否接受草稿中的token,碰到第一个不接受的位置就停下来,在那里用大模型自己的分布重新采样一个token,作为新的起点继续下一轮。
这里最容易混淆的一点是:接受规则不是比较两个模型的logits是否一致,而是一个带随机性的概率判定,具体逻辑下一节细说。先看实现层面的核心逻辑,用伪代码表达大概是这样的:
def speculative_decode(draft_model, target_model, prompt_ids, gamma=4): current_ids = prompt_ids[:] while True: # 草稿模型快速生成gamma个候选token draft_ids = draft_model.generate( current_ids, max_new_tokens=gamma ) # 目标模型一次性前向,拿到每个草稿位置的logits target_logits = target_model.forward(current_ids + draft_ids) accepted_count = 0 for i in range(gamma): p = softmax(target_logits[:, -gamma - 1 + i]) q = draft_logits[:, -gamma - 1 + i] xi = draft_ids[i] if accept_by_probability(p[xi], q[xi]): accepted_count += 1 else: # 最后从调整后的分布中采样一个token break current_ids += draft_ids[:accepted_count] if accepted_count == 0: # 连第一个都没有接受,从大模型分布直接采样 current_ids.append(sample_from_adjusted(target_logits[:, -gamma - 1])) # 终止条件判断...实际工程里不会在这个循环里反复加载两个模型,而是把两个模型常驻显存、用CUDA图或推断引擎分别执行。小模型生成草稿和调节采样参数这些细节也会封装好,但整体骨架就是这样一个"草稿-验证-修正"的循环。
2.2 接受判定的数学逻辑:保证输出分布不变
很多人第一次听到推测解码时会担心:让小模型先写草稿,大模型再改,最终生成的文本和原版大模型自己写出来的还一样吗?答案是:在正确的实现下,分布完全一致。这不是经验上差不多,而是数学上严格保证的。关键在于接受规则本身是一个精心设计的随机化判定。
具体说,设大模型在某个位置的的真实分布为p(x),草稿模型预测的分布为q(x)。对于草稿模型给出的token x,大模型按如下概率接受它:
- 如果 p(x) = q(x),直接接受;
- 如果 p(x) < q(x),以概率 p(x) / q(x) 接受;
- 如果 p(x) > q(x),不仅无保留接受,还要在"拒绝采样"的修正步骤里补偿这个位置的概率质量。
最后一个位置如果没有被接受,则从修正分布 max(p(x) - q(x), 0) 归一化后采样。这套规则本质上是Metropolis-Hastings采样的一种特例。它保证的是:你最终采样到的token序列,与完全从大模型自回归分布p中逐位置采样得到的序列,服从同一个联合分布。换句话说,换了个更快的采样方式,但采样结果看起来就是大模型本人写的。
用编辑审稿来类比:审稿编辑不会真的重写每篇文章,而是拿到草稿后按自己的标准逐句审核,该改的改、该过的过。最终定稿的质量和标准完全由编辑水平决定,草稿只是让编辑少打了一些字而已。如果实习生刚好写得对,编辑就省力;写错了就划掉重写。这套机制不会降低编辑标准,只会影响审稿速度。
2.3 加速比估算:接受率、草稿长度、小模型开销的三角关系
决定推测解码提速幅度的核心指标是接受率α,即某个草稿位置被大模型判定接受的期望概率。假设每个位置的接受概率都是α,验证γ个草稿token后,一次大模型前向实际能产出的token数量期望是:
E(N) = 1 + α + α² + ... + α^γ = (1 - α^(γ+1)) / (1 - α)
这个公式是理解所有提速数据的钥匙。拿几组数字代入就能感觉到它的威力。γ=4、α=0.6时,E≈2.36;α=0.7时,E≈2.77;α=0.8时,E≈3.36。这意味着一轮大模型前向可以"顶"原来自回归2到3个token的产出。
当然,草稿模型的生成成本也要算进去。假设草稿模型单步成本是目标模型的c倍,那么一轮的总耗时大约是 1 + cγ 次"目标模型等价前向"。加速比就是 E / (1 + cγ)。当c=0.05、γ=4、α=0.8时,加速比=3.36 / 1.2 = 2.8倍。当c=0.1时,加速比=3.36 / 1.4 = 2.4倍。标题里的300%就是在类似参数下达到的,前提是草稿模型足够小且接受率足够高。
这三者是一个三角关系:α决定了收益上限,γ决定了一轮最多能往前推多远,c决定了草稿这部分的代价。生产调优的一大部分工作,就是在给定硬件条件下找这三者的最优平衡点。
3. 生产落地的关键决策:选模型、配采样、管缓存
3.1 草稿模型怎么选:tokenizer一致是第一优先级
原理只是第一步,真正落地时最先要过的坎是草稿模型选型。我踩过不少坑之后总结出三条优先级非常明确的标准。
第一,tokenizer必须和目标模型保持一致,这一点优先级最高。如果两个模型的词表不一致,草稿token的ID在大模型里根本没有对应意义,连验证都做不了,只能额外加一层词表映射。映射层会把显存和延迟都拉上去,而且映射本身还可能引入误差,除非两个模型规模差距极大否则非常不划算。所以经验就是尽量选择同一个模型家族里的小尺寸版本当草稿模型,比如目标是7B量级,草稿选0.5B到1B的同一系列就行。
第二,草稿模型的训练数据分布要尽量和目标模型对齐。这不是玄学,接受率直接由q和p的重合程度决定。如果目标模型主要擅长代码而草稿模型是个通用聊天小模型,那在生成代码时草稿几乎句句都不对,接受率会掉得很惨。反过来,如果小模型也在同样语料上做过继续预训练或蒸馏,接受率会明显提升。
第三,容量比例不是固定的,要按任务特性试。我实测过的范围从1:8到1:40都有。通用对话任务因为token多样性高,草稿模型稍大一点接受率才稳;结构化输出如代码、JSON、SQL这类任务,小模型反而容易猜中,可以选更小的草稿模型来压低c值。
3.2 采样参数对接受率的影响:不是温度越高越好
生产环境里大家都习惯用temperature、top_p这些采样参数控制输出的随机性和多样性,但它们对推测解码的接受率影响非常大,而且方向和直觉通常是反的。
核心原因是,接受概率依赖的是两个分布q和p在具体token上的重合度。当target模型用temperature=1的原始softmax分布采样时,接受判定就是严格按照数学规则走,任何分布偏差都会表现为拒绝。当temperature降低,比如调到0.7,分布变得尖锐,高概率位置的token都集中到了argmax附近,q和p在那些位置的重合概率会提高,接受率自然好一些。到temperature=0即贪心解码时,判定就退化成"草稿token是否恰好等于大模型的argmax",接受率在分布对齐良好的情况下可以非常高,但代价是多样性直接归零,只适合那些本来就要求确定性输出的场景。
在生产里我做序列生成时一般保持默认的采样参数,然后动态观察接受率。如果temperature已经调到接近1.0,而接受率还是偏低,优先去查草稿模型的适配问题,而不是继续调温。因为采样参数只是在分布形状上做文章,救不了分布本身不对齐的问题。
3.3 KV Cache的命中和复用:验证阶段的隐藏收益
推测解码有一个容易被忽视的隐藏收益:验证阶段大模型对草稿token做的那次前向,其实已经把后面几个位置需要的KV Cache全部算出来了。如果某个位置的草稿token被接受,那它的KV Cache在下一轮可以直接复用,不用重新算。这等于把原本串行需要算的KV计算也摊进了同一次前向里。
这个规律对实现有一个直接影响:验证时要按照草稿token的完整序列做一次前向,不能拆开循环。拆开就退化了。工程上要保证目标模型的KV Cache支持批量前缀复用,同时草稿模型的cache也不能忽略——小模型虽然占显存少,但它的cache同样随序列长度增长,长对话场景下需要在框架层统一管理。
还有一个细节是关于草稿长度的KV上界。验证时一次拼了γ个草稿token,目标模型的输入序列长度就变长了,KV Cache对应的显存也按这个长度分配。如果在线服务并发很高,这个额外占用必须提前核算进显存预算,否则容易在高峰时触发OOM。经验值上,γ=4时额外显存通常还在可控范围;γ超过8之后,KV占用的代价会开始拉低收益。
3.4 批处理下的真实游戏:单序列优化与整卡吞吐
另一个常被忽略的问题是:推测解码最初是为单序列延迟优化设计的,但在生产推理服务里,一张卡上往往是多个序列并发。并发场景下加速效果不是简单等比例放大,而是要看batch的填充率。
常规自回归解码在batch很大时,单步计算量已经比较饱和,GPU利用率本身不低,推测解码的相对收益会被摊薄。相反,当batch比较小、GPU带宽大量空闲时,推测解码的收益会非常显著。所以在并发较高的服务体系里,放宽batch到接近显存上限之后,如果延迟还不达标,再叠加推测解码效果会更好;而在batch本身就很小、单路延迟敏感的接口上,推测解码几乎是直接见效。
具体的batch处理方式通常是这样:多个序列各自的小模型各自生成草稿,然后所有序列的验证请求拼成一个大的验证batch,一次性交给目标模型前向。这样目标模型每次前向处理的token数变多了,但对应的权重读取只有一次,带宽利用更充分。要注意的是不同序列的草稿长度可以不同,但框架一般会按最长的γ对齐padding,控制整体batch的token上限。
4. 实测效果与排障经验:为什么你的提速没有300%
4.1 我的一组对比数据:不同任务、不同收益
工具怎么选、参数怎么配,最终都要落到实测数据上。这里给出我在一个生成场景里的对比数据,硬件是单张A系列显卡,目标模型7B,草稿模型0.5B,gamma分别取4和6,temperature=1.0,输出长度在200到400个token之间。
| 任务类型 | 原自回归吞吐(tokens/s) | 推测解码吞吐(gamma=4) | 推测解码吞吐(gamma=6) | 实测接受率 |
|---|---|---|---|---|
| 代码生成/结构化输出 | 22 | 58 | 64 | 0.74 |
| 通用对话 | 22 | 47 | 49 | 0.61 |
| 开放域长文生成 | 22 | 41 | 40 | 0.52 |
| 规范化SQL/JSON | 22 | 66 | 69 | 0.81 |
注意,这里列的"吞吐"是端到端除以总耗时的等效吞吐,包含预填充和小模型的成本。可以看到代码、SQL这类结构化任务收益最明显,gamma=6时接近3倍;开放域长文生成收益会差一些,因为小模型预测长文内容的准确率天然偏低,gamma拉长之后后段草稿容易全被拒掉,收益被多算的草稿成本抵消。这组数据也解释了为什么很多文章标题敢写300%——它确实在不少真实任务上能达到,但前提是任务本身适合。
4.2 接受率上不去的三个常见原因
如果实测下来发现接受率只有0.2、0.3,不要怀疑推测解码本身,先按下面几个方向逐项排查。
第一个常见原因是草稿模型和数据分布不匹配。比如目标模型在业务数据上有大量长尾表达,草稿模型并没有见过,q和p的重合度自然很低。这时候优先做的不是调参数,而是找一份目标模型在真实请求上的输出数据,做一轮小模型的继续预训练或者知识蒸馏。
第二个原因是γ设得太长。草稿模型毕竟是能力有限的,预测第3、4个token的准确率会快速下滑。γ=8时有时候后半个草稿全废,这会导致一个尴尬情况:大模型前向成本已经多花了,接受率却没跟上。建议先用γ=3、4起步,统计每一跳位置的边际接受率——第1个接受率0.8、第2个0.6、第3个0.35,明显衰减很陡,那就说明γ取3就够了。
第三个原因是采样参数和环境没对齐。验证过程中目标模型的采样分布是参考基准,如果推理框架内部的采样实现和训练时不一致,perplexity不变但分布细节有偏差,也会影响接受判定。这种情况比较隐蔽,一般的排查思路是在同一次输入上跑草稿模型和目标模型,把两个分布的KL散度打出来,如果KL不高但接受率依然低,基本就是实现层面的问题了。
4.3 我的排查工具链与日常观测指标
生产环境里维护推测解码,我只盯三个指标:接受率、每轮期望产出token数E(N)、以及端到端等效吞吐。日志里每一项都按请求粒度打点,方便按用户画像、任务类型分组统计。
每轮期望产出token数可以直接反映调参是否有效,我就不单纯看接受率了,因为接受率相同、γ不同,收益也可能不同。具体统计方法是维护一个计数器,每轮记录一次实际接受的token数,滚动平均就是E。调γ时直接比较E,很容易找到当前数据分布下那个"再加γ收益就不涨"的拐点。
另外,我还习惯在日志里同时记录草稿模型和大模型的单步延迟比。草稿模型如果因为batch或流水线调度问题跑慢了,c变大,哪怕接受率不变,端到端收益也会缩水。这种问题如果不看延迟比很难察觉,因为单个请求的总耗时看起来波动不大,但整卡吞吐悄悄掉了一截。
4.4 动态γ设置与其他一点主观经验
最后分享一个我认为值得一试的小优化:动态γ。静态γ对人流分布波动大的服务不太友好——请求突然变难了,γ=6里后面几个token大概率被拒掉,草稿成本就浪费了。我实现过一个简单的自适应策略:滚动统计最近的接受率,低则把γ降到3,高则升到6,每几十个请求更新一次。实测在混合负载下比静态γ再多拿到5到10个百分点的端到端收益。
这个技术在延迟敏感、显存有余量的推理场景确实是一个稳定有效的提速手段,但也要泼一盆冷水:如果瓶颈已经出现在整卡吞吐上,batch已经压到显存上限了,那推测解码带来的增益会变得有限,这时候优先增加推理卡或者优化KV内存才是正路。选择用推测解码之前,先用profile工具看清楚瓶颈是卡在单次前向延迟还是全卡吞吐,方向错了,再好的算法也救不回来。