Decoder-Only 大模型的推理过程详解
写作日期:2026-04-09
面向读者:用过 ChatGPT / Claude 这类工具,知道它“在预测下一个词”,但不清楚模型内部到底怎么一步步算出来的
核心问题:Decoder-Only 的推理看起来只是“不断预测下一个 token”,为什么这件事需要一整套 KV Cache、采样、量化的工程体系?
行文约定:中文为主;英文术语保留(token、embedding、RoPE、attention、KV Cache、quantization 等);第一次出现的英文术语给全称
引子:七行代码,几百毫秒的沉默
本文是三篇系列里的第二本账。 第一本是《Transformer Block 设计哲学》(训练期为什么这样设计),第三本是《同一块积木的两本账》(把两本账逐条对上)。本文讲第二本:运行期怎么把同一个模型跑得更快。
写代码的时候,推理循环常常短得不像话:
1 | while not done: |
七行。但你按下回车到看见第一个字,模型已经沉默地算了几百毫秒到几秒。这段时间里没有“循环”,只有一层层矩阵乘法在原地跑。
如果你翻开一个现代 LLM 的实现,会看到几件“多余”的事:
- 既然每次只生成一个 token,为什么不为这一个 token 单独算一遍,而要把整句话再送进去?
- 既然 prompt 里的内容永远不会变,为什么每生成一个字都要把 prompt 的 Key / Value 重算一遍?
- 既然模型的输出只是“一堆概率”,为什么温度、top-k、top-p 这些参数能改变回答的质量?
每一处看起来都可以优化掉。而当你真的试着优化掉,会立刻撞上同一堵墙:
因果掩码规定,第 个 token 只能看到 之前的 token。
这意味着模型的每一次前向传播,都是在“信息不全”的条件下做的条件预测。
这篇文章要做的事,就是从这把锁出发,把 decoder-only 的推理拆到零件级别,讲清楚每个零件为什么必须在那个位置。读完之后,你应该能自己回答:
- 因果掩码到底限制了什么?为什么它是 decoder-only 存在的根本理由,而不是一个实现细节?
- 既然模型只能“往左看”,为什么还是 decoder-only 赢了?它付出了什么代价?
- 一次前向传播里,一个 token 依次经历了哪些部件?每个部件的输出是什么形状?
- KV Cache 缓存的是什么?它为什么能把 O(n²) 的重复计算变成 O(n)?
- 模型只输出概率分布,采样策略在做什么?温度、top-k、top-p 分别改变了分布的什么性质?
- 量化和各种“加速”到底在优化什么?为什么有的能精确等价,有的只能把误差压到可接受?
母题预告:所有推理优化,都在同一把因果掩码的锁下面做同一件事——把“重复的计算”和“不必要的搬运”尽可能消掉,好让剩下的算力全部用在真正的新 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 | 输入:"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。整条路径长这样:
三个地方值得先记下来,它们是后面几节的伏笔:
- 那条黄色的回边: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 | "Hello, how are you?" → [t₁, t₂, t₃, t₄, t₅, t₆] |
切出来的个数和边界完全取决于分词器:换一张 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 | # 位置 i 的 query 向量按 i·θ 旋转,key 向量按 j·θ 旋转 |
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 | # 每层维护一份 cache |
复杂度从 降到 量级,而且是精确等价的 —— 因为被缓存的量本来就不会变。这不是近似加速,是纯粹的工程优化。
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 | def sample(logits, temperature=0.8, top_k=None, top_p=0.9): |
5.2 贪心与 Beam Search
- 贪心(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,再用大模型一次性并行验证。
关键在于它不改变输出分布 —— 图里那个黄色菱形就是全部难点:接受/拒绝的规则经过精心设计,使得最终采样结果与大模型直接采样在统计上完全等价。所以这不是近似加速,而是纯粹的加速。
这条规则为什么必须这么精细? 因为「不改变分布」是硬约束,不是优化目标。规则一旦粗糙一点(比如“接受前 个匹配的,剩下的重新采样”),得到的分布就不再等价——加速还在,但它变成了一个来路不明的启发式。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 的推理,是一把锁定的自回归、因果、迭代的过程:
- Tokenization 把文本变成下标,Embedding 查表成向量
- RoPE 注入位置信息,Causal Mask 锁死“只能看过去”
- Attention 横向混合,FFN 纵向加工
- 输出层给出词表上的概率分布
- 采样从中挑一个 token,KV Cache 记住这一轮算过的 K/V
- 重复,直到结束
而所有的工程 —— 缓存、分页、量化、批处理、投机解码 —— 都只做一件事:
让这把锁下面的每个 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、量化、投机解码分别是从架构的哪一条推出来的