投机采样 Speculative Decoding 核心笔记
1. 投机采样是什么?
投机采样,也叫 Speculative Decoding / Speculative Sampling ,是一种 部署推理阶段的解码加速方法。
核心思想是:
text
小模型先生成几个 draft token;
大模型一次性并行验证这些 draft token;
能接受的 token 直接保留;
不能接受的位置由大模型修正。
它的目标不是提升模型能力,而是减少大模型逐 token 自回归生成的次数。
普通解码:
text
大模型生成 token1
大模型生成 token2
大模型生成 token3
大模型生成 token4
投机采样:
text
小模型先猜 token1 token2 token3 token4
大模型一次 forward 验证这几个 token
如果小模型猜得准,大模型一次 forward 就可以推进多个 token。
2. 小模型输出的 draft token 是怎么来的?
小模型的 draft token 是它自己推理出来的。
小模型仍然按照普通自回归方式逐 token generate:
text
当前上下文 C
小模型生成 d0
当前上下文 C + d0
小模型生成 d1
当前上下文 C + d0 + d1
小模型生成 d2
当前上下文 C + d0 + d1 + d2
小模型生成 d3
对应分布是:
text
q(d0 | C)
q(d1 | C, d0)
q(d2 | C, d0, d1)
q(d3 | C, d0, d1, d2)
其中 q 表示小模型分布。
所以投机采样不是小模型"一次性凭空输出多个 token",而是:
text
小模型串行生成 K 个 draft token;
大模型并行验证这 K 个 draft token。
3. 大模型使用的是小模型的 hidden state 吗?
不是。
大模型不会复用小模型的 hidden state、KV cache 或中间特征。
小模型只提供:
text
draft token id
以及这些 token 在小模型分布下的概率 q
大模型拿到的是 token 序列:
text
C + d0 + d1 + d2 + d3
然后大模型自己重新 forward,计算自己对这些 token 的概率。
也就是说:
text
小模型输出的是 token id,不是 feature。
4. 大模型为什么可以一次性验证多个 token?
因为 Transformer 的 causal mask 允许模型一次性输入完整序列,同时保证每个位置只能看到左侧上下文。
假设当前上下文是:
text
C = prompt
小模型 draft:
text
D = [d0, d1, d2, d3]
大模型输入:
text
[C, d0, d1, d2, d3]
大模型一次 forward 后得到每个位置的 logits:
text
C 最后一个位置 logits -> p(d0 | C)
d0 位置 logits -> p(d1 | C, d0)
d1 位置 logits -> p(d2 | C, d0, d1)
d2 位置 logits -> p(d3 | C, d0, d1, d2)
d3 位置 logits -> p(next | C, d0, d1, d2, d3)
causal mask 保证:
text
d1 位置不能看到 d2、d3;
d2 位置不能看到 d3;
每个位置只能看到自己及左侧 token。
所以它在计算上是并行的,但在概率语义上仍然是自回归的。
5. causal mask 是怎么保证自回归语义的?
Self-Attention 的核心公式是:
text
Attention(Q, K, V) = softmax(QK^T / sqrt(d) + M) V
其中 M 是 causal mask。
假设序列是:
text
t0, t1, t2, t3, t4
causal mask 允许的位置是:
text
t0 t1 t2 t3 t4
t0 ✓ × × × ×
t1 ✓ ✓ × × ×
t2 ✓ ✓ ✓ × ×
t3 ✓ ✓ ✓ ✓ ×
t4 ✓ ✓ ✓ ✓ ✓
也就是说:
text
位置 0 只能看位置 0;
位置 1 只能看位置 0、1;
位置 2 只能看位置 0、1、2;
位置 3 只能看位置 0、1、2、3。
在实现上,被 mask 的未来位置会被加上 -inf,softmax 后概率变成 0。
所以每个位置虽然一起计算,但信息流仍然满足:
text
当前位置只能依赖自己和左侧历史 token。
这就是:
text
计算并行,语义自回归。
6. 大模型验证第二个 draft token 时,是否用了第一个 draft token?
是的。
验证第二个 draft token d1 时,大模型计算的是:
text
p(d1 | C, d0)
也就是说,大模型确实在假设 d0 已经成立的前提下,计算 d1 的概率。
但关键点是:
text
p(d1 | C, d0) 只有在 d0 被接受后才有效。
如果 d0 被拒绝,那么基于 d0 计算出来的所有后续 logits 都会被丢弃。
7. draft token 如何判断是否被大模型接受?
标准 speculative sampling 使用接受概率:
text
alpha_i = min(1, p_i(d_i) / q_i(d_i))
其中:
text
p_i(d_i):大模型在当前位置给 draft token 的概率
q_i(d_i):小模型在当前位置给 draft token 的概率
然后采样一个随机数:
text
r ~ Uniform(0, 1)
如果:
text
r <= alpha_i
则接受该 draft token;否则拒绝。
直观理解:
text
如果大模型比小模型更认可这个 token,则一定接受;
如果小模型比大模型更偏向这个 token,则按比例接受。
例如:
text
q("右转") = 0.50
p("右转") = 0.25
接受概率是:
text
alpha = min(1, 0.25 / 0.50) = 0.5
所以有 50% 概率接受 "右转"。
8. 多个 draft token 是怎么验证的?
多个 draft token 不是独立全部验证,而是从左到右验证连续前缀。
假设:
text
draft = [d0, d1, d2, d3]
验证流程是:
text
先验证 d0
如果 d0 接受,再验证 d1
如果 d0 拒绝,d1/d2/d3 全部丢弃
再验证 d1
如果 d1 接受,再验证 d2
如果 d1 拒绝,d2/d3 全部丢弃
再验证 d2
如果 d2 接受,再验证 d3
如果 d2 拒绝,d3 丢弃
原因是后面的 logits 依赖前面的 draft token。
例如:
text
p(d2 | C, d0, d1)
只有在 d0 和 d1 都被接受时才合法。
所以投机采样接受的是:
text
连续接受前缀
而不是任意位置独立接受。
9. 如果第一个 draft token 被拒绝,用什么继续生成?
假设第一个 token d0 被拒绝。
那么后面的:
text
p(d1 | C, d0)
p(d2 | C, d0, d1)
p(d3 | C, d0, d1, d2)
全部丢弃。
这时使用的是当前位置的大模型分布:
text
p(. | C)
以及小模型分布:
text
q(. | C)
构造修正分布:
text
p'(x) = max(0, p(x | C) - q(x | C)) / sum_x max(0, p(x | C) - q(x | C))
然后从这个修正分布中采样一个新 token,记作 r0。
最终本轮输出:
text
r0
而不是:
text
d0, d1, d2, d3
10. 具体例子:第一个 draft token 被拒绝后用什么?
假设当前上下文是:
text
C = <image> + prompt + 已经生成 "否;3;"
现在模型要生成下一个字段,也就是动作:
text
直行 / 右转 / 左转
小模型在当前位置给出的概率分布是:
text
q_small:
直行 = 0.20
右转 = 0.60
左转 = 0.20
小模型采样到了:
text
d0 = 右转
然后小模型继续 draft 后面的 token,例如:
text
draft = ["右转", ";", "5"]
于是大模型一次性验证:
text
C + "右转" + ";" + "5"
大模型会得到:
text
p("右转" | C)
p(";" | C, "右转")
p("5" | C, "右转", ";")
p(next | C, "右转", ";", "5")
但是大模型当前位置的真实分布可能是:
text
p_big:
直行 = 0.50
右转 = 0.20
左转 = 0.30
也就是说,大模型并不太认可 "右转"。
10.1 判断 "右转" 是否接受
接受概率是:
text
alpha = min(1, p_big("右转") / q_small("右转"))
代入数值:
text
alpha = min(1, 0.20 / 0.60) = 1/3
也就是说:
text
"右转" 只有 1/3 概率被接受
如果随机数导致拒绝,例如:
text
r = 0.70 > 1/3
那么:
text
d0 = "右转" 被拒绝
10.2 d0 被拒绝后,后面的 logits 为什么不能用?
大模型前面算出来的这些分布是:
text
p(";" | C, "右转")
p("5" | C, "右转", ";")
但 "右转" 已经被拒绝了。
所以真实上下文不会变成:
text
C + "右转"
而会变成:
text
C + r0
其中 r0 是重新采样出来的新 token。
因此:
text
p(";" | C, "右转")
p("5" | C, "右转", ";")
全部不能继续使用。
10.3 那到底用什么?
用当前位置的修正分布:
text
p_corrected(x) = normalize(max(0, p_big(x) - q_small(x)))
把两个分布写出来:
text
q_small:
直行 = 0.20
右转 = 0.60
左转 = 0.20
p_big:
直行 = 0.50
右转 = 0.20
左转 = 0.30
计算:
text
p_big - q_small:
直行 = 0.50 - 0.20 = 0.30
右转 = 0.20 - 0.60 = -0.40
左转 = 0.30 - 0.20 = 0.10
对负数截断为 0:
text
max(0, p_big - q_small):
直行 = 0.30
右转 = 0
左转 = 0.10
归一化:
text
sum = 0.30 + 0 + 0.10 = 0.40
所以修正分布是:
text
p_corrected:
直行 = 0.30 / 0.40 = 0.75
右转 = 0 / 0.40 = 0
左转 = 0.10 / 0.40 = 0.25
也就是:
text
拒绝 "右转" 后,不是直接从大模型原分布采样,
而是从 corrected distribution 采样:
直行 75%
右转 0%
左转 25%
如果这次从修正分布采样得到:
text
r0 = 直行
那么本轮最终输出就是:
text
直行
后面的 draft:
text
";", "5"
全部丢弃。
下一轮重新基于新上下文:
text
C + "直行"
继续 draft 和验证。
11. 为什么拒绝后 "右转" 的概率变成 0?
因为小模型已经过度提出了 "右转"。
在这个例子里:
text
q_small("右转") = 0.60
p_big("右转") = 0.20
小模型对 "右转" 的概率质量比大模型多了很多。
接受/拒绝机制已经给了 "右转" 一部分机会:
text
接受概率 = 0.20 / 0.60 = 1/3
如果这次被拒绝,说明 "右转" 在拒绝分支里不能再拿概率质量。
因此修正分布里:
text
右转 = max(0, 0.20 - 0.60) = 0
这正是为了保证最终整体采样分布仍然等价于大模型原始分布。
12. 如果是 greedy decoding,处理更简单
上面讲的是 sampling 情况。
如果是 greedy decoding,也就是每一步都取大模型最大概率 token,那么不需要修正分布。
当前大模型分布是:
text
p_big:
直行 = 0.50
右转 = 0.20
左转 = 0.30
大模型 argmax 是:
text
直行
小模型 draft 是:
text
右转
因为:
text
右转 != 直行
所以拒绝 "右转",直接输出:
text
直行
后面的 draft token 全部丢弃。
13. 为什么拒绝后不能直接从大模型分布 p 采样?
因为小模型提出 token 并被接受/拒绝的过程已经消耗了一部分概率质量。
如果拒绝后再直接从 p 采样,会破坏最终采样分布,使某些 token 的概率被重复计算。
修正分布:
text
max(0, p - q)
表示:
text
大模型有、但小模型没有覆盖好的那部分概率质量。
所以严格 speculative sampling 中,拒绝后要从:
text
normalize(max(0, p_big - p_small))
中采样。
如果是 greedy decoding,则可以更简单:
text
如果 draft token != 大模型 argmax,则直接输出大模型 argmax。
14. 如果第一个 draft token 被拒绝,本轮是不是只输出一个 token?
是的。
如果 draft_len = 4:
text
小模型猜:d0 d1 d2 d3
大模型发现 d0 被拒绝
那么:
text
d0 d1 d2 d3 全部不用
从修正分布采样 r0
本轮只输出 r0
这就是投机采样的最坏退化情况。
它不是 bug,而是正常逻辑。
15. 如果应用场景需要固定长度输出怎么办?
固定长度输出不是要求"一轮投机采样必须输出固定长度",而是多轮循环,直到累计输出达到目标长度。
例如目标输出 4 个 token:
python
generated = []
while len(generated) < 4:
remain = 4 - len(generated)
draft_len = min(num_speculative_tokens, remain)
# 小模型 draft draft_len 个 token
# 大模型验证
# 接受若干 token,或者拒绝后修正采样一个 token
# 加入 generated
如果第一轮只输出 1 个 token,下一轮继续生成剩余 token。
核心逻辑是:
text
每轮推进 token 数不固定;
最终累计长度受 max_new_tokens / stop condition 控制。
16. 固定字段不等于固定 token
例如结构化输出:
text
否;3;右转;5
看起来是 4 个字段,但不一定是 4 个 token。
可能 tokenizer 结果是:
text
["否", ";", "3", ";", "右", "转", ";", "5"]
也可能是:
text
["否", ";", "3", ";", "右转", ";", "5"]
所以业务上不应该简单用"4 个 token"作为停止条件,而应该使用:
text
1. max_new_tokens
2. stop string
3. eos token
4. 格式解析成功
对于结构化任务,更推荐:
text
生成到第一行结束;
解析出固定字段;
字段合法则停止。
17. "如果全部接受,还可以顺手采样一个额外 next token"是什么意思?
当大模型输入:
text
C + d0 + d1 + d2 + d3
它会输出:
text
p(d0 | C)
p(d1 | C, d0)
p(d2 | C, d0, d1)
p(d3 | C, d0, d1, d2)
p(next | C, d0, d1, d2, d3)
前四个分布用于验证 draft token。
最后一个分布:
text
p(next | C, d0, d1, d2, d3)
是 draft 序列之后的下一个 token 分布。
如果 d0, d1, d2, d3 全部被接受,那么上下文确实变成:
text
C + d0 + d1 + d2 + d3
此时最后一个 logits 就可以直接用来采样额外的 next token。
但是这只适用于还有生成预算的情况。
如果固定长度已经满足,就不能再采样额外 token。
18. 对固定长度任务,额外 next token 要不要用?
如果目标长度已经达到,就不要用。
例如目标只需要 4 个 token:
text
draft = [d0, d1, d2, d3]
如果大模型全部接受:
text
输出已经达到 4 个 token
此时直接停止,不要再用最后 logits 采样额外 token。
正确逻辑是:
python
if generated_len >= target_len:
stop()
else:
# 才考虑使用 next logits
sample_next_token()
19. 小模型如果全部猜错,会不会影响大模型结果?
标准 speculative sampling 中,不会影响理论输出分布。
因为小模型只是提出候选,大模型负责验证。
如果小模型猜错:
text
大模型拒绝 draft token;
错误 token 不会进入最终上下文;
后续基于错误 token 的 logits 会被丢弃。
所以小模型不会"污染"大模型结果。
真正受影响的是速度:
text
小模型猜错越多;
接受率越低;
平均每轮推进 token 数越少;
投机采样越可能变慢。
20. 为什么有人说"最坏情况也不会比标准解码慢"是不严谨的?
这个说法容易混淆:
text
大模型 forward 次数
和:
text
端到端推理耗时
如果第一个 draft token 被拒绝:
标准解码推进 1 个 token:
text
大模型 decode 1 次
投机采样推进 1 个 token:
text
小模型 decode K 次
大模型 verify K 个 token 1 次
额外接受/拒绝逻辑
虽然大模型调用次数也是 1 次,但投机采样额外运行了小模型,而且大模型 verify K token 通常比普通 decode 1 token 更重。
所以更准确的说法是:
text
最坏情况下,大模型调用次数没有增加;
但端到端耗时可能更慢。
投机采样不是保证最坏情况不变慢,而是依赖平均接受率足够高时加速。
21. 大模型一次验证 K 个 token 的耗时,是否等于普通 generate 一个 token?
不等于。
普通后续 decode 一个 token 时:
text
query_len = 1
key_value_len = context_len + 1
投机采样验证 K 个 draft token 时:
text
query_len = K
key_value_len = context_len + K
所以验证 K 个 token 通常比普通 decode 1 个 token 更慢。
但它通常小于连续 K 次普通 decode 的总耗时:
text
T_decode_1 < T_verify_K < K * T_decode_1
前提是框架实现比较高效。
22. 大模型验证 K 个 token 和 generate 第一个 token 耗时一样吗?
不一样。
普通 generate 第一个 token 通常是 prefill:
text
输入完整 prompt / image tokens / text tokens
一次性计算完整上下文
这一步通常最贵。
投机采样验证 K 个 draft token 时,一般已经有上下文 KV cache:
text
input_ids = [d0, d1, d2, d3]
past_key_values = KV(C)
此时只处理 draft token 的 query,不需要重新计算 prompt/image 的 KV。
所以一般耗时关系是:
text
普通单 token decode < 投机 verify K token < 完整 prefill 第一个 token
23. 投机采样的收益条件
投机采样要加速,需要满足:
text
K * T_small_decode + T_big_verify_K + overhead < A * T_big_decode_1
其中:
text
K:小模型 draft token 数
A:本轮实际推进 token 数
如果全部接受:
text
A = K
如果还能额外采样 next token:
text
A = K + 1
如果第一个就拒绝:
text
A = 1
所以是否加速,关键看:
text
1. draft model 是否足够快
2. acceptance rate 是否足够高
3. 平均每轮实际推进 token 数是否足够多
4. verify K token 是否明显小于 K 次 decode
24. 小模型和大模型的参数量怎么选?
经验上,draft model 通常应该明显小于 target model。
常见比例:
text
draft model ≈ target model 的 1/5 到 1/10
例如:
text
7B target + 0.5B / 1B / 1.5B draft
14B target + 1.5B / 3B draft
70B target + 7B / 13B draft
如果大模型是 1B,小模型是 0.5B:
text
target = 1B
draft = 0.5B
这个比例通常不太划算。
原因是两者只差 2 倍,draft model 不够便宜。
例如 draft_len = 4:
普通解码近似成本:
text
4 × 1B = 4B
投机采样近似成本:
text
4 × 0.5B + 1 × 1B = 3B
看起来省一点,但实际还有:
text
大小模型调度开销
KV cache 开销
大模型 verify K token 成本
拒绝带来的浪费
所以实际收益可能很小,甚至变慢。
25. 小模型结构需要和大模型一致吗?
理论上不需要。
投机采样不要求:
text
hidden size 一致
层数一致
attention head 数一致
FFN 结构一致
RoPE 参数一致
是否 GQA/MQA 一致
因为大模型不复用小模型的内部特征。
核心要求是:
text
小模型能生成 draft token;
小模型能给出 q(d | C);
大模型能计算 p(d | C);
两者 token 分布可以比较。
所以比结构一致更重要的是:
text
tokenizer 一致
chat template 一致
prompt 语义一致
输出分布接近
工程上通常选择同家族模型,不是因为结构必须一致,而是因为:
text
tokenizer 更可能一致;
模板更一致;
分布更接近;
接受率更高。
26. 大小模型的 prompt 需要一致吗?
通常需要一致。
标准投机采样中,小模型和大模型面对的是同一个上下文:
text
C = prompt + 已接受的历史输出
小模型根据 C 生成:
text
q(d_i | C)
大模型根据同一个 C 验证:
text
p(d_i | C)
如果两者 prompt 不一致,那么比较的就不是同一个条件分布下的概率。
这会导致:
text
接受率下降;
分布校正变复杂;
甚至破坏严格采样正确性。
所以推荐:
text
同 tokenizer
同 chat template
同 system prompt
同 user prompt
同已生成历史
对于 VLM,最好还包括:
text
同一张图
同 image processor
同图像 token 处理逻辑
27. VLM 场景下如何理解投机采样?
如果 target 是 VLM:
text
image + text prompt -> answer
draft model 有两种选择。
方案一:小 VLM 作为 draft
text
target: 大 VLM
draft: 小 VLM
优点:
text
小模型也能看图;
语义 token 接受率更高。
缺点:
text
小 VLM 也要跑视觉 encoder;
成本未必低。
方案二:纯 LLM 作为 draft
text
target: VLM
draft: text-only LLM
优点:
text
小模型很便宜;
格式 token 可能猜得准。
缺点:
text
看不到图像;
图像相关 token 接受率可能低。
比如 VLN 输出:
text
否;3;右转;5
纯 LLM 可能容易猜中:
text
分号
固定格式
常见短语
但很难可靠猜中:
text
是否到达出口
距离编号
直行/左转/右转
角度编号
所以对短结构化 VLM 输出,投机采样未必是最佳加速方案。
28. 投机采样是训练阶段还是部署阶段使用?
主要是部署推理阶段使用。
它属于:
text
inference-time decoding acceleration
也就是模型训练完成后,在生成 token 的过程中使用。
经典投机采样不要求重新训练大模型。
流程是:
text
训练大模型
导出/部署大模型
选择一个小 draft model
推理阶段开启 speculative decoding
不过有些变体会涉及训练额外模块,例如:
text
Medusa
EAGLE
LayerSkip
self-speculative decoding
这些方法可能需要训练额外 head 或 draft module。
但它们最终目的仍然是推理阶段加速。
29. LLaMA-Factory 里有投机采样吗?
LLaMA-Factory 本体主要是训练和微调框架。
它常见推理后端是:
text
huggingface
vllm
LLaMA-Factory 自己没有特别明确的原生 speculative decoding 配置入口。
更合理的工程路线是:
text
LLaMA-Factory 负责训练 / LoRA / merge / export;
vLLM / SGLang / TensorRT-LLM 负责部署推理;
在推理引擎里开启 speculative decoding。
如果通过 vLLM,有可能使用 vLLM 自己的 speculative decoding 配置,例如 draft model、num_speculative_tokens 等。
30. 对短结构化 VLM/VLN 任务是否推荐投机采样?
如果输出是:
text
否;3;右转;5
这类极短结构化结果,投机采样通常不是第一优先级。
原因:
text
输出 token 很少;
decode 阶段占比低;
VLM 的主要耗时可能在 image encoder 和 prefill;
小模型 draft 调度开销可能抵消收益。
更推荐优先优化:
text
1. 降低 image_max_pixels
2. 减少 visual token
3. 缩短 prompt
4. 使用 KV cache
5. 使用 FlashAttention / SDPA
6. 使用 vLLM / continuous batching
7. 做 prefix cache
8. 做权重量化
9. 控制 max_new_tokens
10. 使用结构化输出约束
投机采样更适合:
text
长文本生成
长 CoT
代码生成
长轨迹 token 序列
多句解释
decode 阶段占主要耗时的场景
最终总结
投机采样的本质是:
text
小模型先便宜地提出多个候选 token;
大模型一次并行验证;
接受连续前缀;
遇到拒绝则用修正分布重新采样;
多轮循环直到满足停止条件。
它的关键不在于"小模型是否完全正确",而在于:
text
平均每轮能接受多少 token。
如果接受率高:
text
一次大模型 forward 推进多个 token,速度提升。
如果接受率低:
text
小模型计算浪费;
大模型 verify K token 也更重;
端到端可能变慢。
所以投机采样不是无条件加速,而是一个依赖场景、模型组合、输出长度和推理引擎实现的 decode 优化方法。