苦猿的大模型日记 · Day49 · 投机解码-帮普通人把AI学进简历系列
前言:先猜再验,为什么可能更快
前几天有个读者留言问我:
既然已经让一个小模型先写答案了,为什么不直接用小模型?还要再让大模型检查一遍,这不是多算了一次吗?
这个问题很合理。第一次听到 Speculative Decoding,投机解码 时,我也觉得它像是把一道题先交给小模型做一遍,再交给大模型批改一遍。要是大模型最后还是得自己算,前面那一遍岂不是白忙?
但大模型推理里有一个特别贵的动作:每生成一个 token,都要再跑一次目标模型的 Decode。
一段 300 个 token 的回答,普通自回归解码通常要让目标模型完成很多轮"读入历史、预测下一个 token"的工作。目标模型每轮只往前走一步,GPU 的大矩阵计算却要反复启动。
投机解码换了一个思路:让更快的 Draft Model 先连续猜 4 个、5 个甚至更多 token,再让真正负责质量的 Target Model 一次性检查这一小段。如果这几个猜测大部分都对,目标模型一轮就能向前推进好几步。
反过来,如果 Draft Model 猜得不准,或者它本身就慢得不够"便宜",投机解码就会变成额外工作。
所以它赌的不是"小模型能不能答对",而是:
小模型能不能连续猜中几步,而且猜测成本足够低。

这篇不把投机解码写成一个打开就加速的按钮,而是把它拆成五笔工程账:它到底做了什么,接受率为什么重要,如何写一个不依赖 GPU 的模拟器,真实推理引擎里还要付出什么成本,以及上线前应该看哪些指标。
PART 01:投机解码到底做了什么
1.1 普通解码为什么一轮只走一步
把已经生成的 token 序列记作:
y1, y2, y3, ..., yt
普通自回归解码会根据这段历史计算下一个 token。生成后,把它拼回上下文,再计算下一个 token。于是目标模型的调用路径大致是:
Target -> y1
Target -> y2
Target -> y3
Target -> y4
...
KV Cache 会避免每一步重复计算已经读过的历史,但它没有改变"每一轮只生成一个新 token"这个事实。输出越长,Decode 轮数越多;目标模型越大,每一轮的成本越高。
1.2 Draft Model 负责猜,Target Model 负责拍板
投机解码引入两个模型:
- Draft Model:参数量更小、推理更快,用来提出候选 token。
- Target Model:真正对外提供结果的大模型,负责验证候选,最终采样逻辑仍然由它决定。
设当前上下文是 x,Draft Model 连续提出 gamma 个候选:
d1, d2, d3, ..., dγ
然后 Target Model 沿着这条候选路径,计算每个位置的目标分布:
p1 = Target(x)
p2 = Target(x, d1)
p3 = Target(x, d1, d2)
...
pγ = Target(x, d1, ..., dγ-1)
简化后,候选 token di 的接受概率可以写成:
αi = min(1, pi(di) / qi(di))
目标模型更认可它时,接受概率为 1;目标模型认为它不那么可能时,接受概率按比例下降。如果某个位置拒绝,后面的候选也不能继续沿用,系统会从目标模型的修正分布里采一个新 token,结束这一轮。
1.3 一轮最多能推进多少 token
假设 Draft Model 提议 4 个 token:
候选:d1 d2 d3 d4
结果:√ √ √ ×
这一轮会接受 d1、d2、d3,并在第 4 个位置用 Target Model 的分布产生一个 token。目标模型虽然只完成了一次验证前向,但序列向前推进了 4 个 token。如果四个都接受,Target Model 通常还可以在候选末尾贡献一个 bonus token。
| 情况 | 候选长度 | 接受结果 | 本轮推进 | 说明 |
|---|---|---|---|---|
| 全接受 | 4 | √√√√ | 5 | 4 个草稿 token,加 1 个目标 token |
| 部分接受 | 4 | √√× | 3 | 后续候选作废,目标模型补 1 个 |
| 首个拒绝 | 4 | × | 1 | 目标模型直接采 1 个,草稿成本没有转化成收益 |
这就是投机解码与"先用小模型写答案,再让大模型润色"的差别:前者只在 token 级别做短距离猜测,最终序列仍由 Target Model 的分布控制。
1.4 为什么它不等于质量变差
严格的 speculative sampling 会修正接受/拒绝步骤,使最终采样结果仍然服从 Target Model 的目标分布。它不是把 Draft Model 的答案强行塞给用户。
但工程上仍可能出问题:框架的采样兼容性有限,EOS 和工具调用边界处理不正确,tokenizer 或量化方式不匹配,都会让接受率或输出质量下降。业务如果为了提速偷偷改了采样参数,也已经不是同一个生成分布。
所以质量验收不能只看文本大概一样,还要检查 JSON 合法率、工具调用参数、停止位置和长上下文的关键事实。

PART 02:接受率才是这笔账的核心变量
2.1 不要只问加速几倍,先问每轮推进几步
投机解码最重要的指标不是 Draft Model 的参数量,而是 accepted tokens per step,即每次 Target Model 验证平均能留下多少 token。
常见指标有三个:
- Acceptance rate:被接受的草稿 token 数 / 草稿 token 总数。
- Accepted tokens per step:每轮平均接受多少 token。
- Bonus token rate:候选全部接受后,Target Model 在末尾额外产生 token 的比例。
如果 gamma=4,平均接受率是 0.75,不能机械地说每轮一定接受 3 个。拒绝是从左到右发生的,后面的 token 可能因为前面已经改变而失效。更稳妥的做法是直接统计每轮的 accepted length。
2.2 一个实用的收益模型
记 Cd 为草稿模型单 token 成本,Cv 为目标模型验证 gamma 个位置的成本,Cs 为调度、采样、通信和缓存开销,L 为普通 Decode 单轮成本,K 为投机一轮平均推进 token 数。
Tvanilla ≈ N × L
Tspec ≈ (N / K) × (gamma × Cd + Cv + Cs)
只有当:
K / (gamma × Cd + Cv + Cs) > 1 / L
投机解码才值得。接受率高,不等于一定加速;Draft Model 足够快,也不等于一定加速。
假设普通解码每轮成本为 10,gamma=4,草稿每 token 成本 0.8,目标验证成本 11,调度成本 1:
投机一轮成本 = 4 × 0.8 + 11 + 1 = 15.2
如果一轮平均推进 2 个 token,单位 token 成本是 7.6,理论上有收益;如果 Draft Model 更慢,或者验证成本接近 30,这笔账就会翻负。真实结果还会受 batch size、GPU kernel、显存带宽、跨卡通信和请求长度影响。
2.3 gamma 为什么不是越大越好
gamma 是每轮让 Draft Model 猜多少个 token。猜得越长,一次验证成功后能走得越远;但候选越靠后,分布越容易和 Target Model 拉开差距,草稿成本和临时 KV Cache 也会增加。
- gamma 太小:接受率高,但每轮节省不了多少 Target Decode。
- gamma 太大:偶尔全中时很漂亮,但拒绝点可能很靠前,后面的草稿成本全部浪费。
至少比较 gamma=2、4、8,并按任务分桶:
| 请求类型 | 常见特征 | 重点观察 |
|---|---|---|
| 代码补全 | 局部模式强、重复结构多 | 连续接受长度、换行和缩进 |
| 客服问答 | 模板化程度较高 | 短输出是否被草稿开销拖慢 |
| 开放式创作 | 温度高、分布发散 | 接受率与 P95 TPOT |
| JSON 抽取 | 结构约束强 | JSON 合法率、拒绝点位置 |
2.4 接受率会随输出位置变化
全局 acceptance rate 可能掩盖:开头固定模板容易猜,中间事实内容和长尾表达经常拒绝;短回答的前几个 token 占比高,也会放大平均值。
更有用的观测方式是:
按输出长度:0-32 / 33-128 / 129+
按任务:代码 / 对话 / JSON / 工具调用
按采样:temperature、top-p
按并发:低并发 / 目标并发 / 高并发
低并发接受率 80%、高并发掉到 45% 时,不要急着换模型,先检查两个模型是否争抢显存,以及批处理是否把候选拆碎。

PART 03:写一个可运行的简化实验
真实投机解码需要 GPU、两个模型和推理框架才能测端到端延迟,但我们可以先用离散 token 分布验证机制。这个模拟器只回答三个问题:接受率变化时每轮能推进多少 token,草稿成本变高后什么时候收益消失,以及同样的 gamma 为什么在不同模型相似度下结果不同。
3.1 最小接受/拒绝实现
下面的代码使用纯 Python 标准库。target_probs 和 draft_probs 是同一组 token 的离散概率分布,speculative_step() 模拟一轮投机解码。
from __future__ import annotations
import random
from dataclasses import dataclass
from typing import Dict, List, Sequence
Token = str
Distribution = Dict[Token, float]
def sample_from(probs: Distribution) -> Token:
tokens = list(probs)
weights = [probs[token] for token in tokens]
return random.choices(tokens, weights=weights, k=1)[0]
def normalize(probs: Distribution) -> Distribution:
total = sum(max(value, 0.0) for value in probs.values())
if total == 0:
raise ValueError("probability mass is empty")
return {token: max(value, 0.0) / total for token, value in probs.items()}
def corrected_distribution(target: Distribution, draft: Distribution) -> Distribution:
residual = {
token: max(target.get(token, 0.0) - draft.get(token, 0.0), 0.0)
for token in target
}
return normalize(residual) if sum(residual.values()) else normalize(target)
@dataclass
class StepResult:
accepted: List[Token]
generated: Token
rejected_at: int | None
def speculative_step(
target: Sequence[Distribution],
draft: Sequence[Distribution],
) -> StepResult:
if len(target) != len(draft):
raise ValueError("target and draft must have the same gamma")
candidates = [sample_from(probs) for probs in draft]
accepted: List[Token] = []
for index, candidate in enumerate(candidates):
target_probs = target[index]
draft_probs = draft[index]
p = target_probs.get(candidate, 0.0)
q = draft_probs.get(candidate, 0.0)
acceptance = 1.0 if q == 0 and p > 0 else min(1.0, p / q) if q else 0.0
if random.random() <= acceptance:
accepted.append(candidate)
continue
replacement = sample_from(corrected_distribution(target_probs, draft_probs))
return StepResult(accepted, replacement, index)
bonus = sample_from(target[-1])
return StepResult(accepted, bonus, None)
这个实现保留了关键动作:Draft Model 先采样候选,Target Model 逐个计算接受概率,拒绝后从修正分布采一个新 token,全接受时再产生 bonus token。
它没有实现真实框架里的 KV Cache 复用、批量 logits、CUDA kernel 和流式输出,所以不要把运行时间当成模型 benchmark。它的价值是让"接受率"从一句口号变成可观察的变量。
3.2 跑四组可控实验
可以让草稿分布由目标分布和均匀分布混合,模拟不同模型相似度:
def mix_with_uniform(target: Distribution, similarity: float) -> Distribution:
tokens = list(target)
uniform = 1.0 / len(tokens)
return normalize({
token: similarity * target[token] + (1 - similarity) * uniform
for token in tokens
})
def run_experiment(similarity: float, gamma: int, rounds: int = 2000) -> None:
target_one_step = normalize({
"A": 0.45, "B": 0.25, "C": 0.12,
"D": 0.08, "E": 0.06, "F": 0.04,
})
target = [target_one_step for _ in range(gamma)]
draft = [mix_with_uniform(target_one_step, similarity) for _ in range(gamma)]
accepted = 0
progressed = 0
rejected_rounds = 0
for _ in range(rounds):
result = speculative_step(target, draft)
accepted += len(result.accepted)
progressed += len(result.accepted) + 1
rejected_rounds += result.rejected_at is not None
print({
"similarity": similarity,
"gamma": gamma,
"acceptance_rate": round(accepted / (rounds * gamma), 3),
"avg_progress": round(progressed / rounds, 3),
"reject_round_rate": round(rejected_rounds / rounds, 3),
})
if __name__ == "__main__":
random.seed(7)
for similarity in (0.35, 0.65, 0.90):
for gamma in (2, 4, 8):
run_experiment(similarity, gamma)
相似度提高时,接受率和平均推进 token 数通常上升;gamma 增大后,平均推进可能先增加,再因为拒绝变多而趋于平缓。
这段代码没有给出"固定加速 1.8 倍"的结论。要估算 speedup,还要把成本加入模型:
def estimated_cost(
rounds: int,
gamma: int,
draft_cost: float,
verify_cost: float,
schedule_cost: float,
) -> float:
return rounds * (gamma * draft_cost + verify_cost + schedule_cost)
只要把 draft_cost 提高,或者把 verify_cost 设置得接近多个普通 Decode 轮次,原本漂亮的加速曲线就会掉下来。
3.3 这个模拟器没有告诉你的事
它没有告诉你真实 GPU 上的 kernel 是否高效,也没有模拟:
- 两个模型是否能在同一批次中高效调度。
- 草稿被拒绝后,临时 KV Cache 如何回收。
- 双模型占用显存后,其他请求是否被迫排队。
- 跨卡部署时,候选 token 和隐藏状态传输需要多少时间。
- 流式输出时,用户是否会感知到验证批次的节奏变化。
正确顺序是:先用模拟器理解变量,再用离线请求集测接受率,最后在目标硬件上做端到端压测。
PART 04:真实推理引擎里,速度不是只由算法决定
4.1 Draft Model 放在哪里
最简单的部署方式是同一进程、同一张 GPU 上运行两个模型。好处是通信少;坏处是显存压力大,两个模型会争抢计算和 KV Cache。
第二种方式是把两个模型放到不同 GPU。目标模型显存更充足,但每轮投机都要跨卡传递候选或中间结果。并发提高后,通信排队和同步会进入 P99。
第三种方式是把 Draft Model 做成独立服务。它便于弹性扩缩容,也更容易给多个 Target Model 共享,但网络、序列化、请求关联和失败重试都要计入成本。
| 部署方式 | 优点 | 主要风险 |
|---|---|---|
| 同卡同进程 | 通信少、实现简单 | 显存和算力争抢 |
| 双卡部署 | 目标模型空间更充足 | 跨卡同步、资源利用率不稳定 |
| 独立 Draft 服务 | 弹性和复用性好 | 网络延迟、服务级联、故障处理 |
4.2 KV Cache 预算不能只看模型权重
投机解码会产生一段候选路径。Target Model 验证时,系统需要为候选位置准备临时状态,并在接受或拒绝后保留正确前缀、释放无效分支。
显存预算至少要分成:
模型权重
+ active request 的 KV Cache
+ Prefix Cache 或其他共享缓存
+ speculative decoding 的临时状态
+ 框架运行时和通信缓冲
如果只看到 Target Model 的 KV Cache 还能放下 32 个请求,就直接开启投机解码,很可能在高并发时遇到抢占、驱逐或 OOM。
4.3 它和 Continuous Batching 如何相处
投机解码让某些请求一次推进多个 token,会改变批内请求的节奏:
- 短请求可能更快完成,较早释放 KV Cache。
- 长请求可能连续占用验证预算,影响其他请求的公平性。
- 不同请求的 gamma 不同,批处理形状更不规则。
- 验证批次变大,吞吐可能改善,但单请求等待时间也可能上升。
因此要同时看 TPOT、P95/P99 延迟、完成请求数和 GPU 利用率。单请求 benchmark 上的加速,不代表混合长短请求后的服务体验也会改善。
4.4 它和 Prefix Cache 解决的是不同阶段
Prefix Cache 主要减少不同请求之间重复的 Prefill;投机解码主要减少单个请求 Decode 的轮数。一个请求可以先从稳定前缀命中点开始,再用 Draft Model 猜后面的输出。
但两者也会争抢显存。Prefix Cache 留得太多,可能挤压投机临时状态;候选开得太长,也可能让前缀缓存的可用容量下降。
比较稳妥的调优顺序是:
- 先固定 Prompt 结构,测清楚 Prefix Cache 的复用收益。
- 关闭投机解码,建立 vanilla decoding 的真实 baseline。
- 在相同缓存和并发配置下,逐步增加 Draft Model 与 gamma。
- 最后评估两种优化同时开启时,P99 和 goodput 是否仍然改善。
4.5 采样参数会改变接受率
温度越高,Target Model 的分布通常越发散;top-p 截断也会改变候选 token 的概率质量。Draft Model 如果没有使用一致的 tokenizer、采样策略和停止条件,分布差异会直接反映到接受率上。
建议把这些参数写入结果文件:
model / draft_model / tokenizer
temperature / top_p / top_k
gamma / max_new_tokens
hardware / dtype / quantization
concurrency / prompt_tokens / output_tokens
否则下一次换了采样参数,发现速度变化,很难判断是 Draft Model 不合适,还是请求分布变了。
4.6 推理框架落地不要照抄一条命令
不同版本、不同后端对 speculative decoding 的支持方式并不完全一致。有的需要指定 Draft Model,有的通过 speculative config 传递候选长度,有的还区分 n-gram、独立草稿模型和特定架构。
概念性的配置骨架可以写成:
{
"draft_model": "<small-model>",
"num_speculative_tokens": 4,
"method": "draft-model",
"temperature": 0.2
}
启动前要用目标版本的 --help 和官方文档确认字段名称、后端限制和采样兼容性。真正的验收不是服务启动成功,而是日志里能看到候选 token、接受 token、回退次数和目标模型 forward 次数发生变化。

PART 05:哪些任务适合,哪些任务不适合
5.1 更容易受益的任务
代码补全通常是好候选。代码有明显局部模式,缩进、括号、常见 API 调用和重复结构让相邻 token 的分布更集中。
结构化输出也有机会受益。JSON 的括号、字段名和固定格式让候选路径更规整,但质量验收必须严格:少一个逗号、提前闭合一个括号,都可能让业务解析失败。
模板化客服问答在低温度下可能有不错的接受率,尤其是开头的礼貌语和固定流程。不过回答较短时,草稿成本占比会变高。
5.2 不一定受益的任务
开放式创作、强随机采样和频繁调用工具的 Agent 轨迹,通常更难保持连续猜测。工具调用前后的上下文变化、参数校验和外部结果插入,都会让候选迅速过时。
短回答也要谨慎。假设用户只需要 12 个 token,即使投机解码把每轮推进从 1 提升到 3,启动、同步和缓存管理成本可能已经占掉大部分收益。
Draft Model 和 Target Model 参数量接近,也不代表分布接近。训练数据、词表、对齐方式和领域风格的差异都会影响接受率。
5.3 低接受率时的症状
当投机解码不适合当前流量时,通常能看到:
- accepted_tokens / draft_tokens 很低,按任务分桶后仍低。
- Target Model forward 次数没有明显下降。
- GPU 利用率上升,但 TPOT 和 P99 变差。
- Draft Model 占用了显存,却没有换来更高的 goodput。
- 拒绝点集中出现在工具调用、JSON 边界或长上下文事实回答处。
这时不要只把 gamma 调大。先确认请求类型是否适合,再比较 Draft Model,最后才调 gamma 和并发。
5.4 一个上线前的场景矩阵
| 场景 | 预计接受率 | 建议 gamma | 初步判断 |
|---|---|---|---|
| 低温代码补全 | 高 | 4-8 | 值得优先试验 |
| 固定模板客服 | 中高 | 2-4 | 关注短输出收益 |
| JSON 抽取 | 中 | 2-4 | 质量门槛高,需严格回归 |
| 长文开放式创作 | 低到中 | 2 | 先小流量验证 |
| 多轮工具调用 | 波动大 | 1-2 | 默认关闭,按路由灰度 |
这张表只能帮助你排实验优先级,不能替代真实流量数据。最终决定权应该交给 acceptance rate、TPOT、P99、质量回归和 goodput。
投机解码不是速度开关,而是一笔随输出分布波动的风险投资。

PART 06:上线前的验收清单
6.1 先建立不带投机的 baseline
至少固定这些条件:
- Target Model、tokenizer、dtype 和量化配置。
- Prompt token 数、输出长度分布和停止条件。
- temperature、top-p、top-k 等采样参数。
- GPU 型号、并发数、请求到达率和 Continuous Batching 配置。
- Prefix Cache 是否开启,以及缓存容量和命名空间。
没有 baseline,看到 tokens/s 上升时,不能确定是投机解码带来的,还是并发、缓存命中或请求变短造成的。
6.2 必须记录的指标
| 指标 | 用来回答什么问题 |
|---|---|
| draft_tokens | 草稿模型实际猜了多少 token |
| accepted_tokens | Target Model 接受了多少 |
| acceptance_rate | 猜测和目标分布是否接近 |
| accepted_tokens_per_step | 每轮真正向前推进多少 |
| target_forward_calls | 目标模型调用轮数是否下降 |
| fallback_count | 多少请求切回普通解码 |
| KV cache watermark | 投机状态是否挤压其他请求 |
| P50/P95/P99 TPOT | 用户等待是否真的改善 |
| goodput | 满足 SLO 的完成请求数是否增加 |
其中容易漏掉的是 fallback_count。有些系统在接受率很低时自动退回 vanilla decoding,只看总吞吐会误以为投机配置一直在工作。
6.3 灰度和自动回滚
不要一上来把所有流量切到投机解码,可以先按请求类型或租户灰度:
离线请求集
-> 低并发线上回放
-> 5% 真实流量
-> 20% 目标任务流量
-> 按 acceptance rate 和 P99 自动升降级
连续多个时间窗口中,某类请求的 acceptance rate 低于阈值,或者 P95/P99 TPOT 相比 baseline 恶化,就关闭该路由的 speculative decoding;质量回归失败时直接回滚,不等待性能指标。
上线门槛最好写成具体条件:
- 目标任务的 P50/P95 TPOT 有稳定改善。
- P99 没有超过现有 SLO,高并发下不出现大幅抖动。
- JSON 合法率、工具调用成功率和关键事实准确性通过回归。
- KV Cache 保留足够余量,OOM、抢占和驱逐没有增加。
- goodput 提升,而不是只有 GPU 利用率和峰值 tokens/s 变好。
6.4 十项上线前检查
- Target Model 与 Draft Model 的 tokenizer、EOS 和模板配置一致。
- 已保存 vanilla decoding 的可复现 baseline。
- 已按任务类型统计 acceptance rate。
- 已测试 gamma=2、4、8 或对应候选范围。
- 已测短输出、长输出和高并发混合流量。
- 已记录两个模型和临时 KV Cache 的显存水位。
- 已验证温度、top-p、top-k 等采样参数兼容性。
- 已验证 JSON、工具调用和停止条件。
- 已配置低接受率和 P99 恶化时的自动回滚。
- 已把成功、拒绝、fallback 和异常样本留存下来复盘。

结尾:猜得准才有资格提速
投机解码真正减少的,不是模型要生成的 token 数,而是 Target Model 为了生成这些 token 必须执行的 Decode 轮数。
它把一次大模型推理拆成两个角色:一个负责快速提出短期猜测,一个负责用更高质量的分布做验证。猜得准、猜得快、缓存和调度扛得住,才有可能把"每轮一个 token"变成"每轮多个 token"。
如果接受率低,或者草稿模型本身不够便宜,那些看起来很聪明的候选 token,最后只会变成另一笔延迟账单。
真正值得记住的三条原则是:
- 先用真实流量测接受率,不要凭参数量猜收益。
- 把 gamma、采样参数、KV Cache 和 batching 放进同一张成本表。
- 用 P95/P99、质量和 goodput 验收,而不是只展示 tokens/s 峰值。
推理优化的关键,不是让模型永远少算,而是让每一次少算都能被指标证明。
互动时间:如果给你的业务配一个 Draft Model,你最想先试代码补全、客服问答,还是结构化抽取?
下一篇预告:当 Prefill 和 Decode 开始互相争抢 GPU,接下来要拆的是 Prefill/Decode 分离与 disaggregated serving。
--- END ---
苦猿 · 帮普通人把 AI 学进简历