ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

自投机解码:层跳过与KV复用实现大模型无损推理加速

自投机解码:层跳过与KV复用实现大模型无损推理加速

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 投机解码的核心:把串行生成改成并行验证

自回归解码的本质困难是因果依赖——第 n+1 个 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 层,拿到 k+1 个位置的完整分布(k 个候选位置加上一个额外位置,用于在全部接受时多吐一个 token);逐个位置做接受判定,把被接受的前缀对应的 KV 保留下来,被拒绝的部分对应的 KV 全部回滚掉。一轮下来,最好情况吐 k+1 个 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 - α^(k+1)) / (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.46
21.231.371.531.69
31.151.331.551.81
41.051.261.531.86
60.861.101.411.86
80.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.85,k 的调节空间就很大,可以放心地把 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_layer=0, end_layer=exit_layer, kv_cache=cache, # 写入共享 cache position_offset=seq_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 层,拿到 k+1 个位置的分布,然后从左到右逐个判定。

def verify_stage(model, draft_tokens, cache, seq_len, exit_layer, temp): """一次性验证 k 个候选,返回被接受的前缀""" # 一次性前向,拿到 k+1 个位置的 logits hidden = model.forward_layers( draft_tokens, start_layer=exit_layer, # 复用草稿阶段写好的前 L 层 KV end_layer=model.num_layers, kv_cache=cache, position_offset=seq_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, min=0) 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 浪费。验证阶段所有序列都要跑 k+1 个位置,但接受长度 1 的序列后面那些位置全白算了。一个缓解办法是按 batch 内的最小接受长度动态缩短下一轮的 k,但收益有限——真正有效的是把接受率相近的请求放在同一个 batch 里,也就是按任务类型做路由。代码补全走一批、闲聊走一批,接受率方差会小很多,padding 浪费也跟着降下来。

4.6 一次实测记录

以下是我在一台单卡机器上的相对记录,跑的是 7B 级别的半精度模型,退出层取总层数的 55%,温度 0.7,测试集混合了代码补全和通用对话。绝对数值随硬件和模型差异很大,看相对关系就行。

配置batch=1 延迟(相对)batch=1 吞吐(相对)batch=8 吞吐(相对)平均接受率
基线1.001.001.00-
自投机 k=2 免训练0.861.161.090.62
自投机 k=3 免训练0.831.201.110.62
自投机 k=3 层丢弃训练0.641.561.340.81
自投机 k=5 层丢弃训练0.581.721.380.81
自投机 k=6 层丢弃训练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,别拿公开评测集的数据拍板——公开集上的接受率和你的业务分布之间,往往差着十几个百分点。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进