从零构建LLM 第4章:从零实现GPT
1. GPT 的整体架构
GPT (Generative Pre-trained Transformer) 的架构其实并不复杂。它是 Transformer 的仅解码器 (Decoder-Only) 变体,核心就是一堆相同结构的 TransformerBlock 堆叠在一起:
- Token 嵌入 + 位置嵌入(第2章实现)→ 将输入序列转为向量
- N 个 TransformerBlock(每个含注意力 + 前馈网络)→ 逐层提取和融合特征
- LayerNorm → 最终归一化
- 线性输出层 → 映射回词汇表大小,输出每个 token 的概率
整个模型的行为由一个配置字典完全确定。改变配置就能得到不同规模的 GPT-2 变体:
GPT_CONFIG_124M = {
"vocab_size": 50257, # BPE 词汇表大小
"context_length": 1024, # 最大上下文长度
"emb_dim": 768, # 嵌入维度
"n_heads": 12, # 注意力头数
"n_layers": 12, # TransformerBlock 层数
"drop_rate": 0.1, # Dropout 率
"qkv_bias": False # Q/K/V 是否使用偏置
}
| 模型 | 参数量 | 嵌入维度 | 层数 | 注意力头 |
|---|---|---|---|---|
| GPT-2 Small | 124M | 768 | 12 | 12 |
| GPT-2 Medium | 355M | 1024 | 24 | 16 |
| GPT-2 Large | 774M | 1280 | 36 | 20 |
| GPT-2 XL | 1558M | 1600 | 48 | 25 |
观察规律:所有变体的结构完全相同,只有数字不同。这体现了 Transformer 架构的模块化优势 — 扩展模型只需要增加维度和层数。
2. 归一化与激活:LayerNorm 和 GELU
在组装 TransformerBlock 之前,需要先实现两个基础组件。
2.1 LayerNorm(层归一化)
LayerNorm 对每个 token 的所有特征维度做归一化(均值为 0,方差为 1),然后通过可学习的 scale 和 shift 参数恢复表达能力。
为什么不用 BatchNorm?BatchNorm 在 batch 维度上归一化,但在文本任务中不同序列的同一位置没有统计意义(第3个词可能是名词也可能是动词)。LayerNorm 只看「单个 token 的所有特征」,更适合序列模型。
2.2 GELU 激活函数
GELU (Gaussian Error Linear Unit) 是 GPT 的激活函数。不同于 ReLU 的硬截断(x < 0 时梯度直接为 0),GELU 用高斯分布的 CDF 做「软门控」— 小的负值被轻微抑制而非完全丢弃,有利于梯度流动。
2.3 FeedForward 前馈网络
FeedForward 模块由两个线性层组成,中间维度扩大 4 倍:768 → 3072 → 768。这个「先扩展再压缩」的设计给模型提供了更大的中间表示空间来学习复杂的特征变换。
class LayerNorm(nn.Module):
def __init__(self, emb_dim):
super().__init__()
self.eps = 1e-5
self.scale = nn.Parameter(torch.ones(emb_dim))
self.shift = nn.Parameter(torch.zeros(emb_dim))
def forward(self, x):
mean = x.mean(dim=-1, keepdim=True)
var = x.var(dim=-1, keepdim=True, unbiased=False)
norm_x = (x - mean) / torch.sqrt(var + self.eps)
return self.scale * norm_x + self.shift
class GELU(nn.Module):
def forward(self, x):
return 0.5 * x * (1 + torch.tanh(
torch.sqrt(torch.tensor(2.0 / torch.pi)) *
(x + 0.044715 * torch.pow(x, 3))
))
class FeedForward(nn.Module):
def __init__(self, cfg):
super().__init__()
self.layers = nn.Sequential(
nn.Linear(cfg["emb_dim"], 4 * cfg["emb_dim"]), # 768 → 3072
GELU(),
nn.Linear(4 * cfg["emb_dim"], cfg["emb_dim"]), # 3072 → 768
)
def forward(self, x):
return self.layers(x)
3. TransformerBlock:核心积木
TransformerBlock 是 GPT 的核心重复单元。它将上一章的多头注意力和刚实现的 FeedForward 组合在一起,并用 LayerNorm 和残差连接 (Residual Connection) 将它们串联。
GPT 使用 Pre-Norm 架构(先归一化再计算),而非原版 Transformer 论文中的 Post-Norm(先计算再归一化)。Pre-Norm 的优势是训练更稳定,不容易出现梯度爆炸。
class TransformerBlock(nn.Module):
def __init__(self, cfg):
super().__init__()
self.att = MultiHeadAttention(
d_in=cfg["emb_dim"], d_out=cfg["emb_dim"],
context_length=cfg["context_length"],
num_heads=cfg["n_heads"],
dropout=cfg["drop_rate"], qkv_bias=cfg["qkv_bias"]
)
self.ff = FeedForward(cfg)
self.norm1 = LayerNorm(cfg["emb_dim"])
self.norm2 = LayerNorm(cfg["emb_dim"])
self.drop_shortcut = nn.Dropout(cfg["drop_rate"])
def forward(self, x):
# 子层1: LayerNorm → 多头注意力 → Dropout → 残差连接
shortcut = x
x = self.norm1(x)
x = self.att(x)
x = self.drop_shortcut(x)
x = x + shortcut # 残差:把输入直接加回来
# 子层2: LayerNorm → FeedForward → Dropout → 残差连接
shortcut = x
x = self.norm2(x)
x = self.ff(x)
x = self.drop_shortcut(x)
x = x + shortcut
return x
⚠️ 残差连接的作用:如果没有残差连接,12 层 TransformerBlock 意味着信号需要经过 24 个非线性变换 — 梯度极容易消失。残差连接提供了一条「高速公路」,让梯度可以直接流回浅层,是深层网络能训练的关键。这也是为什么 GPT-2 XL 能堆到 48 层。
4. GPTModel:完整模型
有了所有组件,GPTModel 的实现就是一个「搭积木」的过程:
class GPTModel(nn.Module):
def __init__(self, cfg):
super().__init__()
self.tok_emb = nn.Embedding(cfg["vocab_size"], cfg["emb_dim"])
self.pos_emb = nn.Embedding(cfg["context_length"], cfg["emb_dim"])
self.drop_emb = nn.Dropout(cfg["drop_rate"])
# 堆叠 N 个 TransformerBlock
self.trf_blocks = nn.Sequential(
*[TransformerBlock(cfg) for _ in range(cfg["n_layers"])]
)
self.final_norm = LayerNorm(cfg["emb_dim"])
self.out_head = nn.Linear(cfg["emb_dim"], cfg["vocab_size"], bias=False)
def forward(self, in_idx):
batch_size, seq_len = in_idx.shape
# Token 嵌入 + 位置嵌入(第2章的内容)
tok_embeds = self.tok_emb(in_idx)
pos_embeds = self.pos_emb(torch.arange(seq_len, device=in_idx.device))
x = self.drop_emb(tok_embeds + pos_embeds)
x = self.trf_blocks(x) # 经过 12 个 TransformerBlock
x = self.final_norm(x) # 最终 LayerNorm
logits = self.out_head(x) # 映射到词汇表大小
return logits # [batch, seq_len, 50257]
模型实例化后,可以直接查看参数量:
model = GPTModel(GPT_CONFIG_124M)
total_params = sum(p.numel() for p in model.parameters())
print(f"Total parameters: {total_params:,}")
# Total parameters: 163,009,536
# 注意:实际参数量 (163M) 大于名称中的 124M
# 因为原始 GPT-2 将 token 嵌入层的权重与输出层共享 (weight tying)
# 去掉重复计算后:163M - 50257*768 = 124.4M ≈ 124M
total_params_no_tying = total_params - model.tok_emb.weight.numel()
print(f"Without weight tying: {total_params_no_tying:,}")
# Without weight tying: 124,412,160
ℹ️ Weight Tying 技巧:GPT-2 让 tok_emb(token 嵌入层)和 out_head(输出线性层)共享同一组权重。这不仅减少了 ~39M 参数,而且在语义上也合理 — 嵌入空间中「相近」的词,在输出时也应该有相似的概率分布。
5. 为什么这很重要
本章的核心收获不仅是实现了一个 GPT 模型,更是理解了它的设计哲学:
- 模块化设计:GPT 的全部复杂性被封装在 TransformerBlock 中。改变模型规模只需要调整配置参数,代码不需要改动。这种设计使得 scaling 变得简单直接。
- Scaling Laws:从 124M 到 1558M,GPT-2 的四个变体验证了一个关键发现 — 增加参数量(特别是维度和层数)能持续提升模型能力。这后来被 Kaplan et al. 系统化为 Scaling Laws。
- 参数量估算:理解参数量的构成(嵌入层占多少、注意力占多少、FFN 占多少)对于评估模型的计算和内存需求至关重要。FFN 的 4× 扩展是参数量的主要来源。
- Pre-Norm vs Post-Norm:GPT-2 使用 Pre-Norm 是经过实践验证的选择。后来的 LLaMA 等模型进一步采用了 RMSNorm(去掉了 mean 的计算),更加高效。
- 权重共享:Weight Tying 是一个巧妙的工程优化。在大模型中,嵌入层参数可能占比更高(因为词汇表大小不随模型缩放),这个技巧的价值更大。
ℹ️ 总结 — 第4章 GPT 模型组装:Token Embedding + Positional Embedding → Dropout → 12× TransformerBlock (Pre-LayerNorm → Multi-Head Causal Attention → Residual + Pre-LayerNorm → FeedForward → Residual) → Final LayerNorm → Linear Output Head。此时模型的权重都是随机初始化的 — 下一章我们将学习如何通过预训练让它真正学会语言。
本文是 Build a Large Language Model From Scratch (Sebastian Raschka) 的学习笔记。所有配图版权归原作者所有。代码基于原书示例,有简化和中文注释。