
1. 推理慢在哪先把自回归解码的瓶颈说透聊 self-speculative decoding 之前得先承认一个事实大多数人对大模型推理加速的期待是错的。大家以为慢是因为算力不够于是去买更贵的卡、开更多的机器结果发现单条请求的吐字速度几乎没变。真正卡住的地方不在算力而在显存带宽——解码阶段每生成一个 token整个模型权重都要从显存里完整搬一遍而这一趟搬运换来的有效计算量少得可怜。self-speculative decoding 这类方法之所以值得花时间研究就是因为它绕开的正是这个搬运瓶颈而不是去堆算力。1.1 解码阶段是一个纯带宽受限的活一个 70 亿参数、半精度存储的模型权重文件大概 14 GB。生成第 1 个 token 要读一遍这 14 GB生成第 2 个 token 又要读一遍跟前面的计算结果没关系权重复用率为零。跑满 1000 个 token就是 14 TB 的显存读取量。而每一步的计算量呢主要是矩阵乘向量大约 2×7×10^9 次浮点运算也就是十几 GFLOP 的量级。把这个算术强度算清楚你就明白了每读一个字节的权重只做两次浮点运算。而现在主流加速卡的算力带宽比通常在几十到上百 FLOP/Byte 之间。也就是说加速卡的计算单元有 95% 以上的时间在原地等数据。这时候你把卡的算力翻倍解码速度一点都不会变你把显存带宽翻倍速度立刻翻倍。这就是为什么推理优化里有一句老话decode 阶段优化的是搬运不是计算。这个特性直接决定了后面所有方案的取舍。任何想在解码阶段提速的方法只有两条路可走要么减少搬运的数据量量化、稀疏、剪枝要么在一次搬运里多干几件事批处理、投机解码。self-speculative decoding 属于后者而且是后者里性价比最高的一类。1.2 投机解码的核心把串行生成改成并行验证自回归解码的本质困难是因果依赖——第 n1 个 token 的分布必须等第 n 个 token 确定下来才能算。这个依赖链条没法从数学上打断但可以从工程上赌。赌的方式是这样的先用一个便宜的手段一口气猜出后面 k 个 token 的候选序列然后把这 k 个候选 token 一次性喂给目标模型因为注意力是因果掩码的模型可以在一次前向里同时算出这 k 个位置各自的输出分布最后逐个位置比对从第一个猜错的位置截断前面的全部接受后面的全部丢掉重来。这个流程之所以能保证结果不变靠的是拒绝采样rejection sampling这套数学工具。它不是近似或者差不多对而是严格保证最终输出的概率分布与目标模型单独解码时完全一致。这一点非常关键——任何声称加速但会改变输出的方案在需要复现性的业务里都是不可接受的尤其是代码生成、结构化抽取、评测对比这些场景。一次前向并行验证 k 个位置理论上限是把 k 次串行搬运压缩成 1 次。这就是投机解码全部收益的来源。至于用谁来猜就分出了不同的流派。1.3 独立草稿模型的三个现实成本最早的一批方案思路很直接找一个同系列的小模型当草稿模型比如 7B 配 0.5B小模型负责猜大模型负责验。纸面上很漂亮落地时会撞上三堵墙。第一堵是显存。草稿模型权重不大但它要有自己独立的 KV cache长上下文场景下这部分开销并不小而且它还要占用一份调度资源。在一张卡上同时跑两套模型显存规划会变得很难看。第二堵是分布不匹配。草稿模型哪怕是从同一个大模型蒸馏出来的它的输出分布和目标模型之间依然存在系统性偏差。这个偏差在通用对话上还能忍一旦切到专业领域——法律条文、特定 DSL、内部代码库——小模型根本没学过这些东西接受率会断崖式下跌投机就变成了纯亏。第三堵是部署复杂度。线上要多维护一个模型版本、一套权重加载逻辑、一套批处理调度还要保证两个模型的 tokenizer 完全一致、采样参数完全同步。任何一个环节对不上就会出现输出漂移这种极难排查的问题。这三点加起来让很多团队在评估之后放弃了这个方向。而 self-speculative decoding 要解决的恰好就是能不能不用第二个模型这个问题。2. Self-Speculative Decoding 的整体设计与方案选型所谓 self-speculative字面意思就是自己给自己当草稿模型。同一个模型用一部分计算资源扮演草稿角色用全部计算资源扮演验证角色。听起来像是空手套白狼但它确实成立原因在于大模型内部存在大量的计算冗余——中间层的表示在很多位置其实已经收敛到最终答案了后面几十层做的只是微调。这个表示收敛早于层数结束的现象是所有自投机方法的物理基础。2.1 让前若干层先出个草稿最主流的一种构造方式叫层跳过把模型的 N 层切成两段前 L 层当草稿模型完整的 N 层当验证模型。草稿阶段只跑前 L 层把第 L 层的隐藏状态接上输出头LM head得到一个质量粗糙但形状完整的概率分布从这个分布里采样出 k 个候选 token。这里有个容易被忽略的工程细节草稿阶段跑出来的前 L 层 KV cache 是可以直接被验证阶段复用的。验证阶段并不是从头再算一遍而是从第 L 层往后接着算前 L 层的 KV 直接拿来用。这意味着验证阶段真正新增的计算只有后 N-L 层而不是完整跑一遍再对比。很多人在第一次实现时会犯这个错误白白扔掉了一半的收益。所以完整的一轮是这样走的草稿跑前 L 层得到 k 个候选并写入 KV验证跑后 N-L 层拿到 k1 个位置的完整分布k 个候选位置加上一个额外位置用于在全部接受时多吐一个 token逐个位置做接受判定把被接受的前缀对应的 KV 保留下来被拒绝的部分对应的 KV 全部回滚掉。一轮下来最好情况吐 k1 个 token最差情况吐 1 个 token。2.2 几条自投机路线的横向对比自投机不是只有层跳过一种形态实际可选的路线有好几条各有各的适用面。下面这张表是我按要不要训练、额外显存、典型接受率、实现难度四个维度整理的对比供你在选型时先划掉一半选项。路线是否需训练额外显存典型接受率实现复杂度适用场景纯层跳过免训练否无中低0.5~0.7低快速验证、应急提速层跳过 层丢弃训练是无高0.75~0.9中长期在线服务首选中间层加早退出头是极小一个头中0.6~0.75中层数极深的模型提示词/上下文复制草稿否无依任务0.4~0.85中摘要、改写、RAG 场景同模型降精度草稿半无中0.6~0.7低已量化部署的模型免训练的纯层跳过是最容易上手的不改模型、不动权重把前 L 层的输出直接接到已有的输出头上就行接受率虽然一般但足够让你在两小时内验证这条路在你的模型上到底值不值得走。层丢弃训练是把前 L 层单独训练的可用性补上让前 L 层的输出分布更接近完整模型的分布接受率能提升十到二十个百分点代价是一次小规模的继续预训练。早退出头这条路适合层数特别深的模型比如 60 层以上的前 20 层加一个小分类头就能给出还不错的草稿。但多一个头就多一份维护而且头的训练数据和主模型的对齐要做得很仔细稍不注意就会引入分布偏移。提示词复制草稿这条路比较特殊它不切模型而是假设接下来要生成的很多 token 其实已经出现在输入里了比如摘要任务里输出大量复用原文词汇RAG 场景下答案直接来自检索片段。这类方法在特定任务上接受率高得惊人但通用性差换个任务就崩。2.3 我为什么最终锁定层跳过 KV 复用几条路线我都实际跑过最后稳定用的是层跳过加 KV 复用加上一次轻量的层丢弃训练。原因不是它接受率最高而是它把工程复杂度压到了最低。零额外权重这点太重要了。整条链路只加载一份模型权重显存规划和原方案完全一致不需要重新算 OOM 边界不需要改部署脚本不需要在服务发现里多注册一个模型。你甚至可以在现有服务上加一个开关灰度一部分流量上去。KV 复用是第二个关键。前 L 层的 KV 在草稿阶段算完之后直接被验证阶段接走不存在重复计算前 L 层的浪费。这一条把草稿阶段的边际成本压到了 c 约等于 L/N 的水平——用一个 32 层模型、取前 12 层当草稿草稿每 token 的成本大约只有完整前向的 0.35 上下这是看得见的优势。第三个原因是兼容性。层跳过不改变模型结构、不引入新的算子和连续批处理、分页 KV、量化、CUDA Graph 这些推理框架里已有的优化手段基本都能共存。而早退出头和多模型方案在这些地方会互相打架比如分页 KV 下多一个头就要多分配一份页表代码图捕获也要重写。2.4 无损性从哪来接受判定的数学骨架这里必须把拒绝采样的逻辑讲清楚因为它是整个方案的地基。很多实现翻车都翻在这一点上加速是有了但输出和基线对不上一查发现是接受判定写错了。设草稿分布为 q目标分布为 p同一个模型不同深度给出的两个分布。对草稿采出的 token x以 min(1, p(x)/q(x)) 的概率接受它。如果被拒绝则从残差分布里重新采一个 token残差分布正比于 max(0, p - q)。这一套流程做完最终采出的 token 服从的分布严格等于 p。这个结论不依赖 p 和 q 是否来自同一个模型、同一批参数只要两个分布是在同一个词表上归一化的就成立。所以层跳过草稿和目标验证之间的分布差异在数学上是被完全吸收掉的不存在因为草稿太差所以结果不准的问题——草稿差只会让接受率低、加速少不会让结果错。有两处实操陷阱要提醒。一是温度参数必须两边严格一致。如果草稿阶段用了 0.8、验证阶段用了 1.0比值 p/q 的计算基础就变了残差分布也不再是合法的概率分布输出会悄悄偏掉而且这种偏差很难通过肉眼观察发现。二是贪心解码时的退化。贪心模式下不采样本判定退化为草稿的 argmax 是否等于目标的 argmax接受率通常比采样模式高但此时已经不走拒绝采样那套保证了实现上要单独开一条分支别混在同一个函数里。3. 核心细节拆解接受率是怎么被吃掉的所有自投机方案的性能最终都收敛到两个数字上接受率 α 和草稿成本 c。加速比就是这两个数字的函数其他的都只是工程噪声。这一段把这两个数字的影响因素拆开讲顺便给出我扫参数时用的计算方法。3.1 退出层选在哪不是越浅越好退出层 L 是这套方案唯一的结构性超参数它同时影响 α 和 c而且影响方向相反L 越小草稿越快c 小但分布越糙α 低L 越大草稿越准α 高但草稿成本也水涨船高k 个候选的成本线性上升。我的做法是在一个小验证集上200~500 条覆盖目标业务的真实请求扫 L从总层数的 30% 扫到 80%每个点测两个量一是用固定 k 跑 200 步统计平均接受长度二是用微基准测出单次草稿前向的耗时算出相对成本 c。然后把 (α, c) 代进后面的加速比公式取最大值。实测下来多数模型的最优点落在总层数的 50% 到 65% 之间。这背后有个可以观察到的现象模型的表示在中间层就已经相当接近最终答案了再往后增加层数对 top-1 token 的影响越来越小但对长尾概率的修正还在持续。所以退得太浅草稿的 top-1 就已经开始飘退到中段top-1 基本稳了剩下的差距只体现在低概率的候选上对接受率的影响反而小。还有一个隐蔽的坑不同层的隐藏状态量级不一样。如果你直接从第 L 层接输出头最好确认一下输出头前面那个归一化层的位置。有些实现里输出头前有一层归一化是绑定在最后一层之后执行的你跳到中间层接就绕过了它导致 logits 的量级完全不对采样出来的东西全是垃圾。这个 bug 排查起来很痛苦因为程序不报错只是接受率异常低或者输出乱码。3.2 草稿长度 k 的期望收益计算k 不是越大越好这是很多人的第一直觉误区。k 增大验证阶段的计算量线性增长虽然并行但吞吐是实打实消耗的而接受的 token 数的增长是递减的。先给期望接受 token 数的公式。假设各位置接受事件近似独立接受率为 α草稿长度为 k则一轮的平均产出 token 数是E(k) (1 - α^(k1)) / (1 - α)一轮的时间成本近似为 c·k 1草稿跑 k 步验证跑 1 次完整前向c 是草稿相对成本。加速比就是 E(k) / (c·k 1)。拿具体数字算一遍最直观。假设 c 0.3也就是草稿成本是完整前向的三成草稿长度 kα0.6 加速比α0.7 加速比α0.8 加速比α0.9 加速比11.231.311.381.4621.231.371.531.6931.151.331.551.8141.051.261.531.8660.861.101.411.8680.700.941.281.81这张表信息量很大。α 0.9 时最优点在 k 5 到 6加速接近 1.9 倍α 0.8 时最优点前移到 k 3加速约 1.55 倍α 0.7 时最优点在 k 2加速只有 1.37 倍α 0.6 时任何 k 都不超过 1.3 倍k 取大反而亏。结论很明确接受率低于 0.65 的时候这条路基本不值得做与其花时间调 k不如回去检查退出层选得对不对、有没有漏掉归一化、采样温度是不是设得太高。而接受率一旦上了 0.85k 的调节空间就很大可以放心地把 k 拉到 5 以上。我实际部署时用的是动态 k维护一个最近 64 步的接受率滑动窗口α 上穿 0.85 就把 k 加一下穿 0.7 就把 k 减一k 限制在 2 到 8 之间。这套类似拥塞控制的加减策略比固定 k 稳定得多因为线上流量是混合的——代码补全的请求接受率能到 0.9闲聊可能只有 0.6固定 k 总有一半场景是浪费。3.3 KV cache 复用与位置对齐KV cache 的正确管理是这套方案里最容易出 bug 的地方比接受判定还容易。要处理三个点。第一草稿阶段写入的 KV 要在验证阶段被正确读取。理想情况下草稿写第 0 到 L-1 层的 KV验证从第 L 层开始算读的正是这段 KV。实现上要保证两段用的是同一块 cache 空间、同一个序列槽位而不是各写各的。第二被拒绝的 token 对应的 KV 必须回滚。这一点非常关键。假设 k 5前 3 个 token 被接受、第 4 个被拒那么第 4、5 个位置写入的 KV 是无效的必须把这部分 cache 的写指针退回到已接受前缀长度的位置。如果不回滚下一轮草稿会基于脏数据继续算输出会逐渐偏离而且偏差是累积的前面几十个 token 看着正常后面开始胡说八道。第三位置编码要连续正确。草稿的 k 个 token 拿到的是连续的位置 id验证阶段用的也是同一组 id。这里如果用了分页 KV位置 id 和物理页的映射要单独维护回滚时要按页粒度处理跨页的边界特别容易算错。注意每次回滚后务必断言一下 cache 的写指针等于当前序列长度。这个断言在开发期开着能省掉你至少两天的排查时间。我在第一次实现时就是因为回滚少退了一个位置症状是输出偶尔重复一个词跑了几百条样本才发现。3.4 采样参数对接受率的隐性影响温度、top-p、top-k 这些参数对接受率的影响比大多数人预想的要大。温度升高会让两个分布都变平长尾概率被拉起来草稿和验证在长尾上的差异被放大接受率随之下降。我的实测记录里同一个模型、同一个退出层温度从 0.7 调到 1.0接受率会掉 8 到 15 个百分点加速比直接腰斩。所以如果你要对比不同方案的加速效果务必锁死温度否则数据完全不可比。top-p 截断的影响更微妙。截断后分布在支撑集上重新归一化如果草稿和验证用了不同的截断阈值比值 p/q 的计算基础就不同了。一种稳妥做法是两个分布都先做同样的截断再算比值另一种更简单的做法是草稿阶段不做截断、完整保留分布验证阶段按目标参数截断。我倾向后者因为在完整分布上算比值更接近拒绝采样的原始设定实现也更省事。还有一个实际场景要注意结构化输出。当你用约束解码强制 JSON 格式时每一步的候选集被大幅收窄草稿和验证在合法 token 上的分布差异被压缩接受率通常会上涨有时候能到 0.95。这是个白捡的收益如果你的业务里有大量结构化生成值得专门为这个场景把 k 调大一点。4. 实操从零搭一个能跑的自投机解码流程下面这部分是我实际实现时留下的记录环境和参数都做了脱敏但结构是完整的你可以照着改。整体思路是把草稿和验证拆成两个独立的函数中间用一块共享的 KV cache 和一组位置偏移量串起来。4.1 环境与前置准备硬件上一张 24 GB 显存的加速卡就能跑 7B 级别的模型做验证40 GB 以上可以拿 13B 级别的模型做。软件上需要主流深度学习框架加一个支持自定义 KV cache 操作的推理实现——如果你用的推理框架把 KV cache 完全封装了、不给你插手的机会那就得自己写一层或者换一个开放的实现。准备工作有三项必须提前确认。一是模型能跑到第 L 层并单独取出隐藏状态很多推理框架的 forward 是整体封装的要找到一个支持中途返回的入口。二是输出头可以被独立调用并且前面那个归一化的位置要对。三是 KV cache 支持按序列指定写入偏移和读取长度这一条是硬门槛不支持的话后面所有回滚逻辑都做不了。4.2 草稿阶段的关键实现草稿阶段要做的事情很清晰跑前 L 层取隐藏状态过输出头采 k 个 token同时把这 k 步的 KV 写进共享 cache。def draft_stage(model, input_ids, cache, seq_len, k, exit_layer, temp): 跑前 exit_layer 层自回归采 k 个候选 token draft_tokens [] draft_probs [] # 记录每个位置的草稿分布 q cur_input input_ids # 第一轮是上一个被接受的 token for step in range(k): hidden model.forward_layers( cur_input, start_layer0, end_layerexit_layer, kv_cachecache, # 写入共享 cache position_offsetseq_len step, ) logits model.lm_head(model.final_norm(hidden)) # 归一化别漏 q softmax(logits / temp) token sample(q) draft_tokens.append(token) draft_probs.append(q) cur_input token return draft_tokens, draft_probs这段代码里有两个地方是我踩过坑之后加上的。final_norm那一行前面提过跳到中间层接输出头时必须手动补上归一化否则 logits 量级会失控。position_offset要按已接受序列长度累加而不是每轮从零开始否则位置编码会重排输出会莫名其妙地重复。另外逐 token 采样这 k 步没法并行因为第 2 个草稿 token 依赖第 1 个的 KV。这 k 步是纯串行的但每一步只跑 L 层所以总成本还是可以接受的。有一种优化是让草稿阶段也做小范围投机但收益有限、复杂度上升我没采用。4.3 验证阶段与接受判定的完整流程验证阶段把 k 个草稿 token 一次性喂进去跑后 N-L 层拿到 k1 个位置的分布然后从左到右逐个判定。def verify_stage(model, draft_tokens, cache, seq_len, exit_layer, temp): 一次性验证 k 个候选返回被接受的前缀 # 一次性前向拿到 k1 个位置的 logits hidden model.forward_layers( draft_tokens, start_layerexit_layer, # 复用草稿阶段写好的前 L 层 KV end_layermodel.num_layers, kv_cachecache, position_offsetseq_len, ) logits model.lm_head(model.final_norm(hidden)) p_list [softmax(logits[i] / temp) for i in range(len(draft_tokens) 1)] return p_list def accept_or_reject(draft_tokens, draft_probs, p_list): accepted [] for i, tok in enumerate(draft_tokens): p, q p_list[i], draft_probs[i] ratio p[tok] / q[tok] if q[tok] 0 else 0.0 if random() min(1.0, ratio): accepted.append(tok) else: # 从残差分布重采一个 token 并终止本轮 residual clip(p - q, min0) if residual.sum() 0: residual p accepted.append(sample(residual / residual.sum())) return accepted, i # i 是接受长度用于回滚 # 全部接受额外多吐一个 token accepted.append(sample(p_list[len(draft_tokens)])) return accepted, len(draft_tokens)回滚那一步在这里体现为返回的接受长度 i。调用方拿着这个 i 去把 cache 的写指针重置到seq_len i把多写的部分全部丢弃。这一步千万别省。还有一个边界情况要处理残差分布可能全零。理论上当 q 和 p 完全一致时会出现实际中因为浮点误差也会出现。兜底方案是直接退回用 p 采一个逻辑上等价。4.4 训练侧让前 L 层单独用也不至于太差免训练的层跳过能跑但接受率通常卡在 0.5 到 0.65 之间加速比不到 1.3收益不够看。要让 α 上到 0.8 以上得在训练侧动一点手脚。具体做法是层丢弃加早退出损失。训练时以一定概率随机丢掉中间的若干层让模型学会在不同深度都能给出合理输出同时在每个中间层后都接一个小损失约束它的输出分布向最终分布靠拢。不需要从零训练在原有模型上做一小段继续预训练就能见效数据量大概是原始训练量的百分之几。这里有个取舍层丢弃的比例不能太高否则会伤到模型的完整前向性能。我试过 15% 到 25% 的丢弃率性能损失在可接受范围内。另外早退出损失只在选定的几个候选层上施加不必每一层都加否则训练开销会上去收益反而递减。提示训练完一定要做一次完整前向的回归测试确认困惑度和下游任务指标没有明显退化。我见过有实现为了追求接受率把早退出损失权重设得太大结果前 L 层学得很好后面几十层反而退化了完整前向的性能明显掉了这种损失很难在训练日志里看出来。4.5 批处理下的接受长度不齐怎么处理单条请求跑通不难上批处理才开始有意思。核心问题是同一个 batch 里每条序列的接受长度不一样有的接受了 5 个有的第 1 个就被拒了。这时候 cache 的写指针要按序列独立推进不能用统一偏移量。实现上维护一个长度为 batch_size 的偏移量张量每轮结束后逐序列更新然后再做一次压缩——把每个序列的 cache 按新的有效长度重新对齐。如果你的 KV cache 是分页的这一步就是搬页成本不低能异步就异步。另一个现实问题是 padding 浪费。验证阶段所有序列都要跑 k1 个位置但接受长度 1 的序列后面那些位置全白算了。一个缓解办法是按 batch 内的最小接受长度动态缩短下一轮的 k但收益有限——真正有效的是把接受率相近的请求放在同一个 batch 里也就是按任务类型做路由。代码补全走一批、闲聊走一批接受率方差会小很多padding 浪费也跟着降下来。4.6 一次实测记录以下是我在一台单卡机器上的相对记录跑的是 7B 级别的半精度模型退出层取总层数的 55%温度 0.7测试集混合了代码补全和通用对话。绝对数值随硬件和模型差异很大看相对关系就行。配置batch1 延迟(相对)batch1 吞吐(相对)batch8 吞吐(相对)平均接受率基线1.001.001.00-自投机 k2 免训练0.861.161.090.62自投机 k3 免训练0.831.201.110.62自投机 k3 层丢弃训练0.641.561.340.81自投机 k5 层丢弃训练0.581.721.380.81自投机 k6 层丢弃训练0.611.641.350.81有几个结论值得记下来。训练之后接受率从 0.62 提到 0.81加速比从 1.2 涨到 1.7这个投入产出比相当划算。k 从 3 加到 5 还有收益加到 6 开始回落和前面公式算出来的最优点基本吻合。batch 从 1 扩到 8 之后加速比明显收窄——因为大批量下瓶颈从带宽转向计算草稿阶段多出来的那些计算不再是免费的了。这条曲线也解释了为什么自投机方法在大 batch 的离线推理场景收益有限它的主战场是低并发、低延迟的在线场景比如交互式对话、实时补全、单用户代码助手。如果你的场景是批量处理几百万条数据老老实实做批处理优化比搞这个划算得多。5. 常见问题与排查实录这套方案我在两个不同规模的模型上都落过踩的坑不算少。下面按症状—原因—处置的形式整理出来基本上涵盖了你大概率会遇到的几类问题。5.1 加速比不到 1比基线还慢这是最常见的第一个问题。原因通常有三个层次按排查顺序排一下。先看接受率。如果 α 低于 0.65那基本不用往下查了直接回去优化退出层或者做层丢弃训练加速比公式已经告诉你这个区间没救。我见过有人执着于在 α0.5 的模型上调 k、调批处理调了三天还不如换个退出层。再看成本估算。你是不是把草稿跑 L 层的成本算成了 L/N 的完整前向实际上不是线性的——浅层的 KV cache 小、注意力计算量小但 kernel 启动和访存的开销占比高实际成本往往比 L/N 稍高。如果 c 被低估你以为的最优 k 其实是错的。最后看墙钟时间。token 级加速比和实际延迟是两回事。每一次投机循环都有额外的调度开销、断言判断、Python 层的循环开销在单条请求低延迟场景下这些固定开销占比不小。如果你的单轮时间只有几毫秒那这些开销可能是致命的。用 CUDA Graph 把整轮捕获下来能缓解一部分但草稿那 k 步是串行的图片段会很难写。5.2 输出和基线对不上这类问题比不够快严重得多因为它会悄悄污染你的评测数据。分三种情况。第一种采样参数不一致。温度、top-p、随机种子草稿和验证两边必须共用同一套。检查一下你的 sampler 是不是在两处各初始化了一份随机状态。第二种残差分布算错了。残差应该是 max(0, p - q) 归一化不是 (p - q) 归一化。少了这个截断负概率会被当成合法的比值处理分布就偏了。这个 bug 很隐蔽因为输出看起来还是通顺的。第三种KV 回滚不彻底。前面讲过少退一个位置会导致脏 KV 累积。表现是短输出正常、长输出逐渐崩。定位方法是固定种子跑一条长请求把每一轮接受的前缀长度打出来看是不是某个位置之后开始异常。注意验证无损性的正确做法是关掉所有采样随机性用贪心模式跑同一批输入对比自投机和基线的逐 token 输出。两者必须完全一致。如果不一致先别急着上线。5.3 长上下文下收益消失甚至变负上下文一长问题就来了。原因在于注意力计算量随序列长度增长而草稿阶段那 L 层虽然层数少但注意力部分的开销一点没少。上下文到几千 token 之后草稿成本 c 会从 0.3 涨到 0.5 甚至更高前面那张加速比表整个要重算。应对办法有两个。一是长上下文时动态缩短 k甚至低于 2 就直接关闭投机、退回普通解码。二是把退出层的选择做成上下文长度相关的短上下文用浅层长上下文用稍深的层让 α 保持住。还有一个坑是 KV 显存。草稿阶段虽然只写前 L 层但验证阶段会把整个 cache 填满总显存和基线一致——这一点是优势。但如果你的实现里给草稿单独开了一块 cache那就白涨了 L/N 的显存长上下文下很容易 OOM。5.4 问题速查表症状最可能的原因处置方式加速比低于 1.1接受率不足 0.65重选退出层或做层丢弃训练加速比低于预期但接受率正常草稿成本 c 被低估用微基准实测重算最优 k输出偶发重复词KV 回滚少退位置加断言校验写指针输出整体跑偏归一化层漏掉检查输出头前的 norm长输出后期崩坏脏 KV 累积逐轮打印接受长度定位长上下文加速消失注意力开销占比上升动态缩 k低于 2 关闭投机批处理下显存异常草稿单独开了 cache强制共享同一块 cache结构化输出接受率虚高约束解码收窄候选集属于正常现象可调大 k换任务后加速大幅下滑领域分布不匹配按任务类型分 batch 路由最后补充一个不那么技术但很真实的经验这套方案的收益高度依赖业务分布。如果你的线上流量以短问答为主每轮只生成几十个 token那投机的启动开销占比会很高如果是长文生成、代码补全这种动辄几百上千 token 的场景收益就非常明显。上线前一定要用真实流量采样做一次 A/B别拿公开评测集的数据拍板——公开集上的接受率和你的业务分布之间往往差着十几个百分点。