模块 09 · 第 4 课

搭一个完整的 GPT

把注意力、前馈网络、残差连接和 LayerNorm 组装成 Transformer 块,叠几层,加上嵌入和输出层,就是完整的 GPT。逐段读 gpt.py,数清一百六十万个参数在哪里。

  • 约 50 分钟
  • 难度:深入
  • 实测:2026-09-15 torch 2.14,CPU,固定随机种子

前两课造好了零件:注意力让每个字能从前面的字那里收集信息,多头让它能同时收集好几种信息,位置嵌入让它知道顺序。这一课把零件装起来,得到一个完整的 GPT。

完整的代码在 code/09-transformer/gpt.py,不到 150 行。它的结构和 GPT-2 一样,只是小得多。后面两课的训练和生成都用它。

python inspect_gpt.py

整体结构

  词元编号
     │
  词元嵌入 + 位置嵌入
     │
  ┌──────────────────────────┐
  │  LayerNorm → 多头注意力   │ ─┐
  │        + ←────────────────── ┘ 残差
  │  LayerNorm → 前馈网络     │ ─┐
  │        + ←────────────────── ┘ 残差
  └──────────────────────────┘
     │   (这样的块叠 4 层)
  LayerNorm
     │
  输出层:对词表里每个字打分

从下往上看:每个字先变成一个向量(词元嵌入加位置嵌入),经过若干个结构相同的 Transformer 块,最后由输出层对下一个字打分。块里的注意力我们已经写过了,还有三样新东西:前馈网络、残差连接和 LayerNorm。

前馈网络:每个字自己想一想

注意力负责在字与字之间传递信息。收集完信息之后,每个字还要各自"消化"一下,这就是前馈网络

self.mlp = nn.Sequential(  # 前馈网络:先放大 4 倍,过激活函数,再缩回来
    nn.Linear(cfg.n_embd, 4 * cfg.n_embd),
    nn.GELU(),
    nn.Linear(4 * cfg.n_embd, cfg.n_embd),
    nn.Dropout(cfg.dropout),
)

它就是第 08 模块里那种最普通的两层网络:128 维放大到 512 维,经过激活函数,再缩回 128 维。GELU 是一种和 ReLU 很像的激活函数,只是在 0 附近是平滑的曲线,GPT-2 用的就是它。

前馈网络对每个位置单独计算,位置之间互不影响。所以一个 Transformer 块的分工是:注意力负责"交流",前馈网络负责"思考"。下面会看到,模型的大部分参数其实在前馈网络里。

残差连接:在原来的基础上改一点

def forward(self, x, cache=None):
    x = x + self.attn(self.ln1(x), cache)  # 残差连接:在原来的基础上加一点修改
    x = x + self.mlp(self.ln2(x))
    return x

注意这里是 x = x + ...,而不是 x = self.attn(x)。注意力和前馈网络算出的,不是新的向量,而是对原来向量的"修改量",加回到原来的向量上。这叫残差连接

为什么要这样?第 08 模块第 3 课讲过,反向传播时梯度要一层一层往回乘。层数一多,梯度连乘很多次,很容易变得极小(学不动)或者极大(训练炸掉)。有了残差连接,x + f(x)x 的导数里始终有一个 1,梯度可以沿着"加号"这条路直接传回前面的层,不必穿过每一层的变换。这是几十层、上百层的深度网络能训练起来的关键之一。

也可以换个角度理解:整个模型里有一条从输入直通输出的"主干道",每个块都只是往上面加一点东西。

LayerNorm:让数值保持稳定

第 08 模块第 2 课看到过,输入的数值范围差别太大,训练会很难。一层层计算下来,向量里的数也会越来越大或越来越小。LayerNorm 在每个子层之前,把每个向量调整成均值为 0、标准差为 1,再乘上、加上两组可学的参数(让模型自己决定合适的范围)。

这和第 08 模块的标准化是同一个想法,只是对每个位置的向量单独做,而且在网络中间反复做。

注意 LayerNorm 放在注意力和前馈网络之前self.attn(self.ln1(x))),而不是之后。这叫 Pre-LN,GPT-2 以来的模型基本都这样放,训练起来更稳定。最后输出之前还有一个 LayerNorm(ln_f)。

输入和输出

self.tok_emb = nn.Embedding(cfg.vocab_size, cfg.n_embd)
self.pos_emb = nn.Embedding(cfg.block_size, cfg.n_embd)
...
self.head = nn.Linear(cfg.n_embd, cfg.vocab_size, bias=False)
self.head.weight = self.tok_emb.weight  # 输出层和词元嵌入共用一套参数,GPT-2 也是这样做的

nn.Embedding 就是一张表:第 i 行是第 i 个字的向量。词元嵌入的表有 6289 行(词表大小),位置嵌入的表有 128 行(最多处理 128 个位置)。

输出层把 128 维的向量变成 6289 个分数,每个字一个。这里有一个小技巧:输出层和词元嵌入共用同一个矩阵。词元嵌入把"字"变成"向量",输出层反过来判断"向量"最像哪个"字",用同一套参数很合理,而且能省下一大块参数。

前向计算把这些串起来:

def forward(self, idx, targets=None, caches=None, start=0):
    B, T = idx.shape
    pos = torch.arange(start, start + T, device=idx.device)
    x = self.drop(self.tok_emb(idx) + self.pos_emb(pos))
    for i, block in enumerate(self.blocks):
        x = block(x, None if caches is None else caches[i])
    logits = self.head(self.ln_f(x))  # (B, T, 词表大小):每个位置对下一个词元的打分
    loss = None
    if targets is not None:
        loss = F.cross_entropy(logits.reshape(-1, logits.size(-1)), targets.reshape(-1))
    return logits, loss

cachesstart 是给第 6 课的 KV 缓存用的,现在先忽略。损失就是第 08 模块第 5 课的交叉熵:每个位置都是一个"从 6289 个字里选一个"的分类问题。

形状

== 2. 一批数据流过模型时的形状
  输入的词元编号        (2, 6)
  嵌入之后              (2, 6, 128)
  经过 4 个块之后        (2, 6, 128)
  输出层                (2, 6, 6289):每个位置对 6289 个字各打一个分

两句诗、每句 6 个字,进去是 (2, 6)。嵌入之后每个字变成 128 维的向量。经过 4 个块,形状完全不变,这正是块能随便叠多少层的原因。最后每个位置得到 6289 个分数。

注意输出在每个位置都有:一段 6 个字的文字,训练时同时提供了 6 道"猜下一个字"的题。第 1 个位置看到"白"猜"日",第 2 个位置看到"白日"猜"依"……因为有因果掩码,每道题都没法偷看答案。这让训练的效率高了很多。

参数都在哪里

== 1. 参数都在哪里(词表 6289 个字符)
  词元嵌入   6289 × 128 = 804,992(输出层和它共用,不另算)
  位置嵌入   128 × 128 = 16,384
  每个块     198,272:注意力 66,048,前馈网络 131,712,两个 LayerNorm 512
  4 个块共  793,088
  合计       1,614,720,其中词元嵌入占 50%

一共 161 万个参数。可以自己算一遍:

  • 注意力:qkv 是 128×384 加 384 个偏置,proj 是 128×128 加 128,合计 66048。
  • 前馈网络:128×512 加 512,512×128 加 128,合计 131712,是注意力的两倍。
  • LayerNorm:每个有 128 个缩放和 128 个偏移,两个共 512。

在我们这个小模型里,一半的参数都在词元嵌入这张表上,因为词表有 6289 个字而向量只有 128 维。大模型的比例完全不同:向量维度是几千,层数几十,块里的参数远远多于嵌入。每个块里,前馈网络大约占三分之二,注意力占三分之一,这个比例在大模型里也差不多。

训练之前

== 3. 没训练过的模型,损失应该接近随便猜
  损失 8.775,ln(6289) = 8.747
  下一个字最可能是:骠蔫潮涨湄(随机初始化,毫无道理)

没有训练过的模型,对 6289 个字的打分几乎一样,相当于随便猜,交叉熵应该是 ln(6289) = 8.747。实际是 8.775,非常接近。第 08 模块第 5 课说过:训练开始时的损失如果离这个数很远,多半是哪里写错了。这是搭好模型之后第一个该做的检查。

模型参数的初始值是用 std=0.02 的正态分布随机取的(_init 方法),这样开始时每个字的分数都差不多,损失就接近随便猜。

GPT 和真实的大模型有什么不同

这个 GPT 和 GPT-2 的结构基本相同。截至 2026 年 9 月,主流的开源大模型在这个骨架上做了一些改进,常见的有:

  • 位置信息改用上一课提到的旋转位置编码(RoPE)。
  • LayerNorm 换成计算更简单的 RMSNorm。
  • 前馈网络换成带"门"的结构(比如 SwiGLU)。
  • 多个注意力头共用 K 和 V(分组查询注意力),减少下一课 KV 缓存占的内存。
  • 把前馈网络换成很多个"专家",每个字只用其中几个(混合专家,MoE),DeepSeek 的模型就是这种结构。

但骨架没变:嵌入、若干个"注意力 + 前馈网络"的块、残差连接、归一化、输出层。看懂了这个 150 行的 GPT,再去读这些模型的代码,你会发现大部分都认得。

练习

  1. n_layer 改成 8、n_embd 改成 256,参数变成多少?词元嵌入的占比变成多少?先用本课的公式算,再运行验证。
  2. Block.forward 里的两个残差连接去掉(x = self.attn(self.ln1(x))),用下一课的 train.py 训练 300 步,和原来的损失比较。
  3. 把输出层和词元嵌入共用参数的那一行删掉,参数多了多少?

自测

1. 一个 Transformer 块里有哪些部分?注意力和前馈网络的分工是什么?

两个子层:多头因果自注意力和前馈网络,每个子层之前有一个 LayerNorm,外面套着残差连接。注意力负责在不同位置之间传递信息,前馈网络对每个位置单独做计算,处理收集到的信息。

2. 残差连接为什么能帮助训练很深的网络?

残差连接让每个子层的输出加回到输入上,x + f(x) 对 x 的导数里始终有一个 1。反向传播时梯度可以沿着这条直通的路径传回前面的层,不会因为连乘很多层而变得极小或极大。

3. 为什么没训练过的模型的损失应该接近 ln(词表大小)?

随机初始化的参数很小,模型对每个字的打分差不多,softmax 之后每个字的概率约为 1/词表大小。交叉熵是 -log(正确字的概率),也就是 -log(1/词表大小) = ln(词表大小)。