背景:
- 我们自部署了开源大模型 GLM-5.2(FP8 版,zai-org/GLM-5.2-FP8,BF16 原版 zai-org/GLM-5.2),发现请求量上来之后,生成的速度成了瓶颈,因此希望可以通过投机解码提升模型吞吐。
- GLM-5.2 自带一个 MTP 层,从而可以直接开投机解码,不用另找草稿模型。
- 我们开启投机解码,并进行相同参数下的 A/B 测试:吞吐涨到 1.43~1.91 倍,延迟降到原来的 49%~79%。
这篇文章讲解投机解码 + 我们的实验结果:投机解码为什么能加速、为什么是无损的;GLM-5.2 如何开启投机解码,打开后各个维度的实测加速效果,以及相关踩坑记录。
1 投机解码是什么?一个具体例子
先进行一个约定:模型读和写的最小单位叫 token,可以粗略理解成对应一个字或一个词(实际上中文常见字是一个 token,长的词会被切成几个)。下面为了简化问题,一律按"一个 token 对应一个字"来讨论。
假设模型要接着"今天"往下写,正确输出是"今天天气很好"。
普通解码是这样走的:大模型前向一次(把"今天"读进去,算出下一个字),出"天";把"今天天"读进去再前向一次,出"气";再来一次,出"很"。写 5 个字就要完整跑 5 遍模型。
投机解码在中间加了一个便宜得多的"草稿"。还是接着"今天",草稿先一口气起草 4 个候选:"天""气""的""时"。然后大模型只做一次前向,把"今天"加上草稿的 4 个字凑成"今天天气的时"整段读进去。模型有一种叫注意力掩码的机制,保证每个位置只能看到自己和前面的字、看不到后面的,所以一次前向就能分别算出每个位置"下一个字该是什么",4 个位置并行打分:
| 大模型看到 | 草稿写的是 | 大模型自己选 | 对不对上 |
|---|---|---|---|
| "今天" | 天 | 天 | ✓ 接受 |
| "今天天" | 气 | 气 | ✓ 接受 |
| "今天天气" | 的 | 很 | ✗ 拒绝草稿的"的",用大模型选的"很" |
| "今天天气的" | 时 | - | ✗ 拒绝(前面已断,丢弃) |
对照下来:
- 草稿的"天""气"和大模型自己选的一致,接受;
- 草稿第 3 个字写的是"的",而大模型在"今天天气"之后想选的是"很",对不上,于是拒绝草稿的"的"、由大模型亲自补上"很";
- 更后面的"时"因为前面已经断开,一并丢弃。
- 这一步实际收下"天""气""很"三个字,正好接出"今天天气很",和开头的正确输出"今天天气很好"匹配。
注意三点:
- 大模型验证时,从头到尾读的都是草稿写的这串字,只是逐个位置判断"换作是我,这里会写哪个字",再拿草稿的字去比对;
- 表格里 4 行是同一次前向算出来的,不是跑 4 次,这是加速的来源;
- 拒绝不是跳过,那个位置由大模型亲自补上正确答案,所以输出永远是"大模型认可的文字",后面的草稿则直接扔掉,不会把草稿的错误带出去。
草稿全中时一步能出更多字;全不中时也有大模型补的那个字保底,只白亏一次草稿的开销,而草稿很便宜,所以整体不会比普通解码慢多少。
2 投机解码的加速原理
关键在于解码(decode,也就是大模型一个一个吐 token)的时间花在哪。每出一个 token,显卡都要把模型的全部权重(几百 GB)从显存搬进计算单元过一遍,而每个 token 实际的矩阵运算量很小。也就是说,decode 阶段显卡的大部分时间在等数据搬运,算力是闲置的。
这就决定了"读一遍权重"是固定成本:一次前向算 1 个位置和算 4 个位置,搬运的权重一样多,多花的那点计算远小于搬运。只要草稿的命中率不太低,(比如我们前面的例子里命中了两个字,也就是多命中了一个字),每一步平均多出一两个字,就是几乎白捡的速度。
可能的疑惑:既然速度瓶颈在搬权重,那正常的大模型干脆一次算 4 个位置不就行了?不行,因为第 2 个字依赖第 1 个字算出来的结果,第 3 个又依赖第 2 个,是严格串行的。大模型自己不知道第 1 个字会是什么,就没法提前把"第 1 个字之后的那个位置"该算什么算出来。投机解码正是用草稿把这串依赖"猜"出来,先填上 4 个候选字,大模型才能一次前向把它们并行验证掉,猜错了大不了丢掉重补,猜中就赚到。所以关键不是"能不能并行",而是"有没有一个便宜的东西先把要验证的字猜出来"。
这个技术由两篇论文各自独立提出:Google 的 Leviathan 等人(arXiv 2211.17192,2022-11-30 首次提交,ICML 2023 Oral)和 DeepMind 的 Chen 等人(arXiv 2302.01318,2023-02-02 首次提交)。
草稿从哪来,是工程上的关键:专门训一个小模型成本不低,DeepSeek-V3 在技术报告(arXiv 2412.19437,2024-12-27 提交)里给了一个更省的办法,训练时多训一个叫 MTP(Multi-Token Prediction)的层,专门预测"再下一个 token",推理时直接拿这层当草稿。现在新一代的开源模型不少都自带这一层。GLM-5.2 就是,它的 config.json 里 num_nextn_predict_layers 为 1(自带 1 层 MTP)、架构是 GlmMoeDsaForCausalLM,这是我们能低成本开投机解码的前提。
3 数学保证:为什么是无损的
直觉上容易担心:草稿起草错了怎么办,输出质量会不会下降?
投机解码是无损的,输出质量不会下降。因为接受规则不是"看草稿对不对",而是按概率做拒绝采样。
记大模型在某位置给出字 x 的概率是 P(x),草稿给出 x 的概率是 Q(x)。接受规则是:以 r = min(1, P(x)/Q(x)) 的概率接受草稿的 x;拒绝了,就从修正分布 P'(x) ∝ max(0, P(x) − Q(x)) 里重新采样一个字。这样,最后模型选择 x 的概率为
\\\text{最终选到 } x \\text{ 的概率} = \\underbrace{Q(x)\\cdot\\min\\!\\Big(1,\\tfrac{P(x)}{Q(x)}\\Big)}_{\\text{草稿提 } x \\text{ 且被接受}} \\;+\\; \\underbrace{\\Big(\\sum_z \\max\\big(0,P(z)-Q(z)\\big)\\Big)\\cdot \\frac{\\max(0,\\,P(x)-Q(x))}{\\sum_z \\max\\big(0,P(z)-Q(z)\\big)}}_{\\text{草稿被拒后从修正分布补到 } x} \\
数学细节:第二项前面那个 "草稿被拒的总概率" \(\sum_z \max(0,P(z)-Q(z))\) 的计算过程。记草稿写出字 \(z\)、又被接受的概率是 \(\alpha(z)=\min(1,\tfrac{P(z)}{Q(z)})\)(就是前面说的接受率 \(r\),只是这里对任意字 \(z\) 写)。那么草稿被拒的总概率,就是"1 减去所有字被接受的概率之和",即 \(1-\sum_z Q(z)\alpha(z) = 1-\sum_z\min(Q(z),P(z))\)。而对任意 \(z\) 都有 \(P(z)-\min(P(z),Q(z))=\max(0,P(z)-Q(z))\),两边对 \(z\) 求和正好得到它等于残差总质量 \(\sum_z\max(0,P(z)-Q(z))\),所以可以直接这么写。
于是第一项里 \(Q(x)\cdot\min(1,\tfrac{P(x)}{Q(x)}) = \min\big(Q(x),\,P(x)\big)\);第二项分母约掉后是 \(\max\big(0,\,P(x)-Q(x)\big)\)。两项相加,由 \(\min(a,b)+\max(0,b-a)=b\)(代回即 \(P(x)\)),正好等于 \(P(x)\)。
因此可以证明,这样出来的每个字的分布和大模型自己逐字解码严格相同。所以投机解码被称为"无损加速"。(但注意,"分布等价"不等于"两次运行逐字相同",事实上,真实服务里就算不开投机解码,同一个请求跑两遍也常常逐字不同)
4 在一次 LLM 生成中,投机解码优化了哪个环节
要理解后面的实测,各个维度的性能指标为什么涨、为什么跌,得先知道一个请求从发出到收完,在服务器里经历了什么。按时间顺序是五步:
- 排队调度:请求先进队列,调度器凑齐一批(一个 batch)一起算。GPU 一次算一批比一个一个算划算,所以服务器宁可稍微等一下也要攒批。
- 预填充(prefill):把用户的输入一次性读完,算出上下文缓存(KV cache,可以理解成模型"读过的内容"的压缩记忆)。这一步是"读",计算量大,耗时随输入长度增长。
- 逐字生成(decode):循环"前向一次 → 出一个字 → 追加进上下文 → 再前向",直到写完。前面说过,这一步每次前向的瓶颈是搬权重,不是算。
- 流式返回:出一个字就往客户端推一段,客户端不用等全部写完。
投机解码只改第 3 步,前 4 步里的其他环节一概不动:第 3 步的每次前向,从 "出 1 个字" 变成 "草稿起草 + 一次前向验证多个位置、平均出 2~3 个字"。单请求的生成时间直接按这个倍数缩短。
看懂了这个,后面三个实测现象就都能解释:
- 延迟为什么降:单个请求的第 3 步步数变少了,端到端时间自然短,我们测到降到原来的 49%~79%。
- 吞吐为什么涨:吞吐是"单位时间出多少字"。同一个 GPU,原来一个 batch 槽位一轮出 1 个字,现在出 2~3 个,同样的时间吐的字更多,吞吐涨到 1.43~1.91 倍。
- 为什么并发越高、吞吐提升倍数越缩:这里说的倍数是"开投机解码后的吞吐 / 不开的吞吐",大于 1 就是变快。这是我们的数字里最反直觉的一条,并发 4 时接近翻倍,并发 32 只剩 1.43 倍。原因是 batch 一大,单次前向要算的字数(batch 内所有请求的字加起来)跟着变大,第 3 步的瓶颈就从"搬权重"转向"算"。而投机解码恰恰是在每次前向里多算了好几倍的位置。搬运便宜时这些多算的是白捡的;等计算成了瓶颈,多算的就是实打实的额外开销,净收益就被吃掉了。所以投机解码赚的是"低并发时闲置算力"的钱,高并发下收益缩水。
5 如何开 GLM-5.2 投机解码
我们用 sglang 起服务,模型自带 MTP 层,启动参数里加四行就够:
bash
--speculative-algorithm NEXTN # 用模型自带的 MTP 层当草稿
--speculative-num-steps 3 # 草稿每步起草 3 个候选
--speculative-eagle-topk 1 # 每个位置只取 1 个最可能的候选
--speculative-num-draft-tokens 4 # 一次前向最多验证 4 个位置
怎么确认参数生效:起服日志里模型类名会带上 NextN 后缀,每张卡多占约 2.8GB 显存;/metrics 接口里会出现 accept length(平均每步接受了几个 token)这个指标。开 num-draft-tokens = 4 时,我们在长上下文负载下实测 accept length 约 3.1,短问答约 2.55,实际使用时稳定在 2.6~2.9。
这里顺带说清一个我们自己也确认过的点:accept length 是"每一步实际出了几个 token",包含大模型保底的那 1 个。所以它的理论上限是 num-draft-tokens(我们设 4,即草稿 3 个全中、再加保底 1 个),实测值 2.6~3.1 一直在 1 到 4 之间,对得上。含义:平均每步 2.6~3.1,说明草稿平均每步白赚了 1.6~2.1 个 token。
6 实验 setting 与结果
setting:我们用 16 卡 H200 自部署 GLM-5.2,对外是一个服务端点。测试请求约 2 万 token 上下文、512 token 输出,开启和关闭投机解码各跑一遍(同参数 A/B 对比),分别测了并发 4 和并发 32 两档。
| 指标 | 结果 |
|---|---|
| 吞吐 | 1.91 倍(并发 4)~ 1.43 倍(并发 32) |
| 延迟 | 降到原来的 49%~79% |
| KV 缓存池 | 只小 6%(多一层草稿的代价) |
| 起服时间 | 慢约 3.5 分钟 |
为什么挑并发 4 和 32 这两档?因为模型 decode 阶段单次前向要计算的字数,约等于"batch 里同时在跑的请求数 × 每个请求每步验证的位置数"。并发低时这个乘积小,速度瓶颈在搬权重,投机解码多算的位置是白捡的;并发越高乘积越大,速度瓶颈转向计算,多算的位置就成了实打实的开销。所以并发 4 时收益接近翻倍,并发 32 时缩到 1.43 倍。
实验结果解读:吞吐涨、延迟降,是 decode 每轮出的字变多的直接结果。起服慢 3.5 分钟,是因为开投机解码要多加载那 1 层 MTP 草稿的权重、并额外做初始化(编译、预热)。这是一次性成本,服务开启时出现,之后长跑不再重复。
开启投机解码的代价汇总:每张卡多占约 2.8GB 显存,KV 缓存池小 6%,起服慢 3.5 分钟。对长时运行的服务来说基本可以忽略。
根据实验结果,什么时候值得开投机解码:
- 模型自带 MTP 层:成本最低,几行启动参数的事,建议开。
- 低并发或延迟敏感的负载:收益最大,我们测到接近翻倍。
- 高并发、算力已经打满的负载:收益会缩水(从接近翻倍掉到 1.43 倍,原因见上节)。这时开不开,取决于你要不要那部分延迟改善。
- 模型没有 MTP 层:要另外配草稿。常见做法有两种,一是 EAGLE(一种事后给模型加训一个小草稿头的技术,在模型顶层特征上接一个轻量层,专门学"接下来几个 token 是什么",训练成本比从头训小模型低),二是干脆另配一个现成的小模型当草稿。两者都要额外的训练或部署成本,另算。