一、解码慢,不是因为算力不够
先做个思想实验。你用 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% │ └────────────────────────────────────────────────────────┘所以优化解码速度有两条路:
- 提高 batch(连续批处理、动态批处理),已经被 vLLM / TensorRT-LLM 做到了极致,但当请求量不够时,batch 上不去。
- 让一次搬运产出多个 token,这就是投机解码要做的事。
当你只有零星几条请求、batch 提不上去时(典型场景:交互式对话、代码补全、端侧单用户),投机解码是唯一能继续榨出性能的手段。
二、核心洞察:小模型猜,大模型验
投机解码的想法朴素到有点反直觉:
与其让大模型一个一个"想"token,不如让小模型先快速"猜"出 γ 个 token,再让大模型一次性并行验证这 γ 个。猜对的部分直接采纳,猜错的位置之后全部丢弃重来。
关键在于验证是并行的。大模型验证 γ 个草稿 token,只需要一次前向传播(forward pass),因为验证时 γ 个 token 都已知,可以组成一个batch=γ的矩阵-矩阵乘法,完美命中算力瓶颈而非带宽瓶颈。这一次前向的耗时,和单独生成 1 个 token 几乎一样(搬运量相同,只是计算量从 1 份变成 γ 份,而计算本来就不是瓶颈)。
于是神奇的事情发生了:
| 操作 | 传统解码 | 投机解码(γ=5,接受 3 个) |
|---|---|---|
| 生成 3 个 token | 3 次前向 | 小模型 5 次(很快)+ 大模型 1 次验证 |
| 主要耗时 | 3 次大模型权重搬运 | 1 次大模型权重搬运 |
| 输出分布 | 原始分布 | 与原始分布完全一致 |
最后一行是投机解码最被低估的优点:它在数学上是无损的。量化会损失精度、蒸馏会改变分布、裁剪会掉点,但投机解码通过一套精妙的拒绝采样(rejection sampling)机制,保证最终采样出的 token严格服从原模型的输出分布。这意味着你可以放心用它替换线上服务,不用担心"输出质量下降"。
三、数学原理:接受率与期望加速比
3.1 问题形式化
设目标模型(大模型)分布为p(x_t | x_{1:t-1}),草稿模型(小模型)分布为q(x_t | x_{1:t-1})。
标准投机解码流程(γ 个草稿 token):
- 草稿阶段:用小模型
q自回归采样 γ 个 token:x̃_1, ..., x̃_γ,同时记录每个 token 的概率q(x̃_i | ·)。 - 验证阶段:把原序列 + γ 个草稿拼起来,送进大模型做一次前向,得到 γ+1 个位置的目标分布
p(x̃_i | ·)。 - 逐个判定:对每个位置
i,以概率min(1, p(x̃_i)/q(x̃_i))接受x̃_i;一旦拒绝,立即停止。 - 修正采样:在拒绝位置,从修正分布
(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 |
|---|---|---|---|---|
| 3 | 1.41× | 1.55× | 1.67× | 1.78× |
| 5 | 1.56× | 1.76× | 1.96× | 2.15× |
| 8 | 1.55× | 1.83× | 2.15× | 2.52× |
| 10 | 1.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/56.3 实测数据(A100 80G ×4,Qwen2.5-72B)
| 配置 | 单请求延迟 (t/s) | 128 并发吞吐 | 加速比 | 输出质量 |
|---|---|---|---|---|
| 原始解码 | 22.4 tok/s | 1420 tok/s | 1.00× | 基准 |
| 投机解码 γ=3 | 41.8 tok/s | 2340 tok/s | 1.65× | 无损 |
| 投机解码 γ=5 | 50.2 tok/s | 3180 tok/s | 2.24× | 无损 |
| 投机解码 γ=8 | 48.6 tok/s | 3090 tok/s | 2.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 架构解析:单机多模