《vLLM 内核探秘》完整目录
- 前言
- 第1章 架构总览
- 第2章 EngineCore:引擎的心脏
- 第3章 调度器:Token 的交通指挥
- 第4章 PagedAttention:虚拟内存的启示
- 第5章 KV Cache 管理:寸土寸金的显存
- 第6章 Worker 与 Executor:GPU 军团
- 第7章 模型加载与权重管理
- 第8章 前向计算与 CUDA Graph
- 第9章 采样与输出处理
- 第10章 前缀缓存:零开销的加速
- 第11章 分块预填充与混合批处理
- 第12章 投机解码:以小博大(当前)
- 第13章 量化引擎:精度与速度的平衡
- 第14章 张量并行与流水线并行
- 第15章 多模态推理
- 第16章 LoRA 适配器热切换
- 第17章 API 服务器与生产部署
- 第18章 设计模式与架构哲学
第12章 投机解码:以小博大
"It is better to be approximately right than precisely wrong." -- Warren Buffett
:::tip 本章要点
- 理解自回归瓶颈:为什么解码阶段 GPU 利用率低
- 掌握投机解码的核心思想:猜测-验证范式
- 深入多种投机策略:Draft Model、EAGLE、n-gram
- 理解验证算法:如何保证投机解码的输出与标准解码数学等价
- 认识投机解码的适用场景与局限 :::
12.1 自回归的枷锁
LLM 的解码是**自回归(Autoregressive)**的------第 N 个 Token 的生成依赖于第 N-1 个 Token。这意味着:
- 每步只能生成 1 个 Token
- 每步都要执行一次完整的模型前向传播
- GPU 大部分时间在等待 KV Cache 数据从显存搬运到计算单元
在一张 A100 上,Llama-2-70B 的解码速度约为每秒 30 Token(单请求)。模型有 140 GB 的参数需要读取,即使 A100 有 2 TB/s 的显存带宽,也需要约 70ms 读一遍------这就是解码延迟的理论下限。
投机解码的核心洞察:既然每步都要读一遍参数,为什么不在一次读取中"顺便"验证多个候选 Token?
12.2 猜测-验证范式
投机解码分为两个阶段:
快速生成 k 个候选 Token"] end subgraph "阶段二:验证(Verify)" V["大模型
一次性验证 k 个 Token"] end D --> V V --> |"accept n ≤ k 个"| OUT["输出 n 个正确 Token
+ 1 个大模型采样 Token"] V --> |"reject (k-n) 个"| REJ["丢弃错误候选"] style D fill:#f59e0b,color:#fff,stroke:none style V fill:#3b82f6,color:#fff,stroke:none style OUT fill:#10b981,color:#fff,stroke:none style REJ fill:#ef4444,color:#fff,stroke:none
- 猜测阶段------用一个快速的方法生成 k 个候选 Token(如用 1B 参数的小模型,或 n-gram 统计)
- 验证阶段------将 k 个候选一次性送入大模型,计算每个位置的概率分布
- 接受/拒绝------从左到右逐个检查候选 Token:如果候选的概率足够高,接受;否则拒绝,并用大模型自己的采样结果替代
关键点:验证阶段只需要一次前向传播(处理 k 个 Token),而不是 k 次。因为模型可以并行计算所有位置的注意力------这正是预填充阶段的工作方式。
如果 k 个候选全部被接受,一步就生成了 k+1 个 Token(k 个接受 + 1 个大模型新采样)。即使只接受了 n 个(n < k),也比标准解码的 1 个 Token 多。
数学保证
投机解码不是"近似"------它可以保证输出分布与标准解码完全相同。这通过**拒绝采样(Rejection Sampling)**实现:
对于每个候选 Token x_i:
- 如果
p_big(x_i) >= p_draft(x_i)(大模型的概率 ≥ 小模型的概率),直接接受 - 否则,以概率
p_big(x_i) / p_draft(x_i)接受 - 拒绝时,从修正分布
max(0, p_big - p_draft)中重新采样
这个算法保证了最终的 Token 分布与直接使用大模型采样完全一致------投机解码是精确的,不是近似的。
直觉解释:想象小模型和大模型在同时生成文本。如果小模型在某个位置给出的概率分布与大模型"基本一致"(小模型认为可能性高的 Token,大模型也认为可能性高),那么小模型的预测就很可能被接受。只有当两者"意见分歧"时(小模型认为可能性高但大模型不认同),才需要拒绝并用大模型重新采样。
这也解释了为什么同系列模型作为 Draft(如用 Llama-7B 为 Llama-70B 做投机)效果好------它们在同一数据上训练,输出分布相似度高,接受率自然高。而如果用一个完全不同架构的模型做 Draft(如用 GPT-2 为 Llama 做投机),即使它很快,接受率也会很低------因为两个模型的"语言观"不一致。
贪心采样的特殊情况 :当 temperature=0(贪心解码)时,拒绝采样退化为简单的比较------如果 Draft 模型和 Target 模型选择了相同的 Token,直接接受;如果不同,拒绝并使用 Target 的选择。此时接受率就是"两个模型在当前上下文下选择相同 Token 的概率"。
12.3 猜测策略
vLLM 支持多种猜测策略:
Draft Model(草稿模型)
用一个小版本的同系列模型做猜测。例如用 Llama-2-7B 为 Llama-2-70B 做猜测。小模型与大模型共享词表和分词器,推理速度快很多。
优势:猜测质量高(同系列模型的输出分布相似),接受率通常在 60-80%。 劣势:需要加载两个模型,额外占用 GPU 显存。
EAGLE / EAGLE3
源码 :
vllm/v1/spec_decode/eagle.py
EAGLE 使用一个轻量级的"预测头"替代完整的 Draft 模型。这个预测头直接在大模型的隐藏状态上工作,不需要独立的模型前向传播。
python
# vllm/v1/spec_decode/eagle.py:22-39 (简化)
class EagleProposer:
def __init__(self, vllm_config, device):
self.num_speculative_tokens = (
vllm_config.speculative_config.num_speculative_tokens)
# ...
def propose(
self,
target_token_ids: torch.Tensor, # 大模型已生成的 token
target_hidden_states: torch.Tensor, # 大模型最后一层隐藏状态
next_token_ids: torch.Tensor, # 当前步大模型采样的 token
sampling_metadata: SamplingMetadata,
# ...
) -> torch.Tensor:
"""用大模型的隐藏状态预测接下来的 k 个 token"""
EAGLE 的核心洞察:大模型最后一层的隐藏状态已经包含了丰富的语义信息------用一个小的预测头(通常只有 1-2 层 Transformer)就能从中高效地预测后续 token。
优势:显存占用极小(预测头通常 < 500MB),猜测速度快(复用大模型的隐藏状态,不需要独立前向传播)。EAGLE3 进一步改进了预测头的架构。
劣势:需要额外训练预测头(针对特定的大模型)。
n-gram
源码 :
vllm/v1/spec_decode/ngram_proposer.py
最简单的策略------基于已生成文本的 n-gram 统计做猜测:
python
# vllm/v1/spec_decode/ngram_proposer.py:10-26
class NgramProposer:
def __init__(self, vllm_config):
self.min_n = vllm_config.speculative_config.prompt_lookup_min
self.max_n = vllm_config.speculative_config.prompt_lookup_max
self.k = vllm_config.speculative_config.num_speculative_tokens
# 预热 Numba JIT 编译(< 1秒)
self.propose(np.zeros(1024, dtype=np.int32))
def propose(self, context_token_ids: np.ndarray) -> Optional[np.ndarray]:
"""在上下文中查找 n-gram 匹配,返回匹配后的 k 个 token"""
# 例:context = [1,2,3,4,2,3], min_n=2
# 最后 2 个 token [2,3] 在位置 1-2 出现过
# 返回位置 2 之后的 token: [3,4,...]
n-gram 的实现使用了 Numba JIT 编译(@jit 装饰器),将 Python 循环编译为原生机器码,在 CPU 上高效执行 n-gram 查找。
优势 :零 GPU 开销(纯 CPU),不需要额外模型。 劣势:猜测质量依赖文本重复性------代码生成(大量重复模式)效果好,创意写作效果差。
使用方式:--speculative-model "[ngram]"。
三种策略对比
| 策略 | 接受率 | GPU 开销 | 显存占用 | 适用场景 |
|---|---|---|---|---|
| Draft Model | 60-80% | 中(小模型前向) | 大(需加载小模型) | 通用,高质量 |
| EAGLE | 50-70% | 低(预测头) | 小(< 500MB) | 有预训练头的模型 |
| n-gram | 30-60% | 零(纯CPU) | 零 | 代码生成、翻译 |
12.4 在 vLLM 中的实现
V1 的投机解码模块位于 vllm/v1/spec_decode/。核心流程:
- 调度器分配投机预算------除了正常的 Token 预算,还分配投机步数 k
- Draft 阶段------Worker 调用 Draft 模型/n-gram 生成 k 个候选
- Verify 阶段------Worker 将 k 个候选送入大模型验证
- 接受/拒绝------根据拒绝采样算法确定接受多少个 Token
- 更新状态------接受的 Token 追加到请求上下文,更新 KV Cache
投机解码在 V1 初始 alpha 版中未被支持(因为 V1 的有状态 Worker 增加了投机解码的状态管理复杂度),在后续版本中逐步添加。
12.5 加速比分析
投机解码的理论加速比取决于两个因素:
接受率(α):候选 Token 被大模型接受的概率。接受率越高,每步有效生成的 Token 数越多。
猜测开销比(γ):Draft 阶段的计算时间 / Verify 阶段的计算时间。理想情况下 γ ≈ 0(Draft 几乎不花时间),此时加速比 ≈ 1/(1-α)。
scss
理论加速比 = (1 + α + α² + ... + α^k) / (1 + γ × k)
≈ 1/(1-α) / (1 + γ × k) (当 k 较大时)
实际数字:
- α = 0.7, k = 5, γ = 0.1:加速比 ≈ 2.1×
- α = 0.8, k = 5, γ = 0.1:加速比 ≈ 2.8×
- α = 0.5, k = 5, γ = 0.3:加速比 ≈ 1.2×(几乎没有收益)
这说明投机解码需要高接受率 + 低 Draft 开销才能有效。n-gram 方法的 γ ≈ 0(纯 CPU 查表),但 α 通常只有 0.3-0.5(除非文本高度重复)。Draft Model 的 α 可以到 0.7-0.8,但 γ 不为零(小模型也要 GPU 计算)。
12.6 何时使用投机解码
投机解码不是银弹,它的收益取决于:
- 接受率------猜测的准确度。接受率越高,加速越明显
- Draft 成本------猜测的计算开销。如果 Draft 模型太大,猜测本身就很慢
- batch size------大批次下,验证阶段的额外 Token 可能挤占其他请求的预算
经验法则:
- 单请求/低并发 → 投机解码收益大(GPU 利用率低,有空间做投机)
- 高并发 → 收益减小(GPU 已经满负荷,投机的额外计算反而成为负担)
- 文本重复性高的任务(如代码生成、翻译)→ n-gram 策略效果好
- 通用对话 → Draft Model 或 EAGLE 更稳定
12.7 本章小结
- 自回归瓶颈------解码每步只生成 1 个 Token,GPU 利用率低
- 猜测-验证范式------小模型快速猜测 k 个候选,大模型一次验证
- 数学等价------拒绝采样保证输出分布与标准解码完全相同
- 多种策略------Draft Model(高质量)、EAGLE(低开销)、n-gram(零成本)
- 适用场景------低并发、高重复性任务收益最大
源码导航
- V1 投机解码:
vllm/v1/spec_decode/- 投机解码文档:docs.vllm.ai/en/latest/f...