llm-anatomy 是一个"把 LLM 拆开看"的教学连载:不调库、不背黑话,每一章把大模型的一个器官亲手造一遍,所有结论都配可复现的实验数字。
上一篇《拆开 LLM(二):手撕注意力》把注意力这台发动机完全拆开了。这一篇把整车焊出来:先造一台 2017 年的原版 Transformer,再像换汽车零件一样,把它逐件换成 LLaMA。
先听它说话
这一章的模型训在什么语料上?我自己的博客。 把语料定稿时站里的全部 17 篇文章读进来、剥掉 front matter,拼成一个 122,620 字符的 txt——版权 100% 自有,爬虫都省了。然后训两个 1.2M 参数的字符级语言模型:一个是 2017 原版 Transformer,一个是换完件的 mini-LLaMA。同数据、同步数、同学习率,只换零件。
先贴 mini-LLaMA 的生成样本(温度 0.8,前缀只给了 ## ,节选——完整输出在仓库 results.json):
| |
看到第一行了吗?给它一个 ## ,它开口的第一句是"先看效果"——这个 mini-LLaMA 学会的第一句人话,是我自己写过的句子:《credis(一)》和《拆开 LLM(一)》的第一个小节标题都叫这四个字。用自己的博客当语料就有这种离谱的既视感:它满嘴跑火车,但跑的全是我的火车。
再看 2017 原版同预算训出来的样子(同样温度 0.8、前缀 ## ,完整输出同在 results.json):
| |
两个都半通不通——1.2M 参数的字符级模型就这水平,别指望它写周报。但你应该能感觉到 mini-LLaMA 那段"更成句":词是完整的词,标题、列表、正文的 markdown 骨架也搭得更像回事。这个直觉对不对、对在哪、又有多少水分(剧透:有水分,见实测那节),文末用数字说话。
先把车造出来。
先搭 2017 原版:注意力只是零件,块才是发动机
第 2 章把注意力拆到了螺丝级别:打分函数手撕过了,√d 为什么保命讲过了,causal mask 的四个坑也踩过了——这些这里全部直接引用,不重复推导。但一个能说话的模型不等于一个注意力层。缺的东西叫 Transformer block,它是这样一层楼:
x ──> 注意力(搬运:从别的位置把信息搬过来) ──> 残差相加 ──> norm
──> FFN(加工:对搬来的信息逐位置做非线性变换) ──> 残差相加 ──> norm
四个角色各司其职,一句话记住:注意力负责位置之间搬信息,FFN 负责位置内部加工信息,残差保证梯度有路可走,norm 保证数值不飘。 把这层楼摞 N 层,前面接个 embedding,后面接个输出投影,就是 GPT 系全部的骨架——2017 年到今天没变过。
本章代码在仓库 ch03/(PyTorch 登场,第 0 章约定过:autograd 和注意力已经亲手写过了,轮子的意义已经兑现,现在开始用正经轮子)。整个模型用一个 config 控制每个零件用哪个版本:
| |
默认值就是 2017 原版件:LayerNorm、post-norm、可学习位置编码、标准多头注意力、GELU FFN(考据党注意:原论文用的 ReLU,GPT 系换成了更顺滑的 GELU,二者同属"无门控 FFN",本文拿 GELU 当原版件的代表)。另一个控制变量的决定:所有线性层统一 bias=False——这是 LLaMA 的习惯,但 bias 不在换件清单里,两版都不带,免得它蹭功劳。任务是字符级语言模型:给前 128 个字符,预测下一个字符。中文一字一个 token,词表 1719 个字符,连 BPE 都不用——tokenizer 的复杂度被我们用"字符级"这张免死金牌跳过了,这是玩具实验应得的福利。
原版配置的参数量:1,244,928。其中每层的分布值得看一眼:注意力四个投影矩阵共 65,536 个参数,FFN 却有 131,072 个——FFN 拿走了每层三分之二的参数,Transformer 的"知识容量"大头从来不在注意力里。这个比例记住,换件⑤会用到。
换件清单:五个件,每件都有被换的理由
2017 到 2023(LLaMA 定型),Transformer 的骨架没动,但五个零件被行业逐一换掉。换件不是赶时髦,每个新件都在修旧件的一个具体的病。逐件来。
① LayerNorm → RMSNorm:砍掉均值,不掉点
LayerNorm 干三件事:减均值(中心化)、除标准差(缩放)、再乘 γ 加 β。2019 年 RMSNorm 的作者问了个好问题:这三件事都必要吗?消融的答案是:中心化基本白干,真正起作用的是缩放。那就砍——不减均值、不要 β,只除均方根:
| |
省下来的是一次均值计算和一次减法,看着不多,但 norm 在每层出现两次、每个 token 都要过——几十层的模型里这是纯赚的算力。后来有人系统性地把各种 Transformer 改件放一起赛马(结论普遍是"多数改进换个数据集就失效"),RMSNorm 是少数几个稳定存活的。便宜、有效、无副作用,这种件不换才怪。
② post-norm → pre-norm:把 norm 从主干上挪开
原版把 norm 放在残差相加之后(post-norm),LLaMA 系放在每个子层之前(pre-norm)。代码里就是 Block.forward 的两种接线:
| |
差别在梯度的回家路线上。post-norm 的 norm 站在残差主干上,反向传播时梯度每下一层楼都要被 norm 揉一次,层数一深,训练初期极其难伺候——原论文那个著名的 lr warmup 就是给这个病开的药。pre-norm 把 norm 挪到支路入口,残差主干变成一条不被打扰的恒等通路,梯度从第 32 层直达第 1 层。这招眼熟吗?第 0 章讲 LSTM 时说过"cell state 走加法通道,梯度不再被迫连乘"——同一个药方,隔了二十多年又开了一次。
坑 1:pre-norm 换完忘了补最终 norm。 post-norm 每层出口都归一过,pre-norm 的最后一层出口是"裸"的,直接进输出投影数值会飘。所以 MiniLM 里有一行 self.final_norm = make_norm(cfg) if cfg.norm_pos == "pre" else nn.Identity()——这个细节漏掉不报错,只是训得莫名其妙地差。
③ 可学习位置编码 → RoPE:把相对位置写进点积里
原版的位置信息是一张 (ctx_len, d_model) 的可学习表,第 t 个位置查第 t 行,加到词向量上。三个病:占参数(本模型 16,384 个——这个是小事);训练长度就是天花板——表只有 128 行,第 129 个位置直接越界,想都别想外推,这个是大事;最要命的是它编码的是绝对位置,“我是位置 37"这条信息对语言其实没用,有用的是"你在我前面 3 格”——这个是病根。
RoPE(第 0 章预告过的"位置外挂 2.0")的思路漂亮到发光:不加不拼,把 q、k 向量的每两个维度看成一个二维平面,按位置 t 把这个平面旋转 t×θ 度。两个都被旋转过的向量做点积,结果只取决于夹角差——也就是 (t−s),相对位置自动浮现在 QK 打分里:
| |
不同维度对转速不同(θ 随维度指数衰减):高频对负责分辨"隔 1 格还是隔 2 格",低频对负责"隔 50 格还是隔 100 格"——一套旋转,远近通吃。零参数、不占表、序列多长转多远,长度外推从"不可能"变成"有折扣地可行"。
“只编码相对位置"不是嘴上说说,ch03/model.py 的自检里有个漂亮的证明:把 q、k 的位置整体平移 5(用 cos/sin 表的第 512 行代替第 07 行),QK 打分矩阵逐位不变——
RoPE 自检通过:位置整体平移 5,QK 打分逐位不变(只编码相对位置)
坑 2:q 和 k 的维度配对约定必须一致。 RoPE 有两种流行的配对法:原始论文/GPT-J 把相邻维度 (x₀,x₁)(x₂,x₃) 配对,HF 版 LLaMA / GPT-NeoX 把前后半配对(Meta 原版代码其实是相邻配对,HF 转换权重时 permute 过)——第 i 维和第 i+d/2 维一对,上面代码就是这种。单独用哪种都对——配对本身是任意的,自洽就行;但 q 用一种、k 用另一种,相对位置立刻错乱,这是转换 HuggingFace 权重时的经典事故现场。
④ MHA → GQA:给 KV cache 的显存账单瘦身
这件是纯粹的经济学。第 2 章算过那笔账:7B 级模型、128k 上下文,MHA 的 KV cache 要 64GB——比模型权重本身还大好几倍;把 32 个 KV 头砍到 8 个(GQA),账单直接变 16GB。Q 保留全部头数负责"多视角提问”,K/V 只留几份大家共享——实测质量几乎不掉,显存砍到几分之一,LLaMA-2-70B 之后开源模型人手一份。
实现上有个让代码变优雅的观察:MHA 只是 GQA 在 n_kv_head == n_head 时的特例,所以根本不需要两个注意力模块,一个开关搞定:
| |
最后一行就是第 2 章手撕过的那条公式,一个字没变。
坑 3:别指望 GQA 让训练变快。 它省的是推理时 KV cache 的"份数",训练时我们甚至用 repeat_interleave 把 K/V 又复制回了全头数(真实推理引擎不会真复制,用广播)。本章的训练实验里 GQA 除了少了点参数几乎是白干的——它的价值要到第 2 章那张显存表的场景里才兑现。知道一个件在哪个场景下才有用,和知道它是什么一样重要。
⑤ GELU FFN → SwiGLU:给知识仓库装上门
还记得吗,FFN 占每层参数的三分之二,是真正的知识仓库。原版 FFN 是"升维 4 倍 → 激活 → 降回来"的直筒子,每个维度想过多少信息就过多少。SwiGLU 给它装了门:
| |
up 负责搬运内容,gate 负责逐维度决定"这一维放多少过去"——乘法门控让 FFN 从"所有信息一视同仁"变成"按内容开闸放水",这个思路你也在第 0 章见过:LSTM 的门、注意力的权重,全是同一族想法。三个矩阵比原来两个多一个,所以 hidden 缩到 4d 的 2/3 找齐参数量——LLaMA-2-7B 那个奇怪的 11008 hidden size 就是这么来的。
顺带一提,SwiGLU 论文的结尾是这个领域最诚实的一句话,作者 Shazeer 写道:这些结构为什么有效,我们给不出解释,只能归功于"divine benevolence"(神的恩典)。深度学习的很多换件决策就是这样:实验说了算,故事后补,有的至今没补上。 我觉得把这句话读给你听,比假装每个件都有严密理论推导要有用得多。
一张表收拢五个件
| 件 | 原件的病 | 新件的药 | 收益在哪个场景兑现 |
|---|---|---|---|
| ① LayerNorm → RMSNorm | 中心化基本白干,还费算力 | 只除均方根 | 任何规模,纯赚 |
| ② post-norm → pre-norm | norm 站在主干上,深了难训 | 残差改成恒等通路 | 层数深了才生死攸关 |
| ③ 可学习 PE → RoPE | 绝对位置 + 训练长度天花板 | 旋转角度进 QK 点积 | 长上下文、长度外推 |
| ④ MHA → GQA | KV cache 随头数翻倍 | 每组 Q 头共享一份 K/V | 推理显存 |
| ⑤ GELU FFN → SwiGLU | 直筒仓库,信息一视同仁 | 乘法门控按内容放行 | 任何规模,实验说话 |
盯住最后一列——五个件里只有两件的收益是"任何场景都能看见"的,另外三件都指定了兑现场景。这张表就是下一节实验结果的答案钥匙。
同预算实测:诚实版报告
换件清单讲完了,上秤。两套完整配置:
| |
同一份语料(110,358 字符训练 / 12,262 验证)、同 2000 步、同 batch 32、同 lr 3e-4、同 warmup、同随机种子——连喂进去的 batch 序列都逐条相同。跑在 M 系芯片的 mps 上,结果:
| 配置 | 参数量 | 训练耗时 | 最佳 val loss | 2000 步收官 val |
|---|---|---|---|---|
| vanilla-2017 | 1,244,928 | 148.8 s | 3.706(@1100 步) | 3.982 |
| mini-llama | 1,178,496 | 303.5 s | 3.599(@500 步) | 5.083 |

这张图的信息量比"谁赢了"丰富得多,逐条读:
看得见的差别:mini-LLaMA 学得明显更快,谷底也更低。 它用 500 步就到了 3.599,vanilla 磨到 1100 步也只到 3.706——参数还少 5% 的前提下,用不到一半的步数到达更低的谷底。0.107 nats 的差距不是种子运气:换个种子(42→7)整套复跑,谷底分别是 3.705 和 3.612,差距 0.094,方向不变(这组换种子数字是一次性验证,未入 results.json)。谁的功劳?诚实回答:本实验没做逐件消融,分不清。按上一张总表的逻辑,“任何规模都能看见"的 SwiGLU 和 RMSNorm 是主要嫌疑人,RoPE 的相对位置先验多半也出了力——它不用等长度外推,“用相对位置理解语言"这个先验对小模型的学习效率本身就是加分项。想验证的话,五个开关逐个拨回去各跑一遍——config 就是为这个设计的。
然后它光速过拟合了。 500 步之后 mini-llama 的 val loss 掉头向上,2000 步时已经飙到 5.083,训练 loss 却掉到 0.33——它开始整段整段地背我的博客了。这不是件的锅,是预算设计的必然:2000 步 × 32 × 128 = 819 万字符,等于把 11 万字符的语料翻来覆去读了 74 遍。真实的 LLM 预训练连 1 遍都读不完(数据比算力多得多),“学得快"是纯粹的收益;玩具语料上"学得快"的另一面就是"背得快”。同一个性质,换个数据规模,从美德变成事故——这就是为什么小实验的结论不能无脑外推,也是为什么这场事故反而把"这些件让优化更容易"展示得更清楚了。 顺带自首:开头那段 mini-LLaMA 的样本出自 2000 步收官权重,它显得"更像我”,一部分就是背出来的——所以说有水分。
看不见的差别,才是这个实验最值钱的部分。 五件里有两件在这个尺度上根本没机会表演:pre-norm 的稳定性优势要到几十层才生死攸关,4 层楼谁都稳(想看它表演,试一试第 2 题);RoPE 的长度外推要在"推理长度超过训练长度"时才兑现,我们训练推理都是 128;GQA 的显存收益要有 KV cache 才存在,本章训练路径压根没有 cache。它们不是没用,是这个考场没考它们的科目。 论文里那些"我们的改进在小模型上没有显著差异"的句子,说的就是这种情况。
坑 4:手写件的 wall-clock 会骗人。 看耗时列:mini-llama 反而慢了一倍。FLOPs 明明差不多,慢在哪?nn.LayerNorm 背后是融合好的单一 kernel,我们手写的 RMSNorm 是一串零散的逐元素操作;RoPE 每步前向都要转一遍;repeat_interleave 真金白银地复制了张量。真实框架里这些件全有专门 kernel,RMSNorm 是真省时间的。教学代码测得出"学得快不快”,测不出"跑得快不快"——后者是 kernel 工程的地盘。
换完件,你手里的就是一个 mini-LLaMA
盘点一下换完件的这台车:RMSNorm、pre-norm、RoPE、GQA、SwiGLU、无 bias 的线性层。把它的 config 和真 LLaMA 摆在一起:
| mini-llama(本章) | LLaMA-2-7B | LLaMA-2-70B | LLaMA-3-8B | |
|---|---|---|---|---|
| 层数 | 4 | 32 | 80 | 32 |
| d_model | 128 | 4096 | 8192 | 4096 |
| Q 头 / KV 头 | 4 / 2 | 32 / 32 | 64 / 8 | 32 / 8 |
| FFN 隐层 | 352 | 11008 | 28672 | 14336 |
| norm | RMSNorm, pre | RMSNorm, pre | RMSNorm, pre | RMSNorm, pre |
| 位置 | RoPE | RoPE | RoPE | RoPE |
| FFN | SwiGLU | SwiGLU | SwiGLU | SwiGLU |
| 参数量 | 1.18M | 6.7B | 70B | 8.0B |
右边四列的下半张表完全一样。结构上你和 2023 年定型的 dense LLaMA 之间已经没有代差,只剩规模——以及规模带来的全部工程学。 有趣的细节还有两个:LLaMA-2-7B 的 KV 头是 32(它还是 MHA,GQA 是 70B 和 LLaMA-3 才全面铺开的);11008 这个"丑"数字就是 SwiGLU 那个 2/3 规则加对齐凑出来的,14336 则还多乘了一个 1.3 的扩容系数(LLaMA-3 的 ffn_dim_multiplier)再对齐——现在你看模型 config.json 里的每个字段都该眼熟了。
当然,诚实清单还得列完剩下的差距:真 LLaMA 用 BPE 词表(我们用字符级免死金牌跳过了)、推理走 KV cache 路径(第 2 章写过原理,本章模型没接)、训练要数据配比和几千张卡的并行工程。这些是规模的工程学,不是结构的秘密——结构的秘密,到这一章为止,你已经全部亲手摸过了。
2024–2026 又换了什么件
说"没有代差",限定词是 dense LLaMA。2024 年之后开源旗舰又换了一轮件:DeepSeek V3 系、Llama 4、Qwen3.5、Kimi K2 已经全员把 FFN 换成 MoE(一堆专家里每个 token 只激活几个);注意力层也开始分叉——MLA(第 2 章讲过)、DSA 稀疏注意力,Qwen3.5 甚至把约四分之三的层换成 Gated DeltaNet 线性注意力(与 softmax 注意力层 3:1 混排);QK-norm(给 Q、K 各加一层 norm 再打分)则是最接近"第六件标配"的新件,Qwen3、Gemma 3、Llama 4 都在用。
但注意:本章这五件在 2026 年的 dense 模型里仍然是全套标配,也是读懂上面所有新件的门票——MoE 换的只是 FFN 这一件,QK-norm 不过是在你亲手写过的 attention 里多插两行 norm。地基没变,变的是上面的花样。
试一试(包括把它弄坏)
| |
跑通只是及格,把它弄坏才算学会:
- 拆散 RoPE 的旋转对。 在
model.py里把 k 的那行apply_rope删掉(只旋转 q 不旋转 k),或者给 k 单独换成相邻配对(x[..., ::2], x[..., 1::2])再训一次。两种破坏方式的病因是同一个:q、k 的旋转不再自洽,点积里浮现的就不是干净的相对位置了。对比 val loss 各掉了多少,再想一层:这两种破坏,哪种伤得更重?为什么模型多半不会直接崩成随机数? - 验证 pre-norm 到底在保什么命。 把 llama 配置的
norm_pos换回"post",n_layer从 4 加到 12,再把train.py里的WARMUP改成 0,训一次;然后同条件换回"pre"再训一次。4 层看不出的差别,12 层 + 裸 lr 会看得非常清楚——这就是"稳定性优势要深了才兑现"的现场版。 - 把 KV 头砍到 1(MQA)。
n_kv_head=1训一次,对比 best val loss 掉多少;再口算一下 KV cache 变成了 MHA 的几分之一(4 头→1 头)。你会得到"质量-显存"交换曲线的两个端点——GQA 就是工业界在这条曲线中段选的折中。 - 换成你的语料。 把
ch03/corpus.txt直接替换成你自己的文字(聊天记录、周报、日记,UTF-8 就行),重训。看它学会你的口头禅的那一刻,“语言模型在建模谁"这个问题会变得非常具体。
下一章预告
模型本体到这一章就造完了:梯度(第 1 章)、注意力(第 2 章)、整车骨架(本章)。但"有模型"和"模型为你干活"之间还隔着一个大问题:怎么把一个通用底座掰成你要的样子。
第 4 章讲微调范式的变迁史,照例从一桩悬案开场:NSP——BERT 时代人手一份的"标配预训练任务”,是怎么被整个行业悄悄埋掉的,连讣告都没发。然后同一个任务上,把 BERT 冻结主干训分类头和 LoRA 低秩旁路真刀真枪跑对比,再给 API 微调算一笔明白账——微调的历史和架构史一样,也是一部"修贵"的历史。
仓库在 github.com/KerroKapple/llm-anatomy,每章一个目录,所有实验数字可复现。有问题欢迎开 issue 讨论。
