跳到主要内容
推理优化

5.5 vLLM 投机解码实战:配置、接受率与性能一起测

以 vLLM v0.31.0 为配置基准,依次启动普通 Decode、N-gram 与独立 Draft,采集代码/对话的接受指标、质量输出与延迟吞吐,并核对 Suffix/EAGLE 接入条件

vLLM Speculative Decoding N-gram Draft Model Benchmark Acceptance Rate

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

本节提供一套可复测流程:先跑基线,再跑 N-gram 和独立 Draft;把代码编辑与开放对话分开,记录接受情况、输出内容和性能。命令按 vLLM v0.31.0 的文档与源码编写,GPU 推理需在你的环境中执行,本文不填入未经实测的成绩。

📑 目录


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 dtypeauto保持高精度缓存,不叠加 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% 接受率”。

平均接受长度采用该版本的 1+C/R1+C/R 口径,含修正/额外 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 按任务、并发和基线配对

📊 记录项baselinengram / draft
GPU、Target revision、软件与采样固定配置同样配置及 Proposer 配置
实际输入/输出 Token、成功请求数Benchmark JSONBenchmark 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 延迟
  • 能区分固定输出性能测试与正常结束的质量评测
  • 能根据接受长度、轮耗时与容量定位无收益原因

📚 参考资料