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 开始;用同一请求集扫描任务桶和并发;保存原始结果与环境;最后通过独立推测池灰度。结果可能是全量启用、只路由代码类流量,也可能是保持普通解码。三种结论都比一个脱离工作负载的"理论提速倍数"可靠。

参考资料

相关推荐
HDU-Dade2 小时前
【校招笔试真题】简要介绍 Self-Attention 的前沿算法
人工智能·算法
在所不辞兄3 小时前
【零基础学智能仿真-63】ABACUS作业工具与MCP接口
人工智能·深度学习·神经网络·机器学习·智能仿真
楚山之石3 小时前
【Medical Image Analysis】深度学习医学图像合成综述:伪CT、合成MRI与合成PET的架构、性能与临床边界
图像处理·人工智能·学习·mr·mri
blackappleD3 小时前
百度 Unlimited OCR 2 拆解:SR-SWA 如何让百页文档的 KV 缓存不再涨
人工智能·llm
数字新视界3 小时前
国产动环数据库在智能机房环境监控中的应用与优势分析
大数据·人工智能·数据中心·机房管理·模块化机房
holly就是我3 小时前
AI 编程助手为什么跑偏?用开源 ccglass 看清 Prompt、工具调用和 Token 消耗
人工智能·后端
雨中飘荡的记忆3 小时前
额度又见底了?这个开源项目,给 AI 编程助手接上 61 家"加油站"
人工智能
天远Date Lab4 小时前
零信任架构实战:基于天远企业年报信息核验构建自动化商业保理财务与社保穿透评估网关
人工智能·架构·自动化
EnCi Zheng4 小时前
后端怎么把AI接口稳定安全低成本接进业务:超时重试、密钥隔离与成本熔断清单
人工智能·后端·安全·架构·api
loulanyue_4 小时前
给概率大模型套上确定性缰绳:GraphRAG、神经符号与知识超图重构可信AI底座——读陈华钧杨博等CNCC2026第十届知识图谱论坛实录
人工智能·rag