同一块积木的两本账:Transformer Block 的训练期动机与运行期代价
写作日期:2026-10-09
面向读者:读过 Transformer 的架构讲解,也用过 vLLM 之类的推理框架,但两边对不上——不知道 KV Cache、GQA、投机解码这些工程是从架构的哪一条推出来的
核心问题:训练时我们讲的是「为什么这样设计」,推理时我们讲的是「怎么跑得更快」。这两套话之间,凭什么对得上?
行文约定:中文为主;英文术语保留(attention、residual、FFN、KV Cache、GQA、quantization 等);第一次出现的英文术语给全称
引子:同一块积木,两本账
关于 Transformer Block 的设计动机,这里已经写过一篇——《Transformer Block 设计哲学:从『看似冗余』到『刚好够用』》。它讲的是第一本账:训练期为什么这样设计。
关于推理,也写过一篇——《Decoder-Only 大模型的推理过程详解》。它讲的是第二本账:运行期怎么把同样的模型跑得更快。
问题是,这两本账之间从来没有对上过账。
于是就有了这样一种常见的割裂:你知道 KV Cache 很重要,也知道它的显存公式怎么算,但说不清为什么偏偏是 attention 这个结构让 KV Cache 成为可能,换个架构还能不能成立。同样,GQA 省显存、PagedAttention 消碎片、投机解码提吞吐、量化压字节——这些技术常被当成四个独立的技巧并列介绍,好像它们是一堆互不相干的工程事实。
它们不是。
本文要证明的一件事:推理侧的每一项工程,都能在训练侧的某个设计里找到出处。反过来也成立——有些训练期的设计,在推理侧根本没有账单,而少数几个在训练期是纯粹好处的设计,到了推理期反而变成了负债。
这不是一个类比,是可以逐条算的。所以本文的做法是:挑一个公式,一路推到底。
读完之后,你应该能自己回答:
- 为什么 KV Cache 只对 attention 成立,对 FFN 不成立?
- 为什么 RoPE 只能旋转 Q 和 K,旋转 V 没有任何作用?
- 为什么 GQA 能共享 K / V,却绝不能共享 Q?
- 为什么激活量化迟迟停在 int8,而权重量化已经能上 int4?
- 为什么投机解码的接受 / 拒绝规则必须设计得那么精细,否则整个优化就不成立?
- 哪些 block 设计没有推理账单,哪些设计把训练期的好处变成了推理期的债?
母题预告:全文只推一个公式。
这一行里同时藏着 KV Cache、RoPE、GQA 和激活量化难这四项工程。下面逐个拆开看。
1. 一个公式,四项工程
先把公式拆成五块,每一块后面都跟着一项工程:
| 公式里的部分 | 它规定了什么 | 逼出来的工程 |
|---|---|---|
| 逐位置独立计算 | 每个位置各自持有一份 K / V | KV Cache |
| 打分是 点积 | 位置信息只能通过改 Q / K 进入 | RoPE(旋转位置编码) |
| K / V 逐头独立 | 每个头各有一份检索子空间 | GQA / MQA(多组查询注意力) |
| 打分后过 softmax | logits 被压成一个和为 1 的分布 | 采样的全部花样 |
| softmax 之前的激活值进过矩阵乘 | 激活里有离群通道 | 激活量化至今最难 |
下面这张图就是这张表的展开——绿的是公式里直接规定的性质,红的是最后要付的账:
下面逐条推。
1.1 逐位置独立 → KV Cache
公式第一行的 里, 是形状 的输入矩阵, 是固定的权重。每个位置的 只由它自己的 决定,和别的位置没有任何关系。
这个「没有关系」在训练期是优点:所有位置可以同时算,GPU 吃满了算力。在推理期它是意外的红利:因为 不依赖别的位置,那么当序列增长时,前 个位置的 永远不会被后面的 token 改变。
而 同理。所以生成第 个 token 时,前 个位置的 K / V 本来就不必重算——把它们存下来直接用就行。
这就是 KV Cache,显存账本在另一篇的 4.3 节已经算过:
关键在这里:KV Cache 之所以成立,唯一的原因是「 的输出逐位置独立、且不随序列变长而改变」。这不是一个通用优化,是这个结构白送的东西。
那 FFN 为什么不行? 因为 FFN 在通道维度上运算:
输入只有一个 ,输出只有一个向量,根本没有「和别的位置发生关系」这回事——它本来就不存在可缓存的重复计算。KV Cache 省的是重复,而 FFN 从来没重复过。
1.2 打分是点积 → RoPE
位置信息必须进去,但公式里只有 这一处是可以塞位置的地方。
原因很直白:乘完之后就是权重,而权重是要做 softmax 的,任何在这里加的位置项都会非线性地影响注意力分布。倒不如在点积内部做手脚。
原始 Transformer 选择「加一个位置向量到 embedding 上」——但那样位置信息就混进了 ,会同时污染 、、 三条路(见 1.1 节的分工论证)。更干净的做法是只旋转 和 两条路,让 保持纯粹的语义:
旋转的性质让 ,于是点积里的相对位置自然浮现。
为什么不能旋转 V? 因为 V 不参与打分。输出是
权重 里没有 V 的任何信息。你把 V 整体旋转一次, 一动不动,输出就跟着整体旋转了——那是改变表征,不是注入位置。
这条正好能和另一篇的 2.3 节对上:位置编码(不管是加法还是 RoPE)破的是位置对称,而 那个非对称性来自 这一点。RoPE 动的是前者,和后者正交。所以「用了 RoPE 所以 attention 矩阵不对称了」这句话是错的。
1.3 逐头独立 → GQA 能共享 K / V,不能共享 Q
多头注意力把 切成 份,每份各自投影。所以每个头都有自己的 K / V:
而 KV Cache 的字节数和 成正比——头数是显存账单上最扎眼的那个变量。
于是有人试了:让多个头共用同一份 K / V。这就是 MQA(Multi-Query Attention,多查询注意力,Shazeer 2019)极端版本——所有头共用一组;GQA(Multi-Group Attention,多组查询注意力)折中——32 个头分成 4 组,每组共用一组。
省下来的比例就是头数比:32 头分 4 组 KV,KV Cache 直接缩到 1/8。
为什么 Q 不能共享? 回到打分:
是第 个头的查询向量。如果所有头共用 Q,那么同一行里所有头的 会完全相同——每个头问出同一个问题、看同一批位置、取同一份加权结果。
多头真正买到的多样性能不能成立。另一篇的 1.7 节引了 Michel et al. 的实验:WMT 翻译的 encoder 自注意力一共 96 个头,逐个移除后只有 8 个造成统计显著的 BLEU 变化,其中约一半剪掉之后 BLEU 反而上升;BERT 微调 MultiNLI 上可剪掉最多 40% 的头。多头冗余到这个程度,K / V 跨头共享却还能保住质量——说明冗余主要在「怎么检索」,不在「往哪看」。
一句话:GQA 省的是「每个头各自维护一份检索索引」的冗余,代价是交出「每个头问不同问题」的多样性。这笔账之所以划算,是因为后者虽然可观测,前者却直接乘在每一层每一层的显存上。
1.4 softmax 之前 → 激活量化最难
前三条都是结构性的,这一条稍微绕一点。
权重量化之所以能成立,很大程度上因为权重是静态的、分布平滑的:一批权重里绝大多数挤在 0 附近,按 64 或 128 个一组各算一个缩放因子,精度损失就控制住了(另一篇的 6.1 节有完整推导)。
激活不一样。LLM.int8(Dettmers et al. 2023)的观察是:少数通道的激活值比其他通道大两三个数量级,而且这些离群通道在整个网络里是持续的——同一个通道在前一层大,后一层还大。
这些通道恰恰是网络里信息量最大的部分。用一个全局缩放因子去覆盖它们,其余通道就会被全部压扁到零附近——量化误差不是「小了一点」,而是把大部分信号直接抹掉了。
这里能看出一点架构和量化的耦合:注意力里的 是一个没有归一化保护的点积,而它的结果要直接进 softmax。控制这一段 logits 尺度是模型自己的责任——现代实现里常见的做法是 QK-Norm(对 Q、K 各自做一次归一化后再点积),
nanochat的CausalSelfAttention.forward里就有一句q, k = norm(q), norm(k)。这不是为了量化才加的(它是为了训练稳定性,见另一篇的 4.5 节),但它的效果顺带落在 logits 的尺度上——而 logits 的尺度正是激活量化最容易崩的地方。
2. 因果掩码:这把锁决定了整个推理形态
上一节是 attention 内部。往外扩一层:因果掩码(causal mask)不是一项优化,它是 decoder-only 架构的根本约束。
2.1 它锁住的不只是 attention
把 加进 看起来只是改了 attention 那一行,但它往外推的链条是这样的:
| 约束 | 直接后果 | 再往外一层 |
|---|---|---|
| 第 个 token 只能看到 | 生成必须逐 token 串行 | prefill / decode 负载分裂(2.2 节) |
| 训练也用同一个掩码 | 训练时一次算全部位置,但那是并行的前向,不是并行的生成 | 训练快、推理慢的根本落差 |
| 行的输出不依赖 | 输出分布只在已生成的前缀上条件化 | 采样只能在每一步独立决策(2.4 节) |
第二条是全部困惑的来源:训练和推理用的是同一个函数,但一个能并行、一个不能。 因为训练时整条序列已经存在, 个位置可以同时算;推理时第 个位置的输入还不存在。
2.2 prefill 与 decode:两种完全不同的负载
这次切分是掩码直接推出来的:
| 阶段 | 输入 | 并行度 | 瓶颈 | 干什么 |
|---|---|---|---|---|
| Prefill | 整个 prompt | 高 | 算力 | 一次性把 prompt 的 K / V 全部填进 cache |
| Decode | 每次 1 个 token | 低 | 显存带宽 | 每步都要把整个 KV Cache 读一遍 |
这个切分在实现里就是一个带回边的循环——红色的 decode 每算完一步,就往黄色的 cache 里补写一个位置:
Decode 阶段每生成一个 token,要从显存里读几十上百 MB 的 K / V,只为了做一次矩阵-向量乘法。算力利用率极低,瓶颈完全在搬运上。
所以推理框架那一堆东西其实是同一个思路的不同切面:
| 手段 | 消掉的是哪种浪费 | 精确还是近似 |
|---|---|---|
| KV Cache | 重复计算 | 精确——被缓存的量本来就不变(1.1 节) |
| PagedAttention(vLLM) | KV Cache 的显存碎片 | 精确——只改分配方式 |
| continuous batching | decode 阶段等矩阵乘法的空隙 | 精确——只改调度顺序 |
| 量化 | 读取权重和 K / V 的字节数 | 有损——只能把误差压小 |
这张表来自另一篇的 6.3 节,但根子在这里:前三项之所以能是「精确」的,全靠 1.1 节那个结构性质。结构给了你恒定量,你才有资格精确地缓存它。
2.3 串行是根本约束,只能绕不能解
有一类问题特别值得说清楚:长序列上的并行度。
掩码保证了第 个 token 只看前面,但并没有保证必须一个一个生成。理论上可以一次猜 个位置,然后验证。这就是投机解码的思路。
关键在于验证规则必须极其精密。因为它要同时满足两个互相拉扯的要求:
- 快—— 个草稿一次前向验证完,而不是 次
- 分布不变——最终采样结果必须和「大模型直接逐个采样」在统计上完全等价
如果你只是「接受前 个匹配的 token,剩下的重新采样」,得到的分布就不等价了——这不再是一个正确性无损的加速,只是一个来路不明的启发式。
真正可用的接受 / 拒绝规则(Leviathan et al. 2023)要逐 token 地计算接受概率并做修正,使得被拒绝时的重采样分布恰好等于原始分布。这个规则复杂到需要专门配比,但省不掉——省掉它,整篇论文的正确性就没了。
这是本文最想强调的一次对应:投机解码那条复杂的接受规则,不是推理工程的天才发明,它是「因果掩码下必须逐 token 条件采样」这个约束逼出来的。要让它精确,就得逐 token 地把分布损失补回来。
2.4 采样改的是哪一步
温度、top-k、top-p 全部作用在 softmax 之后的那一步:
它们不碰模型,也不碰任何一个矩阵乘。模型的工作到给出 维概率分布就结束了。
所以它们和前面 1.1 到 1.4 节那些工程完全不在一个层面——那些改的是「怎么算」,这些改的是「算完之后拿结果干什么」。混在一起讲,通常是入门文章把推理讲乱的主要原因。
3. FFN:参数的大头,也是量化收益的大头
前两节都在 attention 里。第 1 节末尾那条线索其实还悬着:softmax 之前的激活值为什么难量化,答案的一半在 FFN 里——因为 FFN 既是参数的大头(省下来的字节主要来自它),又是激活离群通道第一次出现的地方(第 1.4 节那个 )。
3.1 一笔很短的参数量账
用 ,忽略 bias 和 embedding:
| 组件 | 参数量 | 占比 |
|---|---|---|
| Attention() | 1/3 | |
| FFN(ReLU,隐藏维 ) | 2/3 | |
| FFN(SwiGLU,隐藏维 ) | 2/3 |
三分之二的参数在 FFN 里。 所以权重量化省下来的字节,三分之二来自 FFN。
对一个 7B 模型做 int4 量化,权重大约 14 GB → 3.5 GB——这一项省下的 10 GB 里,约 7 GB 是 FFN 贡献的。
3.2 FFN 为什么对量化格外友好
除了参数量大,FFN 还有一个结构上的优势:它逐点计算,没有任何跨 token 的依赖。
位置 的输入只有 。这意味着:
- 量化误差不会沿序列传播。 位置 的 FFN 出错,只影响位置 。
- 对比之下 attention: 里的量化误差会进入 ,传播给所有 的位置——序列越长,累积越严重。
这是结构性的,不是实现细节:FFN 在 token 维度上是恒等映射,attention 在 token 维度上是一张下三角矩阵。
3.3 为什么激活量化还是最难
即便 FFN 逐点,激活量化仍然是三件量化里最难的一件(难度排序见另一篇的 6.1 节):
| 类型 | 量化什么 | 难度 | 为什么 |
|---|---|---|---|
| 权重 | 低 | 静态、分布平滑,分组量化就够用 | |
| KV Cache | 中 | 运行时生成,但分布相当稳定 | |
| 激活 | 中间激活 | 高 | 存在离群通道 |
因为 FFN 的第一层激活是 的输出——它已经把 里的离群通道放大了。3.2 节说的「误差不传播」保证了单次误差可控,但离群通道的问题依然在第一层就出现。
4. 残差与归一化:让尺度可控,也顺便帮了量化
残差和归一化的机制在另一篇的第 4 节讲得很细,这里只记它们和推理的接口。
4.1 归一化把激活拉回固定尺度
Pre-LN 的 block 长这样:
1 | # nanochat/gpt.py 的 Block.forward |
每一层进入子层之前都过一遍 RMSNorm。这对推理的直接意义是:进入任意一个 matmul 的张量,尺度是受控的。
量化要能成立,一个隐含前提就是待量化的量分布足够规整。归一化在训练期是为了稳定梯度,在推理期顺手把激活的动态范围按住了——这是训练期的设计在推理期收到的另一笔红利。
4.2 但离群通道是另一个问题
需要说清楚:归一化解决的是「整体尺度」,不是「离群通道」。
LLM.int8 观察到的离群通道现象恰恰是归一化压不住的——因为它是通道之间的差异,不是通道和通道之间的大小分布。一个通道比其他大一千倍,RMS 除的是所有通道的均方根,除完之后它依然比其他通道大得多。
所以:
归一化让量化「可行」,但离群通道让激活量化「仍然很难」。 这两件事经常被混成一句「归一化让量化变容易」,那句话是不准确的。
这也是为什么 int8 激活量化必须改成逐 token 的动态 scale,而 int4 激活量化到今天仍没有令人满意的通用方案。
5. 反过来看:哪些设计没有账单
前面四节都在讲「训练期设计 → 推理期账单」。但如果这本书只是单向的,那它就不完整。下面是反方向的三种情况。
5.1 有收益,但账单是零
多头在推理侧几乎没有账单——除了 GQA 试图砍掉它这件事本身。头数更多意味着 KV Cache 更大,但这不是多头这个设计的债,是 K / V 逐头存储这件事的账,1.3 节已经算过了。
1:1 的 block 配比同样没有推理账单。它纯粹是个训练期的经验选择,换成别的比例,推理框架一行代码都不用改。
这说明什么? block 的很多设计只影响训练期。它们值得争论「训练期是不是最优」,但对推理工程师来说是不存在的。把训练期的架构争论和推理优化混在一起谈,是很多「模型更快了」的错误来源——快的是模型还是推理引擎,永远说不清。
5.2 训练期的好处,在推理期变成负债
MoE(Mixture-of-Experts,混合专家)是标准的例子。
训练期的账很清楚:把 FFN 换成 个专家、每个 token 只激活 top- 个,总容量大幅上升而激活参数量基本不变——这是训练期极其划算的一笔账,block 的 1 : 1 结构还完全不用动。
推理期的账单却是反过来的:
- 专家路由本身要算:每个 token 先过一次门控网络,得出该去哪些专家。
- 专家是稀疏的,就不能按位置批量算:token 必须被送到它选中的专家所在的位置。这是分布式训练里最贵的那类通信,推理时同样跑不掉。
- 负载不均:一个 batch 里如果大部分 token 都路由到同一个专家,这个专家就是瓶颈,其余设备空转。
- 显存反而更贵:所有专家的参数都得驻留,哪怕每次只激活一小部分。
这一节的结论:训练期的「激活参数少」,在推理期完全不等于「计算量少」。 前者只统计乘加次数,后者还要加上为了把 token 送到对的地方而产生的一切搬运和等待。MoE 在训练账上赢,在推理账上往往更贵——这不是实现没做好,是稀疏化和批处理天然对立。
5.3 没有账单,但制造了麻烦
还有一类设计既不占显存也不增加计算,但会让前面那些优化变难。因果掩码就是这一类的典型——它本身零成本,但它锁死了并行度,于是有了 prefill / decode 的分裂、有了投机解码、有了 continuous batching。
账不一定记在设计头上,但账是从设计上长出来的。
结语:问对问题,账单自己会浮出来
把两本账对完之后,最有用的一条不是那张对应表,而是一个提问习惯:
看到一项推理侧的技术,先问它消掉的是哪种浪费;看到一个 block 里的设计,先问它把什么变成了恒定量。
这两句话互为倒数:
| 训练侧的问题 | 推理侧的答案 |
|---|---|
| 哪些量在序列变长时不变? | → KV Cache |
| 哪些量被多个位置共享? | → GQA / MQA |
| 哪些量尺度失控? | → 归一化、量化、QK-Norm |
| 哪些量逐 token 条件化? | → 采样、投机解码的接受规则 |
反过来:
| 推理侧的观察 | 训练侧的解释 |
|---|---|
| 投机解码的规则复杂到离谱 | 因为掩码要求分布逐 token 条件化 |
| GQA 省 KV 但不敢省 Q | 因为多头买的是「问不同问题」 |
| 权重量化比激活量化容易太多 | 因为 FFN 逐点、误差不沿序列传播 |
| MoE 训练快、推理未必快 | 因为稀疏激活和批量计算天然对立 |
最后一条,也是最容易被跳过的一条:
并不是每个设计都会在推理侧留下账单。 1:1 的配比、SwiGLU 的门控、Q / K / V 的独立投影——它们全是训练期的账,推理侧一行代码都不会因为它们而改写。
所以当一篇文章把训练期的架构争论和推理优化的性能数字放在一起比较时,先确认这两件事说的不是同一个东西。很多「换个架构更快了」的结论,量的其实是训练成本,不是推理成本。
附录:参考资源
论文
- Vaswani et al. 2017, “Attention Is All You Need” —— Transformer 原始论文;因果掩码、正弦位置编码、Post-LN 的出处
- Shazeer 2019, “Fast Transformer Decoding: One Write-Head is All You Need” —— MQA
- Ainslie et al. 2023, “GQA: Training Generalized Multi-Query Transformer Models from Multi-Head Checkpoints” —— GQA;本文 1.3 节说的「从多头检查点转换而来」是它的实际用法,不是重新训练
- Su et al. 2021, “RoFormer” —— RoPE
- Press et al. 2022, “Train Short, Test Long: Attention with Linear Biases Enables Input Length Extrapolation”(ICLR 2022)—— ALiBi,另一条位置外推路线
- Shazeer 2020, “GLU Variants Improve Transformer” —— SwiGLU 与 的参数不变推导
- Leviathan et al. 2023, “Fast Inference from Transformers via Speculative Decoding” —— 投机解码的接受 / 拒绝规则,2.3 节那条精确性的来源
- Gao et al. 2023, “vLLM / PagedAttention” —— 显存分页与 continuous batching
- Dettmers et al. 2023, “LLM.int8()” —— 激活离群通道的发现,1.4 节与 4.2 节的依据
- Xiong et al. 2020, “On Layer Normalization in the Transformer Architecture”(ICML 2020)—— Pre-LN 与输出层梯度的关系
- Michel et al. 2019, “Are Sixteen Heads Really Better than One?” —— 头冗余性,1.3 节「K / V 冗余度更高」那个判断的依据
- Lepikhin et al. 2020, “GShard: Scaling Giant Models with Conditional Computation and Automatic Sharding” —— MoE 的分布式实现;5.2 节说的 all-to-all 路由开销在这类工作里最直观
- Sukhbaatar et al. 2022, “Optimal Expert Routing in Mixture-of-Experts Models” —— Switch Transformer 的路由
工程资源
- nanochat(Karpathy)
nanochat/gpt.py—— 本文几个代码事实的对照实现:CausalSelfAttention.forward里的q, k = norm(q), norm(k)就是 1.4 节说的 QK-NormBlock.forward是 Pre-LN 的标准写法,也是 4.1 节那段代码GPT.forward在lm_head之前补了一次 final norm——因为 Pre-LN 的残差流到最后没有约束n_kv_head字段支持 GQA,但默认配置下n_kv_head == n_head,实际退化成标准多头;1.3 节说的 GQA 要显式把它调小才生效- 它的 MLP 是平方 ReLU 而不是 SwiGLU:
c_fc: d → 4d、F.relu(x).square()、c_proj: 4d → d
- vLLM —— 把 2.2 节那张表变成可执行代码的一整套实现:
PagedAttention、continuous batching、GQA 的 KV 头配置 - HuggingFace Transformers ·
cache_utils.py——DynamicCache/StaticCache的取舍,对应 1.1 节的 KV Cache
同系列
三篇一套,按这个顺序读最顺:
- 《Transformer Block 设计哲学:从『看似冗余』到『刚好够用』》(2026-06-25)—— 第一本账:训练期为什么这样设计。本文引用的三处出处都在那里:1.2 节说 RoPE 破不了关系对称,对应它的 2.3 节;1.3 节说 K / V 跨头可共享而 Q 不能,对应它的 1.5 节脚注和 1.7 节的头冗余实验;4.1 节那段 Pre-LN 代码对应它的 4.2、4.3 节
- 《Decoder-Only 大模型的推理过程详解》(2026-04-09)—— 第二本账:运行期怎么跑得更快。本文引用的四处出处都在那里:1.1 节的显存公式在它的 4.3 节;1.4 和 4.2 节的离群通道分析在它的 6.1 节;2.2 节那张「精确 / 有损」的表在它的 6.3 节,prefill / decode 的分工在它的 4.4 节
- 本文(2026-10-09)—— 把两本账逐条对上