5.5 vLLM 投机解码实战:配置、接受率与性能一起测
以 vLLM v0.31.0 为配置基准,依次启动普通 Decode、N-gram 与独立 Draft,采集代码/对话的接受指标、质量输出与延迟吞吐,并核对 Suffix/EAGLE 接入条件
前四节把“先猜后验”拆成了采样规则、候选来源、辅助网络和性能账本。现在把它们接回服务:同一个 Target、同一张卡、同一批请求,打开投机之后到底改变了什么? 只有接受率而没有普通 Decode 基线,或者只有 Token/s 而没有任务输出,都不够回答这个问题。
本节提供一套可复测流程:先跑基线,再跑 N-gram 和独立 Draft;把代码编辑与开放对话分开,记录接受情况、输出内容和性能。命令按 vLLM v0.31.0 的文档与源码编写,GPU 推理需在你的环境中执行,本文不填入未经实测的成绩。
📑 目录
- 1. 固定环境与对照变量
- 2. 准备代码与对话两组输入
- 3. 依次启动基线、N-gram 与 Draft 服务
- 4. Suffix 与 EAGLE:核对产物再配置
- 5. 接受率采集:逐请求看见真实分母
- 6. 性能对比:相同工作量与并发
- 7. 质量与结果解释
- 8. 排错与下一轮调优
- 总结
- 自我检验清单
- 参考资料
1. 固定环境与对照变量
1.1 先确认 GPU 与软件条件
使用 vLLM 支持的 Linux/WSL2 GPU 环境,按 GPU 安装文档确认驱动、CUDA 与 Wheel 兼容性。基线要装得下 Target 权重、KV、工作空间和 CUDA Graph,Draft 组还需要额外资源。
在隔离环境中安装并记录版本:
python -m venv .venv
source .venv/bin/activate
python -m pip install 'vllm==0.31.0'
python -c "import torch, vllm; print('torch:', torch.__version__); print('vllm:', vllm.__version__); print('cuda:', torch.version.cuda)"
nvidia-smi
python -m pip freeze > environment.txt
vllm serve --help > serve-help.txt
vllm bench serve --help > bench-help.txt
这些是准备步骤,不保证任意 GPU 都支持所有 Kernel。若使用其他 vLLM 版本,按该版本文档核对方法和字段;早期 V1 的独立 Draft 支持状态与当前不同,不能跨版本照搬。
1.2 三组实验只改变提议方法
Target 采用 Qwen/Qwen3-8B,独立 Draft 采用 Qwen/Qwen3-0.6B,与该版本 官方 Draft 示例一致。正式实验记录模型 revision 和 Tokenizer revision,下载本地固定快照会更方便复测。
| 📊 固定项 | 本例设置 | 为什么 |
|---|---|---|
| Target / dtype | 同一 Target,BF16(检查点原始精度) | 不混入权重量化变量,也不额外引入一次类型转换 |
| GPU / TP | 同一张卡,TP=1 | 不把卡数变化算作投机收益 |
| KV dtype | auto | 保持高精度缓存,不叠加 FP8 KV |
| 上下文与调度 | 同样上限与 Token 预算 | 保持服务条件可比 |
| Prefix Cache | 关闭 | 避免重复请求命中 KV 影响对照 |
| 采样与模板 | 显式指定,三组一致 | 避免默认值改变工作量与接受情况 |
Qwen3 默认可以进入 Thinking 模式,本例统一用 enable_thinking=False。这是控制实验变量,不代表某种模式在业务上更好;切回 Thinking 时应重新评测输出长度和接受情况。
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})
Path(f"data/{task}.jsonl").write_text(
"\n".join(json.dumps(row, ensure_ascii=False) for row in rows) + "\n",
encoding="utf-8",
)
print("已生成 code/chat,各 64 条;用于流程演示,非正式业务评测集。")
python prepare_workloads.py
mkdir -p results
sha256sum data/*.jsonl > results/input-sha256.txt
2.2 这两组数据能说明什么
这只是机制演示。代码组刻意包含可复用文本,不能用它代表所有代码生成;对话组也不代表全部语言、领域和温度配置。正式报告应换成足够规模的业务样本,保持三个服务版本读同一份文件。
输入长度不同,所以首先在同一个 task 内比较基线与投机。代码接受率比对话高时,也不能仅凭两组总耗时比较加速,应同时检查输入/输出 Token 数和对应基线。
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[@]}"
等模型加载、编译和图捕获完成,在终端 B 检查:
curl http://localhost:8000/health
curl http://localhost:8000/v1/models
先完成第 5、6 节的基线采集,再在终端 A 用 Ctrl+C 停止服务,确认进程退出、显存释放。三组顺序运行,不要让它们同时抢同一 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 个。它没有额外 Draft 权重,但有查找与验证开销。没命中时可以回到普通生成,不能把配置长度当成每轮实际提出长度。
3.3 第三组:独立 Draft
同样停止 N-gram 服务、确认资源释放后启动:
vllm serve "$TARGET_MODEL" "${COMMON[@]}" \
--speculative-config '{"method":"draft_model","model":"Qwen/Qwen3-0.6B","num_speculative_tokens":4}'
把 Tokenizer、Target/Draft 最大长度、额外显存和实际 Kernel 写进记录。--speculative-config 是 JSON;temperature、top_p 是客户端采样参数,不应塞进该对象。
📌 关键点:同一服务名 spec-demo 保证客户端请求一致。启动成功只说明配置通过一部分检查,仍需验证真实输出和性能。
4. Suffix 与 EAGLE:核对产物再配置
4.1 Suffix 的额外依赖
按所用版本的说明安装 Arctic Inference,安装时仍约束 vLLM 版本,避免依赖解析悄悄改变对照环境:
python -m pip install 'vllm==0.31.0' arctic-inference
python -m pip check
python -m pip freeze > environment-suffix.txt
vllm serve "$TARGET_MODEL" "${COMMON[@]}" \
--speculative-config '{"method":"suffix","num_speculative_tokens":16,"suffix_decoding_max_cached_requests":0}'
这里把跨请求全局历史缓存设为 0,使演示先关注当前请求历史。16 是候选长度上限;实际长度动态变化。若包版本不兼容,应选择满足该版本说明的组合,并重新跑普通 Decode 基线。
4.2 EAGLE-3 需要匹配的辅助 checkpoint
下面路径是明确的占位符,需要替换为已经检查来源的 Target 与对应 EAGLE-3 模型;不能拿任意模型目录直接执行:
EAGLE_TARGET=/path/to/target-snapshot
EAGLE3_MODEL=/path/to/matching-eagle3-checkpoint
vllm serve "$EAGLE_TARGET" "${COMMON[@]}" \
--speculative-config "{\"method\":\"eagle3\",\"model\":\"${EAGLE3_MODEL}\",\"num_speculative_tokens\":4,\"draft_tensor_parallel_size\":1}"
原 EAGLE 路径使用 method="eagle" 与其对应产物。不能只改方法字符串就让旧头成为 EAGLE-3,也不能把论文的动态树直接视为所有 vLLM 后端的执行结构。
若换了 Target,还要换客户端 Tokenizer,并为这个 Target 重做普通 Decode 基线。EAGLE 对照不能混入前面的 Qwen 基线成绩。
5. 接受率采集:逐请求看见真实分母
5.1 为什么用逐请求指标
v0.31.0 的 --per-request-spec-decode-metrics summary 让单序列请求在响应的 metrics.speculative_decoding 中携带接受统计。它是实验性接口,这里固定版本就是为了让字段可核对。
把下面保存为 collect_acceptance.py。它调用非流式 Chat API,记录两类任务的输出与指标;普通 Decode 的投机字段为 null 是正常情况。
import argparse
import json
import time
from pathlib import Path
from urllib.request import Request, urlopen
parser = argparse.ArgumentParser()
parser.add_argument("label", choices=["baseline", "ngram", "draft", "suffix", "eagle3"])
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 保持不变。只输出完整 Python 代码,不要围栏。\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},
}
request = Request(
"http://localhost:8000/v1/chat/completions",
data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"},
)
start = time.perf_counter()
with urlopen(request, timeout=180) as response:
result = json.load(response)
elapsed = time.perf_counter() - start
choice = result["choices"][0]
spec = (result.get("metrics") or {}).get("speculative_decoding")
if args.label != "baseline" and spec is None:
raise RuntimeError("缺少接受指标:核对 vLLM 版本、服务启动参数和 n=1")
rows.append({
**case,
"output": choice["message"]["content"],
"finish_reason": choice["finish_reason"],
"usage": result.get("usage"),
"request_seconds": elapsed,
"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 = [row["speculative"] for row in rows
if row["task"] == task and row["speculative"] is not None]
accepted = sum(item["num_accepted_draft_tokens"] for item in stats)
drafted = sum(item["num_draft_tokens"] for item in stats)
steps = sum(item["num_spec_steps"] for item in stats)
rate = accepted / drafted if drafted else None
length = 1 + accepted / steps if steps else None
print(task, {"accepted": accepted, "drafted": drafted,
"steps": steps, "draft_acceptance_rate": rate,
"mean_acceptance_length": length})
5.2 三个服务分别运行同一脚本
# 每条命令在对应服务运行期间执行。
python collect_acceptance.py baseline
python collect_acceptance.py ngram
python collect_acceptance.py draft
这四题用于验证采集与输出流程。正式比较要替换成更大规模、长度可比的独立样本,并先预热;脚本的非流式总耗时不能当作 TTFT 或 TPOT。
统计应先加总接受 Token、提出 Token 与轮数,再计算比值,不能直接平均各请求的百分比。drafted=0 时输出 None,表示没有分母,不应伪装成“0% 接受率”。
平均接受长度采用该版本的 口径,含修正/额外 Token;结束条件、未投机步骤等因素会使它不等于整个请求的“输出 Token 数 / 全部调度步数”。
5.3 与服务汇总指标交叉核对
可以保存 /metrics,查看 vllm:spec_decode_num_drafts_total、vllm:spec_decode_num_draft_tokens_total 和 vllm:spec_decode_num_accepted_tokens_total。使用时间窗口内的增量或 rate,不要把进程启动以来的累计数当成本次实验。
混合业务的服务汇总没有自动区分 code/chat。应使用逐请求统计分组,或在隔离实验窗口中分别压测;预热请求也会计入汇总,需要单独处理。流式请求的接受指标在最终 usage chunk 中,需开启 usage 报告。
6. 性能对比:相同工作量与并发
6.1 在终端 B 运行 Benchmark
当前服务跑哪一组,就设置对应 LABEL。运行下面的 Bash 循环,保存任务与并发组合的结果:
LABEL=baseline # 切换服务后改为 ngram 或 draft
mkdir -p results
for TASK in code chat; do
for CONCURRENCY in 1 8; do
vllm bench serve \
--backend openai \
--base-url http://localhost: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 的 Chat Template,随后把格式化 Prompt 发给 Completions API;本例显式关闭 Thinking。质量采集则让 Chat API 在服务端应用同一模板设置,二者用途不同,但都必须保持配置一致。
6.2 固定长度用于性能,不用于质量结论
--ignore-eos 让服务继续生成到固定输出长度,避免两组自然结束长度不同。这是控制工作量的设置,可能让输出超出正常答案;不能拿这批文本评价业务质量。
这里的 64 请求是可运行的起点,P95/P99 在小样本下并不稳定。正式实验增加样本与重复次数,保留原始 JSON,再测试有限到达速率的生产流量。
6.3 按任务、并发和基线配对
| 📊 记录项 | baseline | ngram / draft |
|---|---|---|
| GPU、Target revision、软件与采样 | 固定配置 | 同样配置及 Proposer 配置 |
| 实际输入/输出 Token、成功请求数 | Benchmark JSON | Benchmark JSON |
| Output Throughput、P95 TTFT/TPOT/ITL | 实测值 | 实测值 |
| KV Token 容量、额外权重、抢占 | 日志记录 | 日志记录 |
| 接受率、平均长度、无候选情况 | 不适用 | 分任务统计 |
| 正常生成质量、结束原因 | 逐题输出 | 逐题输出 |
输出吞吐和 TPOT 可能受益,TTFT 则主要还受排队与 Prefill 影响。流式 chunk 可能包含多个 Token,ITL 不能简单替换成单 Token 时间。
7. 质量与结果解释
7.1 先排除明显配置错误
比较 *-acceptance.json:代码能否保持未修改函数,编辑是否正确,对话是否正常结束。若 finish_reason="length",先判断输出预算是否不足;不能把截断答案直接归因于投机采样。
贪心模式下,输出差异是排查信号,但仍需考虑数值计算、模板和 Batch 形状。随机模式下,相同 Seed 不保证逐字相同,应比较任务指标与分布,而不是只检查字符串相等。
7.2 再进入独立任务评测
正式评测应独立于 Draft 训练或校准样本,覆盖代码执行正确性、数学、结构化输出、长文、多轮和长生成。测试中恢复正常 EOS/Stop 行为,明确是否使用精确验收、量化 Target 或经过联合训练的模型。
如果只量化 Draft,可以保持同一 Target 作基准;量化 Target 或换 EAGLE 对应的 Target 后,应重新建立基线,避免把几种变化混在一起。
7.3 四种常见结果怎么读
- 代码接受率更高,但加速不明显:看验证长度、查找/Draft 开销和原基线是否已经很快。
- N-gram 高接受率,却大量请求没候选:报告覆盖与无候选情况,不能只呈现命中片段。
- 低并发更快,高并发吞吐变差:可能在高负载下争算力或减少 KV 容量,应按服务门槛选择。
- 平均 TPOT 更好,输出停顿更长:检查流式 chunk 与每轮验证时间,交互体验不只由平均 Token/s 决定。
不要先填一组预期加速倍数,再挑能符合预期的 Prompt。保留无收益和失败配置,才知道方案的适用范围。
8. 排错与下一轮调优
| 🔍 现象 | 优先检查 |
|---|---|
| 启动提示未知字段或方法 | 版本、JSON 格式、speculative_config 字段 |
| Draft Token 或模型不兼容 | 词表映射、架构、上下文、Target/Draft revision |
| EAGLE 加载后异常 | 辅助头来源、Target 版本、特征与 dtype 约定 |
| Suffix 依赖错误 | Arctic Inference 与 vLLM 版本组合 |
| 没有逐请求接受指标 | summary 开关、n=1、非流式响应或最终 usage chunk |
| 接受率低 | 任务匹配、查找窗口、采样、草稿长度 |
| 接受长度高但速度慢 | Draft/验证成本、节点数、KV 容量、抢占 |
| 结果难以复现 | 输入文件哈希、模型快照、模板、预热与其他 GPU 任务 |
下一轮先改变一个因素:N-gram 比较窗口与长度,独立 Draft 比较模型与长度,辅助头方案比较兼容产物和验证预算。每次变化都保留同样的基线与任务集。
✅ 可采纳的结论:在明确版本、模型、任务、负载和质量门槛下,某个配置达到服务目标。
❌ 不能外推的结论:某次复制代码任务很快,就宣称所有代码、对话、高并发或量化组合都能得到同样加速。
📝 总结
- 先固定 GPU、Target、版本、模板与采样,再依次比较普通 Decode 和投机。
--speculative-config集中表达方法、模型与候选长度,客户端采样参数单独配置。- N-gram 无额外权重;Draft 有模型与缓存成本;Suffix/EAGLE 还要核对依赖或适配产物。
- code/chat 分组要保留相同输入与对应基线,不能用任务名称替代性能解释。
- 逐请求指标先加总计数再算接受率,零分母与缺字段要明确处理。
- 固定输出 Benchmark 控制工作量,正常结束的独立任务测试判断质量。
- 吞吐、TTFT、TPOT、流式停顿、KV 容量与接受长度共同解释结果。
- 本节提供配置与复测流程,实际速度和质量结论需来自真实 GPU 实验。
🎯 自我检验清单
- 能按固定版本创建实验环境并保存模型与输入来源
- 能依次启动基线、N-gram 和独立 Draft,避免共用 GPU 干扰
- 能区分查找窗口、候选长度与客户端温度
- 能解释 Qwen3 Thinking 设置为什么需要保持一致
- 能说明 Suffix 依赖和 EAGLE 辅助模型不能随意替换
- 能从响应中提取接受 Token、提出 Token 和验收轮数
- 能解释零候选、汇总窗口与预热请求对接受率的影响
- 能用相同任务与并发比较吞吐和 P95 延迟
- 能区分固定输出性能测试与正常结束的质量评测
- 能根据接受长度、轮耗时与容量定位无收益原因