给推理服务上了投机解码(speculative decoding)。论文上的数字是 2–3 倍加速,实测 P50 快了 8 %,P99 反而慢了 15 %,而且少数长回答里出现了整段重复短语。回滚之后又试了另一种草稿模型,这次 P50 快了 40 %,但 A/B 里人工评分掉了 0.4 分。
两次都栽在同一个地方:把"加速比"当成一个可以拍板的常数。投机解码的收益完全由接受率决定,而接受率由任务形状决定——摘要与分类高、创意与长推理低。更麻烦的是,接受率是个随机变量,它把延迟方差放大,于是均值变好、尾部变坏。
这篇拆三个账:接受率怎么变成加速比、草稿模型自己要吃多少资源、以及什么形状的任务根本不该上这套机制。
1. 它到底在买什么
投机解码的机制很朴素:用一个小模型先猜 k 个 token,再用大模型一次 forward 验证这 k 个。验证是并行的,成本接近"大模型跑 1 个 token";猜对了就直接接受,猜错了就截断到第一个不一致处,剩下的丢掉。
| 角色 | 干什么 | 资源画像 | 关键约束 |
|---|---|---|---|
| 草稿模型 | 自回归猜 k 个 token | 权重小、KV 少、但串行 k 步 | 必须与目标模型同词表 |
| 目标模型 | 一次并行验证 k 个 | 一次 forward,成本 ≈ 1 token | 采样分布必须严格一致 |
| 调度器 | 组批、回滚 KV | 批内混跑时对齐 | 回滚未接受部分 |
它买的不是"算得更快",是**"用一次大模型 forward 换掉 k 次串行 forward"**;前提是猜对的 token 本来也要算,猜错的等于白送。
// 一轮的时间构成(8B 目标 + 1B 草稿,k=4)
draft: 4 × 6ms = 24ms // 串行,小模型也可能不比大模型快很多
verify: 1 × 28ms = 28ms // 接受 n 个 token(n ≤ 5,含 bonus token)
total: 52ms → 产出 n+1 个 token
// n=4(全接受):52ms / 5 token = 10.4ms/token ← 原 28ms/token,2.7×
// n=1(只中 1):52ms / 2 token = 26ms/token ← 几乎没赚
// n=0(全错): 52ms / 1 token = 52ms/token ← 亏 1.9×
最后一行是全部风险的来源:接受率低的时候,投机解码是负收益,而它在监控上表现为"没变快"或"变慢",不是"报错"。
2. 接受率是唯一决定收益的变量
设单步接受率为 α、草稿长度 k,则每轮期望产出 token 数为
E[n] = (1 − α^(k+1)) / (1 − α)
α = 0.8、k = 4 时 E[n] = 3.36;α = 0.5 时 E[n] = 1.94。每轮成本约等于"1 次大模型 forward + k 次小模型 forward",把它折算成"单 token 成本 c",收益就是 E[n] / c。
| α(单步接受率) | k=2 | k=4 | k=8 | 实际加速(含草稿开销) |
|---|---|---|---|---|
| 0.9 | 2.71 | 4.10 | 6.13 | 2.6–3.4× |
| 0.8 | 2.44 | 3.36 | 4.66 | 2.1–2.6× |
| 0.7 | 2.19 | 2.77 | 3.60 | 1.6–2.0× |
| 0.6 | 1.96 | 2.31 | 2.75 | 1.2–1.4× |
| 0.5 | 1.75 | 1.94 | 2.13 | 0.9–1.05×(临界) |
| 0.4 | 1.56 | 1.62 | 1.66 | 0.7–0.8×(亏) |
三件事从这张表里直接读出来:第一,α < 0.55 就别上,它不是"效果差一点",是稳定亏;第二,k 的边际收益随 α 降低而迅速消失(α=0.4 时 k=2 与 k=8 几乎一样),所以低 α 场景调大 k 是纯粹浪费;第三,α 高的时候 k 越大越好,但 k 太大会让"验证失败"的沉没成本变大——这解释了为什么生产上 k 一般取 3–5,而不取 8。
// 现场估 α 的最简方法:统计一段流量里"连续接受长度"的分布
// 不需要改引擎,读 trace 里的 accepted_token_count 即可
def accept_rate(counts, k):
// counts[i] = 一轮接受 i 个 token 的次数(i=0..k)
total = sum(counts)
drafted = sum(i * counts[i] for i in range(len(counts))) // 实际发出的草稿数
accepted = sum(i * counts[i] for i in range(len(counts)))
return accepted / max(drafted, 1)
// 注意分母口径:按"发出草稿数"算,而不是按"轮数"算
3. 四种地形:α 与草稿成本一起看
只看 α 会漏掉一半。草稿模型自己的成本有两个来源:权重与 KV 占显存(挤掉并发)、串行 k 步的时间(大模型哪怕再快也省不掉)。
| 地形 | α | 草稿成本 | 结果 | 典型任务 |
|---|---|---|---|---|
| 甜点 | 0.8+ | 低(< 0.2×) | 2–3× 净收益 | 摘要、抽取、分类、改写 |
| 划算 | 0.7 | 中(0.3×) | 1.5× 左右 | 结构化输出、模板化回答 |
| 临界 | 0.55 | 高(0.4×) | 0.9–1.1×,白忙 | 通用问答 |
| 亏损 | < 0.5 | 任意 | < 0.8× | 创意写作、长链推理、代码生成 |
最容易被忽略的是第二列:草稿模型不是免费的。一个 1B 草稿在 8B 目标旁边要吃掉 ~2 GB 权重 + 自己的 KV。原来能跑 58 个 8k 并发的卡,现在只能跑 50 个——并发降 14 %,收益要先扣掉这一块。
// 上投机解码前的三行预算检查(任一不满足就别上)
speculative_decoding:
vram_check: draft_vram + target_vram + kv_at_peak_p99 <= gpu_total * 0.95
accept_check: measured_alpha_on_real_traffic >= 0.6 // 用真实流量,不用样例
tail_check: p99_latency_after <= p99_before * 1.05 // 尾部不能变差
// 三条同时成立才允许灰度;只满足前两条,说明你换来的是均值好看、尾部更抖
4. 为什么尾延迟会变差
均值和尾部走反方向,机制有三层。
第一层是接受率的随机性:α 在 prompt 不同位置不同——开头模板区高,中段自由生成低。P99 请求恰好是那些"α 全程偏低 + 草稿全错"的请求,它们的成本是普通请求的 1.9 倍。
第二层是回滚成本:验证失败要丢弃未接受的 KV,实现不精细时会把整轮的 KV 重算一遍。这层成本不在论文模型里,但在真实引擎里存在。
第三层是批内不齐:一个批里有的接受 4 个、有的接受 0 个,步调被最慢的决定,批越大方差越明显。
| 现象 | 机制 | 可观测信号 |
|---|---|---|
| P50 变好、P99 变差 | α 的方差 + 全错轮 | 接受长度的分布双峰 |
| 输出出现重复短语 | 回滚边界处理错,KV 与 token 错位 | 同一段重复出现的 n-gram |
| 吞吐提升但成本没降 | 并发被草稿模型挤掉 | 峰值并发下降、单价持平 |
| 小流量测试很好,上线变差 | 样例流量 α 偏高 | 线上 α 比离线低 0.15+ |
// 接受长度的直方图:双峰即"P99 被拖坏"的直接证据
n=0 ████████ 18% ← 全错轮,这些请求多等一整轮
n=1 ██████ 14%
n=2 ████ 9%
n=3 ██████ 14%
n=4 ██████████████████ 45%
// 均值 2.4 很好看,但 18% 的请求走了最贵的那条路径
第三行是个典型误判:吞吐(token/s)上去了,但因为并发降了、单位时间处理的请求数没变,每请求成本反而升。这也是为什么评估要盯"每千 token 成本",而不是只盯吞吐。
5. 草稿模型从哪来
三种来源,代价结构完全不同。
| 来源 | α 水平 | 额外资源 | 工程成本 | 适用 |
|---|---|---|---|---|
| 同家族小模型(1B / 3B) | 0.7–0.85 | 独立权重 + KV | 低(换模型即可) | 大多数场景首选 |
| n-gram / prompt 查找 | 0.5–0.7(模板化任务可到 0.9) | 几乎为零 | 中(要建索引) | 高频模板、固定格式 |
| 目标模型剪枝 / 蒸馏 | 0.75–0.9 | 与目标共享部分结构 | 高(要训练) | 有训练能力的团队 |
第二行常被低估:模板化的客服话术、固定格式的报表摘要,n-gram 草稿的 α 能到 0.9 且零显存成本,这是性价比最高的一档。而通用问答上 n-gram 的 α 只有 0.5 左右,纯亏。
// 灰度期的三段观测:α、尾部、并发,缺一个就会看错
$ curl -s localhost:8000/metrics | grep -E 'spec_accept|spec_draft|p99|num_running'
// 先看 spec_accept / spec_draft 是否 > 0.6,再看 p99 是否退化,
// 最后看 num_running 是否掉了 > 5%(掉了就是草稿模型在挤并发)
6. 排错:五种"开了投机反而更慢"
| 现象 | 八成原因 | 处理方向 |
|---|---|---|
| 整体慢 0.7–0.9× | α < 0.5,纯亏 | 关掉,或换任务分流 |
| 均值快、尾部慢 | α 方差 + 回滚重算 | 检查回滚实现,限 k |
| 内容质量下降 | 采样分布不等价(未做拒绝采样校正) | 核对验证器实现 |
| 重复短语 | KV 回滚错位 | 关投机验证,只跑目标模型 |
| 吞吐涨、成本没降 | 草稿挤掉并发 | 降 k 或降草稿精度 |
第四行不是"慢",是正确性问题:输出分布要与大模型逐位一致,靠"接受/拒绝采样"校正;少一个 min(1, p_target/p_draft) 就退化成近似 greedy。
7. 边界:什么任务不该上
| 场景 | 为什么不该 | 替代做法 |
|---|---|---|
| 创意写作 / 长链推理 | α 常 < 0.5 | 调批处理与量化 |
| 输出极短(< 20 token) | 草稿预热成本占比过高 | 直接跑,减小 k |
| 显存已满(> 90 %) | 草稿模型会把并发压垮 | 先扩卡或降精度 |
| 强确定性要求(逐位一致) | 实现稍错就漂移 | 只在验证器充分测试后启用 |
判断口径一句话:先在真实流量上量出 α,再决定要不要上;α 不够 0.6 就别谈"再调调参数",那不是调参问题。而 α 达标之后,真正的判据换成第三条——P99 不能变差;均值好看而尾部更抖的"加速",在交互场景里等于把投诉从少数用户转移到更多用户身上。
8. 自测
- 投机解码买的到底是什么?为什么它的收益不是常数?
- 写出 α=0.7、k=4 时的期望产出 token 数,并说明 k 增大为什么边际收益递减。
- 为什么 α 低时调大 k 是浪费?α 高时为什么又不建议 k > 6?
- "吞吐上去、成本没降"的机制是什么?该盯哪个指标?
- 输出出现重复短语说明哪一层实现错了?它属于性能问题还是正确性问题?
- 三个上线前置检查分别拦的是什么?
9. 一句话总结
投机解码是把"k 次串行 forward"换成"1 次并行验证",所以它的收益完全由接受率 α 决定:α ≥ 0.7 时净收益 1.5–2.5×,α < 0.55 时稳定亏损,而 α 由任务形状决定、必须在真实流量上量。更硬的判据是尾部——α 的方差会把 P99 推高,均值变好不算赚。上线三条检查:α ≥ 0.6、显存留够、P99 不退化;三条缺一条,这套机制买到的只是更贵的复杂度。