跳到主要内容
推理优化 · 更新于 2026年11月1日

5.5 vLLM 投机解码实战

以 vLLM v0.32.0 为基准,依次启动普通 Decode、N-gram、独立 Draft 与 EAGLE-3,采集代码/对话两组任务的逐请求接受指标、输出内容与延迟吞吐,并核对 Suffix、MTP 的接入条件

vLLM Speculative Decoding N-gram EAGLE-3 Benchmark Acceptance Rate

前四节把”先猜后验”拆成了采样规则、候选来源、辅助网络和性能账本。现在把它们接回推理服务:同一个 Target、同一张卡、同一批请求,打开投机之后到底变了什么? 只有接受率没有普通 Decode 的基线,或者只有 Token/s 不看输出内容,都回答不了这个问题。

本节给出一套可复测的流程:先跑基线,再依次跑 N-gram、独立 Draft 和 EAGLE-3;代码编辑和开放对话分开统计,同时记录接受情况、输出内容和性能。命令按 vLLM v0.32.0 的文档与源码编写,GPU 实验需要你在自己的环境中执行,文中不填写未经实测的成绩。

⏰ 信息时效:基于 vLLM v0.32.0(2026-10-18 发布)。逐请求接受指标是实验性接口,字段可能随版本变化,务必固定版本。

📑 目录


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 / dtypeQwen3-8B,BF16(检查点原始精度)不混入量化变量
GPU / TP同一张卡,TP=1不把卡数变化算成投机收益
KV dtypeauto不叠加 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 节的 RR
num_draft_tokens提出的候选总数 DD,已扣除被结构化输出约束作废的候选
num_accepted_draft_tokens接受的候选总数 CC,不含额外 Token
acceptance_histogram长度为 num_speculative_tokens + 1,第 jj 项是恰好接受 jj 个候选的轮数
mean_acceptance_length1+C/R1+C/R

设成 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。

汇总时先把 CC、DD、RR 分别加总,再算比值,不要对每个请求的百分比取平均。D=0D=0 时输出 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 分别对应逐请求字段 RR、DD、CC 的累加。vllm:spec_decode_num_accepted_tokens_per_pos_total 带 position 标签,第 ii 位的计数是”至少接受了 i+1i+1 个候选”的轮数,除以 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 JSONBenchmark JSON
Output Throughput、P95 TTFT/TPOT/ITL实测实测
KV Cache 容量、额外权重、抢占次数启动与运行日志启动与运行日志
C/DC/D、1+C/R1+C/R、接受直方图不适用分 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 接受率高,但很多步没候选:看直方图和 D/RD/R,把候选覆盖一起报告。
  • 并发 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-configBatch 形状与 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 让每个响应带上 CC、DD、RR 和接受直方图;汇总时先加总再算比值。
  • 性能测试用 --ignore-eos 固定工作量,质量判断则用正常结束的输出,两批数据分开。
  • 结果要和 KV 容量、抢占、P95/P99 一起解读,无收益的配置也要记录。

🎯 自我检验清单

  • 能列出对照实验中必须固定的变量,并说明各自的理由
  • 能写出 N-gram、独立 Draft、EAGLE-3、Suffix 和 MTP 的 --speculative-config
  • 能说明为什么 EAGLE-3 权重不能换一个 Target 直接用
  • 能开启逐请求接受指标,并从响应中读出 CC、DD、RR 和直方图
  • 能按 task 汇总接受率与平均接受长度,并区分 D=0D=0 和接受率 0%
  • 能用 /metrics 的前后快照差值交叉核对逐请求统计
  • 能用 vllm bench serve 在相同工作量和并发下对比基线与投机组
  • 能解释 --ignore-eos 为什么只适合性能测试
  • 能根据”接受率高却不提速""高并发吞吐下降”等现象定位原因

📚 参考资料