☰
投机解码 Speculative Decoding :小模型草稿 + 大模型验证,推理提速 2-3 倍
2026/10/13 18:15:14 网站建设 项目流程

一、解码慢,不是因为算力不够

先做个思想实验。你用 vLLM 部署一个 7B 模型,单条请求生成一句话,打开nvidia-smi看 GPU 利用率,大概率会看到一个令人困惑的数字:30%~50%,甚至更低。但同一张卡跑批量推理(比如同时处理 256 条请求),利用率能轻松飙到 90%+,吞吐更是高出几十倍。

同样是矩阵乘法,为什么单条这么慢?

答案藏在**计算强度(arithmetic intensity)**里。解码阶段每生成一个 token,要做的事本质上是一次batch=1的矩阵-向量乘法:把几十亿个权重参数从 HBM(显存)搬到计算单元,做完乘法再搬回去。搬运量正比于模型大小,计算量却极小。于是一个残酷的事实摆在面前,解码阶段是显存带宽受限(memory-bandwidth bound),而不是算力受限(compute bound)。

┌────────────────────────────────────────────────────────┐ │ 单条解码 (batch=1) │ │ ┌──────────────┐ │ │ │ 权重 14GB │ ──搬运──> 计算单元 ──> 1 个 token │ │ │ (HBM) │ 带宽瓶颈 │ │ └──────────────┘ 耗时 ~= 14GB / 带宽 │ │ 算力利用率: ~5% ← 搬运 14GB 只为算一次矩阵-向量乘 │ ├────────────────────────────────────────────────────────┤ │ 批量解码 (batch=256) │ │ ┌──────────────┐ │ │ │ 权重 14GB │ ──搬运──> 计算单元 ──> 256 个 token │ │ └──────────────┘ 同样的搬运,产出 ×256 │ │ 算力利用率: ~90% │ └────────────────────────────────────────────────────────┘

所以优化解码速度有两条路:

  1. 提高 batch(连续批处理、动态批处理),已经被 vLLM / TensorRT-LLM 做到了极致,但当请求量不够时,batch 上不去。
  2. 让一次搬运产出多个 token,这就是投机解码要做的事。

当你只有零星几条请求、batch 提不上去时(典型场景:交互式对话、代码补全、端侧单用户),投机解码是唯一能继续榨出性能的手段。

二、核心洞察:小模型猜,大模型验

投机解码的想法朴素到有点反直觉:

与其让大模型一个一个"想"token,不如让小模型先快速"猜"出 γ 个 token,再让大模型一次性并行验证这 γ 个。猜对的部分直接采纳,猜错的位置之后全部丢弃重来。

关键在于验证是并行的。大模型验证 γ 个草稿 token,只需要一次前向传播(forward pass),因为验证时 γ 个 token 都已知,可以组成一个batch=γ的矩阵-矩阵乘法,完美命中算力瓶颈而非带宽瓶颈。这一次前向的耗时,和单独生成 1 个 token 几乎一样(搬运量相同,只是计算量从 1 份变成 γ 份,而计算本来就不是瓶颈)。

于是神奇的事情发生了:

操作传统解码投机解码(γ=5,接受 3 个)
生成 3 个 token3 次前向小模型 5 次(很快)+ 大模型 1 次验证
主要耗时3 次大模型权重搬运1 次大模型权重搬运
输出分布原始分布与原始分布完全一致

最后一行是投机解码最被低估的优点:它在数学上是无损的。量化会损失精度、蒸馏会改变分布、裁剪会掉点,但投机解码通过一套精妙的拒绝采样(rejection sampling)机制,保证最终采样出的 token严格服从原模型的输出分布。这意味着你可以放心用它替换线上服务,不用担心"输出质量下降"。

三、数学原理:接受率与期望加速比

3.1 问题形式化

设目标模型(大模型)分布为p(x_t | x_{1:t-1}),草稿模型(小模型)分布为q(x_t | x_{1:t-1})。

标准投机解码流程(γ 个草稿 token):

  1. 草稿阶段:用小模型q自回归采样 γ 个 token:x̃_1, ..., x̃_γ,同时记录每个 token 的概率q(x̃_i | ·)。
  2. 验证阶段:把原序列 + γ 个草稿拼起来,送进大模型做一次前向,得到 γ+1 个位置的目标分布p(x̃_i | ·)。
  3. 逐个判定:对每个位置i,以概率min(1, p(x̃_i)/q(x̃_i))接受x̃_i;一旦拒绝,立即停止。
  4. 修正采样:在拒绝位置,从修正分布(p - q)_+ / normalize中重新采样一个 token(保证分布严格等于p)。

3.2 为什么这样能保证分布无损?

这是拒绝采样的经典结论。设我们希望从p采样,但只有来自q的样本,且已知p, q的密度值。拒绝采样告诉我们:

  • 以概率min(1, p(x)/q(x))接受来自q的样本x;
  • 若被拒绝,则从残差分布r(x) = (p(x) - q(x))_+ / Σ(p-q)_+重采样。

数学上可以证明,这个组合过程输出的样本精确服从p。直觉理解:q比p概率高的地方(草稿模型"过度自信"),按p/q的比例打折接受;q比p概率低的地方(草稿模型"没想到"),全部接受并从残差里补上被低估的部分。两部分拼起来,恰好还原p。

注意一个反直觉的细节:当q(x) > p(x)时接受概率是p/q < 1,这意味着草稿模型越自信、目标模型越不认可,越容易被拒绝。所以草稿模型和目标模型的"分布对齐度"直接决定加速比。

3.3 期望加速比公式

设α为平均接受率(每个草稿 token 被接受的概率),c为小模型单次前向耗时与大模型的比值(通常c ≈ 0.02~0.05,因为小模型参数量小 10~100 倍)。

一次投机解码迭代的期望产出 token 数为:

E[accepted] = (1 - α^(γ+1)) / (1 - α)

一次迭代的耗时为c·γ + 1(小模型 γ 次 + 大模型 1 次,以大模型单次前向为时间单位)。

于是期望加速比:

Speedup(γ, α, c) = E[accepted] / (c·γ + 1) = (1 - α^(γ+1)) / ((1 - α) · (c·γ + 1))

代入几个典型值感受一下(c = 0.04):

草稿长度 γ接受率 α=0.5α=0.6α=0.7α=0.8
31.41×1.55×1.67×1.78×
51.56×1.76×1.96×2.15×
81.55×1.83×2.15×2.52×
101.47×1.78×2.16×2.65×

几个能直接指导调参的结论:

  • 接受率 α 是命门。α 从 0.5 提到 0.7,加速比提升幅度远超调 γ。选草稿模型时,"分布对齐"比"绝对能力"更重要。
  • γ 不是越大越好。可以看到 γ=8 之后收益开始变缓甚至回落(α 低时尤其明显),因为小模型连续猜对的概率是指数衰减的。工程上 γ 通常取3~7。
  • c 越小越好。草稿模型要足够快,一般取目标模型参数量的1/50 ~ 1/100。

四、算法流程:一图胜千言

目标模型 M_p (72B) 草稿模型 M_q (0.5B) │ │ │ ① 自回归采样 γ=5 个草稿 │ q: [t1 t2 t3 t4 t5] │ 同时记录 q(ti | ·) │ │ │ ② 拼接 [prefix + t1..t5] 送 M_p 做一次前向 │<─────────────────────────┘ │ │ ③ 得到 6 个位置的 p 分布,逐位置拒绝采样 │ ├── 位置1: p(t1)/q(t1)=0.92 → min(1,0.92)=0.92 → 掷骰子 → 接受 ✓ ├── 位置2: p(t2)/q(t2)=0.75 → 接受 ✓ ├── 位置3: p(t3)/q(t3)=0.31 → 掷骰子 0.31 → 拒绝 ✗ │ └─> 从修正分布 (p-q)_+ 重采样 → t3' ├── 位置4,5: 丢弃(位置3 已拒绝,后面的前缀已改变) │ ▼ 本轮产出: [t1, t2, t3'] 共 3 个 token(1 次大模型前向)

关键点:即使只接受了 3 个,本轮也产出了 3 个 token,而只花了 1 次大模型前向 + 5 次小模型前向。传统解码产出 3 个 token 需要 3 次大模型前向。这就是加速的来源。

五、源码走读:vLLM 怎么实现投机解码

vLLM 把投机解码抽象成几个核心组件,位于vllm/spec_decode/。我们走读最关键的三块。

5.1 调度层:SpecDecodeWorker协调双模型

vllm/spec_decode/spec_decode_worker.py负责把草稿模型的"提议"和目标模型的"验证"串起来。核心循环(简化):

python

class SpecDecodeWorker: def _run_speculative_decoding_step(self, execute_model_req): # ① 让草稿模型(drift/draft worker)跑 num_spec_tokens 步 draft_token_ids = self.draft_worker.execute_model( execute_model_req=execute_model_req, num_steps=self.num_spec_tokens, # γ ) # ② 把草稿 token 挂到请求上,交给目标模型验证 execute_model_req.spec_token_ids = draft_token_ids # ③ 目标模型一次前向,输出 γ+1 个位置的 logits target_output = self.target_worker.execute_model(execute_model_req) # ④ 拒绝采样:决定每个位置接受还是重采样 return self._verify_and_sample(draft_token_ids, target_output)

注意num_steps=self.num_spec_tokens,草稿模型是在一次调用里跑 γ 步的,vLLM 内部会循环执行并把中间 token 收集起来,避免 Python 层的多次调度开销。

5.2 核心算法:RejectionSampler

最精妙的部分在vllm/spec_decode/sample.py的RejectionSampler。它把拒绝采样向量化,一次处理整个 batch:

python

class RejectionSampler: def forward(self, draft_token_ids, draft_probs, target_logits, ...): # ① 目标模型 logits → 概率分布(注意:目标模型输出 γ+1 个位置) target_probs = self._softmax(target_logits) # 取前 γ 个位置(第 γ+1 个是"bonus token",见下文) target_probs = target_probs[:, :-1] # ② 计算接受概率 min(1, p/q),向量化 p, q = target_probs, draft_probs accept_prob = torch.minimum( torch.ones_like(p), p / q.clamp(min=1e-10), # 防止除零 ) # ③ 生成均匀随机数,逐位置判定(这是拒绝采样的掷骰子) uniform = self._get_uniform_samples(...) accepted = uniform < accept_prob # ④ 找第一个被拒绝的位置(cumprod 技巧:前面全接受才继续) accept_mask = torch.cumprod(accepted, dim=-1) # 一旦 False 后面全 False ... # ⑤ 在被拒绝的位置,从修正分布 (p - q)_+ 重采样 recovered_probs = self._recover_rejected(p, q, ...)

第 ④ 步的cumprod用得很漂亮:接受是"前缀"性质的(第一个拒绝之后全部作废),而cumprod天然表达"前面全为 True 才为 True",一行搞定前缀掩码。

第 ⑤ 步的修正分布是关键。被拒绝时不能简单丢弃,必须从(p-q)_+重采样,否则分布会有偏:

python

def _recover_rejected(self, p, q, ...): # 残差分布:p 比 q 多出来的概率质量 residual = (p - q).clamp(min=0) residual = residual / residual.sum(dim=-1, keepdim=True) # 归一化 return torch.multinomial(residual, num_samples=1) # 重采样

5.3 Bonus Token:白捡的一个

原始论文里有个容易被忽略但很实用的细节:目标模型验证时输出的是γ+1个位置(因为输入了 γ 个草稿,每个位置预测下一个,共 γ+1 个分布)。前 γ 个用于判定草稿,而第 γ+1 个位置的分布是"免费的",如果 γ 个草稿全部被接受,可以直接从这个分布再采样一个 token(bonus token),不花额外的大模型前向。

vLLM 里对应target_probs[:, -1]那份,在全部接受时启用。这让 γ=5 的投机解码最多能产出 6 个 token,进一步拉高加速比。

5.4 KV Cache 管理:草稿被拒后怎么回滚

投机解码对 KV Cache 管理提出了新要求:被拒绝的 token 对应的 KV 必须立即失效,否则会污染后续生成。vLLM 的做法是:

  • 草稿 token 的 KV 先写入,标记为"暂定";
  • 验证后,用num_accepted截断,把被拒位置之后的 KV 块释放回 block pool;
  • 修正 token(t3')的 KV 重新计算并写入。

这块在vllm/spec_decode/与 block manager 的交互里,实现上依赖 PagedAttention 的块式管理,这也解释了为什么投机解码在 vLLM 上落地最顺:它天然需要细粒度的 KV 增删,而 PagedAttention 的分块设计正好提供了这个能力。

六、实战:给 Qwen2.5-72B 挂上 0.5B 草稿模型

6.1 用 vLLM 离线推理开启

python

from vllm import LLM, SamplingParams llm = LLM( model="Qwen/Qwen2.5-72B-Instruct", speculative_model="Qwen/Qwen2.5-0.5B-Instruct", # 草稿模型 num_speculative_tokens=5, # γ speculative_draft_tensor_parallel_size=1, # 草稿模型不切分 tensor_parallel_size=4, # 目标模型 4 卡 gpu_memory_utilization=0.9, enforce_eager=False, ) outputs = llm.generate( ["用 Python 实现一个 LRU 缓存,要求 O(1) 时间复杂度。"], SamplingParams(temperature=0.8, max_tokens=512), ) print(outputs[0].outputs[0].text)

6.2 在线服务(OpenAI 兼容 API)

bash

python -m vllm.entrypoints.openai.api_server \ --model Qwen/Qwen2.5-72B-Instruct \ --speculative-model Qwen/Qwen2.5-0.5B-Instruct \ --num-speculative-tokens 5 \ --tensor-parallel-size 4 \ --served-model-name qwen72b

线上日志会打印实时接受率,这是调优的核心指标:

Processed prompts: 100%|████| 128/128 Speculative metrics: Draft acceptance rate: 0.68 ← 接受率 Accepted tokens: 3.42 per step ← 每步平均接受 Efficiency: 68.4% ← 3.42/5

6.3 实测数据(A100 80G ×4,Qwen2.5-72B)

配置单请求延迟 (t/s)128 并发吞吐加速比输出质量
原始解码22.4 tok/s1420 tok/s1.00×基准
投机解码 γ=341.8 tok/s2340 tok/s1.65×无损
投机解码 γ=550.2 tok/s3180 tok/s2.24×无损
投机解码 γ=848.6 tok/s3090 tok/s2.18×无损

可以看到 γ=8 反而略低于 γ=5,与前面的理论分析一致(草稿越长,连续接受概率越低,白白浪费小模型算力)。

特别提醒:低并发场景收益最大。上面 128 并发时加速 2.24×,而单请求场景(batch=1)实测加速可达2.8×,因为在 batch 已经很大、GPU 算力已经打满时,投机解码的空间本来就不大。

七、变种:Medusa / EAGLE / 自投机

标准投机解码需要一个"小而对齐"的草稿模型,但现实中合适的草稿模型未必存在(比如你用的是自研模型、或者领域特化模型)。于是出现了几个重要变种:

方案草稿来源是否需要额外模型典型加速优点缺点
标准 Speculative独立小模型是2~3×简单、无损需找/训草稿模型
Medusa目标模型上挂多个"预测头"否(微调 head)2~3×无需外部模型需微调,head 有训练成本
EAGLE目标模型中间层特征 → 轻量头否3~4×接受率极高(特征复用)实现复杂
自投机(Self-speculative)目标模型自己(跳过层/早退)否1.5~2×零额外模型加速有限
提示词查找(Prompt Lookup)从输入里匹配 n-gram否1.5~3×零成本,适合摘要/抽取只对"复述型"任务有效

几个选型建议:

  • 有现成小模型且同族(Qwen2.5-0.5B 对 72B、Llama-3.2-1B 对 70B)→ 直接用标准投机解码,性价比最高。
  • 自研/领域模型,找不到同族小模型→ 上EAGLE或Medusa,用目标模型自己的特征做草稿,接受率明显高于随便找个通用小模型。
  • 任务是摘要、信息抽取、代码补全(输出大量复述输入内容)→ 试试Prompt Lookup Decoding,vLLM 里一行参数开启,零模型成本,实测在抽取类任务上能有 2~3×。
  • 完全不想折腾→ 至少开启 vLLM 的连续批处理 + prefix caching,别一上来就上投机解码。

八、生产踩坑

坑 1:草稿模型与目标模型词表不一致。这是最常见的启动失败原因。两个模型的 tokenizer 必须完全相同(vocab_size一致且 token id 映射一致)。跨系列混用(比如用 Llama 小模型给 Qwen 做草稿)会直接报错或产生乱码。选型时优先同系列同版本。

坑 2:temperature 设太高,接受率暴跌。投机解码的接受率与采样温度强相关。temperature=0(贪心)时接受率最高(可到 0.85+),temperature=1.0 时可能掉到 0.4 以下。如果你的线上服务用高温采样,需要重新评估收益,必要时调小 γ。

坑 3:显存被草稿模型吃掉。草稿模型虽然小,但也要占显存、要存 KV Cache。在显存紧张的卡上(比如单卡 24G 跑 70B 量化版),加草稿模型可能导致目标模型的 KV Cache 空间被压缩,反而降低并发能力。建议:gpu_memory_utilization适当上调,或用--speculative-draft-tensor-parallel-size 1让草稿模型只放一张卡。

坑 4:批量很大时收益归零甚至变慢。当并发请求已经把 GPU 算力打满(比如 batch ≥ 256),再加投机解码只是白白增加小模型开销和调度复杂度,实测会变慢 5%~10%。所以理想做法是动态开关:监控当前 batch size,低于阈值时启用投机解码,高于阈值时关闭。vLLM 社区已有相关 PR 在推进这个自适应策略。

坑 5:接受率不达标却不自知。很多团队开了投机解码就不管了,实际上如果接受率只有 0.3,加速比会低于 1(反而变慢)。务必在监控里加上"草稿接受率"指标,低于 0.5 就该换草稿模型或者关掉。

坑 6:与结构化输出 / 约束解码冲突。如果你的服务用了 JSON mode、grammar-based 约束解码(如 outlines、XGrammar),草稿模型生成的 token 可能不符合约束,导致验证阶段大量拒绝。这类场景收益会明显下降,需要专门适配(让草稿模型也带上约束)。

九、结论

回到开头那个"GPU 利用率只有 30%"的困惑。

我最想留下的其实不是投机解码这个技巧本身,而是它背后的思路:先搞清楚你到底卡在哪。解码卡的是带宽,所以堆算力没用,真正有用的是让一次搬运多干点活,要么把 batch 加大,要么上投机解码。这个判断顺序搞反了,再怎么调参都是白费力气。

还有一点值得强调:无损优化永远排在前面。量化、蒸馏、裁剪每一样都会改模型行为,上线前得重新评估业务指标,很麻烦。投机解码不一样,它在数学上保证输出分布不变,属于可以放心直接换上去的那种优化。

γ 和 α 的权衡也比看上去敏感。我见过不少团队上手就把 γ 调到 10,结果接受率跟不上,反而更慢。正确的顺序是先想办法把 α 提上去,选同族小模型、把采样温度降一点、或者换 EAGLE 架构,这些都比堆 γ 有用得多。

我们线上把投机解码叠在连续批处理和 PagedAttention 之上,单请求交互场景的延迟从 22 tok/s 提到 50 tok/s。用户体感从"能忍"变成"流畅",而这个改动没有动任何一个模型权重,也没有损失输出质量。


往期回顾:

  • AI推理优化:AWQ 量化原理,激活感知量化如何保住 4
  • LangGraph vs AutoGen 深度对比:多 Agent 协作框架该选谁
  • AI 推理优化 | Triton Inference Server 架构解析:单机多模

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询