Speculative Decoding 什么时候真能提速:接受率、草稿模型与瓶颈定位

同一个大模型服务,离线脚本每秒能生成不少 Token,接到聊天接口后却总让用户觉得慢。团队看到 Speculative Decoding,往往会把它理解成一个"打开就能加速"的开关:再放一个小模型做草稿,大模型一次验证多个 Token,速度自然提升。真正上线时却可能出现相反结果------单请求延迟下降了,十几个并发后的吞吐反而降低;短回答变快,长上下文变慢;代码补全效果很好,开放式问答几乎没有收益。

原因不是算法失效,而是推测解码交换了资源:它用额外的草稿计算和批量验证,换取更少的目标模型串行解码步数。草稿与目标越一致,一次验证能带走的 Token 越多,这笔交易越划算;接受率低、GPU 已经被大批量请求填满,或者草稿模型本身不够轻时,新增工作就会超过节省的时间。

因此,问题不该是"推测解码能提升多少倍",而应该是:在指定硬件、模型、采样参数、提示词分布和并发下,目标指标是否改善。本文以当前 vLLM 的通用机制为依据,构造一套不依赖宣传数据的验证方法。示例中的模型名、端口和阈值都只是配置模板,任何性能结论都必须用自己的请求集重新测量。

1. 先定位普通解码为什么慢

自回归模型一次只能把已经确定的 Token 作为下一步输入。预填充阶段会并行处理整段提示词,通常计算量大;进入解码阶段后,每一步只产生一个新 Token,却要读取大量模型权重和 KV Cache。低并发时,GPU 算力可能没有吃满,延迟更多花在权重读取、内核调度和逐步同步上。这正是推测解码最容易发挥作用的区域。

如果瓶颈本来就在预填充,例如输入经常有几万 Token,而输出只有十几个 Token,缩短解码步数对端到端时延帮助有限。如果高并发已经把目标模型组成足够大的连续批次,验证更多候选 Token 会争用真实请求需要的计算资源,吞吐可能下降。网络排队、分词、检索或工具调用占据大部分时间时,推测解码同样治不了根因。

上线前至少拆开四个指标:

  1. 排队时间:请求进入服务到开始计算之间等待多久。
  2. 首 Token 时间,也就是 TTFT:它主要受排队、预填充和调度影响。
  3. Token 间时延,也就是 TPOT:它更直接反映解码阶段速度。
  4. 端到端时延和吞吐:分别回答单个用户等多久、整个服务每秒处理多少有效 Token。

只报告"每秒 Token 数"不够。单请求吞吐提高,可能是其他请求等待更久换来的;平均值提高,也可能掩盖 P95 尾延迟恶化。优化必须先写清主目标:交互式助手通常更在意 TTFT、TPOT 和 P95,离线批处理更在意总吞吐与单位成本。

2. 推测解码到底做了什么

设目标模型为 T,草稿模型为 D,每轮最多提议 k 个 Token。普通解码需要目标模型串行执行 k 次;推测解码先让 D 产生一段候选,再让 T 在一次前向计算中并行评分这些位置。验证器从左到右接受与目标分布一致的候选,遇到第一个拒绝位置便停止后续候选,并按目标分布修正采样。

严格的推测采样不是"只在 Token 相同的时候接受"那么简单。贪心解码可以直观看成候选是否与目标选择一致;随机采样需要拒绝采样和修正分布,才能在理论上保持目标模型的输出分布。vLLM 文档将标准路径描述为在硬件数值精度范围内保持理论无损,并通过算法测试验证。某些近似接受策略可能用质量换吞吐,不能与严格路径混为一谈。
#mermaid-svg-nsfokrPq0vmhOtGs{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-nsfokrPq0vmhOtGs .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-nsfokrPq0vmhOtGs .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-nsfokrPq0vmhOtGs .error-icon{fill:#552222;}#mermaid-svg-nsfokrPq0vmhOtGs .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-nsfokrPq0vmhOtGs .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-nsfokrPq0vmhOtGs .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-nsfokrPq0vmhOtGs .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-nsfokrPq0vmhOtGs .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-nsfokrPq0vmhOtGs .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-nsfokrPq0vmhOtGs .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-nsfokrPq0vmhOtGs .marker{fill:#333333;stroke:#333333;}#mermaid-svg-nsfokrPq0vmhOtGs .marker.cross{stroke:#333333;}#mermaid-svg-nsfokrPq0vmhOtGs svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-nsfokrPq0vmhOtGs p{margin:0;}#mermaid-svg-nsfokrPq0vmhOtGs .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-nsfokrPq0vmhOtGs .cluster-label text{fill:#333;}#mermaid-svg-nsfokrPq0vmhOtGs .cluster-label span{color:#333;}#mermaid-svg-nsfokrPq0vmhOtGs .cluster-label span p{background-color:transparent;}#mermaid-svg-nsfokrPq0vmhOtGs .label text,#mermaid-svg-nsfokrPq0vmhOtGs span{fill:#333;color:#333;}#mermaid-svg-nsfokrPq0vmhOtGs .node rect,#mermaid-svg-nsfokrPq0vmhOtGs .node circle,#mermaid-svg-nsfokrPq0vmhOtGs .node ellipse,#mermaid-svg-nsfokrPq0vmhOtGs .node polygon,#mermaid-svg-nsfokrPq0vmhOtGs .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-nsfokrPq0vmhOtGs .rough-node .label text,#mermaid-svg-nsfokrPq0vmhOtGs .node .label text,#mermaid-svg-nsfokrPq0vmhOtGs .image-shape .label,#mermaid-svg-nsfokrPq0vmhOtGs .icon-shape .label{text-anchor:middle;}#mermaid-svg-nsfokrPq0vmhOtGs .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-nsfokrPq0vmhOtGs .rough-node .label,#mermaid-svg-nsfokrPq0vmhOtGs .node .label,#mermaid-svg-nsfokrPq0vmhOtGs .image-shape .label,#mermaid-svg-nsfokrPq0vmhOtGs .icon-shape .label{text-align:center;}#mermaid-svg-nsfokrPq0vmhOtGs .node.clickable{cursor:pointer;}#mermaid-svg-nsfokrPq0vmhOtGs .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-nsfokrPq0vmhOtGs .arrowheadPath{fill:#333333;}#mermaid-svg-nsfokrPq0vmhOtGs .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-nsfokrPq0vmhOtGs .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-nsfokrPq0vmhOtGs .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-nsfokrPq0vmhOtGs .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-nsfokrPq0vmhOtGs .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-nsfokrPq0vmhOtGs .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-nsfokrPq0vmhOtGs .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-nsfokrPq0vmhOtGs .cluster text{fill:#333;}#mermaid-svg-nsfokrPq0vmhOtGs .cluster span{color:#333;}#mermaid-svg-nsfokrPq0vmhOtGs div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-nsfokrPq0vmhOtGs .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-nsfokrPq0vmhOtGs rect.text{fill:none;stroke-width:0;}#mermaid-svg-nsfokrPq0vmhOtGs .icon-shape,#mermaid-svg-nsfokrPq0vmhOtGs .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-nsfokrPq0vmhOtGs .icon-shape p,#mermaid-svg-nsfokrPq0vmhOtGs .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-nsfokrPq0vmhOtGs .icon-shape rect,#mermaid-svg-nsfokrPq0vmhOtGs .image-shape rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-nsfokrPq0vmhOtGs .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-nsfokrPq0vmhOtGs .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-nsfokrPq0vmhOtGs :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 连续接受 m 个
首个拒绝位置
已有上下文
草稿器提出 k 个 Token
目标模型并行验证
一次提交 m 个候选
按目标分布修正
进入下一轮

一次验证不一定只得到已接受的草稿 Token。在标准流程里,验证步骤还可能产生一个由目标模型确认的额外 Token。vLLM 的 mean_acceptance_length 指标因此按"一加上每轮平均接受草稿数"计算。完全不接受草稿时,它接近 1,意味着没有减少目标模型步数;若 k 个候选经常全部接受,它才接近 k+1。

加速的直觉可以写成一笔账。普通方案生成一段输出需要多次目标模型解码;推测方案每轮支付草稿耗时、目标批量验证耗时和采样整理耗时,却可能一次前进多个 Token。只有"每轮有效前进量"的增长超过"每轮额外成本"的增长,端到端才会更快。接受率只是这笔账的一部分,不是最终结论。

3. 接受率为什么不是一个固定属性

很多人把接受率看成两个模型之间的固定分数,其实它同时依赖请求和解码配置。草稿模型与目标模型使用不同词表,通常无法直接逐 Token 对齐;同一家族也不代表概率分布足够接近。训练数据、指令模板、系统提示词和领域语言都会改变候选质量。

温度、top_p、重复惩罚和结构化输出约束也会改变验证行为。低温、格式固定、重复片段多的任务通常更可预测;创意写作、高温采样和多语言混合更容易让草稿与目标分叉。代码补全、固定 JSON、对话模板尾部等场景可能出现较长连续匹配,而开放式推理不能仅凭平均接受率下结论。

上下文长度也会影响草稿器。草稿模型若只擅长短上下文,长对话中对下一 Token 的判断可能迅速偏离目标。请求级平均值会掩盖这种变化,因此压测集需要按任务、输入长度、输出长度和采样策略分桶。某个桶的收益不能外推到所有流量。

最后要区分两个容易混淆的指标。draft_acceptance_rate 是被接受草稿 Token 数除以实际提出数;mean_acceptance_length 是每次验证平均推进量,包含额外确认 Token。前者回答草稿浪费多少,后者更接近减少了多少串行验证轮次。两者都高仍不保证服务更快,因为草稿和验证可能太贵。

4. vLLM 中可选的提议方法

当前 vLLM 的 speculative_config 采用 JSON 对象,常见 method 包括 draft_model、ngram、suffix、mtp、eagle3 和 dflash。不同版本、硬件后端与模型并不支持完全相同的组合,所以第一条生产规则是固定 vLLM 镜像版本,按该版本文档检查能力,不要把最新版网页参数直接抄进旧镜像。

draft_model 最接近经典论文:一个更小、更快且词表兼容的模型自回归生成候选。它直观、易理解,但要额外加载权重和 KV Cache。草稿器如果每个候选仍需一次串行前向,k 越大,草稿成本也越高。选择"尽可能小的模型"并不总对;模型太弱会把接受率打到很低,需要在速度和分布接近程度之间平衡。

ngram 不运行第二个神经网络,而是从当前提示词与已生成文本中查找重复序列。它占用资源较少,适合代码、文档续写和包含重复模式的任务;若文本几乎没有可复用片段,它可能很少提出有效候选。suffix 方法利用跨请求或后缀匹配建立候选,也依赖工作负载的重复性和缓存命中。

MTP 使用目标模型原生的多 Token 预测能力,EAGLE 等方法则使用专门训练的推测头或辅助模型。它们可能减少独立草稿模型的负担,但前提是目标模型、检查点和当前 vLLM 路径明确支持。不能把一个普通小模型强行标成 MTP,也不能假设所有 Hugging Face 模型都有相应头。

先用最简单且可验证的方案建立基线。重复性强的任务可以先测 ngram;已有官方兼容草稿模型时再测 draft_model 或相应专用方法。不要同时更换量化方式、张量并行、批处理参数和推测算法,否则即使结果改变,也无法知道是哪一个变量造成的。

5. 启动基线与推测服务

以下命令展示配置形状,不绑定具体模型。两组服务必须使用同一个目标模型、相同张量并行、相同显存利用率和同一镜像,只让推测配置成为变量。模型路径应替换为已经校验的本地快照或固定提交,避免压测期间远端仓库更新。

bash 复制代码
# 基线服务
vllm serve /models/target \
  --host 0.0.0.0 \
  --port 8000 \
  --served-model-name target

# 独立草稿模型方案
vllm serve /models/target \
  --host 0.0.0.0 \
  --port 8001 \
  --served-model-name target \
  --speculative-config '{
    "method": "draft_model",
    "model": "/models/draft",
    "num_speculative_tokens": 3
  }' \
  --per-request-spec-decode-metrics summary

文档明确要求目标模型参数仍放在服务顶层,草稿张量并行应使用 draft_tensor_parallel_size,而不是在 speculative_config 中填写 tensor_parallel_size。temperature、top_p 属于请求采样参数,也不应塞进推测配置。配置能启动只是最低门槛,启动日志中的实际 method、模型、k 值和后端都要保存进测试报告。

ngram 方案不需要第二个模型:

bash 复制代码
vllm serve /models/target \
  --port 8002 \
  --served-model-name target \
  --speculative-config '{
    "method": "ngram",
    "num_speculative_tokens": 3,
    "prompt_lookup_min": 1,
    "prompt_lookup_max": 3
  }' \
  --per-request-spec-decode-metrics summary

不要一开始就把 k 调得很大。更长候选只有在后部 Token 仍有较高存活概率时才有价值;若第一个或第二个位置经常拒绝,后面候选虽然已计算,却没有机会提交。可以从 2、3、5 这样的小范围开始,用真实分桶数据选值。当前 vLLM 也在发展自适应验证方案,正说明"所有并发共用一个静态 k"并非天然最优。

6. 构造能复现的压测请求集

随机写十个问题重复发送,得到的只是缓存、固定答案和短时抖动。可靠请求集应来自脱敏后的真实流量分布,至少记录任务类型、输入 Token 桶、期望输出 Token 桶、采样参数和是否使用结构化约束。若不能使用生产文本,可以按照这些分布生成等价模板,但必须声明数据是合成的。

每个服务都先预热,直到模型加载、CUDA Graph 捕获和常用内核初始化完成;然后以相同随机顺序发送同一批请求。测试期间固定 GPU、功耗模式、驱动、镜像和其他进程,分别测并发 1、2、4、8、16 等真实区间。每个并发档至少运行到尾延迟稳定,不能只截取最好的一分钟。

下面的客户端只负责公平发送和保存原始结果。它使用标准库,分别记录 TTFT、总时延、输出 Token 数和响应中可能存在的推测指标。不同 vLLM 版本的实验指标结构可能变化,因此解析使用容错读取;依赖该字段的生产采集必须固定版本并加契约测试。

python 复制代码
# bench_chat.py
from __future__ import annotations

import argparse
import json
import time
import urllib.request
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from typing import Any


def post_stream(base_url: str, case: dict[str, Any]) -> dict[str, Any]:
    body = {
        "model": case["model"],
        "messages": case["messages"],
        "temperature": case.get("temperature", 0.0),
        "max_tokens": case.get("max_tokens", 128),
        "stream": True,
        "stream_options": {"include_usage": True},
    }
    request = urllib.request.Request(
        f"{base_url}/v1/chat/completions",
        data=json.dumps(body).encode("utf-8"),
        headers={"Content-Type": "application/json"},
        method="POST",
    )
    started = time.perf_counter()
    first_token_at: float | None = None
    usage: dict[str, Any] = {}
    spec_metrics: dict[str, Any] | None = None
    with urllib.request.urlopen(request, timeout=180) as response:
        for raw_line in response:
            line = raw_line.decode("utf-8").strip()
            if not line.startswith("data: ") or line == "data: [DONE]":
                continue
            event = json.loads(line[6:])
            choice = (event.get("choices") or [{}])[0]
            content = choice.get("delta", {}).get("content")
            if content and first_token_at is None:
                first_token_at = time.perf_counter()
            usage = event.get("usage") or usage
            metrics = event.get("metrics") or {}
            spec_metrics = metrics.get("speculative_decoding") or spec_metrics
    ended = time.perf_counter()
    if first_token_at is None:
        first_token_at = ended
    return {
        "case_id": case["id"],
        "group": case["group"],
        "ttft_s": first_token_at - started,
        "latency_s": ended - started,
        "completion_tokens": usage.get("completion_tokens", 0),
        "speculative": spec_metrics,
    }


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--url", required=True)
    parser.add_argument("--cases", type=Path, required=True)
    parser.add_argument("--concurrency", type=int, default=1)
    parser.add_argument("--out", type=Path, required=True)
    args = parser.parse_args()
    cases = json.loads(args.cases.read_text(encoding="utf-8"))
    with ThreadPoolExecutor(max_workers=args.concurrency) as pool:
        futures = [pool.submit(post_stream, args.url, case) for case in cases]
        results = [future.result() for future in as_completed(futures)]
    args.out.write_text(
        json.dumps(results, ensure_ascii=False, indent=2),
        encoding="utf-8",
    )


if __name__ == "__main__":
    main()

请求集是 JSON 数组,每条必须有稳定 id 和 group。例如 group 可以是 code、qa_short、qa_long、json。不要把基线和实验组分别生成两份随机提示词,也不要在两个服务使用不同 max_tokens。若客户端不能取得服务端详细计时,至少保持网络路径相同,并把原始流式事件保存下来。

7. 从原始结果计算可比较指标

性能报告应展示每个并发档和每个任务桶,而不是只给全局平均数。TTFT、端到端时延至少报告 P50、P95;输出吞吐可以用总 completion_tokens 除以整个测试墙钟时间;单请求解码速度可以在近似条件下用输出 Token 数除以"总时延减去 TTFT",但第一个 Token 的计数和流式缓冲会造成偏差,所以最好同时采集 vLLM 自身指标。

下面脚本汇总一组结果。示例不输出预设结论,也不把零 Token 请求硬算成无限速度。若需要比较两组,可以分别运行后把表格并排放置;不要让脚本自动宣布"胜出",因为是否接受还取决于业务阈值。

python 复制代码
# summarize_bench.py
from __future__ import annotations

import argparse
import json
from collections import defaultdict
from pathlib import Path
from statistics import mean
from typing import Iterable


def percentile(values: Iterable[float], q: float) -> float:
    ordered = sorted(values)
    if not ordered:
        raise ValueError("values must not be empty")
    index = (len(ordered) - 1) * q
    lower = int(index)
    upper = min(lower + 1, len(ordered) - 1)
    weight = index - lower
    return ordered[lower] * (1 - weight) + ordered[upper] * weight


def summarize(rows: list[dict]) -> None:
    groups: dict[str, list[dict]] = defaultdict(list)
    for row in rows:
        groups[row["group"]].append(row)
    for name, items in sorted(groups.items()):
        latencies = [float(item["latency_s"]) for item in items]
        ttfts = [float(item["ttft_s"]) for item in items]
        specs = [item["speculative"] for item in items if item.get("speculative")]
        accepted = sum(s.get("num_accepted_draft_tokens", 0) for s in specs)
        drafted = sum(s.get("num_draft_tokens", 0) for s in specs)
        steps = sum(s.get("num_spec_steps", 0) for s in specs)
        print(
            name,
            {
                "requests": len(items),
                "ttft_p50_s": round(percentile(ttfts, 0.50), 4),
                "ttft_p95_s": round(percentile(ttfts, 0.95), 4),
                "latency_p50_s": round(percentile(latencies, 0.50), 4),
                "latency_p95_s": round(percentile(latencies, 0.95), 4),
                "acceptance_rate": round(accepted / drafted, 4) if drafted else None,
                "mean_acceptance_length": (
                    round(1 + accepted / steps, 4) if steps else None
                ),
                "mean_output_tokens": round(
                    mean(item["completion_tokens"] for item in items), 2
                ),
            },
        )


if __name__ == "__main__":
    parser = argparse.ArgumentParser()
    parser.add_argument("result", type=Path)
    args = parser.parse_args()
    summarize(json.loads(args.result.read_text(encoding="utf-8")))

这些 Python 示例已把"采集"和"判断"分开。一次运行得到的数据不能当成普遍性能数字。压测报告应列出硬件型号与数量、显存、驱动、vLLM 镜像摘要、目标和草稿模型提交、并行配置、输入输出分布、并发、运行时长与预热方式;缺少这些信息的"提升百分比"几乎无法复现。

8. 如何读接受率直方图

只看总体接受率容易错过连续性。假设 k 为 4,接受率都是 50%,一种情况可能是每轮稳定接受前两个;另一种情况可能是一半轮次全收、一半一个不收。两者对调度、尾延迟和不同请求的影响并不相同。acceptance_histogram 记录每轮恰好接受多少草稿 Token,可以看出收益来自稳定的小步前进,还是少数高命中请求。

按请求指标尤其适合发现流量分层:代码组平均接受长度高,开放问答组接近 1;短上下文正常,长上下文随长度增长下降;低温请求稳定,高温请求抖动。遇到这种结果,不必强求一个全局开关,可以把明确受益的流量路由到推测池,其他流量继续走基线。

当前 vLLM 文档把 metrics.speculative_decoding 标为实验接口,字段形状可能变化,而且单次返回只适用于单序列生成;n 大于 1 时可能为空。流式模式下,指标位于最后的 usage 事件,客户端需要请求 include_usage。它适合诊断和版本内观测,不应未经适配直接成为长期计费契约。

服务端聚合指标与请求级指标也不一定直接相等。聚合端可能包含 n 大于 1 等未返回请求级详情的流量,结构化约束还可能让实际提议数变化。验证监控时应先用单序列、无额外路由的受控流量对账,再扩展到生产。

9. 为什么高接受率仍可能变慢

第一种原因是草稿模型不够便宜。它虽然比目标模型小,却占用了额外显存、带宽和调度时间;如果还跨设备通信,开销更明显。接受率很高但草稿耗时接近普通解码耗时,节省的目标步数可能无法覆盖成本。

第二种原因是并发改变了目标模型的工作区间。低并发下,目标模型每步读取权重却只服务少数 Token,批量验证可利用空闲计算;高并发下,连续批处理已经让每次前向服务许多真实 Token,额外候选会扩大批次并消耗算力。被拒绝的候选尤其是纯开销。官方自适应验证说明也明确指出,负载上升后推测槽位会与真实 Token 竞争,静态 k 的最佳点会移动。

第三种原因是显存挤压。加载草稿模型会减少可用于 KV Cache 的空间,导致可并发序列数下降、抢占增加或最长上下文受限。只看活跃请求的 TPOT,可能没有看到更多请求正在队列里等待。必须同时记录 KV Cache 使用、抢占、队列长度和 OOM。

第四种原因是输出很短。若平均只生成几枚 Token,初始化、首轮草稿和流式响应占比太高,尚未积累足够轮次就结束。相反,长输出虽然提供更多摊销机会,但上下文持续增长后草稿质量可能变化,所以仍要按生成位置分析。

10. 常见错误与排查顺序

服务无法启动时,先检查当前镜像文档、模型架构和词表兼容,而不是不断改 JSON。专用推测头需要对应目标模型,独立草稿模型需要兼容配置;显存不足则应计算目标权重、草稿权重、KV Cache 和运行缓冲总量。错误日志中如果实际推断出的 method 与预期不同,应停止压测。

服务能跑但接受率接近零时,检查聊天模板是否一致、模型家族是否匹配、采样参数是否过于随机,并按任务桶定位。不要通过放宽验证标准来"做高"接受率,除非业务明确接受输出分布变化并完成质量评测。严格路径的价值正是速度优化不应偷偷改变语义。

接受率不错但 TPOT 没改善时,分别采集草稿耗时、验证耗时、调度等待、GPU 利用率和显存。降低 k、替换更轻的草稿器、改用 ngram,或只路由受益流量都比盲目扩大候选长度合理。若高并发吞吐下降,应把并发扫描作为选择门,而不是拿并发 1 的结果上线。

TTFT 恶化而 TPOT 改善时,要判断用户体验的主导部分。可能是草稿模型加载、调度或资源竞争让首 Token 更慢。对于十几 Token 的短回答,TTFT 恶化通常无法由后续解码追回;对于长篇生成,适度 TTFT 增加可能可接受。最终以完整请求时延和产品指标决定。

11. 质量验证不能被性能测试替代

严格推测采样在理论上保持目标分布,不代表两次随机运行应逐字相同。使用温度大于零时,本来就存在随机性;并行执行和浮点数值也可能改变随机路径。验证方法应该与采样模式匹配:温度为零的确定性集合可以做逐 Token 或文本对比,随机采样则应比较大样本分布和业务质量指标。

若启用任何近似接受、特殊后端优化或非标准采样器,应单独标注并运行任务级评测。评测至少覆盖格式合法率、事实性、安全拒答、工具参数和长文本完整性。性能提升不能抵消越权调用、JSON 失效或事实错误。

基线与实验必须使用相同目标模型权重、聊天模板、分词器、采样参数和停止条件。若推测组顺便升级了模型版本,输出差异就无法归因。结构化输出会约束候选空间,既可能提高可预测性,也可能减少实际提议数量,需要从指标而非猜测判断。

12. 灰度上线与自动回退

最稳妥的上线方式是保留独立基线池和推测池。网关按固定哈希把少量相同类型请求送入推测池,确保用户或会话稳定分组;同时记录服务版本、路由组、任务桶、TTFT、TPOT、端到端时延、接受率和错误码。灰度期间不要让两个池共享一个会随负载变化的模糊标签。
#mermaid-svg-OQhfBFsjig3MZDbe{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-OQhfBFsjig3MZDbe .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-OQhfBFsjig3MZDbe .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-OQhfBFsjig3MZDbe .error-icon{fill:#552222;}#mermaid-svg-OQhfBFsjig3MZDbe .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-OQhfBFsjig3MZDbe .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-OQhfBFsjig3MZDbe .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-OQhfBFsjig3MZDbe .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-OQhfBFsjig3MZDbe .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-OQhfBFsjig3MZDbe .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-OQhfBFsjig3MZDbe .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-OQhfBFsjig3MZDbe .marker{fill:#333333;stroke:#333333;}#mermaid-svg-OQhfBFsjig3MZDbe .marker.cross{stroke:#333333;}#mermaid-svg-OQhfBFsjig3MZDbe svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-OQhfBFsjig3MZDbe p{margin:0;}#mermaid-svg-OQhfBFsjig3MZDbe .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-OQhfBFsjig3MZDbe .cluster-label text{fill:#333;}#mermaid-svg-OQhfBFsjig3MZDbe .cluster-label span{color:#333;}#mermaid-svg-OQhfBFsjig3MZDbe .cluster-label span p{background-color:transparent;}#mermaid-svg-OQhfBFsjig3MZDbe .label text,#mermaid-svg-OQhfBFsjig3MZDbe span{fill:#333;color:#333;}#mermaid-svg-OQhfBFsjig3MZDbe .node rect,#mermaid-svg-OQhfBFsjig3MZDbe .node circle,#mermaid-svg-OQhfBFsjig3MZDbe .node ellipse,#mermaid-svg-OQhfBFsjig3MZDbe .node polygon,#mermaid-svg-OQhfBFsjig3MZDbe .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-OQhfBFsjig3MZDbe .rough-node .label text,#mermaid-svg-OQhfBFsjig3MZDbe .node .label text,#mermaid-svg-OQhfBFsjig3MZDbe .image-shape .label,#mermaid-svg-OQhfBFsjig3MZDbe .icon-shape .label{text-anchor:middle;}#mermaid-svg-OQhfBFsjig3MZDbe .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-OQhfBFsjig3MZDbe .rough-node .label,#mermaid-svg-OQhfBFsjig3MZDbe .node .label,#mermaid-svg-OQhfBFsjig3MZDbe .image-shape .label,#mermaid-svg-OQhfBFsjig3MZDbe .icon-shape .label{text-align:center;}#mermaid-svg-OQhfBFsjig3MZDbe .node.clickable{cursor:pointer;}#mermaid-svg-OQhfBFsjig3MZDbe .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-OQhfBFsjig3MZDbe .arrowheadPath{fill:#333333;}#mermaid-svg-OQhfBFsjig3MZDbe .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-OQhfBFsjig3MZDbe .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-OQhfBFsjig3MZDbe .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-OQhfBFsjig3MZDbe .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-OQhfBFsjig3MZDbe .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-OQhfBFsjig3MZDbe .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-OQhfBFsjig3MZDbe .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-OQhfBFsjig3MZDbe .cluster text{fill:#333;}#mermaid-svg-OQhfBFsjig3MZDbe .cluster span{color:#333;}#mermaid-svg-OQhfBFsjig3MZDbe div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-OQhfBFsjig3MZDbe .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-OQhfBFsjig3MZDbe rect.text{fill:none;stroke-width:0;}#mermaid-svg-OQhfBFsjig3MZDbe .icon-shape,#mermaid-svg-OQhfBFsjig3MZDbe .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-OQhfBFsjig3MZDbe .icon-shape p,#mermaid-svg-OQhfBFsjig3MZDbe .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-OQhfBFsjig3MZDbe .icon-shape rect,#mermaid-svg-OQhfBFsjig3MZDbe .image-shape rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-OQhfBFsjig3MZDbe .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-OQhfBFsjig3MZDbe .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-OQhfBFsjig3MZDbe :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 基线组
实验组
满足
不满足
生产请求
固定灰度路由
普通 vLLM 池
推测解码池
统一指标平台
分桶门槛是否满足
逐档扩大流量
回退到基线池

回退条件要在发布前写好,例如某一关键流量桶的 P95 超过基线允许范围、错误率上升、抢占次数增加或有效吞吐下降。门槛应由容量目标和用户体验决定,不能照搬示例数值。回退只需停止新请求进入推测池,已有请求自然完成;不要在服务繁忙时在线重配同一进程,制造第二个变量。

容量规划也要以最差合理流量为准。如果白天代码生成占比高而夜间开放问答占比高,接受率和容量会周期变化。可以按请求类别路由,或者在收益不稳定时维持普通解码。不开启某项优化不是失败;用测量证明它不适合当前负载,本身就是有效结论。

13. 一张可执行的决策表

适合优先试验的信号包括:解码阶段是主要耗时;低到中并发占比高;输出较长;任务格式稳定或文本重复度高;存在轻量且兼容的草稿器;GPU 留有计算余量。代码补全、模板化生成、固定结构输出经常值得单独建桶验证。

需要谨慎的信号包括:长输入短输出;高并发持续饱和;草稿模型占用大量显存;高温开放生成;任务分布变化频繁;服务的主要瓶颈是排队、检索或网络。此时先优化批处理、KV Cache、前缀缓存或上游链路可能更直接。

接受一项方案需要同时满足四类门槛:性能门槛确认目标分位数与吞吐改善;容量门槛确认显存和并发不退化;质量门槛确认任务指标不下降;运维门槛确认指标、回退和版本固定完整。只达到其中一项,不足以进入全量。

14. 生产化边界

推测解码不是模型压缩,不会减少目标模型必须给出的最终判断;它也不是前缀缓存,不能跳过预填充;更不是无损地"提高 GPU 算力"。它优化的是特定工作区间内的解码调度。硬件、内核、模型和请求分布一变,原结论就需要复测。

当前 vLLM 的方法、参数与兼容矩阵仍在快速演进。生产环境应固定容器摘要和模型版本,把启动参数纳入变更审查,并在升级前运行同一套回归压测。实验性请求指标可以用于诊断,但采集器必须允许字段缺失,并通过版本标签隔离。

最后,不要从论文中的加速倍数推导自己的容量。论文结果是在特定模型、硬件和实现上得到的,价值在于证明算法成立,而不是承诺任意服务都能复制。真正可靠的数字只来自可复现、同条件、覆盖实际并发和流量分布的对照实验。

15. 把性能收益换算成容量与成本

工程团队最终关心的通常不是某个请求快了几毫秒,而是相同服务等级下能否少用实例,或者相同实例能否承载更多请求。换算前要先确定约束:如果服务承诺 P95 TTFT 和 P95 TPOT,那么超过任一阈值的吞吐都不能算有效容量。把队列无限加长得到的高 Token 吞吐,对在线用户没有意义。

可以在每个并发档统计"满足服务等级的完成请求数",然后寻找基线池和推测池各自的最大稳定点。这里的稳定不仅是短时间无报错,还包括显存不持续攀升、KV Cache 抢占处于允许范围、队列不会不断累积、质量回归通过。只有推测池在目标流量组合下提高了这个稳定点,才可能减少实例。

成本核算还要加入草稿模型的显存和功耗。若原来一张卡能部署一个目标实例,加入草稿后因显存不足只能降低最大并发,单请求变快未必降低总成本。若草稿模型被放在另一张卡上,则必须计算第二张卡、跨卡传输和运维复杂度。对云实例还应考虑计费粒度:节省少量运行时间但无法减少实例数,账单可能没有变化。

流量结构变化会改变容量。假设代码类请求接受率较好,而开放问答较差,白天和夜间的业务占比不同,单一峰值数字就不可靠。容量测试应至少覆盖日常组合、峰值组合和最不利合理组合。网关按任务类型分池时,还要避免小流量池因突发请求形成新的排队热点。

因此,成本报告最好同时给出三层结果:微观层展示接受率、平均接受长度和验证开销;服务层展示 TTFT、TPOT、尾延迟与有效吞吐;资源层展示显存、GPU 利用率、功耗和实例数。三层互相解释,才能避免"算法指标漂亮、用户体验没变、成本反而上升"的局面。

16. 建立不会误导人的实验记录

每次实验都应生成不可变记录,而不是只在群里贴一张截图。记录中保存测试时间、代码提交、容器摘要、目标和草稿模型校验值、完整启动参数、GPU 与驱动信息、请求集版本、并发档、随机种子以及原始结果文件。图表是二次产物,任何人都应该能从原始数据重新计算。

运行顺序也会造成偏差。如果总是先测基线、再测实验,后者可能受到机器温度、共享集群干扰或缓存状态影响。可以采用交错运行:预热后依次执行基线一轮、实验一轮,再交换顺序,重复多次。若两组必须部署在不同机器,应先做机器互换或普通解码对照,确认硬件差异没有主导结果。

异常请求不能静默删除。超时、空输出、取消和服务错误都属于结果,应按预先规定的方法计入成功率;只有明确证明由压测客户端故障造成的样本才可剔除,并保留原因。输出长度不同也会影响总时延,比较时既要展示实际输出分布,也可使用固定 max_tokens、禁止提前停止的合成实验隔离解码性能,但后者不能代替真实流量测试。

统计显著并不自动等于业务显著。请求量很大时,极小差异也可能稳定存在,却不足以改变用户体验或实例数量。上线门槛应事先写成可执行条件,例如关键桶的尾延迟改善且有效吞吐不退化,同时显存仍满足最长上下文目标。先看结果再移动门槛,会把一次探索变成自我证明。

最后给实验设置失效条件。升级 vLLM、替换模型、改变量化、迁移 GPU、显著修改系统提示词或业务流量后,旧结论都应标记为待复测。推测解码不是一次配置后永久有效的属性,它是一项依赖运行环境和数据分布的服务策略。

小结

Speculative Decoding 的核心不是"用小模型替大模型回答",而是让草稿器提出候选,再由目标模型严格验证,从而减少串行解码轮次。接受率和平均接受长度解释候选是否有用,TTFT、TPOT、尾延迟、吞吐和显存则决定这项交换是否真的划算。

最短的落地路径是:先拆出当前瓶颈,固定基线;选一种兼容方法,从小 k 开始;用同一请求集扫描任务桶和并发;保存原始结果与环境;最后通过独立推测池灰度。结果可能是全量启用、只路由代码类流量,也可能是保持普通解码。三种结论都比一个脱离工作负载的"理论提速倍数"可靠。

参考资料

相关推荐
天远Date Lab1 小时前
零信任架构实战:基于天远名下车辆车牌查询A构建自动化社区车位摇号核验网关
运维·人工智能·架构·自动化
中防喷墨1 小时前
选UV喷码机还是激光喷码机,哪款更适合流水线?
人工智能·uv
ctlover1 小时前
Pandas进阶
人工智能·机器学习·pandas
LaughingZhu1 小时前
Product Hunt 每日热榜 | 2026-10-03
人工智能·深度学习·神经网络·搜索引擎·百度
一只爱撸猫的程序猿1 小时前
当 Spec 遇见遗留系统:构建一个带人工审核节点的长任务 Agent
人工智能
代码方舟1 小时前
零信任架构实战:基于天远名下车辆车牌查询A构建自动化机动车辆资产清查网关
大数据·人工智能·架构·自动化
张彦峰ZYF1 小时前
从智能体到生产系统:企业 Agent 规模化的治理、记忆、知识与安全
人工智能·安全·架构·operatingsystem·agent platform·agentcontrol·agent mesh
LaughingZhu2 小时前
Product Hunt 每日热榜 | 2026-10-05
大数据·人工智能
时速GEO系统2 小时前
深圳科飞时速推出桌面级AI应用软件 -初元AI 24天内迭代三个版本,面向零基础用户提供建站与业务软件生成能力
网络·人工智能