
投机解码Speculative Decoding是我近几年见过最符合“卧槽我怎么没想到”这个评价的 LLM 推理加速方案。第一次在论文里看到这个名字时我还以为又是那种堆算力、改内核、上 CUDA 优化的重型工程方案结果把原理读完才发现底层逻辑简单到可以用一句话讲清楚让一个又快又笨的小模型先写几版草稿再让又大又聪明的大模型像批改作业一样并行检查一遍能不改的就不改该改的只改一个词。整个流程里大模型只需做一次前向却可能同时产出好几个 token。这其实是一个非常适合拿来“居然还能这样”的项目方向。我把投机解码从直觉到实现完整过一遍包括草稿模型和验证机制到底怎么配合、实际接入推理服务时有哪些参数值得调以及当你发现加速比不如预期时该怎么排查。如果你正在做 LLM 应用、被生成延迟卡得难受或者只是对模型加速有好奇心但不想硬啃论文这篇应该能让你少走不少弯路。1. 为什么说“你本可以想到投机解码”1.1 从自回归生成的“笨”说起所有主流大语言模型在生成时都是自回归的也就是一次前向只吐出一个 token然后把这个 token 拼到序列末尾再重新跑一遍整条网络继续预测下一个。这个机制保证了生成质量但也带来一个很难受的约束即使当前这个位置只差一个词就结束模型也还是要把几十亿甚至上百亿参数从头到尾算一遍。我最早做推理优化的时候开 profiling 一看GPU 的算力利用率经常低得离谱尤其在小 batch 场景下访存带宽才是瓶颈计算单元大量时间都在空转。也就是说模型其实“算得动”只是被自回归的串行逻辑卡住了。那时候大家都想着怎么压缩模型、怎么量化、怎么把算子融合默认就把“一次只能生成一个 token”当成了不可挑战的物理定律。可如果你把这些事实摆在一起大模型单次前向算力有余、小模型跑得快但质量不高、生成文本里大量 token 其实是很容易预测的“废话”——那自然就会冒出一个想法能不能让便宜的模型先把话说完贵的模型只负责把关1.2 一个立刻能懂的类比实习生写初稿专家审稿这个思路用职场类比特别好解释。假设你是团队里最资深的专家一份报告需要你逐字敲出来。你的输入速度很慢但判断力很强。这时候如果给你配一个刚入职的实习生让他先按你的习惯把初稿写出来你拿到稿子后从头扫一眼大部分内容和你的想法一致就整段放行偶尔有不对的地方你只改那一句效率是不是立刻就上来了投机解码干的正是这件事。小模型相当于实习生大模型相当于专家。小模型以极低的推理成本快速“续写”出一段候选 token大模型把这整段候选当作一次验证任务并行检查每个位置的概率分布如果某个位置的候选 token 在大模型看来也足够合理就接收它如果遇到一个明显不对的位置大模型在这个位置重新采样一个 token并丢弃后面所有草稿。关键点在于专家不需要对每个字都重新思考一遍只需要“审”。而审查是可以并行的一次过目一整段这正好绕开了自回归只能串行的限制。1.3 为什么大多数人没往这个方向想既然思路这么自然怎么直到 2022 年底、2023 年初才被系统整理成论文我复盘下来主要有三个思维惯性。第一个惯性是“一个模型用到底”。工程上部署 LLM 时大家潜意识里都是一个模型负责所有生成顶多再用个 ngram 缓存做高频词匹配。很少会去思考“让两个模型协作”是不是更划算。第二个惯性是“加速必须压榨单模型”。量化、剪枝、蒸馏都是对同一个模型做减法方向是让这个模型本身更快而不是让另一个模型去帮它。第三个惯性是“候选草稿听起来会降低质量”。当时很多人第一反应是大模型怎么可能接受小模型生成的东西接受了质量不就变差了吗但投机解码恰恰通过巧妙的拒绝采样机制在统计上保证了采样分布和原始大模型一致。这一点后面我会详细讲。所以“你本可以想到投机解码”不是一句客套话。它真的只需要你能跳出“一个模型从第一个字写到最后一个字”的默认假设。2. 投机解码的原理草稿模型、并行验证和拒绝采样2.1 草稿模型从哪来小模型、n-gram 与自草稿草稿模型最常见的形态是一个更小、更快的同系列模型。比如目标模型是 7B 参数草稿模型可能选 1B 甚至 0.5B目标模型是 70B草稿模型可以选择 7B。要求通常是词表一致、tokenizer 一致、能力分布尽量接近。这样小模型预测出来的候选大概率也是大模型认可的内容。小模型不是唯一选择。后来有不少工作用 n-gram 模型或 prompt 检索来做草稿这类方法在代码编辑、JSON 生成、格式化文本等高度重复的场景下效果意外地好。做法很简单从上下文里找重复片段把可能延续的文本当作草稿大模型再验证一次。优点是完全不需要额外加载模型缺点是遇到真正的创作型文本基本帮不上忙。还有一种思路是让目标模型自己给自己当草稿比如投机解码中的某些变体利用模型浅层或者中间层输出提前终止然后由深层验证。这类方法降低了“再训练一个小模型”的成本但实现复杂度高一些工程上用的相对少。对于大多数团队先老老实实配一个同源小模型是最稳的起步方式。2.2 一次前向验证多个 token 是怎么做到的很多人第一次听投机解码时会困惑自回归明明是一个位置一个位置预测的大模型怎么可能一次验证一整段草稿关键点在于大模型的前向计算可以同时输出序列中每个位置对“下一个 token”的预测概率。举个例子你输入序列是今天天气真它输出 logits 后你不仅知道今天天气真后面最可能跟什么还能从输入今天天气预测真的概率从输入今天预测天气的概率。只是因为自回归生成时只用了最后一个位置的预测结果前面那些位置的预测能力被白白浪费了。投机解码正是把这种“浪费”利用起来。假设小模型生成了一段候选 token好适合出去散步大模型把这串 token 拼到完整前缀之后一次前向算完整个序列于是每个候选 token 位置上都拿到了大模型的真实概率。逐个位置比较草稿 token 在大模型分布下的概率如果高到一定程度就保留否则就在该位置重采样。整个过程只需要一次自回归前向却可能验证几个甚至十几个 token。这也是工程实现里最需要留意的地方attention mask要正确序列长度要包含全部草稿 token输出 logits 的位置对齐要仔细。如果位置错了一位后面的接受率会崩得很莫名其妙。2.3 收益怎么算接受率、gamma 与加速比要理解投机解码能带来多少收益先明确几个参数。gamma是每轮让小模型生成的草稿 token 数量通常设置成 3 到 8。接受率α 表示草稿 token 被大模型接受的平均概率。理想情况下每轮投机解码期望生成的有效 token 数是[ E(\gamma) \frac{1 - \alpha^{\gamma1}}{1 - \alpha} ]这个公式怎么来的如果每个草稿 token 被接受的概率都是 α那么在第一个被拒绝的位置之前接受 0 个、1 个、2 个……直到全部接受全部接受时大模型还会额外再生成一个 token所以总期望就是上面这个等比数列求和。当 α 接近 1 时收益非常可观当 α 很小比如 0.3gamma 再大收益也有限。举个例子α0.7gamma4 时[ E(4) \frac{1 - 0.7^5}{0.3} 2.77 ]也就是说大模型每做一次验证平均能“批发”出 2.77 个 token。但如果把 gamma 拉到 8[ E(8) \frac{1 - 0.7^9}{0.3} 3.20 ]提升并不明显。原因是草稿越长后面的 token 越难保持高接受率。小模型在前面几个 token 上往往模仿得很好但越往后越容易跑偏。所以 gamma 不是越大越好一般取 4 到 6 就性价比很高。实际加速比还要把小模型的生成成本算进去。小模型每轮要自回归生成 gamma 个 token这部分也有耗时。整体加速比近似等于大模型期望生成的 token 数除以“小模型生成 gamma 个 token 的耗时 大模型一次验证耗时”。如果小模型不够快或者大模型和它速度差距不大收益会被明显摊薄。2.4 关键性质采样分布为什么能保持不变投机解码最容易被质疑的一点是让大模型接受小模型的输出输出质量不是被拉低了吗事实上标准的投机采样是有数学保证的最终采样分布和原始大模型完全一致至少在理论分布上没有任何偏差。核心机制是拒绝采样。当小模型给出候选 token x大模型算出它觉得 x 的概率是 p小模型自己觉得 x 的概率是 q。如果大模型比小模型更看好这个 token也就是 p ≥ q直接接受如果大模型没那么看好就以 p/q 的概率接受否则拒绝并从大模型的真实分布中重新采样一个 token 来替换。这个接受/拒绝规则配合重采样本质上是把大模型分布中那些小模型“多分”的概率质量修正回来。最终每个位置上的 token 分布和直接用大模型从头采样完全一样。如果大模型做的是贪心解码投机解码也会复现出完全相同的 greedy 输出。这个“无损”属性是投机解码能被大规模使用的重要原因它不是一个靠牺牲质量换速度的近似方案。不过需要提醒的是这种无偏性要求实现完全正确。很多“超高速投机解码” demo 其实悄悄关掉了随机采样或者用了不正确的接受准则结果看似输出没问题实际分布已经偏了。做线上服务时这种隐性偏差比速度慢更危险。3. 实操用 Transformers 搭一个投机解码管线3.1 最快路径assisted decoding如果你用的是 HuggingFace Transformers已经有现成的 assisted decoding 接口。最简单的做法是在model.generate时传入assistant_model一行代码就能让目标模型和草稿模型协作。from transformers import AutoModelForCausalLM, AutoTokenizer target_model AutoModelForCausalLM.from_pretrained(your-target-model) draft_model AutoModelForCausalLM.from_pretrained(your-draft-model) tokenizer AutoTokenizer.from_pretrained(your-target-model) input_ids tokenizer(写一段关于春天的小作文, return_tensorspt)[input_ids].cuda() output_ids target_model.generate( input_ids, assistant_modeldraft_model, max_new_tokens256, do_sampleTrue, temperature0.7, num_assistant_tokens5, ) print(tokenizer.decode(output_ids[0], skip_special_tokensTrue))这里最关键的参数是num_assistant_tokens它对应上面说的 gamma。Transformers 内部会自动调度草稿模型的生成和主模型的验证日志中会输出类似 “assisted decoding” 的信息说明已经跑在投机模式。实际使用时有一件特别容易被忽略的事草稿模型和目标模型必须兼容主要是 tokenizer 和词表。如果两个模型的 tokenizer 不一样小模型生成的 token id 在大模型眼里可能是完全无关的内容接受率会直接崩塌。用同源模型一般不会有这个问题混搭不同公司的模型时一定要先跑几个 prompt 对比。3.2 草稿模型的选型参数草稿模型选得好不好直接决定加速比是 3 倍还是 0.8 倍。我踩过不少坑之后总结出三个原则。第一个原则是“同源优于异源”。同一个基座预训练出来的小模型在分布上与目标模型最接近。比如目标模型是 7B 版本草稿模型尽量选择同一个系列的 1B 或 0.5B 版本。跨系列模型不是不能用但接受率通常低一截尤其是创意写作和代码场景。第二个原则是“速度比能力重要”。草稿模型的作用不是生成最终答案而是生成“大模型大概率认可的候选”。因此它的推理速度越快越好。如果草稿模型只比大模型快 2 倍投机解码整体收益很难超过 1.5 倍如果快 5 到 10 倍收益才会明显。选型时不要只看模型质量榜最好实测定延迟。第三个原则是“gamma 要动态看”。固定 gamma5 不是最优解。序列开头通常容易接受长草稿后段接受率低。Transformers 的 assisted decoding 支持根据接受率动态调整下一步草稿长度但如果自己实现建议在每轮验证后统计接受数量如果经常全部接受就适当增大 gamma如果第一个 token 就被拒绝就减小 gamma。这个反馈控制在长文本生成里能省下不少无效计算。3.3 自己动手实现一个最简版投机采样用现成接口没问题但为了彻底理解我建议自己写一个最简实现。下面是一个教学级 Python 伪代码主流程就是“小模型草稿 - 大模型并行验证 - 拒绝采样修正”。import torch import torch.nn.functional as F torch.no_grad() def speculative_sample(draft_model, target_model, tokenizer, prompt, gamma4, max_new_tokens64, temperature1.0): input_ids tokenizer(prompt, return_tensorspt)[input_ids].cuda() generated input_ids for _ in range((max_new_tokens gamma) // (gamma 1)): L generated.shape[1] # 1. 小模型先自回归生成 gamma 个候选 token draft_out draft_model.generate( generated, max_new_tokensgamma, do_sampleTrue, temperaturetemperature, pad_token_idtokenizer.eos_token_id, ) draft_ids draft_out[:, L:] # 2. 大小模型分别对完整序列做一次前向 full_ids torch.cat([generated, draft_ids], dim1) target_logits target_model(full_ids).logits / temperature draft_logits draft_model(full_ids).logits / temperature # 对齐到草稿 token 对应的预测位置logits[:, L-1 : -1] target_probs F.softmax(target_logits[:, L-1:-1], dim-1) draft_probs F.softmax(draft_logits[:, L-1:-1], dim-1) # 3. 逐个位置判断接受或拒绝 accepted_flags [] for i in range(draft_ids.shape[1]): token_id draft_ids[0, i] p target_probs[0, i, token_id].item() q draft_probs[0, i, token_id].item() if p q: accepted_flags.append(True) else: accepted_flags.append(torch.rand(1).item() p / q) accept_count 0 for flag in accepted_flags: if flag: accept_count 1 else: break # 4. 按接受数量拼接结果 if accept_count draft_ids.shape[1]: # 全部接受大模型额外生成一个 token next_logits target_logits[:, -1, :] next_id torch.multinomial(F.softmax(next_logits, dim-1), 1) new_ids torch.cat([draft_ids, next_id], dim1) else: # 在第一个被拒绝的位置用大模型重新采样 rej_logits target_logits[:, L - 1 accept_count, :] rej_id torch.multinomial(F.softmax(rej_logits, dim-1), 1) new_ids torch.cat([draft_ids[:, :accept_count], rej_id], dim1) generated torch.cat([generated, new_ids], dim1) if generated[0, -1].item() tokenizer.eos_token_id: break return tokenizer.decode(generated[0], skip_special_tokensTrue)这个版本里有两个容易写错的地方。第一个是 logits 取位L-1:-1这一段才是草稿 token 对应的预测分布位置偏移一维都会导致概率完全对不上。第二个是采样修正拒绝时不能用大模型继续生成后面的草稿而是要在被拒绝的位置重新采样并把后面的草稿全部丢掉。丢掉的部分看起来可惜但这是保证无偏采样的代价。3.4 实测中的加速比怎么解读我自己在 7B 目标模型 1B 草稿模型的组合上试过单条请求下gamma 取 4 到 5普遍能拿到 2 到 3 倍的端到端加速。如果是代码补全、JSON 生成这类重复度较高的任务接受率更高有些场景能跑到 4 倍以上。但如果你跑的是开放域故事创作草稿模型很容易胡思乱想加速比可能只有 1.5 倍左右。还有一个很容易被 demo 掩盖的事实投机解码在 batch size 增大后收益会下降。原因在于小 batch 时大模型前向主要受访存限制多验证几个 token 几乎不增加耗时所以收益显著但 batch 变大后大模型单次前向的吞吐已经被填满草稿验证从“顺带做”变成“额外负担”这时投机解码的优势就没那么明显。所以它特别适合在线低并发、单用户交互、流式输出的场景反而不太适合离线高吞吐批量跑任务。4. 常见问题与排查技巧实录4.1 投机解码比直接生成还慢如果跑完发现速度不升反降先不要怀疑方法绝大多数情况是下面几个原因。最常用的是草稿模型选大了。比如目标模型是 7B草稿模型也选 7B那两段自回归加起来等于跑了两遍模型当然慢。草稿模型最好比目标模型小 5 倍以上否则没有足够的速度差来覆盖验证开销。第二个原因是 gamma 设置过大。草稿模型每轮都要自回归生成 gamma 个 token这个耗时是线性增长的。如果接受率只有 0.5gamma16 时后半段草稿基本全是无效劳动整体速度反而被拖垮。可以先把 gamma 调小到 3 或 4观察接受数量和端到端延迟再逐步增大找最优点。第三个原因是小模型没有真正共享大模型的 KV cache 机制。在标准实现中草稿模型和主模型各自维护自己的 KV cache。如果草稿模型每次都要重新处理完整前缀它的耗时会被前缀长度放大长对话场景下非常吃亏。遇到这种情况最好确认框架是否支持 prefix caching或者考虑 n-gram 草稿模型来彻底省掉额外 KV。4.2 接受率上不去怎么办接受率低通常意味着草稿模型和目标模型的“想法”差异太大。最简单的解决路径是换同源模型接受率会立刻改善不少。如果同源模型依旧低可以对草稿模型做一轮面向目标模型输出的蒸馏用目标模型生成的文本作为训练数据微调草稿模型。蒸馏之后小模型会刻意模仿大模型的输出习惯接受率能明显提升。也可以换个思路不用语言模型当草稿改用 prompt lookup。对于格式化文本、代码、重复性文案先从已有上下文里复制候选片段让大模型验证几乎零成本接受率还高。我在代码注释补全和日志模板生成场景里试过保守也有 2 倍以上收益。还有一个容易被忽略的细节如果你在采样时用了较高的 temperature草稿模型和目标模型各自的分布都会变平token 之间概率差距变小接受率可能会下降。此时可以降低 temperature或者将草稿模型单独用较小的 temperature 生成再校正到目标温度。实现时要先确认采样分布是否需要严格无偏如果业务能接受轻微偏差可以放宽一点。4.3 采样参数、温度和 batch 下的隐藏问题投机解码在 greedy 解码下最稳因为大模型对候选只有“接受”和“拒绝”两种判断没有额外随机性。但一旦开启do_sampleTrue每次采样都可能出现同一 prompt 输出不同的情况这会让很多人在调试时分不清是 bug 还是随机性。我的建议是先用 temperature0、do_sampleFalse 验证投机解码和原生解码输出是否完全一致。如果一致说明整体链路是对的如果不一致优先检查概率对齐位置和拒绝采样公式。一致性验证通过后再开启随机采样这样才能把变量控制住。还有个隐藏坑是 batch size。投机解码对单请求效果好但如果你把几百条请求同时送进去框架可能会走普通解码路径或者在 batch 维度上对每条序列分别管理草稿长度导致逻辑复杂度上升。线上使用前一定要看一眼日志里的实际解码模式确认不是静默回退。4.4 问题排查速查表现象可能原因优先尝试加速比小于 1草稿模型不够快换更小的草稿模型确认速度差大于 5 倍加速比不稳定gamma 过大或过小动态调整 gamma观察每轮接受数量接受率长期低于 0.4草稿模型与目标模型分布差异大换同源模型或对草稿模型做蒸馏长上下文下越来越慢草稿模型重复处理完整前缀开启 prefix cache或改用 n-gram 草稿随机采样输出异常拒绝采样实现有偏差先切贪心模式验证一致性再回归采样高并发 batch 下收益低大模型前向已接近饱和评估是否值得用投机解码考虑关闭5. 从“想到”到“做到”这个思路还能往哪延伸投机解码让我重新理解了什么叫“工程创新的杠杆”。它没有改变任何一个模型的参数也没有发明新的算子只是把“一个模型自回归生成”重新组织成了“两个模型一个草稿一个验证”的流水线。这种思维一旦打开能延伸的方向非常多。比如在结构化生成场景里可以用一个基于正则或 schema 的快速生成器当草稿大模型只负责语义校验速度可能比通用投机解码更快。在 MoE 模型里不同 expert 之间天然有速度差也可以借鉴投机思想让轻量路由先选一批候选再让重 expert 验证。甚至在扩散模型采样中类似的“先粗后精、再修正”的思路也在不断出现。我个人现在的习惯是拿到一个推理加速需求时不再默认“优化单模型”而是先问一句这个任务能不能拆出一个更快的“先说答案”模型再让主模型做裁判如果你能从今天开始带着这个问题看手里的系统那投机解码就不仅是论文里的一个方法而真正变成了你自己的工具箱里的一件趁手工具。