一、解码慢不是因为算力不够先做个思想实验。你用 vLLM 部署一个 7B 模型单条请求生成一句话打开nvidia-smi看 GPU 利用率大概率会看到一个令人困惑的数字30%~50%甚至更低。但同一张卡跑批量推理比如同时处理 256 条请求利用率能轻松飙到 90%吞吐更是高出几十倍。同样是矩阵乘法为什么单条这么慢答案藏在**计算强度arithmetic intensity**里。解码阶段每生成一个 token要做的事本质上是一次batch1的矩阵-向量乘法把几十亿个权重参数从 HBM显存搬到计算单元做完乘法再搬回去。搬运量正比于模型大小计算量却极小。于是一个残酷的事实摆在面前解码阶段是显存带宽受限memory-bandwidth bound而不是算力受限compute bound。┌────────────────────────────────────────────────────────┐ │ 单条解码 (batch1) │ │ ┌──────────────┐ │ │ │ 权重 14GB │ ──搬运── 计算单元 ── 1 个 token │ │ │ (HBM) │ 带宽瓶颈 │ │ └──────────────┘ 耗时 ~ 14GB / 带宽 │ │ 算力利用率: ~5% ← 搬运 14GB 只为算一次矩阵-向量乘 │ ├────────────────────────────────────────────────────────┤ │ 批量解码 (batch256) │ │ ┌──────────────┐ │ │ │ 权重 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 个 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草稿阶段用小模型q自回归采样 γ 个 tokenx̃_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.831.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 个 token1 次大模型前向关键点即使只接受了 3 个本轮也产出了 3 个 token而只花了 1 次大模型前向 5 次小模型前向。传统解码产出 3 个 token 需要 3 次大模型前向。这就是加速的来源。五、源码走读vLLM 怎么实现投机解码vLLM 把投机解码抽象成几个核心组件位于vllm/spec_decode/。我们走读最关键的三块。5.1 调度层SpecDecodeWorker协调双模型vllm/spec_decode/spec_decode_worker.py负责把草稿模型的提议和目标模型的验证串起来。核心循环简化pythonclass 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_reqexecute_model_req, num_stepsself.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_stepsself.num_spec_tokens草稿模型是在一次调用里跑 γ 步的vLLM 内部会循环执行并把中间 token 收集起来避免 Python 层的多次调度开销。5.2 核心算法RejectionSampler最精妙的部分在vllm/spec_decode/sample.py的RejectionSampler。它把拒绝采样向量化一次处理整个 batchpythonclass 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(min1e-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)_重采样否则分布会有偏pythondef _recover_rejected(self, p, q, ...): # 残差分布p 比 q 多出来的概率质量 residual (p - q).clamp(min0) residual residual / residual.sum(dim-1, keepdimTrue) # 归一化 return torch.multinomial(residual, num_samples1) # 重采样5.3 Bonus Token白捡的一个原始论文里有个容易被忽略但很实用的细节目标模型验证时输出的是γ1个位置因为输入了 γ 个草稿每个位置预测下一个共 γ1 个分布。前 γ 个用于判定草稿而第 γ1 个位置的分布是免费的如果 γ 个草稿全部被接受可以直接从这个分布再采样一个 tokenbonus token不花额外的大模型前向。vLLM 里对应target_probs[:, -1]那份在全部接受时启用。这让 γ5 的投机解码最多能产出 6 个 token进一步拉高加速比。5.4 KV Cache 管理草稿被拒后怎么回滚投机解码对 KV Cache 管理提出了新要求被拒绝的 token 对应的 KV 必须立即失效否则会污染后续生成。vLLM 的做法是草稿 token 的 KV 先写入标记为暂定验证后用num_accepted截断把被拒位置之后的 KV 块释放回 block pool修正 tokent3的 KV 重新计算并写入。这块在vllm/spec_decode/与 block manager 的交互里实现上依赖 PagedAttention 的块式管理这也解释了为什么投机解码在 vLLM 上落地最顺它天然需要细粒度的 KV 增删而 PagedAttention 的分块设计正好提供了这个能力。六、实战给 Qwen2.5-72B 挂上 0.5B 草稿模型6.1 用 vLLM 离线推理开启pythonfrom vllm import LLM, SamplingParams llm LLM( modelQwen/Qwen2.5-72B-Instruct, speculative_modelQwen/Qwen2.5-0.5B-Instruct, # 草稿模型 num_speculative_tokens5, # γ speculative_draft_tensor_parallel_size1, # 草稿模型不切分 tensor_parallel_size4, # 目标模型 4 卡 gpu_memory_utilization0.9, enforce_eagerFalse, ) outputs llm.generate( [用 Python 实现一个 LRU 缓存要求 O(1) 时间复杂度。], SamplingParams(temperature0.8, max_tokens512), ) print(outputs[0].outputs[0].text)6.2 在线服务OpenAI 兼容 APIbashpython -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 ×4Qwen2.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×而单请求场景batch1实测加速可达2.8×因为在 batch 已经很大、GPU 算力已经打满时投机解码的空间本来就不大。七、变种Medusa / EAGLE / 自投机标准投机解码需要一个小而对齐的草稿模型但现实中合适的草稿模型未必存在比如你用的是自研模型、或者领域特化模型。于是出现了几个重要变种方案草稿来源是否需要额外模型典型加速优点缺点标准 Speculative独立小模型是2~3×简单、无损需找/训草稿模型Medusa目标模型上挂多个预测头否微调 head2~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 DecodingvLLM 里一行参数开启零模型成本实测在抽取类任务上能有 2~3×。完全不想折腾→ 至少开启 vLLM 的连续批处理 prefix caching别一上来就上投机解码。八、生产踩坑坑 1草稿模型与目标模型词表不一致。这是最常见的启动失败原因。两个模型的 tokenizer 必须完全相同vocab_size一致且 token id 映射一致。跨系列混用比如用 Llama 小模型给 Qwen 做草稿会直接报错或产生乱码。选型时优先同系列同版本。坑 2temperature 设太高接受率暴跌。投机解码的接受率与采样温度强相关。temperature0贪心时接受率最高可到 0.85temperature1.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 量化原理激活感知量化如何保住 4LangGraph vs AutoGen 深度对比多 Agent 协作框架该选谁AI 推理优化 | Triton Inference Server 架构解析单机多模