Decoder-Only 大模型的推理过程详解

作者 · kailfrong

写作日期:2026-04-09
面向读者:用过 ChatGPT / Claude 这类工具,知道它“在预测下一个词”,但不清楚模型内部到底怎么一步步算出来的
核心问题:Decoder-Only 的推理看起来只是“不断预测下一个 token”,为什么这件事需要一整套 KV Cache、采样、量化的工程体系?
行文约定:中文为主;英文术语保留(token、embedding、RoPE、attention、KV Cache、quantization 等);第一次出现的英文术语给全称


引子:七行代码,几百毫秒的沉默

本文是三篇系列里的第二本账。 第一本是《Transformer Block 设计哲学》(训练期为什么这样设计),第三本是《同一块积木的两本账》(把两本账逐条对上)。本文讲第二本:运行期怎么把同一个模型跑得更快。

写代码的时候,推理循环常常短得不像话:

1
2
3
4
while not done:
logits = model(tokens)
next_token = sample(logits)
tokens.append(next_token)

七行。但你按下回车到看见第一个字,模型已经沉默地算了几百毫秒到几秒。这段时间里没有“循环”,只有一层层矩阵乘法在原地跑。

如果你翻开一个现代 LLM 的实现,会看到几件“多余”的事:

  • 既然每次只生成一个 token,为什么不为这一个 token 单独算一遍,而要把整句话再送进去?
  • 既然 prompt 里的内容永远不会变,为什么每生成一个字都要把 prompt 的 Key / Value 重算一遍?
  • 既然模型的输出只是“一堆概率”,为什么温度、top-k、top-p 这些参数能改变回答的质量?

每一处看起来都可以优化掉。而当你真的试着优化掉,会立刻撞上同一堵墙:

因果掩码规定,第 个 token 只能看到 之前的 token。
这意味着模型的每一次前向传播,都是在“信息不全”的条件下做的条件预测。

这篇文章要做的事,就是从这把锁出发,把 decoder-only 的推理拆到零件级别,讲清楚每个零件为什么必须在那个位置。读完之后,你应该能自己回答:

  1. 因果掩码到底限制了什么?为什么它是 decoder-only 存在的根本理由,而不是一个实现细节?
  2. 既然模型只能“往左看”,为什么还是 decoder-only 赢了?它付出了什么代价?
  3. 一次前向传播里,一个 token 依次经历了哪些部件?每个部件的输出是什么形状?
  4. KV Cache 缓存的是什么?它为什么能把 O(n²) 的重复计算变成 O(n)?
  5. 模型只输出概率分布,采样策略在做什么?温度、top-k、top-p 分别改变了分布的什么性质?
  6. 量化和各种“加速”到底在优化什么?为什么有的能精确等价,有的只能把误差压到可接受?

母题预告:所有推理优化,都在同一把因果掩码的锁下面做同一件事——把“重复的计算”和“不必要的搬运”尽可能消掉,好让剩下的算力全部用在真正的新 token 上。理解了这句话,KV Cache、continuous batching、PagedAttention、投机解码就都不再是零散技巧,而是同一条思路的不同切面。


1. 因果掩码:Decoder-Only 的唯一“锁”

1.1 Encoder-Decoder 与 Decoder-Only 的分水岭

Transformer 原始论文(Vaswani et al. 2017)的结构是 Encoder-Decoder:

  • Encoder 双向读取输入,每个位置能看到整个句子 —— 适合理解
  • Decoder 单向生成输出,每个位置只能看到自己之前的内容 —— 适合生成

Decoder-Only 做的事情,是只保留 Decoder,并把输入也当成“已经生成的部分”喂进去。GPT、Claude、Llama、Qwen 全都是这个结构。

代价是什么?模型失去了双向读取输入的能力。它在读 prompt 的时候,和在写回答的时候,用的是完全一样的、只能往左看的能力。

1.2 掩码矩阵

这把锁在代码里长这样:

1
2
3
4
5
6
7
8
输入:"Hello, how are"
预测:"you"

注意力矩阵(行 = 当前 query,列 = 被关注的 key):
Hello how are
Hello ✓ ✗ ✗
how ✓ ✓ ✗
are ✓ ✓ ✓

✗ 的位置在 softmax 之前被置为 ,softmax 之后权重严格等于 0。写成公式:

其中 是上三角为 、下三角为 0 的掩码矩阵。

注意一个容易被忽略的后果:因为 行的输出只依赖 的前 行,所以第 个位置的 Key / Value 向量永远不会被后面的 token 改变。这一点是第 4 节 KV Cache 的全部前提——严格说,是”为什么 KV Cache 是精确等价而不是近似加速”的全部前提。

1.3 为什么不能解开这把锁

一个自然的想法是:既然双向注意力效果更好,为什么不把掩码去掉?

因为训练目标和推理方式会脱节。 训练时如果能看到未来,模型学到的就是“填空题”的解法;推理时未来是空的,它根本填不了。更具体地说:

假设训练时模型能看到 ["北京", "是", "?", "的", "首都"],它可以把 ? 学成“中国的”。但推理到第 3 个 token 时,输入只有 ["北京", "是"],模型必须在没有未来信息的情况下做出预测。训练和推理的分布不一致,模型会明显退化。

这就是自回归(autoregressive)建模的根本约束:训练和推理必须是同一个计算过程。


2. 为什么是 Decoder-Only

代价已经说清楚了(1.1 节),该算收益。既然“只能往左看”是实打实的表达力损失,为什么主流还是全都走了这条路?四个理由:

理由 说明
训练与推理统一 同一个前向过程,不需要额外的 encoder 阶段
统一的任务接口 分类、翻译、摘要、代码,全部变成“补全一段文本”
缩放友好 结构极简,缩放实验基本都在 decoder-only 上做,结论可直接迁移
推理友好 因果结构让 KV Cache 成立,这是批量推理的前提

这四条不是并列的。第一条是定义性的,1.3 节已经论证过:自回归建模要求训练和推理是同一个函数,否则模型学到的解法在推理时填不了。Encoder-Decoder 当然也能做自回归生成,但它的训练目标和生成过程是两个不同的函数——两套代码、两套超参、一次额外的对齐。Decoder-Only 一次解决掉了这件事,代价就是 1.1 节那个损失。

第四条是工程含量最高的。因果结构让 逐位置恒定(1.2 节末尾那条性质),KV Cache 因此成立;而 KV Cache 一旦成立,量化、PagedAttention、continuous batching 这一整串优化才有了作用对象——它们全都是在缓存或者搬运那份本来就不必重算的量。把这条线索追到底,就是另一篇《同一块积木的两本账》1.1 节的论证。

代价也很明确:长上下文的显存开销(4.3 节),以及必须串行生成这件事本身。前者已经有很多缓解手段,后者目前只能靠投机解码这类“用并行换串行”的办法绕——而这是推理优化里唯一没有“精确解”的一类(6.2 节)。

到这里,因果掩码这把锁的两侧都交代清楚了:它为什么必须在(1.3 节),选它要付什么代价(本节)。下面回到机械层面——这把锁具体把一次推理切成了什么样。


3. 一次前向传播里,一个 token 经历了什么

假设输入是 ["Hello", ",", "how", "are", "you", "?"],模型要预测第 7 个 token。整条路径长这样:

flowchart TD T["文本<br/>Hello, how are you?"] -->|"分词器"| E["Token id 序列<br/>共 6 个"] E -->|"查表"| X["Embedding<br/>X: (6 × d)"] X -->|"注入位置"| R["位置信息<br/>RoPE / ALiBi"] R --> B1["Block 1"] B1 --> B2["Block 2"] B2 --> BL["…… 一共 L 层 ……"] BL --> FN["final norm<br/>残差流到此才被约束"] FN -->|"lm_head"| L["logits<br/>(V 维)"] L -->|softmax| S["下一个 token 的概率分布"] B1 -.->|"K / V 存下来"| C[("KV Cache")] C -.->|"下一轮直接读<br/>不再重算"| B1 style C fill:#fff3cd,stroke:#ffc107 style FN fill:#f8d7da,stroke:#dc3545 style S fill:#d4edda,stroke:#28a745

三个地方值得先记下来,它们是后面几节的伏笔:

  • 那条黄色的回边:K / V 之所以能存下来,靠的是 1.2 节末尾那条性质——第 个位置的 K / V 永远不会被后面的 token 改变。这是结构白送的,不是通用优化:结构给了你恒定量,你才有资格精确地缓存它,第 4 节整节都在算它的账
  • 那个红色的 final norm:残差流一路只有加号、没有衰减,到最后尺度是失控的,送进 lm_head 之前必须补一刀(Pre-LN 的代价,出处见另一篇《Transformer Block 设计哲学》4.3 节)
  • 绿色的终点之后没有模型了:概率分布就是模型能给出的全部,后面发生的事都不在模型里——第 5 节

3.1 Tokenization 与 Embedding

自然语言先被切成 token,每个 token 查表变成一个 维向量:

1
2
"Hello, how are you?"  →  [t₁, t₂, t₃, t₄, t₅, t₆]
→ X ∈ ℝ^(6 × d)

切出来的个数和边界完全取决于分词器:换一张 BPE 词表,同一句英文可能就切成 7 个。所以「一句话 = 多少 token」永远是相对于某个具体分词器而言的。

注意这一步是可逆的:token id 可以还原成字符串,所以模型看到的从来不是“文字”,而是词表里的下标。

3.2 位置编码:告诉模型“你在第几个”

纯 attention 是置换等变(permutation-equivariant)的 —— 把输入顺序打乱,输出也跟着打乱,模型完全察觉不到。“猫追老鼠”和“老鼠追猫”的 token 完全一样,区别只在顺序,而没有位置信息的模型看不出这个区别。

所以必须显式注入位置信息。它的本质是给模型一个坐标系:让同一个词出现在不同位置时,代表不同的东西。

原始 Transformer(Vaswani et al. 2017)用一组固定的正弦 / 余弦函数生成位置向量,加到 token 的 embedding 上:

为什么是正弦 / 余弦? 因为它有相对位置的可线性组合性:位置 的编码是位置 编码的一个线性变换,所以模型能学到“相隔多远”,而不只是“在第几个”。三角函数的加法公式恰好满足这个性质,这正是选它而不是随便一组数字的原因。

后来它为什么被放弃? 两条路都撞了墙。

可学习的位置嵌入(Learned Positional Embedding,GPT-1 / GPT-2 / BERT 都用过)直接把每个下标映射到一个向量,简单直接,但没训练过的下标根本没有对应的向量可查——上下文长度就此被 embedding 表锁死。

正弦 / 余弦理论上可以外推,但实际外推效果很差:它的高频分量在远离训练长度时旋转过快,小幅的位置偏移就会让角度完全对不上。

结果就是 GPT-2 的上下文是 1024,GPT-3 只涨到 2048。当时把这件事归因于位置编码是外推不了,属于通行的解释,但它是解释而不是论文里被证明的因果——同一时期算力和数据规模也都在涨,两个因素分不开。可以确定的只是事实:那一代模型没能把上下文做长,而“位置信息无法外推”确实是一个卡在那里的真实约束。

现代模型基本都用 RoPE(Rotary Position Embedding,旋转位置编码):不额外添加位置向量,而是按位置对 Q 和 K 做一次旋转,让它们之间的点积自然地只依赖相对距离。

1
2
3
4
5
6
7
# 位置 i 的 query 向量按 i·θ 旋转,key 向量按 j·θ 旋转
# 两者点积 = cos((i-j)·θ),只依赖相对位置 i-j
def rope(x, pos, theta):
freq = pos * theta # [B, T, d/2]
cos, sin = freq.cos(), freq.sin()
return concat(x[..., ::2] * cos - x[..., 1::2] * sin,
x[..., ::2] * sin + x[..., 1::2] * cos)

RoPE 正好补上上面两个缺口:位置信息是通过旋转算子隐式注入的,没有需要查表的向量,长度不由 embedding 表决定;而它只在低维子空间上做旋转,外推时的退化比正弦 / 余弦的高频分量温和得多。

代价是它只对 Q 和 K 有意义——因为它依赖的是 这个点积,V 不参与打分,旋转 V 不会改变任何输出。所以 RoPE 属于「只改打分函数、不动子空间分工」的那一类设计:旋转是位置的职责,Q / K 的独立投影是语义的职责,两者不冲突。

一个容易顺手当成结论的推论:既然 RoPE 让点积只依赖相对距离,那它是不是也顺带打破了 attention 矩阵的对称性?不是。 RoPE 管的是位置,而 那个非对称性来自 这一点。两件事互不相干——把「位置对称」和「关系对称」混成一件事,是这一段最常见的误读,完整论证见另一篇《Transformer Block 设计哲学》的 2.3 节。

RoPE 之外还有 ALiBi(Attention with Linear Biases)——不旋转向量,直接往 logits 上加一个与距离成正比的偏置,原理更简单、外推也更稳,但表达能力上不如旋转方案。目前两种都在用。

3.3 Attention:横向混合

每个 token 拿着自己的 去问所有前面的 token 的 ,按相似度加权取回它们的 :

这一步是横向的:在token 维度上混合信息 —— 每个位置吸收了前面各个位置的信息。

多头的意义在于:不同的头可以关注不同类型的关系(有的头盯句法依存,有的头盯共指)。

3.4 FFN:纵向加工

Attention 之后接一个前馈网络(FFN, Feed-Forward Network),它在通道维度上做非线性变换:

如果说 attention 是“开会”(每个 token 听所有人发言),FFN 就是“各自消化”(每个 token 独立地把听到的东西转成自己的表示)。一个负责横向混合,一个负责纵向加工,缺一不可。

3.5 输出层与 Logits

最后一个 Linear 把 维隐状态映射回词表大小 维,得到 logits:

softmax 之后就是“下一个 token 的概率分布”。到此为止模型的工作就结束了 —— 它只负责给分布。但在进入“挑哪一个”之前,还悬着一件同样基本的事:这一轮辛苦算出来的 不能白算。那是第 4 节。


4. KV Cache:自回归的“账本”

4.1 一个显然的浪费

回到开头那个循环。生成第 2 个 token 时,输入是 [Hello, ,];生成第 3 个时,输入是 [Hello, ,, how]。

但 Hello 和 , 的 Key / Value 从来没变过(见 1.2 节的注意)—— 却被重算了一遍。序列越长,重复计算的占比越高:生成第 个 token 时,有 个位置的 K/V 是白算的。

所以单步代价是 ,而生成 个 token 累计就是 量级 —— 而其中绝大部分算在了永远不会再变的那些位置上。

4.2 缓存了什么,变了什么

KV Cache 的做法极其朴素:把每一层算出来的 K、V 存下来,下次只算新 token 的那一份,然后拼接上去。

1
2
3
4
5
6
# 每层维护一份 cache
cache.k = concat([cache.k, new_k], dim=seq) # [B, n, n_heads, d_head]
cache.v = concat([cache.v, new_v], dim=seq)

# 新 token 的 attention:只算 1 个 query,对全部 n 个 cached key
scores = new_q @ cache.k.transpose(-2, -1) / sqrt(d_head)

复杂度从 降到 量级,而且是精确等价的 —— 因为被缓存的量本来就不会变。这不是近似加速,是纯粹的工程优化。

4.3 缓存要吃多少显存

这是 KV Cache 的真实代价:

  • 层数, 头数, 每头维度()
  • 序列长度, batch size, 每个数的字节数(fp16 = 2)
  • 前面的 2 是 K 和 V 各一份

代入一个 7B 模型(、)、fp16、上下文 4096、batch=1:

模型权重才 14GB,KV Cache 就要 2.1GB;上下文拉到 32k,它变成 16.8GB。 这就是为什么长上下文推理会先 OOM —— 瓶颈不在算力,在显存带宽。

三个常见的缓解方向:

手段 做法 效果
GQA / MQA 多个 query 头共享一份 K/V 头 KV Cache 直接按 K/V 头数缩小:32 头分 4 组 KV 就是 1/8,分 8 组就是 1/4
量化 KV Cache K/V 用 int8 甚至更低位宽存储 直接减半或更多,损失很小
PagedAttention(vLLM) 像操作系统分页一样把 KV Cache 切成固定大小的块按需分配 消除碎片,长短请求混跑时显存利用率大幅提升

4.4 Prefill 与 Decode:两种完全不同的负载

一个请求会被拆成两个阶段:

阶段 输入 并行度 瓶颈 特点
Prefill 整个 prompt 高(所有位置同时算) 算力(compute-bound) 一次性把 prompt 的 KV 全部填进 cache
Decode 每次 1 个 token 低(必须串行) 显存带宽(memory-bound) 每一步都要把整个 KV Cache 读一遍

这个区分很重要:Decode 阶段每生成一个 token,都要把几十上百 MB 的 KV Cache 从显存读一遍,只为了做一次矩阵-向量乘法。算力利用率极低。这也是 continuous batching(让不同请求的 decode 步骤交错执行)能带来接近 10 倍吞吐提升的原因 —— 它填的就是这些空隙。

到这里,一次前向的账已经算清了:算了两遍的部分被缓存掉了,搬运的重负被摊到了两个性质完全不同的阶段。但还有一件更简单的事没讲 —— 模型给出的是 个概率,“挑哪一个”根本不在模型里。那是下一节。


5. 采样:模型只给分布,选择是另一件事

模型输出的是 个概率。从中挑哪一个,是完全独立于模型的一步决策。

5.1 三种最常用的手段

方法 做什么 直觉
Temperature 除 logits 再 softmax, 变贪心, 大则更随机 调“整体的随机程度”
Top-k 只保留概率最高的 个,其余置零后重归一化 砍掉长尾里明显不合理的选项
Top-p(nucleus) 保留累计概率刚超过 的最小集合 按概率质量切,而不是按个数切

Top-k 和 Top-p 的区别值得多说一句:分布很尖时(模型很确定),top-50 和 top-5 差不多;分布很平时(模型不确定),top-50 会放进大量垃圾,而 top-p 可能只留 3 个。Top-p 是自适应的,Top-k 是固定的,这就是两者经常一起用的原因。

下面是一个把三种策略合在一起的示意实现(nanoGPT / nanochat 的 generate() 里只有 temperature 和 top_k,没有 top_p)——

1
2
3
4
5
6
7
8
9
10
11
12
13
def sample(logits, temperature=0.8, top_k=None, top_p=0.9):
logits = logits / temperature
if top_k:
kth = logits.topk(top_k).values[-1]
logits[logits < kth] = -inf
probs = logits.softmax()
if top_p: # 按累计概率从小到大排序后切
sorted_probs, idx = probs.sort(descending=True)
cutoff = sorted_probs.cumsum() - sorted_probs # 保留累计和 ≤ top_p 的部分
mask = cutoff > top_p
sorted_probs[mask] = 0
probs = torch.zeros_like(probs).scatter_(0, idx, sorted_probs)
return torch.multinomial(probs, 1)
  • 贪心(greedy):每步取 argmax。可复现,但容易陷入重复和退化(“我不知道”→“我不知道”→“我不知道”……)
  • Beam Search:维护 条候选,按累计 log 概率排序。适合翻译这类有明确正确答案的任务;开放式生成反而更差 —— 它倾向于选“最安全、最平庸”的句子,这就是 beam search 生成的文本常有种“翻译腔”和“套话感”的原因

现代对话模型基本都不用 beam search,默认走采样。

5.3 停止条件

生成停止有三个来源:采到 EOS token;命中 stop 字符串;达到最大长度或预算上限。

一个容易踩的坑:top_k / top-p / min_p 这类截断策略如果把 EOS 也砍掉,模型就再也吐不出 EOS,生成停不下来。截断带来的「停止」是副作用而不是停止机制 —— 实现时通常要把 EOS 排除在截断范围之外。

采样讲完了,模型这一侧就没有更多东西了 —— 剩下的问题全部是“怎么把它算得更快”。这就是第 6 节。


6. 推理优化:都在同一把锁下换算力

6.1 量化

先说清楚这件事凭什么能成立。直觉上“把 16 位压到 4 位”听起来像砍掉四分之三的信息,但模型权重不是均匀分布的随机数:它们大致服从正态分布,绝大多数数值挤在 0 附近,只有少数很大。而模型的行为主要由后者决定。把中间那一大片压到极粗的刻度,损失小得出乎意料。

具体做法只有两步 —— 缩放和取整。以一组权重为单位,先算出这组的绝对值上界 ,然后

于是 区间里成千上万个不同的浮点数,被塞进了 4 个(int4)或 256 个(int8)格子里。存储时只留 ,算的时候再乘回 。

关键在于怎么划组。如果全模型共用一个 (per-tensor),任何一个大数值都会把 撑大,导致其余绝大多数权重只能分到一两个有效位 —— 等于白压。所以实际做法是分组量化(group-wise):每 64 或 128 个权重一组,各组各算各的 。这一步是精度的主要来源。

在这个前提下,三类可量化的东西难度递增:

类型 量化什么 难度 原因
权重量化(weight-only) 低 权重是静态的、分布平滑,group-wise 就够用
KV Cache 量化 、 中 运行时生成,但分布相当稳定;见 4.3 节,长上下文收益最大
激活量化 中间激活 高 存在离群值(outlier),至今仍是最难啃的

激活之所以难:LLM.int8 的观察是,少数通道的激活值比其他通道大两三个数量级。用一个全局 覆盖它们,其余通道会被全部压扁到零附近。所以 int8 激活量化必须改成逐 token 的动态 scale,而 int4 激活量化到今天仍没有令人满意的通用方案。

收益是直接的:int8 让权重和 KV 的显存与带宽减半,int4 减到四分之一。同样的机器能开更长的上下文、更大的 batch。

6.2 投机解码(Speculative Decoding)

Decode 阶段受限于串行。投机解码的思路是:用一个很小的模型快速草拟 个 token,再用大模型一次性并行验证。

flowchart TD P["已有前缀"] --> D["小模型 draft<br/>自回归草拟 k 个 token"] D --> V["大模型一次前向<br/>并行算出这 k 个位置的真实分布"] V --> C1{"接受规则<br/>第 1 个命中?"} C1 -->|"命中"| C2{"第 2 个命中?"} C1 -->|"拒绝"| R["丢弃全部草稿<br/>按修正分布重采 1 个<br/>本轮结束"] C2 -->|"命中"| E["k 个全部采纳<br/>本轮结束"] C2 -->|"拒绝"| R style C1 fill:#fff3cd,stroke:#ffc107 style C2 fill:#fff3cd,stroke:#ffc107 style R fill:#f8d7da,stroke:#dc3545 style E fill:#d4edda,stroke:#28a745

关键在于它不改变输出分布 —— 图里那个黄色菱形就是全部难点:接受/拒绝的规则经过精心设计,使得最终采样结果与大模型直接采样在统计上完全等价。所以这不是近似加速,而是纯粹的加速。

这条规则为什么必须这么精细? 因为「不改变分布」是硬约束,不是优化目标。规则一旦粗糙一点(比如“接受前 个匹配的,剩下的重新采样”),得到的分布就不再等价——加速还在,但它变成了一个来路不明的启发式。Leviathan et al. 2023 花了整篇论文把修正项推出来,代价是这条规则复杂到必须专门配比。

换个角度说:这份复杂度不是推理工程的天才发明,是因果掩码逼出来的。掩码要求每一步都在「只看到前缀」的条件下采样,于是“一次算 步”就必须把中间丢掉的分布信息一视同仁地补回来。完整推导见另一篇《同一块积木的两本账》2.3 节。

6.3 一条贯穿始终的主线

回头看第 3 节到这一节的每一个技术:

优化 消除的是什么浪费 是否精确
KV Cache 重算不会变的 K/V(重复计算) 精确——被缓存的量本来就不会变
PagedAttention KV Cache 的显存碎片(搬运) 精确——只改分配方式,不动数值
continuous batching Decode 阶段等矩阵乘法的空隙(搬运) 精确——只改调度顺序
投机解码 串行步数(并行度) 精确——接受/拒绝规则保证分布不变
量化 读取权重和 KV 的字节数(搬运) 有损——近似值,只能把误差压小

这个区分很要紧:前四项是免费的,它们换来的每一分性能都不花精度;只有量化是拿精度换速度。所以在生产里,KV Cache、分页、批处理总是无脑开,而量化要按模型和任务实测——GPTQ / AWQ 之所以还要做 group-wise、还要挑离群值通道做特殊处理,就是在跟这份额价搏斗。

没有一个能解除因果掩码这把锁。 它们做的全部是同一件事:在锁不变的前提下,把浪费消掉。 这也是为什么所有推理框架的优化看起来都长得像 —— 因为它们在解的是同一道题。

为什么前三项能“精确”,量化不能? 差别不在优化质量,在被优化的那个量是不是恒定的。KV Cache 缓存的 逐位置不变,所以缓存它和重算它结果完全一样;量化动的是一个本来就该是连续实数的东西,压成有限格点必然有误差。结构给了你恒定量,你才有资格精确地缓存它——这条线索追到架构侧就是另一篇《同一块积木的两本账》1.1 节,那里把注意力公式的五个部分和四项工程逐条对上了。


结语:先问它消除的是哪种浪费

Decoder-Only 的推理,是一把锁定的自回归、因果、迭代的过程:

  1. Tokenization 把文本变成下标,Embedding 查表成向量
  2. RoPE 注入位置信息,Causal Mask 锁死“只能看过去”
  3. Attention 横向混合,FFN 纵向加工
  4. 输出层给出词表上的概率分布
  5. 采样从中挑一个 token,KV Cache 记住这一轮算过的 K/V
  6. 重复,直到结束

而所有的工程 —— 缓存、分页、量化、批处理、投机解码 —— 都只做一件事:

让这把锁下面的每个 token,都用最少的重复计算和最少的显存搬运被算出来。

所以下次再看到某个推理框架又加了一个优化项,不必惊讶:先问它消除的是哪种浪费。


附录:参考资源

论文

  • Vaswani et al. 2017, “Attention Is All You Need” —— Transformer 原始论文,Encoder-Decoder 结构的出处
  • 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,给 logits 加线性距离偏置的那条路
  • Shazeer 2017, “Fast Transformer Decoding” —— KV Cache 的提出
  • Gao et al. 2023, “vLLM / PagedAttention” —— 显存分页与 continuous batching
  • Leviathan et al. 2023, “Fast Inference from Transformers via Speculative Decoding” —— 投机解码
  • Dettmers et al. 2023, “LLM.int8” —— 激活量化中离群值的发现与逐 token 动态量化
  • Frantar et al. 2023, “GPTQ”;Lin et al. 2023, “AWQ” —— 权重量化路线
  • Shazeer 2019, “Fast Transformer Decoding: One Write-Head is All You Need” —— MQA

工程资源

  • nanochat(Karpathy) gpt.py + engine.py —— 和本文对得上的一份,三处都能直接对上:

    • apply_rotary_emb() 就是 §3.2 的 RoPE(注意它转的是 θ,与教科书约定相反,但只影响相对旋转,功能等价)
    • engine.py 里的 KVCache 是 §4 的完整实现:按 Flash Attention 3 的 flash_attn_with_kvcache 设计,张量是 布局、原地更新、每个 batch 用 cache_seqlens 各自记位置
    • generate_from_prompt() 做的正是 §4.4 的分工——batch=1 跑一次 prefill,然后把 KV cache 克隆给 N 路采样并行 decode
    • 另外它用 F.rms_norm(RMSNorm 而非 LayerNorm)、Pre-LN、以及滑窗注意力(window_pattern 默认 "SSSL");n_kv_head 支持 GQA,但默认配置下 n_kv_head == n_head,实际退化成 MHA
  • nanoGPT(Karpathy) model.py —— 最好读的一份,适合先建立整体印象。CausalSelfAttention 对应第 1 节和第 3 节(Q/K/V 三个投影、因果掩码、注意力输出的形态);MLP 是 GELU + 4 倍隐藏维度。
    但它有两处和本文对不上,而且是故意的:用 wpe = nn.Embedding(block_size, n_embd) 这种可学习的绝对位置嵌入,不是 §3.2 的 RoPE;generate() 每步重跑整个前向、完全没有 KV cache——这恰恰就是 §4.1 说的那种浪费本身,Karpathy 为了可读性放弃了优化。§4 要对照实现,看上一条

  • HuggingFace Transformers —— 工业级实现,能看到 DynamicCache 如何把第 4.3 节的显存账本落到具体代码上

同系列

三篇一套,按这个顺序读最顺:

  • 《Transformer Block 设计哲学:从『看似冗余』到『刚好够用』》(2026-06-25)—— 第一本账:训练期为什么这样设计。本文反复引用的两处出处都在那里:1.3 节说因果掩码让训练 / 推理脱节,对应它的 1.3 节那条论证;3.2 节说 RoPE 破不了关系对称,对应它的 2.3 节
  • 本文(2026-04-09)—— 第二本账:运行期怎么把同一个模型跑得更快
  • 《同一块积木的两本账:Transformer Block 的训练期动机与运行期代价》(2026-10-09)—— 把两本账逐条对上:KV Cache、GQA、RoPE、量化、投机解码分别是从架构的哪一条推出来的