1. 为什么投机采样值得单独拿出来讲
大模型推理优化这个话题,能聊的东西太多了。量化、KV Cache 管理、算子融合、连续批处理、PagedAttention,每一个方向都能写一整篇。但投机采样(Speculative Sampling)是个特别的存在——它不改变模型结构,不损失输出质量,却能在特定场景下把解码速度拉高两到三倍。我第一次在生产环境里跑通这套方案的时候,看着 tokens/s 从 35 跳到 90 多,说实话有点不太敢相信。
这个系列的第一篇聊的是量化,主要解决显存占用和计算密度的问题。但量化有个天花板:它优化的是单次前向传播的效率,而自回归解码的本质瓶颈在于——每生成一个 token 就要完整跑一遍模型。你量化做得再好,7B 模型生成 100 个 token 还是要跑 100 次前向。投机采样的思路完全不同:它想办法让这 100 次前向变成 30 次甚至更少。
适合谁来读这篇?如果你已经在本地或者服务器上部署过大模型,用过 vLLM、llama.cpp、TensorRT-LLM 这类推理框架,对 KV Cache、batch size、temperature 这些概念不陌生,那这篇的内容你可以直接拿去用。如果你还在选模型、配环境的阶段,建议先把基础推理跑通再回来看,不然有些细节会缺少体感。
我下面会从原理讲到实操,包括 EAGLE 和草稿树这两种主流方案的对比、参数怎么调、踩过哪些坑。代码层面以 Python 和 HuggingFace 生态为主,但思路是通用的,你用什么框架都能套。
2. 投机采样的核心思路拆解
2.1 自回归解码到底慢在哪
要理解投机采样为什么有效,得先看清楚标准解码流程的瓶颈。假设你有一个 7B 的模型,生成一个 token 的流程是:把当前所有 token 的 KV 送进模型,做一次完整的前向传播,得到下一个 token 的概率分布,采样出一个 token,追加到序列末尾,然后重复。
这个过程有两个特点。第一,计算是串行的——第 n+1 个 token 必须等第 n 个 token 生成完才能开始。第二,每次前向传播的计算量其实很小,因为 batch size 通常是 1(单用户场景),GPU 的并行计算能力远远没有被吃满。换句话说,GPU 大部分时间在等内存带宽,而不是在算。
这里有个常见的误解:很多人以为大模型推理慢是因为计算量大。实际上在 decode 阶段,瓶颈往往是显存带宽——每生成一个 token 都要把整个模型的权重从显存读一遍。7B 模型 FP16 精度下大约 14GB 权重,每次前向都要读一遍,这个带宽开销是固定的。
所以核心矛盾是:串行解码 + 低 GPU 利用率。投机采样就是冲着这个矛盾去的。
2.2 用一个"小模型"来加速"大模型"
投机采样的核心思想可以用一句话概括:用一个便宜的小模型先猜出接下来几个 token,然后让大模型一次性验证这些猜测对不对。
打个比方。你是一个资深翻译(大模型),每翻译一个词都要停下来想很久。现在给你配了一个实习生(小模型),实习生先把接下来五个词都翻出来,你只需要扫一眼,确认哪些是对的、哪个错了、错的应该改成什么。如果实习生猜对了四个,你一次前向就确认了四个 token,效率直接翻四倍。
这里的关键在于:验证过程只需要一次前向传播。你把原始序列加上实习生猜的 k 个 token 一起送进大模型,大模型会输出每个位置的概率分布。然后从第一个猜测位置开始比对,如果大模型采样出的 token 和实习生猜的一致,就接受;遇到第一个不一致的,就拒绝后面的所有猜测,用大模型自己的结果替换。
数学上可以证明,这种接受-拒绝机制产生的输出分布和直接用大模型逐个生成是完全一样的。这不是近似,是无损加速。这一点非常重要——意味着你不用担心投机采样会降低输出质量。
2.3 投机采样、EAGLE、草稿树的区别在哪
这三个概念经常被混在一起说,但它们的层次不太一样。
投机采样是框架性的思路,核心就是"猜-验证"这个范式。至于用什么来猜、怎么猜、猜多少,有不同的实现方案。
EAGLE 是一种具体的投机采样实现,它的特点是:不用一个独立的小模型来猜,而是在大模型本身的特征上训练一个轻量的 draft head。这个 head 复用大模型的隐藏层状态,预测下一个 token 的特征表示,再通过大模型的 LM head 输出 token。因为复用了大模型已经算出来的特征,EAGLE 的猜测质量比独立小模型高很多,接受率通常能到 0.7-0.8 甚至更高。
草稿树(Draft Tree)则是另一种思路。它不只猜一条序列,而是同时猜多条候选路径,形成一棵树。验证的时候,大模型可以并行验证树上的多个分支,一次前向能接受更多 token。代价是验证时的计算量会增加,因为要处理树结构上的注意力掩码。
| 方案 | 猜测来源 | 典型接受率 | 额外显存 | 实现复杂度 |
|---|---|---|---|---|
| 独立小模型 | 同系列小模型 | 0.4-0.6 | 小模型权重 | 低 |
| EAGLE | 大模型特征+轻量head | 0.7-0.85 | head权重+特征缓存 | 中 |
| 草稿树 | 多分支候选 | 0.6-0.8 | 树结构KV | 高 |
选哪个?我的经验是:如果你用的是 llama.cpp 或者 vLLM,它们已经内置了投机采样支持,直接用独立小模型方案最省事。如果你在追求极致性能,愿意花时间训练 draft head,EAGLE 的收益最大。草稿树目前在生产环境用得相对少,主要是实现复杂度高,调试起来比较痛苦。
3. 实操环境搭建与模型准备
3.1 硬件和基础环境
我这边的测试环境是一台单卡 A100 80G 的机器,CUDA 12.1,PyTorch 2.1.0。如果你用的是消费级显卡,比如 4090 24G,跑 7B 模型加一个 1B 左右的 draft 模型也是够的。显存估算大概是:主模型 FP16 占 14G,draft 模型 FP16 占 2G,KV Cache 看序列长度,2048 上下文大概 1-2G。总共 18G 左右,24G 卡能跑。
软件层面,我建议用 vLLM 或者 llama.cpp 作为推理后端。vLLM 对投机采样的支持比较成熟,配置也简单。llama.cpp 更适合本地部署场景,CPU+GPU 混合推理也能用。下面我以 vLLM 为主来讲,llama.cpp 的配置我会在后面的章节补充。
pip install vllm==0.4.2 pip install transformers==4.40.0 pip install torch==2.1.0版本这块要注意,vLLM 不同版本对投机采样的 API 有变化。0.4.x 之后接口基本稳定了,用speculative_model参数指定 draft 模型就行。
3.2 选一对合适的模型组合
这是最容易踩坑的地方。不是随便找个小模型就能当 draft 用的。几个原则:
同系列优先。比如主模型用 Qwen2.5-7B-Instruct,draft 就用 Qwen2.5-0.5B 或者 1.5B。同系列模型的 tokenizer 一致,词表分布接近,猜测的接受率会高很多。我试过用 Llama 的小模型给 Qwen 的大模型做 draft,接受率直接掉到 0.2 以下,基本没有加速效果。
词表必须一致。这个不用多解释,词表不一样的话,draft 模型猜出来的 token id 在主模型里对应的完全是另一个词,验证根本没法做。
draft 模型不能太小。0.5B 是个下限,再小的话猜测质量太差,接受率低到没有意义。1B-1.5B 是比较甜的点,猜测质量够用,推理开销也不大。
from vllm import LLM, SamplingParams llm = LLM( model="Qwen/Qwen2.5-7B-Instruct", speculative_model="Qwen/Qwen2.5-1.5B-Instruct", num_speculative_tokens=5, tensor_parallel_size=1, gpu_memory_utilization=0.9, )num_speculative_tokens这个参数控制每次猜多少个 token。设太小加速不明显,设太大验证时浪费的计算多。后面我会详细讲怎么调。
3.3 验证环境是否跑通
跑通之后先做个简单的基准测试,确认加速确实生效了。
import time prompts = ["请用中文解释什么是机器学习,要求通俗易懂。"] sampling_params = SamplingParams(temperature=0.7, max_tokens=256) # 预热 llm.generate(prompts, sampling_params) # 计时 start = time.time() outputs = llm.generate(prompts, sampling_params) elapsed = time.time() - start total_tokens = sum(len(o.outputs[0].token_ids) for o in outputs) print(f"生成 {total_tokens} tokens,耗时 {elapsed:.2f}s,速度 {total_tokens/elapsed:.1f} tokens/s")把speculative_model去掉再跑一遍,对比一下速度。正常情况下应该有 1.5-2.5 倍的提升。如果没有提升甚至更慢,大概率是 draft 模型选得不对,或者num_speculative_tokens设得太激进。
4. 关键参数调优与性能分析
4.1 num_speculative_tokens 怎么定
这个参数直接决定了每次猜测的 token 数量,也决定了验证时的计算量。设成 k,意味着每次前向要处理 k+1 个位置(原始位置加上 k 个猜测位置)。
理论上,如果接受率是 α,那么每次验证期望接受的 token 数是 (1-α^(k+1))/(1-α)。当 k 增大时,这个期望值会趋近于 1/(1-α),但验证成本也线性增长。所以存在一个最优的 k。
我实测下来,对于 EAGLE 方案,k=5 到 7 比较合适。对于独立小模型方案,k=3 到 5 就够了,因为接受率本身低一些,猜太多浪费。
# 不同 k 值的对比测试 for k in [1, 3, 5, 7, 10]: llm = LLM( model="Qwen/Qwen2.5-7B-Instruct", speculative_model="Qwen/Qwen2.5-1.5B-Instruct", num_speculative_tokens=k, ) # ... 跑基准测试我跑出来的一组数据(A100,Qwen2.5-7B + 1.5B draft,temperature=0.7):
| k 值 | 接受率 | 速度 (tokens/s) | 相对加速比 |
|---|---|---|---|
| 0(基线) | - | 38 | 1.0x |
| 1 | 0.72 | 52 | 1.37x |
| 3 | 0.68 | 78 | 2.05x |
| 5 | 0.65 | 91 | 2.39x |
| 7 | 0.61 | 88 | 2.32x |
| 10 | 0.55 | 79 | 2.08x |
可以看到 k=5 是个拐点,再往上加收益就下降了。当然这个数字跟具体模型、任务类型都有关系,你需要在自己的场景下测。
4.2 温度参数对接受率的影响
temperature 越高,采样越随机,draft 模型猜中的概率就越低。这个很好理解——如果每次都是从概率分布里随机抽,那猜中的概率自然低。
实测数据:temperature=0 时接受率能到 0.85 以上,temperature=0.7 时降到 0.65 左右,temperature=1.0 时只有 0.5 出头。
这意味着什么?如果你的应用场景是代码生成、结构化输出这种确定性强的任务,投机采样的加速效果会非常好。如果是创意写作、头脑风暴这种需要高 temperature 的场景,加速比会打折扣,但通常也还有 1.5 倍左右的提升。
有个小技巧:如果你的场景允许,把 temperature 设低一点(比如 0.3-0.5),既能保持一定的多样性,又能让投机采样发挥更好的效果。当然这要看具体需求,不能为了加速牺牲输出质量。
4.3 显存和吞吐的权衡
投机采样会额外占用显存,主要是 draft 模型的权重和它自己的 KV Cache。在显存紧张的情况下,你可能需要调低gpu_memory_utilization或者减小max_model_len。
另一个容易被忽略的点是:投机采样在 batch size 较大的时候收益会下降。因为大 batch 本身就能把 GPU 利用率拉高,decode 阶段的瓶颈从显存带宽转移到了计算。这时候投机采样带来的额外计算反而可能成为负担。
我的建议是:batch size 在 1-8 的时候用投机采样,收益最明显。如果 batch size 超过 16,可以考虑关掉投机采样,用连续批处理来提升吞吐。
5. 常见问题与排查实录
5.1 加速效果不明显甚至变慢
这是最常见的问题。排查思路按优先级来:
第一,检查 draft 模型和主模型的 tokenizer 是否一致。这个用AutoTokenizer加载两个模型,对比vocab_size和几个特殊 token 的 id 就能确认。
第二,看接受率。vLLM 的日志里会输出接受率相关的指标。如果接受率低于 0.3,基本可以判断是模型组合的问题。
第三,检查num_speculative_tokens是否设得太大。设成 10 以上,验证成本会吃掉大部分收益。
第四,确认你的场景是不是 batch size 太大。前面说过,大 batch 下投机采样收益会下降。
5.2 输出质量出现异常
理论上投机采样是无损的,输出分布和标准解码完全一致。但实际中如果实现有 bug,可能会出现重复、乱码、截断等问题。
我遇到过一次:draft 模型和主模型的 padding token id 不一致,导致验证时位置对齐出错,输出全是乱码。解决办法是显式指定pad_token_id,确保两个模型一致。
还有一种情况是 KV Cache 的索引对不上。这个在自定义实现的时候容易出,用 vLLM 这类成熟框架基本不会遇到。
5.3 显存溢出(OOM)
投机采样的显存开销比标准推理大,主要是多了一个模型。如果 OOM 了,可以按这个顺序调:
- 降低
gpu_memory_utilization,比如从 0.9 降到 0.85 - 减小
max_model_len,比如从 4096 降到 2048 - 换更小的 draft 模型
- 用 FP8 或者 INT8 量化 draft 模型
注意:draft 模型量化后猜测质量会下降,接受率可能降低 10-15 个百分点。需要权衡一下。
5.4 常见问题速查表
| 现象 | 可能原因 | 排查方法 | 解决方式 |
|---|---|---|---|
| 无加速效果 | draft 模型不匹配 | 对比 tokenizer | 换同系列模型 |
| 加速效果差 | k 值设置不当 | 测试不同 k 值 | 调到 3-5 |
| 输出乱码 | padding id 不一致 | 检查 pad_token_id | 显式指定一致的值 |
| OOM | 显存不足 | 监控显存占用 | 降 gpu_memory_utilization |
| 接受率低 | temperature 太高 | 查看日志接受率 | 适当降低 temperature |
| 大 batch 下变慢 | 计算瓶颈转移 | 对比不同 batch size | batch>16 时关闭投机采样 |
6. 进阶:EAGLE 方案的实际落地
6.1 EAGLE 和独立小模型的本质区别
前面说的独立小模型方案,draft 模型和大模型是独立的,它自己完整地跑一遍前向,输出 token 给大模型验证。EAGLE 不一样,它不跑完整的前向,而是复用大模型已经算出来的隐藏层特征,用一个很轻的 head 来预测下一个 token 的特征。
这个区别带来的好处是:draft 的开销极小,几乎可以忽略不计。同时因为复用了大模型的特征,猜测质量更高。代价是你需要额外训练这个 draft head,不能直接拿现成的模型用。
EAGLE 的论文里报告的数据是:在 Vicuna 7B 上,EAGLE 能达到 2.7-3.5 倍的加速,接受率 0.75 以上。我自己的实测数据略低一些,大概 2.2-2.8 倍,但确实比独立小模型方案好不少。
6.2 训练 draft head 的流程
如果你决定走 EAGLE 路线,训练流程大概是这样的:
第一步,准备数据。用你的目标场景数据,或者通用的指令跟随数据,让主模型生成一批输出,保存每步的隐藏层状态。数据量大概 10万-50万条就够。
第二步,定义 draft head 结构。EAGLE 的 head 通常是一个单层或者两层的 Transformer decoder,输入是主模型最后一层的隐藏状态,输出是预测的下一个 token 的特征。
第三步,训练。损失函数是预测特征和真实特征的 MSE,加上 token 预测的交叉熵。学习率用 1e-4 左右,训练 1-2 个 epoch。
第四步,集成到推理框架。vLLM 从 0.4.x 开始支持 EAGLE,需要把训练好的 head 权重转成对应的格式。
# EAGLE head 的简化结构示意 import torch.nn as nn class EagleHead(nn.Module): def __init__(self, hidden_size, vocab_size, num_layers=1): super().__init__() self.layers = nn.ModuleList([ nn.TransformerDecoderLayer(d_model=hidden_size, nhead=8, batch_first=True) for _ in range(num_layers) ]) self.lm_head = nn.Linear(hidden_size, vocab_size, bias=False) def forward(self, hidden_states): x = hidden_states for layer in self.layers: x = layer(x, x) return self.lm_head(x)实际训练的时候还有很多细节,比如特征归一化、位置编码的处理、多步预测的展开方式等等。这块展开讲能写一整篇,我这里先给个框架,后面有机会单独聊。
6.3 草稿树的实现思路
草稿树的核心是在 draft 阶段不只生成一条序列,而是生成一棵树。比如每个位置生成 2-3 个候选,形成一棵深度为 k 的树。验证的时候,大模型一次前向处理整棵树,用树形注意力掩码确保每个节点只能看到自己的祖先。
这样做的好处是,即使某条路径猜错了,其他分支可能猜对,整体接受率更高。代价是验证时的计算量增加,因为要处理更多的位置。
实现上,草稿树的难点在于注意力掩码的构造和 KV Cache 的管理。vLLM 目前对草稿树的支持还在实验阶段,生产环境用得不多。如果你对这个方向感兴趣,建议先看 Medusa 和 SpecInfer 这两篇论文,它们对树形验证的工程实现讲得比较清楚。
7. 我踩过的坑和几条实用建议
第一个坑是模型版本不匹配。我有一次用 Qwen2.5-7B 做主模型,draft 用了 Qwen2-1.5B,看着名字差不多,实际上 tokenizer 有细微差异,导致接受率极低。后来换成同版本的 Qwen2.5-1.5B 就正常了。所以选 draft 模型的时候,一定要确认是同一个系列、同一个版本。
第二个坑是忽略了 warmup。第一次推理的时候,draft 模型和主模型都要加载权重、编译 CUDA kernel,速度会慢很多。做基准测试的时候一定要先跑几轮预热,不然数据会很难看。
第三个坑是盲目追求高 k 值。我一开始觉得 k 越大越好,设成 10,结果速度反而比 k=5 慢。后来才想明白,验证成本是线性增长的,而接受率的提升是递减的,存在一个最优值。
几条实用建议:
- 先用独立小模型方案跑通,确认加速效果,再考虑上 EAGLE
- 接受率低于 0.5 的时候,优先检查模型匹配问题,而不是调参数
- 生产环境部署前,一定要在你的真实数据上测接受率和加速比,公开 benchmark 的数字只能参考
- 如果显存紧张,draft 模型可以量化,但主模型尽量保持 FP16,不然输出质量会受影响
- 投机采样和连续批处理可以叠加使用,但要注意 batch size 大了之后收益会下降
最后分享一个我常用的调试技巧:在 vLLM 启动的时候加上--disable-log-requests之外的日志参数,把接受率、平均接受长度这些指标打出来。观察这些指标的变化,比单纯看 tokens/s 更能定位问题。接受率突然下降,通常是输入数据的分布变了,或者 draft 模型遇到了不擅长的任务类型。
这个方向后续还可以往几个方向扩展:一是结合量化,把 draft 模型压到 INT4,进一步降低显存开销;二是针对特定领域微调 draft 模型,把接受率再往上推;三是探索自适应 k 值,根据当前上下文动态调整猜测长度。这些我还在折腾,有结果了再写一篇。