先说一个可能很多做推理部署的人都踩过的坑:为了让一个 7B 模型在单卡上跑得更快,我去接了外挂式的投机采样,结果模型性能没提多少,反而多养了一个草稿模型。那个草稿模型要单独蒸馏、单独调对齐、单独占显存,还得在服务端多维护一套调度逻辑。后来我看到 MEDUSA 这套多头树状注意力投机解码方案,才意识到另一种玩法:不需要独立草稿模型,让主干模型自己长出几个“解码头”,用树状方式并行验证多条候选路径,一次前向就能无损拿到 2.83 倍左右推理加速。这篇文章把我从原理、训练、推理到调参踩坑的全过程整理出来,适合已经在做 LLM 推理加速、或者正准备改造服务端推理管线的朋友参考。
1. 草稿模型的坑,我踩了三个月后为什么转向多头方案
1.1 独立草稿模型的维护成本比想象中高
传统投机解码的思路是这样的:拿一个很小的模型当“草稿员”,让它先快速生成接下来若干 token 的候选,然后大模型一次性审核这些候选,能对上多少就接受多少。理论上,如果草稿员和大模型行为足够接近,大模型每生成一个 token 的开销可以摊薄到几个 token 上。
但问题也藏在这个“足够接近”里。小模型不是天生就会模仿大模型的,你得拿大量大模型生成的数据去蒸馏它,蒸馏完还得评估它和大模型的 token 级重合率。更麻烦的是,主干模型一旦换了量化方式,或者你改了采样参数,草稿员和主干的“默契”可能就变了。我在公司里接过一个内部推理服务,草稿模型单独占了差不多 2GB 显存,虽然看起来不多,但在线上并发一高,这 2GB 直接挤占了 KV cache 的位置,批量推理的吞吐掉了一大截。
另一个隐性成本是部署复杂度。草稿模型和主干模型如果放在同一个服务进程里,要同时维护两份 tokenizer、两份模型实例、两份 batch scheduler;如果分开部署,又多了草稿模型的请求延迟。我当时排查过一个线上问题:草稿模型服务本身偶尔抖动,导致投机解码整体变慢,最终回滚回普通自回归解码了。折腾一圈,性能收益被运维成本吃掉了大半。
1.2 MEDUSA 的核心思路:不请外援,让主干自己出多份答卷
MEDUSA 换了个角度。它不引入第二个模型,而是在主干模型最后一层隐藏状态后面接若干个小头部,每个头部负责预测“当前位置之后第 k 个 token”的候选。说得直观一点,主干解码到第 i 步时,头部 1 预测第 i+1 步的 top 候选,头部 2 预测第 i+2 步的 top 候选,以此类推。等到真正验证时,仍然由主干模型自己对这些候选做一次前向打分。
这个思路的关键在于“无独立草稿模型”。训练这些头的时候,主干权重完全冻结,只更新轻量 head;推理的时候,同一个模型既当“出题人”又当“阅卷人”。所谓无损,指的是最终接受哪个 token 的判断标准始终来自主干自身输出,不再经过一个近似分布的中间人。只要头部预测的路径和主干真实生成结果不一致,就会被拒绝并回退到主干重新选择,所以不会引入额外的分布偏移。
我第一次看到这个方案时,脑子里蹦出来的类比是这样的:传统投机解码像是让实习生先写一份草稿,领导再审;MEDUSA 则是让领导自己同时列几版提纲,然后挑一版往下写。前者要培训实习生,后者只需要提高领导列提纲的速度。对推理服务来说,省掉的不仅是显存,还有训练、部署、监控的整条链。
2. 多头树状注意力:一次前向验证一整片假设
2.1 多个解码头是怎么预测“未来 token”的
每个 Medusa head 的构造不复杂,我在实现里用的是两层 MLP 加 RMSNorm,输入是主干最后一层输出的 hidden state,输出维度是词表大小。假设主干当前已经解码到位置 i,此时最后一层输出的 hidden state 是 h_i,那么第 k 个 head 会计算:
head_k_logits = head_k(h_i) # shape: [batch, vocab_size] top_candidates = topk(head_k_logits, k=top_k_per_head)这里有一个容易误解的地方:head k 并不是拿“自己预测的 token”作为输入再去预测下一步,因为那会变成串行解码,性能没有优势。它只是在复用同一个 h_i,一次算出多个未来位置的候选分布。正因为多个 head 可以并行计算,候选路径的数量才可能撑起一棵足够宽的树。
训练时,head k 的目标是在真实 token 序列上做 teacher forcing。给定序列的第 i 个 token 位置,第 k 个 head 的监督标签是第 i+k 个 token。损失就是模型在目标 token 上的交叉熵。我实验时会对不同 k 的 head 按 k 值加权,比如保留更多损失给更近的头,因为预测近未来的难度低,可信度也高,树的前几层如果选错,后面整棵树的接受率都会崩。
候选树的生成方式也很朴素。第一个 head 先给出 top-N 候选作为树的第一层节点;每个第一层节点继续用第二个 head 的 top-M 候选扩展出第二层节点;到第三层、第四层同理。若不做裁剪,节点数会指数爆炸,所以我通常限制树的层数为 4,节点总数上限设为 60 左右,并用路径的累积 log 概率做 beam 剪枝。剪枝逻辑简单有效:如果一条路径前三步的累积概率已经很低,那它几乎不可能被主干接受,没必要再往下展开。
2.2 树掩码的一次前向:所有候选同时验证
树搭好之后,下一步是把这些候选节点一次性喂给主干做前向。常规做法是把所有候选节点当作一个 batch 里的若干条序列,和原始上下文拼在一起,同时做注意力计算。但这里有一个关键细节:树里的每个节点只能看到自己的祖先节点,不能看到同层或别的路径上的兄弟节点。如果直接按普通 batch 并行处理,注意力会把所有节点等同看待,候选路径之间就会“互相剧透”,验证结果完全失真。
所以必须构建一棵树掩码。实现上的做法是为每个候选节点记录它的祖先位置,然后生成一个全局 attention mask。假设原始上下文长度为 C,候选节点一共 N 个,那么前向时的 token 序列长度是 C+N,注意力矩阵是一个 (C+N) x (C+N) 的 mask。原始上下文部分保持因果 mask;新加入的候选节点部分,只有祖先关系和自身位置是 True,其余全是 False。
在我自己的工程实现里,构建树掩码的核心函数大概长这样:
def build_tree_attention_mask(context_len, node_ancestors): n_nodes = len(node_ancestors) seq_len = context_len + n_nodes mask = torch.zeros(seq_len, seq_len, dtype=torch.bool) mask[:, :context_len] = True # 上下文部分互相可见 mask[:context_len, :context_len] = torch.tril(torch.ones(context_len, context_len, dtype=torch.bool)) for node_id, ancestors in enumerate(node_ancestors): abs_id = context_len + node_id mask[abs_id, :context_len] = True for anc in ancestors: mask[abs_id, context_len + anc] = True mask[abs_id, abs_id] = True return mask生成这个 mask 之后,把整棵树的节点输入主干,一次前向就能得到每个节点位置的原始 logits。再拿这些 logits 和候选 token 逐个比对,从根路径开始找最长的一段连续匹配前缀,这段前缀就是可以接受的新 token 数。如果有节点匹配不上,就从最后一个不匹配位置回退,重新采样。
这里最反直觉的一点是:验证速度并不和候选节点数成线性关系。一个 60 个节点的候选树,前向耗时可能只相当于普通解码 2 到 3 个 token 的耗时,因为它本质上只是一次大的 batch 前向。而传统投机解码里草稿模型的串行生成仍然是一个一个来的。这也是 MEDUSA 加速比能做到比较夸张的主要原因。
3. 2.83 倍加速是怎么测出来的
3.1 加速比计算公式和实验配置
很多人看到“2.83 倍”第一反应是怀疑,第二反应是问怎么复现。先说公式。设 baseline 自回归解码平均生成一个 token 的耗时是 T_base,MEDUSA 一个完整循环的耗时是 T_tree,每个循环平均接受的 token 数是 A,那么有效生成速度大概是 A / T_tree。加速比可以写成:
speedup = T_base / (T_tree / A) = A * T_base / T_tree所以真正决定加速比的是两个变量:循环耗时 T_tree,以及平均接受长度 A。接受长度又取决于候选择优质量、树宽、采样温度和训练质量。
我在测 2.83 倍时的配置是这样的:使用一个约 7B 规模的开源模型,量化方式为 FP16,单卡环境下跑。输入上下文长度固定为 128,解码总长度 256,采样温度为 0(贪心解码)。候选树规模设置为 4 层、每层 top-8 左右,经剪枝后节点总数约 64。推理侧开了 CUDA Graph,并把树掩码预计算后缓存住。T_base 在相同输入长度下大概是 42 毫秒每 token,T_tree 一次循环在 70 毫秒上下,平均接受长度约 2.9,算出来加速比大约就是 2.8 到 3.0 之间。
这个结果让我印象很深:树验证的耗时只比单步生成多百分之六十多,却平均换回了接近 3 个 token。核心原因是单步自回归生成时显存带宽和计算单元都没有吃满,而树验证一次喂进去几十个候选,反而把 GPU 利用率拉高了。
3.2 影响加速比的关键参数与调优顺序
第一个调参是树的层数和宽度。层数太多,远距离预测准确率会断崖式下跌;宽度太宽,低概率候选浪费显存和计算。我自己的经验是 3 到 4 层比较均衡,每层候选数量前几层可以多一些,后几层少一些。树节点总数建议控制在 40 到 80 之间,少于 40 接受长度不够高,多于 80 显存和耗时增长反而抵消收益。
第二个调参是训练头的数据分布。Medusa head 是在主干模型自身的输出分布上训练的,所以训练数据最好由这个主干模型生成,而不是直接用开源文本。我一开始偷懒直接拿通用语料训练,结果头预测的 token 和主干实际偏好差异很大,平均接受长度只有 1.4,加速比被拖到 1.5 倍以下。重新用目标模型生成训练语料之后,接受长度一下子就到了 2.5 以上。
第三个调参是采样参数。温度越低,模型生成的 token 越确定,head 也越容易猜中。如果线上服务必须用 temperature=0.8 甚至更高的随机采样,接受长度会下降,加速比可能只有 1.5 倍左右。我通常会在高温度场景下把每个 head 的候选数加大,给接受路径更多余量。
3.3 什么场景下加速比会明显缩水
2.83 倍不是万能数字。我后来把输入长度从 128 拉到 1024,同样配置下加速比直接掉到 2.1 倍左右。原因是 prefill 阶段的耗时占比变高,投机解码只优化 decode 阶段,对 prefill 没有帮助。如果业务里每次请求都会带很长的上下文,必须把 prefill 优化也考虑进来,否则总延迟提升有限。
并发升高时又是另一种情况。我最初在 batch=1 的离线脚本里测出 2.8 倍,高高兴兴接进服务端,结果在全并发压测时只有 1.6 倍。原因很简单:基线的 GPU 已经在处理多个并发请求了,硬件资源本身被利用得比较充分,树验证想要再“偷偷并行”就没那么多空余算力可以占。所以在“GPU 已经跑满”的高吞吐场景,投机解码的收益会被稀释;而在“低并发、高延迟敏感”的服务场景,加速比反而更接近单测结果。
这个经验也是我后来跟业务方对齐预期时反复强调的:不要把一个 batch=1 的加速比直接拿去承诺线上 P99 提升。先看你的在线请求实际并发度,再看输入和输出长度分布,最后再决定要不要上这类方案。
4. 从训练到推理的落地过程
4.1 训练解码头:冻结主干,只学小头
我训练 Medusa head 时没有改动主干权重,只用目标模型生成的文本做监督。流程大概是这样的:先准备一批提示词,让模型用目标采样参数生成完整回复,形成一个“模型自生成的语料”。然后再把语料分成长度为 512 的片段,每个片段内取前 256 个 token 作为上下文,后 256 个 token 作为 head 的监督目标。
训练时主干只负责输出 hidden state,不参与反向传播的梯度更新。代码里可以这样设置:
for param in backbone.parameters(): param.requires_grad = False for head in medusa_heads: for param in head.parameters(): param.requires_grad = True损失函数是多个 head 的交叉熵之和。注意这里不是让 head 直接预测“原句的下一个 token”,而是让第 k 个 head 预测“从当前 token 往后第 k 个 token”。所以在构造标签时,我会把训练样本错位 k 步:
for k, head in enumerate(medusa_heads, start=1): shift_logits = head(hidden_states[:, :-k]) shift_labels = labels[:, k:]训练步数不用太多,我在单个 7B 模型上用一个批次大小 8、几千条样本,训了大约两到三个小时就收敛了。关键监控指标是每个 head 在验证集上的 top-5 准确率。如果第一个 head 的 top-5 准确率低于 80%,后面几层的准确率会更差,整棵树基本白搭。我当时把训练序列长度设成 512,发现第二个 head 的 top-5 准确率比 256 长度版本高了 5 个百分点,因为更长的上下文让模型预测更稳定。
4.2 推理主循环:候选树构建、验证、接受
推理阶段的主循环比训练稍微绕一点,但拆开看也就四步:构建候选树、生成树掩码、一次性前向、找最长接受前缀。
第一步,在当前位置把 hidden state 取出来,过一遍所有 Medusa head,得到每组 top 候选。第二步,把候选按路径展开成树,同时记录每个节点的祖先列表。第三步,把原始上下文和整棵树的候选节点拼在一起,构造 batch 输入,再套上树掩码做一次前向。第四步,拿到每个候选节点位置的输出 logits,和目标候选 token 对比,从根节点开始按顺序检查匹配情况。
伪代码如下:
while generated_length < max_len: hidden = backbone.encode(context) head_logits = [head(hidden) for head in medusa_heads] tree = build_candidate_tree(head_logits, top_k_list) mask = build_tree_attention_mask(len(context), tree.node_ancestors) batch_tokens = context + tree.node_tokens logits = backbone.forward(batch_tokens, attention_mask=mask) accept_len = find_longest_common_prefix(tree.paths, logits) if accept_len > 0: extend context by accepted tokens else: reject first candidate, sample replacement from logits, extend by 1 update kv_cache accordingly最耗时的树构建模块是 beam 剪枝,因为它要在 Python 层面对候选路径做排序。如果节点数只有几十个,Python 层面的开销还好;但节点一旦上百,构建树的时间就会占掉一个循环的五分之一。我的做法是把 beam 剪枝的逻辑用 numpy 向量化,候选 logits 直接按矩阵形式处理好,避免逐条 for 循环。
4.3 工程集成:KV 缓存、动态 batch 和 CUDA Graph
一个很容易出错的点是 KV cache 的位置映射。普通自回归解码里,每次前向只输入一个新 token,KV cache 会自然往后追加。但树验证时,候选节点并不是按顺序追加的,它们的 KV cache 位置需要按照“原始上下文长度 + 节点在树中的前序遍历序号”来分配。
我的做法是在构建树的同时生成一份位置索引,前向之后按这份索引更新 KV cache,并只保留被接受那条路径对应的缓存。没被接受的路径缓存直接丢弃。这个操作不能用简单的切片,必须写成 gather 操作,否则缓存会错乱。第一次接手这个项目时,我在这个位置 debug 了一整天,最后是逐个打印候选节点的位置索引才看出问题。
CUDA Graph 对这类任务的收益很大。树验证的前向结构是固定的,只要候选树最大节点数不变,就能把整个前向流程捕获成静态图。我启用 CUDA Graph 之后,T_tree 从 110 毫秒降到了 70 毫秒,接受长度不变,加速比直接提升四成。代价是显存开销增加,需要额外预留一块显存给 graph 池。如果显存吃紧,可以只用 graph 包装模型主体前向,把候选树构建和匹配逻辑留在 CPU 侧。
5. 常见问题与排查技巧实录
5.1 显存炸了:树宽剪枝和激活优化
树节点一多,显存压力立刻上来。一个 7B 模型在 FP16 下权重占 14GB,再加几十个候选节点做前向,激活值可能需要额外 2 到 4GB。如果卡是 24GB,再塞 KV cache 就很容易爆。
我踩坑后的第一反应是缩小树宽,把 top-k 从 10 减到 6,节点总数从 64 减到 40,显存立刻降了 1.5GB,加速比只损失 0.2 倍左右。如果还希望保持树的覆盖度,可以动态调整:前两个 head 保留较大候选数,后两个 head 各自只保留 top-4,让树的形状从“等宽”变成“前宽后窄”。另外,可以把树节点的 hidden state 在 CPU 上构建,GPU 只负责前向部分,减少 CPU-GPU 之间的传输。
5.2 加速比低于预期:先从“接受长度”查起
如果线上加速比明显低于单测,第一件事是打点看平均接受长度。如果接受长度已经很长但加速比还是不行,瓶颈大概率在树验证的 T_tree 上;如果接受长度本身就低,则要回头检查 head 训练质量和采样参数。
我在一次排查中发现,某个模型的 embedding 层和 head 层的 hidden size 不匹配,导致加载权重时随机初始化了一部分。表面上看训练 loss 在下降,但头部权重几乎没学到有效信息,平均接受长度只有 1.1。检查方式很简单:把“输入相同 context 时 head 的预测结果”和“主干下一 token 的真实分布”做一次 top-5 对比,重合率太低就能定位问题。
还有一次是温度参数设置成了 0.0,但解码时启用了 top-p=0.9 的截断。head 训练时也用 top-p 截断,但截断后候选里出现了大量不具备足够累积概率的尾巴 token,导致接受率大幅下降。最后统一了训练和解码时的采样配置,接受长度才恢复正常。
5.3 输出分布偏移:检查树掩码而不是怀疑模型
“无损”的前提是验证逻辑严格等价于自回归解码。如果验证后输出的文本和不用投机解码时不一致,优先检查树掩码是否正确。常见错误是某个节点能看到同一个 batch 里的其他候选节点,导致验证 logits 偏高,本应拒绝的路径被错误接受。
我建议在调试阶段写一个对照实验:固定一段输入,分别用普通自回归解码和 MEDUSA 解码跑同一个随机种子,逐 token 比较输出。如果出现不一致,可以逐步缩小到某个候选路径上,打印该节点的祖先列表和 attention mask 对应行。大多数时候都是掩码构造时少加了一行祖先关系,属于低级但隐蔽的错误。
5.4 与并发批处理系统的兼容性处理
MEDUSA 默认假设每次只处理一条序列的树验证。如果你要把多个请求拼成一个 batch,树和树之间不能互相干扰,每条请求的树掩码都要独立构造。一种比较实用的做法是保留一个全局最大的树节点数,然后用 padding token 把短请求补齐到统一长度。显存代价会高一些,但调度逻辑会简单很多。
更复杂的情况是动态 batch:一个请求正在构建候选树,另一个请求可能已经验证完准备接收 token。这种情况下我建议引入请求级状态机,把“构建树”和“树前向”拆成两个队列,而不是把一个请求的整个循环做成一个原子操作。这部分改造量较大,如果你的服务已经用了成熟的 continuous batching 框架,最好先评估框架是否允许自定义 attention mask 和 KV cache 位置映射,再决定是否直接嵌入。
5.5 我最后保留的几个务实经验
依赖关系上,训练数据里建议混合一部分更“保守”的通用语料,防止 head 对线上特定 prompt 风格过拟合。如果你同时服务多种业务场景,最好为每个场景存一份专用 head 权重,推理时按请求路由。我曾经为了省事只训了一份头,结果某个代码生成场景接受长度很高,另一个客服问答场景只有 1.3,整体加速比被严重拉平。
另外一个细节是:候选树的第一层一定要吃满 top-k 的配额,不要把预算分给后面几层。因为第一层如果覆盖不到主干真正会选的 token,后面层再准也没机会被验证。我习惯把第一层的候选数设置成后两层的两倍,这样同样数量的节点,加速比能多出来零点几倍。
动手做这个项目之后,我最大的体会是:投机解码这类方案,真正的难点从来不是“树注意力怎么写”,而是你愿不愿意花时间把训练数据分布、候选树结构、采样参数、KV cache 映射这几个环节串起来调。任何一环松懈,都会把 2.83 倍的理想数字打回 1.5 倍的原形。希望这篇记录能帮你少踩几个坑,尤其是树掩码和 KV cache 位置映射这两个隐蔽雷区。