从 Transformer Block 到 Mini-GPT
一台完整的 GPT 模型,数据流要经过三段:输入端的 Embedding 把 token ID 变成向量,中间的 Transformer Block 让向量之间交换信息,输出端的投影层把向量变回对词表的预测。上一节实现的 Transformer Block 位于数据流的中间——它接收
[batch, seq, d_model]的向量,做 Attention 和 FFN,输出同样形状的向量。但 Block 的两端还是断的。它不能直接接收整数 token ID,也不能输出词表大小的 logits。这一节把输入和输出接上去,跑通完整的数据流。
Embedding 是一张 [vocab_size, d_model] 的矩阵,查表即可把整数变成向量。Self-Attention 只看向量之间的关系,不知道谁先谁后,所以 Embedding 之后还要加上位置编码。Block 处理完之后,用一个线性层投影到 vocab_size 维,得到 logits。
假设词表 30 个 token,d_model=64。输入三个 token ID [5, 12, 3],经过 Embedding 和位置编码变成 [3, 64],穿过 N 层 Block 后形状不变,最后投影为 [3, 30]——每个位置输出 30 个分数,表示模型对下一个 token 的预测倾向。
GPT-2 Small 的配置是公开的。下面从它的数字出发,先算清楚 Embedding、Block、输出投影各占多少参数,再逐步实现每个环节。
# 不依赖 transformers,直接用 GPT-2 Small 的公开配置
gpt2_config = {
"vocab_size": 50257,
"n_positions": 1024,
"n_embd": 768,
"n_layer": 12,
"n_head": 12,
}
vocab_size = gpt2_config["vocab_size"]
n_positions = gpt2_config["n_positions"]
n_embd = gpt2_config["n_embd"]
n_layer = gpt2_config["n_layer"]
n_head = gpt2_config["n_head"]
d_ff = 4 * n_embd
print("=== GPT-2 Small 配置(公开结构参数)===")
print(f"词表大小: {vocab_size}")
print(f"隐藏维度: {n_embd}")
print(f"层数: {n_layer}")
print(f"注意力头数: {n_head}")
print(f"最大序列长度: {n_positions}")
GPT-2 的参数主要分布在三部分:Embedding 表、每层 Transformer Block、以及最后的 LayerNorm。先算 Embedding 部分。
# Embedding 层的参数:Token Embedding + Position Embedding
wte_params = vocab_size * n_embd # Token Embedding 表:每个 token 一个 d_model 维向量
wpe_params = n_positions * n_embd # Position Embedding 表:每个位置一个 d_model 维向量
print(f"Token Embedding (wte): {wte_params:>10,}")
print(f"Position Embedding (wpe): {wpe_params:>10,}")
print(f"Embedding 合计: {wte_params + wpe_params:>10,}")
print()
print("关键观察:Embedding 占了 GPT-2 近 40% 的参数,但 GPT-2 把 wte 和 lm_head 共享权重,实际不额外增加。")
接下来算每个 Transformer Block 里的参数。一个 Block 包含两层 LayerNorm、一个 Attention、一个 MLP。
# LayerNorm 参数:weight + bias,各 d_model 个
ln_params = 2 * n_embd
# GPT-2 的 attention: c_attn 一次性算 Q/K/V,再接 c_proj
attn_params = n_embd * (3 * n_embd) + (3 * n_embd) # c_attn: 合并的 Q/K/V 投影
attn_params += n_embd * n_embd + n_embd # c_proj: 输出投影
# GPT-2 的 MLP: 先扩到 4 倍维度,再压回 n_embd
mlp_params = n_embd * d_ff + d_ff # fc1: d_model → 4*d_model
mlp_params += d_ff * n_embd + n_embd # fc2: 4*d_model → d_model
layer_total = ln_params + attn_params + ln_params + mlp_params
print("=== 每层 Transformer Block 参数 ===")
print(f"LayerNorm 1: {ln_params:>10,}")
print(f"Attention: {attn_params:>10,}")
print(f"LayerNorm 2: {ln_params:>10,}")
print(f"MLP: {mlp_params:>10,}")
print(f"每层合计: {layer_total:>10,}")
print()
print("关键观察:MLP 占了每层参数的约 2/3,Attention 约 1/3。")
最后把所有部分加起来,得到 GPT-2 Small 的总参数量。
# 汇总:Embedding + N 层 Block + 最终 LayerNorm
ln_f_params = 2 * n_embd # 最后的 LayerNorm
total_unique_params = wte_params + wpe_params + n_layer * layer_total + ln_f_params
print("=== GPT-2 Small 参数量汇总 ===")
print(f"Token Embedding (wte): {wte_params:>10,}")
print(f"Position Embedding (wpe): {wpe_params:>10,}")
print(f"{n_layer} 层 Block 合计: {n_layer * layer_total:>10,}")
print(f"Final LayerNorm: {ln_f_params:>10,}")
print(f"LM Head 额外参数: {0:>10,} ← GPT-2 与 wte 权重共享")
print(f"{'-' * 55}")
print(f"总参数量(不重复计算共享权重): {total_unique_params:>10,}")
print()
print("关键观察:GPT-2 和 MiniGPT 的骨架一致,但 GPT-2 还用了可学习位置编码和权重共享。")
Weight Tying:输入 Embedding 与输出投影
前面参数统计中有一行 LM Head 额外参数: 0。原因是 GPT-2 让 Token Embedding 和输出投影层共享同一份权重矩阵,这个做法叫 weight tying。Embedding 矩阵的形状是 [vocab_size, d_model],输出投影的方向相反——把 hidden state 映射回词表大小的 logits。Weight tying 直接复用 Embedding 矩阵的转置作为输出投影,不再单独存一份参数。
这个做法最早由 Press & Wolf (2016) 系统论证。核心观察是:输出投影矩阵的每一行对应一个 token 的打分模板,本身构成一种 token 表示——和输入 Embedding 回答的是同一个问题:"这个 token 的语义是什么"。共享之后,参数减半,且同一组权重同时接收输入端和输出端的梯度,等价于隐式的多任务学习。对 GPT-2 来说,weight tying 节省了 50257 × 768 ≈ 3860 万个参数。
现代大模型的趋势是不再共享。以下表格列出几个代表性模型的做法:
| 模型 | Weight Tying | 说明 |
|---|---|---|
| GPT-2 | ✓ | 节省参数 |
| LLaMA 3.2 1B / 3B | ✓ | 小模型省参数 |
| LLaMA 3 / 3.1 8B+ | ✗ | 独立权重 |
| Qwen2.5 ≤ 3B | ✓ | 小模型省参数 |
| Qwen2.5 ≥ 7B | ✗ | 独立权重 |
| Qwen3(全系列) | ✗ | 全部独立 |
| DeepSeek-V3 | ✗ | 独立权重 |
分界线在参数规模。小模型(≤ 3B)用 weight tying 省参数;大模型(≥ 7B)倾向让输入和输出各自学最优表示。对 DeepSeek-V3 来说,分开存多占约 1.85B 参数,相对 671B 总量可以忽略。
# 对比:Weight Tying 对参数量的影响
demo_vocab = 100
demo_d = 64
# 不共享:wte 和 lm_head 各自独立
wte_only = demo_vocab * demo_d # Token Embedding
lm_head_only = demo_vocab * demo_d # 输出投影
# 共享:只存一份
tied = demo_vocab * demo_d
print("=== Weight Tying 的参数节省(vocab=100, d=64)===")
print(f"不共享:wte {wte_only:,} + lm_head {lm_head_only:,} = {wte_only + lm_head_only:,}")
print(f"共享: 只存 {tied:,}(节省 50%)")
print()
# GPT-2 的实际数字
print("=== GPT-2 的实际数字 ===")
print(f"wte 参数: {vocab_size * n_embd:,}")
print(f"如果 lm_head 独立: 额外 {vocab_size * n_embd:,}")
print(f"weight tying 后: lm_head 额外 0,节省 ~{vocab_size * n_embd / 1e6:.0f}M 参数")
先把 GPT-2 和原始 Transformer 的差别画清楚。GPT-2 只保留 Decoder 主干,用来不断预测下一个 token;它没有 Encoder,也没有 Cross-Attention。
GPT-2 / Decoder-Only
Token IDs
↓
Token Embedding + Position Embedding
↓
Masked Self-Attention ← 只能看当前位置及之前的 token
↓
Feed-Forward Network
↓
重复很多层 Decoder Block
↓
LM Head
↓
预测下一个 token
作为对比,原始 Transformer 是 Encoder-Decoder 结构,常用于翻译这类「先读输入,再生成输出」的任务:
原始 Transformer / Encoder-Decoder
输入句子 → Encoder → Encoder 输出
↓
目标前缀 → Decoder → Cross-Attention 读取 Encoder 输出
↓
生成下一个 token
将两张图对比来看:原始 Transformer 的 Decoder 有两类 Attention——Masked Self-Attention(看自己的前缀)和 Cross-Attention(看 Encoder 的输出)。GPT-2 只有 Masked Self-Attention,因为它是纯生成模型,没有 Encoder。
import torch
import numpy as np
torch.manual_seed(42)
np.random.seed(42)
1. GPT 的整体结构
GPT 的数据流可以看成一条流水线。输入是 token ID 序列,输出是每个位置对整个词表的预测分数:
Token IDs
↓
Token Embedding + Position Embedding
↓
Transformer Block × N
↓
LayerNorm
↓
Linear(投影到词表大小)
↓
logits:每个位置预测下一个 token
这条流水线里,中间的维度一直保持 d_model 不变,只有最后一步才展开成 vocab_size。用 shape 来看就是:
[batch, seq] → [batch, seq, d_model] → ... → [batch, seq, vocab_size]
接下来沿着这条流水线,一步一步实现。
2. 复用 Transformer Block
为了让本 Notebook 单独运行,先把上一节的三个零件放在这里。
这里不重新解释每一行的原理,只记住它们的职责:
MultiHeadAttention:让 token 看见当前位置及之前的上下文。FeedForward:每个 token 自己过一个小网络。TransformerBlock:把 Attention、FFN、Residual、LayerNorm 串起来。
import torch.nn as nn
import torch.nn.functional as F
import math
class MultiHeadAttention(nn.Module):
"""
多头自注意力;传入 causal mask 时就是因果自注意力。
参数:
d_model: 输入/输出维度
num_heads: 注意力头数
"""
def __init__(self, d_model, num_heads):
super().__init__()
assert d_model % num_heads == 0, "d_model 必须能被 num_heads 整除"
self.d_model = d_model
self.num_heads = num_heads
self.d_k = d_model // num_heads # 每个头的维度
# Q、K、V 的线性变换(把 num_heads 个头合并到矩阵操作里)
self.W_Q = nn.Linear(d_model, d_model)
self.W_K = nn.Linear(d_model, d_model)
self.W_V = nn.Linear(d_model, d_model)
# 输出投影
self.W_O = nn.Linear(d_model, d_model)
def forward(self, x, mask=None):
"""
输入: x shape = [batch, seq_len, d_model]
输出: shape = [batch, seq_len, d_model]
"""
batch_size, seq_len, _ = x.shape
# 1. 线性变换 + 拆成多头
# [batch, seq_len, d_model] → [batch, num_heads, seq_len, d_k]
Q = self.W_Q(x).view(batch_size, seq_len, self.num_heads, self.d_k).transpose(1, 2)
K = self.W_K(x).view(batch_size, seq_len, self.num_heads, self.d_k).transpose(1, 2)
V = self.W_V(x).view(batch_size, seq_len, self.num_heads, self.d_k).transpose(1, 2)
# 2. 注意力分数: Q @ K^T
scores = (Q @ K.transpose(-2, -1)) / math.sqrt(self.d_k)
# 3. Mask(例如把未来的位置设为 -inf)
if mask is not None:
scores = scores.masked_fill(mask == 0, float('-inf'))
# 4. Softmax
weights = F.softmax(scores, dim=-1)
# 5. 加权求和
attn_output = weights @ V # [batch, num_heads, seq_len, d_k]
# 6. 拼回头并投影
attn_output = attn_output.transpose(1, 2).contiguous()
attn_output = attn_output.view(batch_size, seq_len, self.d_model)
return self.W_O(attn_output)
import torch.nn as nn
import torch.nn.functional as F
class FeedForward(nn.Module):
"""FFN:两层全连接,先扩 4 倍再压回"""
def __init__(self, d_model, d_ff=None):
super().__init__()
d_ff = d_ff or 4 * d_model
self.fc1 = nn.Linear(d_model, d_ff)
self.fc2 = nn.Linear(d_ff, d_model)
def forward(self, x):
return self.fc2(F.relu(self.fc1(x)))
class TransformerBlock(nn.Module):
"""一个 Transformer 解码器层:Attention + FFN,各带残差 + LayerNorm"""
def __init__(self, d_model, num_heads, d_ff=None):
super().__init__()
self.attention = MultiHeadAttention(d_model, num_heads)
self.ffn = FeedForward(d_model, d_ff)
self.norm1 = nn.LayerNorm(d_model)
self.norm2 = nn.LayerNorm(d_model)
def forward(self, x, mask=None):
x = self.norm1(x + self.attention(x, mask)) # Attention + 残差 + Norm
x = self.norm2(x + self.ffn(x)) # FFN + 残差 + Norm
return x