其余三个零件 · LayerNorm、MLP、Block
啃完注意力,剩下三个零件轻松得像甜点。逐行读 nanoGPT 的 LayerNorm(为什么自己写)、MLP(先胀 4 倍再压回)、Block(Pre-LN + 残差怎么把注意力和 MLP 拼成一层)。
其余三个零件 · LayerNorm、MLP、Block
注意力是五个零件里最硬的骨头,啃完了。剩下三个——LayerNorm、MLP、Block——简单得多。这一章一口气收拾掉,你会发现它们干净利落,读完就集齐了拼一个 GPT 的全部积木。
LayerNorm:给数据「校准量纲」
先看 nanoGPT 自己写的 LayerNorm(model.py,约 18–27 行):
class LayerNorm(nn.Module):
""" LayerNorm but with an optional bias. PyTorch doesn't support simply bias=False """
def __init__(self, ndim, bias):
super().__init__()
self.weight = nn.Parameter(torch.ones(ndim))
self.bias = nn.Parameter(torch.zeros(ndim)) if bias else None
def forward(self, input):
return F.layer_norm(input, self.weight.shape, self.weight, self.bias, 1e-5)它在干嘛? 归一化——把每个 token 向量,沿着特征维减均值、除标准差,拉回「零均值、单位方差」,再用一个可学习的 weight(缩放)和 bias(平移)调整回合适的范围。好处是:各层数值的「量纲」被稳住,梯度不容易爆或消,训练更顺。
逐行看:
self.weight = nn.Parameter(torch.ones(ndim)):缩放系数,初始化为全 1(一开始「不缩放」)。self.bias = ... if bias else None:平移量,初始为全 0;bias=False时干脆设成None。forward里直接调 PyTorch 的F.layer_norm,最后那个1e-5是防止除零的eps。
为什么要自己写,不直接用 nn.LayerNorm? 注释第一行就抱怨了:PyTorch 自带的 nn.LayerNorm 不支持简单地关掉 bias。而 nanoGPT 想让 bias 成为一个可配的开关(我们在 GPTConfig 里见过 bias: bool),所以只好手搓一个「bias 可选」的版本。又一个「为了一个小小的可配置性,宁可自己写」的务实细节。
MLP:先胀 4 倍,再压回来
MLP 是每个 Block 里负责「逐位置深加工」的前馈网络(model.py,约 78–92 行):
class MLP(nn.Module):
def __init__(self, config):
super().__init__()
self.c_fc = nn.Linear(config.n_embd, 4 * config.n_embd, bias=config.bias)
self.gelu = nn.GELU()
self.c_proj = nn.Linear(4 * config.n_embd, config.n_embd, bias=config.bias)
self.dropout = nn.Dropout(config.dropout)
def forward(self, x):
x = self.c_fc(x)
x = self.gelu(x)
x = self.c_proj(x)
x = self.dropout(x)
return x它的形状变化是一个漂亮的「先胀后缩」:
c_fc:把n_embd维放大 4 倍到4 * n_embd(默认 768 → 3072)。gelu:一个非线性激活(GELU,比 ReLU 更平滑)。非线性是关键——没有它,两个线性层叠起来还是线性,白搭。c_proj:再压回n_embd(3072 → 768)。dropout:随机失活,抗过拟合。
为什么先放大再缩小? 打个比方:把信息摊到一个更大的空间里,才有足够的「工作台面」做复杂的非线性变换,加工完再收拢回原尺寸。这个「4 倍」是 Transformer 沿用至今的惯例。注意力负责「跨位置」交换信息,MLP 负责「单个位置」把自己的向量揉得更有表达力——一横一纵,分工明确。
还记得上一个专栏说「前馈层是参数大头、也是 MoE 动刀的地方」吗?看这里就懂了:c_fc 和 c_proj 两个 4× 的大矩阵,正是整个 Block 里最占参数的部分。真实大模型的 MoE,拆的就是这个 MLP——把它换成一排专家。而 nanoGPT 里,它就是老老实实的一个稠密 MLP。
Block:两行 forward,拼出一层 Transformer
最后,Block 把注意力和 MLP 组装成「一层」(model.py,约 94–106 行):
class Block(nn.Module):
def __init__(self, config):
super().__init__()
self.ln_1 = LayerNorm(config.n_embd, bias=config.bias)
self.attn = CausalSelfAttention(config)
self.ln_2 = LayerNorm(config.n_embd, bias=config.bias)
self.mlp = MLP(config)
def forward(self, x):
x = x + self.attn(self.ln_1(x))
x = x + self.mlp(self.ln_2(x))
return x__init__ 备齐四个零件:两个 LayerNorm、一个注意力、一个 MLP。精华全在 forward 那两行——模型全景那章已经点过,这里对着真实代码再钉一遍:
x = x + self.attn(self.ln_1(x)):先归一化ln_1(x),再进注意力,输出加回x。x = x + self.mlp(self.ln_2(x)):先归一化ln_2(x),再进 MLP,输出加回x。
两个关键点:
- Pre-LN:归一化在子层前面(
ln_1(x)先算,再喂给attn)。这比原始 Transformer 的 Post-LN 训练更稳,是现代 GPT 的标配。 - 残差
x + ...:子层输出是加到x上,不是替换。这条「加法高速路」让梯度能直通到底,深层网络也学得动。
这两个设计——Pre-LN 的位置、RMSNorm 还是 LayerNorm——正是《AI 大模型解构》里专门对比过的一整章。nanoGPT 用的是「LayerNorm + Pre-LN」这个 GPT-2 经典组合;而 Llama、DeepSeek 们把 LayerNorm 换成了 RMSNorm,OLMo 2 甚至把 Pre 换回了 Post。你在这里读到的每个小设计,到了真实大模型那儿,都是一个可以拨动的旋钮。
五个零件,集齐了
盘一下:LayerNorm(校准)、CausalSelfAttention(跨位置看)、MLP(单位置加工)、Block(把后两个拼成一层)。加上马上要讲的 GPT(把 Block 叠 N 层、配上入口出口),model.py 的五个类就全了。
积木备齐,下一章我们动手搭整台机器:GPT 类怎么把词嵌入、一摞 Block、输出层拼起来,前向传播怎么从 token 编号一路算到 loss,以及那个「权重绑定」和「推理期只算最后一个位置」的小机灵。👉 搭起整台机器:GPT 的 init 与 forward
参考文献
- [1] Ba et al. “Layer Normalization.” 2016. arXiv:1607.06450. —— LayerNorm。
- [2] Hendrycks & Gimpel. “Gaussian Error Linear Units (GELUs).” 2016. arXiv:1606.08415. —— GELU 激活。
- [3] He et al. “Deep Residual Learning for Image Recognition.” CVPR 2016. arXiv:1512.03385. —— 残差连接。
- [4] Xiong et al. “On Layer Normalization in the Transformer Architecture.” ICML 2020. arXiv:2002.04745. —— Pre-LN 的稳定性。
- 源码:Andrej Karpathy, nanoGPT,
model.py. https://github.com/karpathy/nanoGPT