5.5 vLLM 投机解码实战
以 vLLM v0.32.0 为基准,依次启动普通 Decode、N-gram、独立 Draft 与 EAGLE-3,采集代码/对话两组任务的逐请求接受指标、输出内容与延迟吞吐,并核对 Suffix、MTP 的接入条件
前四节把”先猜后验”拆成了采样规则、候选来源、辅助网络和性能账本。现在把它们接回推理服务:同一个 Target、同一张卡、同一批请求,打开投机之后到底变了什么? 只有接受率没有普通 Decode 的基线,或者只有 Token/s 不看输出内容,都回答不了这个问题。
本节给出一套可复测的流程:先跑基线,再依次跑 N-gram、独立 Draft 和 EAGLE-3;代码编辑和开放对话分开统计,同时记录接受情况、输出内容和性能。命令按 vLLM v0.32.0 的文档与源码编写,GPU 实验需要你在自己的环境中执行,文中不填写未经实测的成绩。
⏰ 信息时效:基于 vLLM v0.32.0(2026-10-18 发布)。逐请求接受指标是实验性接口,字段可能随版本变化,务必固定版本。
📑 目录
- 1. 固定环境与对照变量
- 2. 准备代码与对话两组输入
- 3. 依次启动基线、N-gram 与 Draft 服务
- 4. EAGLE-3、Suffix 与 MTP:核对产物再配置
- 5. 接受率采集:逐请求看见真实分母
- 6. 性能对比:相同工作量与并发
- 7. 质量与结果解释
- 8. 排错与下一轮调优
- 总结
- 自我检验清单
- 参考资料
1. 固定环境与对照变量
1.1 安装并记录版本
使用 vLLM 支持的 Linux GPU 环境,先按 GPU 安装文档确认驱动、CUDA 与 Wheel 的兼容性。显存要装得下 Target 权重、KV Cache、工作空间和 CUDA Graph;Draft 组还需要额外空间。
在隔离环境中安装,并把版本信息存档:
python -m venv .venv
source .venv/bin/activate
python -m pip install 'vllm[bench]==0.32.0' # bench 子命令需要额外依赖
python -c "import torch, vllm; print('torch', torch.__version__, 'cuda', torch.version.cuda, 'vllm', vllm.__version__)"
nvidia-smi
mkdir -p results
python -m pip freeze > results/environment.txt
vllm serve --help > results/serve-help.txt
vllm bench serve --help > results/bench-help.txt
换用其他版本时,先对照该版本的文档核对方法名和字段。投机解码的配置项在近几个版本中变动频繁,例如模型专用的 MTP 方法名已统一为 mtp,旧的 --speculative-model 参数也已废弃,命令行改用 --speculative-config(简写 -sc)。
1.2 只改变提议方法
Target 统一用 Qwen/Qwen3-8B。这样选是因为它有三类现成的搭档:同家族的 Qwen/Qwen3-0.6B 可作独立 Draft(vLLM 文档的官方示例),RedHatAI/Qwen3-8B-speculator.eagle3 是为它训练的 EAGLE-3 权重,N-gram 则不需要额外权重。四组实验共用同一个基线。
| 📊 固定项 | 本例设置 | 原因 |
|---|---|---|
| Target / dtype | Qwen3-8B,BF16(检查点原始精度) | 不混入量化变量 |
| GPU / TP | 同一张卡,TP=1 | 不把卡数变化算成投机收益 |
| KV dtype | auto | 不叠加 FP8 KV |
| 上下文与调度 | 相同的长度上限与 Token 预算 | 服务条件可比 |
| Prefix Cache | 关闭 | 避免重复请求命中 KV,干扰对照 |
| 采样与模板 | 显式指定,各组一致 | 避免默认值悄悄改变工作量 |
Qwen3 默认会进入 Thinking 模式,本例统一用 enable_thinking=False。这是为了控制变量,也和该 EAGLE-3 权重的训练条件一致(模型卡注明训练时关闭了 Thinking)。若业务要开 Thinking,输出长度和接受情况都会变,需要重新测。
正式实验应记录每个模型的 revision,最好下载到本地固定快照,方便复测。
2. 准备代码与对话两组输入
2.1 生成可检查的 JSONL
把下面的代码保存为 prepare_workloads.py,运行后得到两组各 64 条输入。代码组让模型只改一个函数、原样保留其余部分;对话组要求解释不同主题,几乎没有可复制的原文。
import json
from pathlib import Path
Path("data").mkdir(exist_ok=True)
source = "\n\n".join(
f"def scale_{i}(value):\n return value * {i + 1}" for i in range(12)
)
topics = [
"解释推理服务为什么要同时关注吞吐与延迟",
"说明缓存命中率很高、用户却仍在等待的可能原因",
"讨论团队怎样选择一个便于维护的部署方案",
"解释为什么平均指标会掩盖少量严重的失败",
]
for task in ["code", "chat"]:
rows = []
for i in range(64):
if task == "code":
prompt = (
f"只把 scale_{i % 12} 的乘数改成 {100 + i},其余函数保持不变。"
"输出完整的 Python 文件,不要解释,也不要代码围栏。\n" + source
)
else:
prompt = f"案例 {i}:请用两段完整的文字{topics[i % len(topics)]}。"
rows.append({"prompt": prompt, "output_tokens": 128})
with open(f"data/{task}.jsonl", "w", encoding="utf-8") as f:
f.writelines(json.dumps(r, ensure_ascii=False) + "\n" for r in rows)
print("已生成 data/code.jsonl 与 data/chat.jsonl,各 64 条(流程演示用)")
python prepare_workloads.py
sha256sum data/*.jsonl > results/input-sha256.txt
2.2 这两组数据能说明什么
这是机制演示。代码组刻意放大了可复制的文本,不能代表所有代码生成;对话组也不能代表所有语言、领域和温度。正式评测应换成规模足够的真实业务样本,并保证所有服务版本读的是同一份文件。
两组输入长度不同,所以比较一律在同一个 task 内进行:code 的投机结果对比 code 的基线,chat 对比 chat。
3. 依次启动基线、N-gram 与 Draft 服务
3.1 在终端 A 定义公共参数
下面是 Bash 示例。公共参数放进一个数组,后面每组只追加投机配置:
TARGET_MODEL=Qwen/Qwen3-8B
COMMON=(
--served-model-name spec-demo
--host 127.0.0.1
--port 8000
--dtype bfloat16
--tensor-parallel-size 1
--kv-cache-dtype auto
--max-model-len 4096
--max-num-seqs 16
--max-num-batched-tokens 4096
--gpu-memory-utilization 0.85
--no-enable-prefix-caching
--generation-config vllm
--seed 42
--per-request-spec-decode-metrics summary
)
# 第一组:普通 Decode,不传 --speculative-config
vllm serve "$TARGET_MODEL" "${COMMON[@]}"
几个参数的作用:--generation-config vllm 让服务忽略模型仓库自带的默认采样参数,所有采样设置都由客户端显式给出;--per-request-spec-decode-metrics summary 让每个响应带上本请求的接受统计(第 5 节)。
等模型加载、编译和 CUDA Graph 捕获完成后,在终端 B 检查:
curl http://127.0.0.1:8000/health
curl http://127.0.0.1:8000/v1/models
完成第 5、6 节的基线采集后,在终端 A 按 Ctrl+C 停止服务,用 nvidia-smi 确认显存已释放。各组顺序运行,不要让多个服务同时抢同一张 GPU。
3.2 第二组:N-gram
vllm serve "$TARGET_MODEL" "${COMMON[@]}" \
--speculative-config '{"method":"ngram","num_speculative_tokens":4,"prompt_lookup_min":2,"prompt_lookup_max":4}'
查找窗口 2–4 个 Token,每轮最多提议 4 个。不显式设置窗口时,vLLM 默认两端都是 5。查不到匹配的步骤不投机,所以要关注候选覆盖(第 5.2 节第 7 小节)。
3.3 第三组:独立 Draft
停掉上一个服务、确认显存释放后启动:
vllm serve "$TARGET_MODEL" "${COMMON[@]}" \
--speculative-config '{"method":"draft_model","model":"Qwen/Qwen3-0.6B","num_speculative_tokens":4}'
官方示例用的是 5 个候选,这里取 4 是为了和 N-gram 组对齐。启动日志里会打印 Draft 的配置与 KV Cache 容量,把它和基线组的容量一起记下来,就能看到 Draft 挤占了多少空间。
--speculative-config 只负责投机本身的配置。temperature、top_p 等采样参数属于客户端请求,不要放进这个 JSON。
📌 关键点:各组都用同一个服务名 spec-demo,客户端脚本不用改。服务能启动只说明配置通过了检查,输出和性能仍要逐项验证。
4. EAGLE-3、Suffix 与 MTP:核对产物再配置
4.1 第四组:EAGLE-3
EAGLE-3 权重必须和 Target 配套。RedHatAI/Qwen3-8B-speculator.eagle3 的模型卡注明其 verifier 是 Qwen/Qwen3-8B,因此可以直接接在本例的 Target 上:
vllm serve "$TARGET_MODEL" "${COMMON[@]}" \
--speculative-config '{"method":"eagle3","model":"RedHatAI/Qwen3-8B-speculator.eagle3","num_speculative_tokens":3}'
候选数 3 取自模型卡的 vLLM 示例,卡中的接受长度表也显示超过 3–4 个后增益明显变小。模型卡还说明该权重训练时关闭了 Thinking,应通过 /v1/chat/completions 使用,第 5 节的采集脚本正好满足这两点。
换成其他 Target 时,要换成对应的 EAGLE-3 权重,并为新 Target 重新跑一遍普通 Decode 基线。method="eagle" 对应的是第一代 EAGLE 权重,两代产物不能通过改方法名互换。
4.2 Suffix Decoding
Suffix 依赖 Arctic Inference。安装时仍约束 vLLM 版本,避免依赖解析悄悄换掉对照环境:
python -m pip install 'vllm==0.32.0' arctic-inference
python -m pip check
python -m pip freeze > results/environment-suffix.txt
vllm serve "$TARGET_MODEL" "${COMMON[@]}" \
--speculative-config '{"method":"suffix","num_speculative_tokens":16,"suffix_decoding_max_cached_requests":0}'
suffix_decoding_max_cached_requests: 0 关闭跨请求的全局树,只保留当前请求的历史,演示时更容易解释结果。16 是每轮候选数的上限,实际长度动态变化。如果依赖版本冲突,按 Arctic Inference 的说明选择兼容组合,并重新跑基线。
4.3 MTP:只适用于自带 MTP 的模型
Qwen3-8B 没有 MTP 模块,所以 MTP 不在本例的对照组里。对于自带 MTP 的模型,配置最简单:
vllm serve XiaomiMiMo/MiMo-7B-Base \
--speculative-config '{"method":"mtp","num_speculative_tokens":1}'
官方建议从 1 个候选开始调。这类实验要以该模型自己的普通 Decode 作为基线。
⚠️ 注意:rejection_sample_method: "synthetic" 会按预设的接受率放行候选,用来在没有好 Draft 时压测系统上限。它不保分布,只能用于性能测试,不能出现在线上配置里。
5. 接受率采集:逐请求看见真实分母
5.1 响应里的接受统计
开启 --per-request-spec-decode-metrics summary 后,n=1 的请求会在响应的 metrics.speculative_decoding 中带上本请求的统计。主要字段:
| 📊 字段 | 含义 |
|---|---|
num_spec_steps | 验收轮数,即第 5.2 节的 |
num_draft_tokens | 提出的候选总数 ,已扣除被结构化输出约束作废的候选 |
num_accepted_draft_tokens | 接受的候选总数 ,不含额外 Token |
acceptance_histogram | 长度为 num_speculative_tokens + 1,第 项是恰好接受 个候选的轮数 |
mean_acceptance_length |
设成 detailed 还会附上每轮的 per_step_accepted 和 per_step_drafted,适合观察 Suffix 这类动态长度方法。投机开启但请求一个候选都没提出时,字段照样存在,直方图全为 0;基线服务没有投机,这个字段为 null。
5.2 采集脚本
把下面的代码保存为 collect_acceptance.py。它用非流式 Chat API 跑四道题,保存每题的输出和统计,再按 task 汇总:
import argparse
import json
import time
from pathlib import Path
from urllib.request import Request, urlopen
parser = argparse.ArgumentParser()
parser.add_argument("label", help="如 baseline / ngram / draft / eagle3 / suffix")
args = parser.parse_args()
code = "def add(a, b):\n return a + b\n\ndef multiply(a, b):\n return a * b\n"
cases = [
{"id": "edit", "task": "code",
"prompt": "把 add 改名为 subtract 并改成减法,multiply 保持不变。只输出完整代码,不要围栏。\n" + code},
{"id": "copy", "task": "code", "prompt": "原样输出以下代码,不要解释或围栏:\n" + code},
{"id": "explain", "task": "chat", "prompt": "用两段话解释推理服务中吞吐与单请求延迟的区别。"},
{"id": "tradeoff", "task": "chat", "prompt": "团队要在低成本和稳定延迟之间做取舍,请给出一套有理有据的讨论方法。"},
]
rows = []
for case in cases:
payload = {
"model": "spec-demo",
"messages": [{"role": "user", "content": case["prompt"]}],
"temperature": 0, "top_p": 1, "seed": 42, "n": 1,
"max_tokens": 256, "stream": False,
"chat_template_kwargs": {"enable_thinking": False},
}
req = Request("http://127.0.0.1:8000/v1/chat/completions",
data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json"})
start = time.perf_counter()
with urlopen(req, timeout=180) as resp:
result = json.load(resp)
choice = result["choices"][0]
spec = (result.get("metrics") or {}).get("speculative_decoding")
if args.label != "baseline" and spec is None:
raise RuntimeError("缺少接受指标:检查版本、启动参数与 n=1")
rows.append({**case, "output": choice["message"]["content"],
"finish_reason": choice["finish_reason"], "usage": result.get("usage"),
"request_seconds": time.perf_counter() - start, "speculative": spec})
Path("results").mkdir(exist_ok=True)
Path(f"results/{args.label}-acceptance.json").write_text(
json.dumps(rows, ensure_ascii=False, indent=2), encoding="utf-8")
for task in ["code", "chat"]:
stats = [r["speculative"] for r in rows if r["task"] == task and r["speculative"]]
C = sum(s["num_accepted_draft_tokens"] for s in stats)
D = sum(s["num_draft_tokens"] for s in stats)
R = sum(s["num_spec_steps"] for s in stats)
hist = [sum(col) for col in zip(*(s["acceptance_histogram"] for s in stats))]
print(task, {"C": C, "D": D, "R": R,
"acceptance_rate": C / D if D else None,
"mean_acceptance_length": 1 + C / R if R else None,
"histogram": hist})
5.3 每组服务各跑一次
# 在对应服务运行期间执行
python collect_acceptance.py baseline
python collect_acceptance.py ngram
python collect_acceptance.py draft
python collect_acceptance.py eagle3
四道题只用来验证采集流程。正式比较要换成规模更大、长度可比的样本,并先做预热。脚本记录的非流式总耗时包含 Prefill 和排队,不能当作 TTFT 或 TPOT。
汇总时先把 、、 分别加总,再算比值,不要对每个请求的百分比取平均。 时输出 None,表示没有分母,和”接受率 0%“是两回事。直方图的第 0 项(一个都没接受的轮数)往往最能说明 chat 组为什么收益有限。
5.4 与服务端汇总指标交叉核对
/metrics 中的 vllm:spec_decode_num_drafts_total、vllm:spec_decode_num_draft_tokens_total、vllm:spec_decode_num_accepted_tokens_total 分别对应逐请求字段 、、 的累加。vllm:spec_decode_num_accepted_tokens_per_pos_total 带 position 标签,第 位的计数是”至少接受了 个候选”的轮数,除以 num_drafts_total 就是逐位置的累计接受率,可以直接看出它随位置衰减的速度。
这些是进程启动以来的累计计数,使用时取实验前后两次快照的差值。服务端汇总不区分 code/chat,预热请求和 n>1 的请求也会计入。流式请求的逐请求指标在最后一个 usage chunk 里,需要设置 stream_options.include_usage: true 或用 --enable-force-include-usage 启动服务。
6. 性能对比:相同工作量与并发
6.1 在终端 B 运行 Benchmark
当前服务是哪一组,就把 LABEL 设成对应的名字,再运行下面的循环,为每个”任务 × 并发”组合保存结果:
LABEL=baseline # 切换服务后改为 ngram / draft / eagle3
for TASK in code chat; do
for CONCURRENCY in 1 8; do
vllm bench serve \
--backend openai \
--base-url http://127.0.0.1:8000 \
--endpoint /v1/completions \
--model spec-demo \
--tokenizer Qwen/Qwen3-8B \
--dataset-name custom \
--dataset-path "data/${TASK}.jsonl" \
--chat-template-kwargs '{"enable_thinking":false}' \
--custom-output-len 128 \
--num-prompts 64 \
--num-warmups 4 \
--max-concurrency "$CONCURRENCY" \
--request-rate inf \
--temperature 0 \
--top-p 1 \
--ignore-eos \
--seed 42 \
--percentile-metrics ttft,tpot,itl \
--metric-percentiles 50,95,99 \
--save-result \
--result-dir results \
--result-filename "${LABEL}-${TASK}-c${CONCURRENCY}.json"
done
done
custom 数据集会在客户端用 Target 的 Tokenizer 套上 Chat Template(这里显式关闭 Thinking),再把格式化好的文本发给 Completions API。第 5 节的采集脚本则走 Chat API,由服务端套模板。两条路径用途不同,但模板设置必须一致。
6.2 固定长度只用于性能
--ignore-eos 让每个请求都生成满 128 个 Token,避免各组因为自然结束长度不同而工作量不同。代价是输出可能超出正常答案的范围,所以这批文本不能拿来评价质量。
64 个请求是能跑通的起点,P95/P99 在这么小的样本上并不稳定。正式实验要增加样本和重复次数,保留原始 JSON,并补测有限到达速率(--request-rate 设为具体数值)下的表现。
6.3 按任务、并发与基线配对记录
| 📊 记录项 | baseline | 各投机组 |
|---|---|---|
| GPU、模型 revision、软件版本与采样参数 | 固定 | 相同,另记投机配置 |
| 实际输入/输出 Token 数、成功请求数 | Benchmark JSON | Benchmark JSON |
| Output Throughput、P95 TTFT/TPOT/ITL | 实测 | 实测 |
| KV Cache 容量、额外权重、抢占次数 | 启动与运行日志 | 启动与运行日志 |
| 、、接受直方图 | 不适用 | 分 task 统计 |
| 正常生成的输出与结束原因 | 逐题保存 | 逐题保存 |
投机主要改善 TPOT 和输出吞吐;TTFT 主要取决于排队和 Prefill。一个流式 chunk 可能包含多个 Token,ITL 的分布会和基线组形状不同,第 5.4 节第 7 小节解释了原因。
7. 质量与结果解释
7.1 先排除明显的配置错误
对比各组的 *-acceptance.json:代码题是否保留了未修改的函数、修改是否正确,对话题是否正常结束。finish_reason 为 "length" 时,先检查输出预算是否不够,再考虑其他原因。
本例用的是贪心解码,各组输出理应一致。出现差异就要排查:先查模板和采样参数是否一致,再考虑 Batch 形状、Kernel 带来的数值差异(第 5.1 节第 8 小节)。随机采样时,相同 Seed 也不保证逐字相同,应比较任务指标的分布。
7.2 再做独立的任务评测
正式的质量评测要与 Draft 或 EAGLE 权重的训练数据分开,覆盖代码执行正确性、数学、结构化输出、长文本、多轮对话等场景,并恢复正常的 EOS 和 Stop 行为。报告里写明验收方式(标准规则还是其他),以及 Target 是否量化。
只量化 Draft 时,可以沿用原 Target 的基线;量化 Target 或换用其他 Target 后,要重新建立基线,避免把几种变化混在一起。
7.3 四种常见结果怎么读
- code 组接受率高,加速却不明显:看验证长度、Draft 耗时,以及基线本身是否已经很快。
- N-gram 接受率高,但很多步没候选:看直方图和 ,把候选覆盖一起报告。
- 并发 1 变快,并发 8 吞吐下降:高负载下投机在和其他请求抢算力,或者 KV 容量变小,可以考虑 Dynamic SD(第 5.4 节第 3 小节)。
- 平均 TPOT 更好,流式停顿更长:检查 ITL 的 P95/P99,交互体验不只由平均 Token/s 决定。
先定好要比较的配置和样本,再看结果。无收益和失败的配置也要保留,它们标出了方案的适用边界。
8. 排错与下一轮调优
8.1 常见现象与排查顺序
| 🔍 现象 | 先查什么 | 再查什么 |
|---|---|---|
| 服务启动失败 | --help 存档里是否有该参数,方法名是否写对 | 辅助权重与 Target 是否配套、显存是否够 |
响应里没有 speculative_decoding | 是否带了 --per-request-spec-decode-metrics,请求是否 n=1 | 流式请求是否打开 include_usage |
| 接受率接近 0 | 词表、Chat Template、Thinking 开关是否与权重训练条件一致 | Target 是否量化;量化后接受率骤降时先确认版本,v0.32.0 就修复过 DeepSeek-V4 系列 NVFP4 检查点接受率为 0 的问题 |
| 接受率很高却没提速 | Draft 耗时、验证长度、基线 TPOT | 是否误用了 synthetic 等压测模式 |
| 并发上去后吞吐下降 | KV 容量、抢占次数 | 候选数是否过大,考虑按并发分段 |
| 各组贪心输出不一致 | 模板、采样参数、--generation-config | Batch 形状与 Kernel 的数值差异 |
一次只改一个变量。如果同时换了候选数、模型和并发,结果变好变坏都说不清原因。
8.2 下一轮调什么
- 候选数:在固定并发下扫 1、2、3、4…,画出 TPOT 和平均接受长度的曲线,找到收益开始回落的位置(第 5.4 节第 3 小节)。
- 按并发分段:如果低并发和高并发的最优长度不同,用
num_speculative_tokens_per_batch_size分段设置,高负载段可以设为 0。 - N-gram 窗口:同时看接受率和候选覆盖,窗口放宽后覆盖上升、接受率下降是正常的,要看两者共同作用下的 TPOT。
- 换方法:复制多的业务先比较 N-gram 与 Suffix;新内容多的业务比较独立 Draft 与 EAGLE-3;模型自带 MTP 时优先试 MTP。
✅ 可以上线的信号:目标负载下 TPOT 与吞吐都优于基线,质量评测通过,容量损失可以接受。
❌ 应该放弃的信号:只在远低于目标负载的并发下有收益,或者接受率高但端到端时间没有改善。
📝 总结
- 所有投机组共用一个普通 Decode 基线,只改变
--speculative-config,各服务顺序运行。 - 代码与对话分组统计,比较只在同一个 task 内进行。
- N-gram 和 Suffix 不需要额外权重;独立 Draft 要同家族、同词表;EAGLE-3 权重必须与 Target 配套;MTP 只适用于自带模块的模型。
--per-request-spec-decode-metrics让每个响应带上 、、 和接受直方图;汇总时先加总再算比值。- 性能测试用
--ignore-eos固定工作量,质量判断则用正常结束的输出,两批数据分开。 - 结果要和 KV 容量、抢占、P95/P99 一起解读,无收益的配置也要记录。
🎯 自我检验清单
- 能列出对照实验中必须固定的变量,并说明各自的理由
- 能写出 N-gram、独立 Draft、EAGLE-3、Suffix 和 MTP 的
--speculative-config - 能说明为什么 EAGLE-3 权重不能换一个 Target 直接用
- 能开启逐请求接受指标,并从响应中读出 、、 和直方图
- 能按 task 汇总接受率与平均接受长度,并区分 和接受率 0%
- 能用
/metrics的前后快照差值交叉核对逐请求统计 - 能用
vllm bench serve在相同工作量和并发下对比基线与投机组 - 能解释
--ignore-eos为什么只适合性能测试 - 能根据”接受率高却不提速""高并发吞吐下降”等现象定位原因
📚 参考资料
- vLLM v0.32.0 Release Notes
- vLLM — GPU 安装
- vLLM — Speculative Decoding
- vLLM — N-Gram Speculation
- vLLM — Draft Models
- vLLM — EAGLE Draft Models
- vLLM — Suffix Decoding
- vLLM — Multi-Token Prediction (MTP)
- vLLM — Per-Request Acceptance Metrics
- vLLM — Dynamic Speculative Decoding
- vLLM —
vllm bench serveCLI - vLLM v0.32.0 — Benchmark 数据集实现(CustomDataset)
- Hugging Face — RedHatAI/Qwen3-8B-speculator.eagle3
- Hugging Face — Qwen/Qwen3-8B
- Hugging Face — Qwen/Qwen3-0.6B
- Snowflake — Arctic Inference