投机采样为什么能保持同分布:用三个词元把残差算出来
“无损加速”最容易被误解成两件事:输出文本逐字不变,或者小模型猜错了也无所谓。随机采样下,真正要保留的是目标模型的输出分布。草稿模型负责提出候选,接受规则和拒绝后的补偿分布共同保证结果;漏掉后半段,就不是原来的算法。
投机解码的早期论文已给出保持目标分布的采样方法。Leviathan 等人的论文,2022/2023 2026 年 10 月 14 日,vLLM 的自适应验证说明又提醒了一个工程风险:根据已经抽中的候选自身概率决定是否裁掉它,可能改变候选分布,不能直接沿用原来的接受公式。vLLM 官方说明
三个词元足够看清补偿
设目标分布 p=(0.5,0.3,0.2),草稿分布 q=(0.2,0.5,0.3),词表只有 A、B、C。草稿抽到 x 后,以min(1,p(x)/q(x))的概率接受。
直接接受的概率质量是 min(p,q)=(0.2,0.3,0.2),总量只有 0.7。剩余 0.3 要从正残差 max(p−q,0)=(0.3,0,0) 中补回。将残差归一化,得到拒绝后应采样的分布 r=(1,0,0)。于是总输出又回到 (0.5,0.3,0.2)。
图中的残差分支省略了归一化步骤;实际抽样必须先除以残差总和。若 p=q,接受概率总量为 1,拒绝分支不会发生,不能让程序在不可达分支里除以零后产生 NaN。
用精确分数验证,不用随机频率凑答案
这段代码枚举全部概率质量,不调用模型,也不依赖随机种子。保存为 Python 文件即可运行。
from fractions import Fraction as F p = [F(5, 10), F(3, 10), F(2, 10)] q = [F(2, 10), F(5, 10), F(3, 10)] assert sum(p) == sum(q) == 1 accepted = [min(a, b) for a, b in zip(p, q)] reject = 1 - sum(accepted) residual = [max(a - b, F(0)) for a, b in zip(p, q)] assert sum(residual) == reject if reject: r = [x / reject for x in residual] output = [a + reject * b for a, b in zip(accepted, r)] else: output = accepted assert output == p print("accepted:", list(map(str, accepted))) print("reject:", str(reject)) print("output:", list(map(str, output)))代码证明的是这组离散分布的代数关系。把词表换大,公式仍要求 p、q 对应同一个候选上下文,并且使用实际参与抽样的归一化分布。温度、Top-P 或其他 logits 处理如果改变了分布,就必须体现在这些概率里。
三种“相同”不要混用
| 说法 | 能证明什么 | 不能顺便推出什么 |
|---|---|---|
| 分布相同 | 无限重复采样的理论概率一致 | 固定种子下文本逐字一致 |
| 单次答案一致 | 这一条运行产生相同文本 | 任意输入下分布一致 |
| 任务分数接近 | 指定数据集上的指标相近 | 算法严格无偏、尾部行为不变 |
同分布也不意味着同耗时。草稿生成、目标验证、KV 管理和通信都有成本。高并发下,验证可能占用更多计算资源;某个场景接受率很高,也不能直接写成所有场景固定三倍加速。
工程实现应分别检查分布正确性和资源收益。前者需要核对抽样、拒绝补偿与上下文回滚;后者需要固定模型对、采样配置、负载形状与硬件。本文没有运行真实模型或 GPU 投机内核,离线枚举通过只是一项算法检查,不能替代部署验收。