vLLM 动态投机解码实战:按运行期批大小自动调整 Draft Token 深度 K
投机解码(Speculative Decoding, SD)以固定的 draft token 数 K 换取解码加速,但 K 并非越大越好。vLLM 的动态投机解码(Dynamic Speculative Decoding, DSD)允许用户按"并发区间"配置 K 值:调度器在每个调度步骤根据当前实际 batch size 从预生成的查表数组中取出该步应使用的 K,从而在高并发时自动收缩甚至关闭投机,在低并发时放大投机收益。读完本文,你将掌握 num_speculative_tokens_per_batch_size 的完整语义与校验规则、从配置到每步 K 查取的源码链路,以及该功能在 CUDA Graph、数据并行等场景下的限制与自动回退行为。
一、为什么需要动态投机解码
SD 方法在解码阶段需要为每个序列验证 K 个 draft token。随着 batch size(BS)增大,验证阶段的有效批大小变为 BS × K,验证所需的计算量随之线性增长。当 BS × K 越过某个临界 BS 后,投机解码非但不再加速,反而会拖慢解码速度(TPOT 变差)。DSD 的作用就是把 K 动态调到一个随并发变化的最优值,让服务持续吃到投机解码的收益而不被其反噬。
(引自 dynamic_speculative_decoding.md 的 "Why is Dynamic SD needed?" 一节)
二、典型适用场景
文档给出了两类典型用例:
- 变并发工作负载:同一部署承载波动并发的流量。并发升高时 K 自动降低,并发回落时 K 恢复,无需重启或改配置;
- RL rollout 长尾阶段:RL 训练 rollout 初期 batch size 很高,但末期往往只剩少数长尾请求还在生成大量 token,此时 BS 已经变小。DSD 可以在 rollout 末期自动把 K 调大,减少长尾等待。
这与 Speculative Decoding 总览 中对 Dynamic Speculative Decoding 的定位一致:"Useful for RL or workload with fluctuating QPS"。
三、--speculative-config 中的 DSD 配置项
要启用动态 SD,只需在某个 SD 方法的 --speculative-config JSON 中额外提供 num_speculative_tokens_per_batch_size 字段(列表的列表)。每个条目是一个三元组 [start_bs, end_bs, optimal_K]:当运行期并发落在闭区间 [start_bs, end_bs] 内时,draft token 数使用 optimal_K。
文档给出的标准示例:
--speculative-config '{
"method": "eagle",
"model": "yuhuili/EAGLE-LLaMA3.1-Instruct-8B",
"num_speculative_tokens": 3,
"num_speculative_tokens_per_batch_size": [
[1, 64, 3],
[65, 128, 1],
[129, 512, 0]
]
}'
含义逐条拆解:
| 并发区间 | 使用的 K | 行为 |
|---|---|---|
| [1, 64] | K=3 | 全量投机 |
| [65, 128] | K=1 | 只投 1 个 draft token |
| [129, 512] | K=0 | 不产生任何 draft token(等价于关闭投机,但仍走验证路径) |
注意两点:
- 静态的
num_speculative_tokens仍然需要填写(示例中为 3),它既是各区间 K 的上限,也是后续 DP 回退时的静态值; - 该配置项在
SpeculativeConfig中定义为list[tuple[int, int, int]] | None,只要该字段非None,vLLM 就判定处于动态投机模式(见 speculative.py 与 uses_dynamic_speculative_decoding()):
# required configuration params passed from engine
num_speculative_tokens_per_batch_size: list[tuple[int, int, int]] | None = None
"""Batch-size schedule used to dynamically choose speculative-token count.
Each entry is ``(range_start, range_end, num_speculative_tokens)`` with an
inclusive batch-size range.
"""
四、调度表的校验与展开:从区间列表到稠密查表数组
DSD 的实现核心在 vllm/v1/spec_decode/dynamic/utils.py,由两个函数构成。
4.1 校验与归一化:validate_and_normalize_dynamic_sd_schedule
启动阶段会对用户传入的 schedule 做严格校验(utils.py):
- 字段必须是非空
list,每个条目必须是 3 元素序列; range_start、range_end必须为正整数,且start <= end;- K 值必须
>= 0(K=0 是合法语义:该区间不投机); - 各区间按
range_start排序后必须互不重叠; - 第一个区间的
range_start必须为 1——保证任意运行期批大小都有定义好的 K。
这些约束在文档中未逐条列出,但从源码可见:如果并发区间覆盖不完整(例如从 5 开始),启动会直接报错,而不是静默取默认值。
4.2 展开为稠密查表数组:build_dynamic_sd_schedule_lookup
校验通过后,schedule 会被展开成一张 1 起始索引的稠密数组 dense_schedule(utils.py),索引 batch_size 处直接存放该批大小应使用的 K,使调度器每步只需一次数组下标访问,而无需对区间列表做查找。展开逻辑有三个值得注意的细节:
- K 会被静态上限裁剪:每个位置实际写入的是
min(vllm_num_speculative_tokens, num_speculative_tokens),即区间内配置的 K 不可能超过num_speculative_tokens; - 区间空洞向前填充:若配置了
[(1, 16, 3), (32, 128, 2)],则 17–31 的空洞区会沿用上一个区间的 K=3 填充; - 末尾延伸到
vllm_max_batch_size:最后一个配置区间之后的所有批大小,沿用最后一个 K 值填充到系统最大批大小为止。因此文档示例中[129, 512, 0]之后的更高并发同样是 K=0。
vllm_max_batch_size 由调度器传入,取值为 scheduler_config.max_num_seqs(见 scheduler.py)。
五、调度器如何按步选 K
在 Scheduler 初始化时,若检测到 num_speculative_tokens_per_batch_size 配置,就会调用上述函数构建 self.dynamic_sd_lookup(scheduler.py):
self.dynamic_sd_lookup: list[int] | None = None
if speculative_config is not None:
if speculative_config.num_speculative_tokens_per_batch_size:
self.dynamic_sd_lookup = build_dynamic_sd_schedule_lookup(
speculative_config.num_speculative_tokens_per_batch_size,
vllm_max_batch_size=self.scheduler_config.max_num_seqs,
vllm_num_speculative_tokens=self.num_spec_tokens,
)
在每次调度步骤中,当本轮有请求被调度时,调度器用当前实际批大小作为索引取 K(scheduler.py):
if self.dynamic_sd_lookup is not None and len(num_scheduled_tokens) > 0:
num_spec_tokens_to_schedule = self.dynamic_sd_lookup[...]
也就是说,K 是随每个调度步骤的运行期批大小动态变化的:并发下降(比如 rollout 长尾只剩几个请求)后,同一批配置会立即切到更大的 K,无需任何人工干预。相应地,当 dynamic_sd_lookup 存在时,调度约束也切换为动态模式——源码中可见只有 self.num_spec_tokens > 0 and self.dynamic_sd_lookup is None(静态 K)或动态 K 两种模式二选一(scheduler.py)。
六、CUDA Graph 支持情况
DSD 下每步的 K 不同,意味着验证阶段的 token 数不再是固定值,这直接影响 CUDA Graph 的捕获方式。文档的 Limitations 一节明确:
- Full Cudagraph 仅在 Model Runner V2 下可用;
- MRv1 仅支持 piece-wise CUDA Graph。
在源码中可以看到两处对应实现:
- cudagraph_utils.py 在检测到
uses_dynamic_speculative_decoding()时,会基于同一份 schedule 构建稠密查表,用于按批大小确定各捕获批次对应的 K; - vllm/config/vllm.py 中的
_maybe_override_dynamic_sd_cudagraph_mode()会在动态 SD 启用时覆写 cudagraph 模式(对不满足 full graph 条件的场景降级为 piece-wise)。
这也是文档"Online Examples"里显式带上 VLLM_USE_V2_MODEL_RUNNER=0 的原因:示例运行在 Model Runner V1 上,此时动态 K 走的是 piece-wise CUDA Graph 路径。
七、与数据并行的不兼容及自动回退
DSD 不兼容数据并行(--data-parallel-size > 1)。原因:各 DP rank 独立调度,运行期批大小可能不同,从而选出不同的 K,这会引发 DP 集合通信(collective)分歧与死锁。
vLLM 对此采取了自动防御而非报错:在配置解析阶段,_maybe_disable_dynamic_sd_for_data_parallel()(vllm/config/vllm.py)检测到 data_parallel_size > 1 且启用了动态 SD 时,会打印日志
"Disabling num_speculative_tokens_per_batch_size and falling back ..."
并将 speculative_config.num_speculative_tokens_per_batch_size 置为 None,回退到静态 num_speculative_tokens 值。也就是说在 DP 部署中配置 DSD 不会崩溃,但动态特性会被静默关闭,建议留意启动日志确认是否真的生效。
八、完整上线示例
文档提供的两个可直接参考的 vllm serve 示例(完整继承原文档):
8.1 Dynamic SD + EAGLE drafter
VLLM_USE_V2_MODEL_RUNNER=0 vllm serve meta-llama/Llama-3.1-8B-Instruct \
--speculative-config '{
"method": "eagle",
"model": "yuhuili/EAGLE-LLaMA3.1-Instruct-8B",
"num_speculative_tokens": 3,
"num_speculative_tokens_per_batch_size": [
[1, 64, 3],
[65, 128, 1],
[129, 512, 0]
]
}'
8.2 Dynamic SD + EAGLE3 drafter
VLLM_USE_V2_MODEL_RUNNER=0 vllm serve meta-llama/Llama-3.1-8B-Instruct \
--speculative-config '{
"method": "eagle3",
"model": "yuhuili/EAGLE3-LLaMA3.1-Instruct-8B",
"num_speculative_tokens": 3,
"num_speculative_tokens_per_batch_size": [
[1, 16, 5],
[17, 32, 4],
[33, 64, 3],
[65, 128, 1],
[129, 512, 0]
]
}'
两个示例的梯度思路一致:低并发段给足 K,中段收缩,高并发段归零。EAGLE3 示例还展示了细粒度多档写法(5/4/3/1/0),说明 schedule 的档位数量没有硬性限制,只受第四节的校验规则约束。
九、限制与注意事项汇总
- 方法兼容性:官方仅验证过 Eagle、Eagle-3 和 DFlash;其他 SD 方法(如 draft_model、ngram)是否开箱可用不作保证;
- CUDA Graph:Full Cudagraph 仅 Model Runner V2 支持;MRv1 下仅 piece-wise CUDA Graph;
- 数据并行:
--data-parallel-size > 1时功能自动禁用并回退静态 K(见第七节),不会产生正确性问题但也不会产生动态收益; - 配置合法性:区间必须从 1 开始、互不重叠、K ≥ 0,且区间内 K 会被
num_speculative_tokens裁剪——配置时建议让静态值 ≥ 所有区间的 K 值,避免被静默截断。
十、相关资源
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00