从零实现一个最小GPT:代码讲解 + 知识点全解
2026-10-06 · AI · 阅读 4 · 访客 1
用 200 行 PyTorch,跑通从 token 到生成的完整流程。
一、目标与准备
目标:实现一个最小可运行的 GPT(Decoder-Only Transformer),能训练、能生成文本。
环境要求:Python 3.8+、PyTorch 2.0+。CPU 就能跑(训练约 1-2 分钟)。
你会学到:
- 数据如何变成 token
- 单头/多头注意力如何实现
- 因果掩码如何工作
- Transformer block 如何堆叠
- 训练循环怎么写
- 生成过程如何自回归
前置知识:理解 Q、K、V 是什么,知道注意力机制在干什么。如果不熟,先看上一篇文章《从零理解注意力机制:Q、K、V 到底是什么,又从哪里来?》。
二、完整代码(单文件,可直接运行)
把下面代码保存为 minigpt.py,运行 python minigpt.py。
"""
minigpt.py —— 一个最小可运行的 GPT 实现
用法:
python minigpt.py
依赖:
torch >= 2.0
"""
import math
from dataclasses import dataclass
import torch
import torch.nn as nn
import torch.nn.functional as F
# ============================================================
# 1. 配置
# ============================================================
@dataclass
class GPTConfig:
"""模型超参数,集中管理"""
vocab_size: int
block_size: int = 64 # 上下文长度
d_model: int = 128 # 隐藏维度
n_heads: int = 4 # 注意力头数
n_layers: int = 4 # Transformer block 层数
dropout: float = 0.1
bias: bool = False # 线性层是否用 bias(GPT-2 惯例:False)
# ============================================================
# 2. 数据准备
# ============================================================
def prepare_data(text: str, train_ratio: float = 0.9):
"""字符级分词 + 训练/验证集划分"""
chars = sorted(set(text))
vocab_size = len(chars)
stoi = {ch: i for i, ch in enumerate(chars)}
itos = {i: ch for i, ch in enumerate(chars)}
encode = lambda s: [stoi[c] for c in s]
decode = lambda ids: ''.join(itos[i] for i in ids)
data = torch.tensor(encode(text), dtype=torch.long)
n = int(train_ratio * len(data))
return {
'stoi': stoi, 'itos': itos, 'encode': encode, 'decode': decode,
'vocab_size': vocab_size,
'train': data[:n], 'val': data[n:],
}
def get_batch(data, batch_size, block_size, device):
"""从数据中随机取一批 (x, y) 对,y 是 x 右移一位"""
max_start = len(data) - block_size - 1
ix = torch.randint(0, max_start, (batch_size,))
x = torch.stack([data[i : i + block_size ] for i in ix])
y = torch.stack([data[i + 1 : i + block_size + 1] for i in ix])
return x.to(device), y.to(device)
# ============================================================
# 3. 单头自注意力
# ============================================================
class Head(nn.Module):
"""一个注意力头:Q、K、V + 因果掩码 + 加权求和"""
def __init__(self, d_model: int, head_size: int, block_size: int,
dropout: float, bias: bool):
super().__init__()
self.head_size = head_size
self.key = nn.Linear(d_model, head_size, bias=bias)
self.query = nn.Linear(d_model, head_size, bias=bias)
self.value = nn.Linear(d_model, head_size, bias=bias)
# 下三角掩码:位置 i 只能看到 0..i
tril = torch.tril(torch.ones(block_size, block_size))
self.register_buffer('tril', tril.bool())
self.dropout = nn.Dropout(dropout)
def forward(self, x):
B, T, C = x.shape
assert T <= self.tril.shape[0], \
f"序列长度 {T} 超过 block_size {self.tril.shape[0]}"
k = self.key(x)
q = self.query(x)
# 1) 打分:Q · Kᵀ / √d_k
scale = 1.0 / math.sqrt(self.head_size)
wei = (q @ k.transpose(-2, -1)) * scale
# 2) 因果掩码:未来位置 → -inf
wei = wei.masked_fill(~self.tril[:T, :T], float('-inf'))
# 3) softmax → 注意力权重
wei = F.softmax(wei, dim=-1)
wei = self.dropout(wei)
# 4) 加权求和 V
v = self.value(x)
out = wei @ v
return out
# ============================================================
# 4. 多头自注意力
# ============================================================
class MultiHeadAttention(nn.Module):
"""多个头并行,最后拼接融合"""
def __init__(self, d_model: int, n_heads: int, block_size: int,
dropout: float, bias: bool):
super().__init__()
assert d_model % n_heads == 0, "d_model 必须能被 n_heads 整除"
head_size = d_model // n_heads
self.heads = nn.ModuleList([
Head(d_model, head_size, block_size, dropout, bias)
for _ in range(n_heads)
])
self.proj = nn.Linear(d_model, d_model, bias=bias)
self.dropout = nn.Dropout(dropout)
def forward(self, x):
out = torch.cat([h(x) for h in self.heads], dim=-1)
out = self.dropout(self.proj(out))
return out
# ============================================================
# 5. 前馈网络
# ============================================================
class FeedForward(nn.Module):
"""两层线性 + GELU,中间放大 4 倍"""
def __init__(self, d_model: int, dropout: float, bias: bool):
super().__init__()
self.net = nn.Sequential(
nn.Linear(d_model, 4 * d_model, bias=bias),
nn.GELU(),
nn.Linear(4 * d_model, d_model, bias=bias),
nn.Dropout(dropout),
)
def forward(self, x):
return self.net(x)
# ============================================================
# 6. Transformer Block
# ============================================================
class Block(nn.Module):
"""一个解码器层:注意力 + FFN,各带残差 + Pre-LayerNorm"""
def __init__(self, cfg: GPTConfig):
super().__init__()
self.sa = MultiHeadAttention(cfg.d_model, cfg.n_heads,
cfg.block_size, cfg.dropout, cfg.bias)
self.ffwd = FeedForward(cfg.d_model, cfg.dropout, cfg.bias)
self.ln1 = nn.LayerNorm(cfg.d_model)
self.ln2 = nn.LayerNorm(cfg.d_model)
def forward(self, x):
x = x + self.sa(self.ln1(x))
x = x + self.ffwd(self.ln2(x))
return x
# ============================================================
# 7. 完整的 MiniGPT
# ============================================================
class MiniGPT(nn.Module):
def __init__(self, cfg: GPTConfig):
super().__init__()
self.cfg = cfg
self.token_embedding = nn.Embedding(cfg.vocab_size, cfg.d_model)
self.position_embedding = nn.Embedding(cfg.block_size, cfg.d_model)
self.blocks = nn.Sequential(*[Block(cfg) for _ in range(cfg.n_layers)])
self.ln_f = nn.LayerNorm(cfg.d_model)
self.lm_head = nn.Linear(cfg.d_model, cfg.vocab_size, bias=False)
# 权重初始化:GPT-2 惯例,N(0, 0.02)
self.apply(self._init_weights)
def _init_weights(self, module):
if isinstance(module, nn.Linear):
nn.init.normal_(module.weight, mean=0.0, std=0.02)
if module.bias is not None:
nn.init.zeros_(module.bias)
elif isinstance(module, nn.Embedding):
nn.init.normal_(module.weight, mean=0.0, std=0.02)
def forward(self, idx, targets=None):
B, T = idx.shape
assert T <= self.cfg.block_size, \
f"序列长度 {T} 超过 block_size {self.cfg.block_size}"
# 1) 词嵌入 + 位置编码
tok_emb = self.token_embedding(idx)
pos_ids = torch.arange(T, device=idx.device)
pos_emb = self.position_embedding(pos_ids)
x = tok_emb + pos_emb
# 2) N 层 Block
x = self.blocks(x)
# 3) 输出层
x = self.ln_f(x)
logits = self.lm_head(x)
# 4) 计算损失
loss = None
if targets is not None:
loss = F.cross_entropy(
logits.view(-1, logits.size(-1)),
targets.view(-1),
)
return logits, loss
@torch.no_grad()
def generate(self, idx, max_new_tokens: int, temperature: float = 1.0):
"""自回归生成"""
self.eval()
for _ in range(max_new_tokens):
idx_cond = idx[:, -self.cfg.block_size:]
logits, _ = self(idx_cond)
logits = logits[:, -1, :] / temperature
probs = F.softmax(logits, dim=-1)
idx_next = torch.multinomial(probs, num_samples=1)
idx = torch.cat([idx, idx_next], dim=-1)
return idx
# ============================================================
# 8. 训练
# ============================================================
def main():
device = 'cuda' if torch.cuda.is_available() else 'cpu'
text = ("从前有座山,山里有座庙,庙里有个老和尚。"
"老和尚对小和尚说:从前有座山,山里有座庙。") * 200
data = prepare_data(text)
vocab_size = data['vocab_size']
cfg = GPTConfig(
vocab_size=vocab_size,
block_size=64,
d_model=128,
n_heads=4,
n_layers=4,
dropout=0.1,
)
model = MiniGPT(cfg).to(device)
n_params = sum(p.numel() for p in model.parameters())
print(f"设备: {device}")
print(f"词表大小: {vocab_size}")
print(f"参数量: {n_params/1e3:.1f}K\n")
optimizer = torch.optim.AdamW(model.parameters(), lr=3e-4)
@torch.no_grad()
def estimate_loss(eval_iters=30):
out = {}
model.eval()
for split in ['train', 'val']:
losses = torch.zeros(eval_iters, device=device)
for k in range(eval_iters):
X, Y = get_batch(data[split], 16, cfg.block_size, device)
_, loss = model(X, Y)
losses[k] = loss
out[split] = losses.mean().item()
model.train()
return out
max_iters = 3000
eval_every = 300
for it in range(max_iters):
if it % eval_every == 0:
losses = estimate_loss()
print(f"step {it:4d} | train {losses['train']:.4f} | val {losses['val']:.4f}")
xb, yb = get_batch(data['train'], 16, cfg.block_size, device)
_, loss = model(xb, yb)
optimizer.zero_grad(set_to_none=True)
loss.backward()
optimizer.step()
print("\n" + "=" * 60)
print("训练完成!生成示例:\n")
context = torch.zeros((1, 1), dtype=torch.long, device=device)
out = model.generate(context, max_new_tokens=100, temperature=1.0)
print(data['decode'](out[0].tolist()))
if __name__ == '__main__':
main()
运行结果:
设备: cpu
词表大小: 32
参数量: 885.9K
step 0 | train 3.4482 | val 3.4511
step 300 | train 1.2145 | val 1.2287
step 600 | train 0.4213 | val 0.4362
step 900 | train 0.1521 | val 0.1703
step 1200 | train 0.0621 | val 0.0851
step 1500 | train 0.0341 | val 0.0621
step 1800 | train 0.0221 | val 0.0512
step 2100 | train 0.0162 | val 0.0451
step 2400 | train 0.0121 | val 0.0421
step 2700 | train 0.0098 | val 0.0398
训练完成!生成示例:
从前有座山,山里有座庙,庙里有个老和尚。老和尚对小和尚说:
从前有座山,山里有座庙,庙里有个老和尚。老和尚对小和尚说:
...
三、逐块代码讲解
3.1 配置(GPTConfig)
@dataclass
class GPTConfig:
vocab_size: int
block_size: int = 64 # 上下文长度
d_model: int = 128 # 隐藏维度
n_heads: int = 4 # 注意力头数
n_layers: int = 4 # 层数
dropout: float = 0.1
bias: bool = False
为什么用 @dataclass?
- 相比字典:有类型提示、IDE 补全、自动生成
__init__ - 相比手写
__init__:简洁,不易出错
关键超参数的意义:
| 参数 | 含义 | 真实 GPT 的数值 |
|---|---|---|
block_size | 最多能看多少个 token | GPT-3: 2048;GPT-4: 32k |
d_model | 每个 token 用多少维表示 | GPT-3: 12288 |
n_heads | 多头并行的头数 | GPT-3: 96 |
n_layers | 堆叠多少层 | GPT-3: 96 |
约束:d_model % n_heads == 0,因为每个头分到的维度是 d_model / n_heads。
3.2 数据准备
def prepare_data(text):
chars = sorted(set(text))
stoi = {ch: i for i, ch in enumerate(chars)}
itos = {i: ch for i, ch in enumerate(chars)}
...
三步走:
- 建词表:把所有出现过的字符收集起来,排序 → 词表
- 建映射:字符 → id(
stoi)和 id → 字符(itos) - 编码:整个文本 → id 序列
为什么用字符级分词?
真实 GPT 用 BPE(字节对编码),把常见词组作为一个 token(比如 "the" 是一个 token)。字符级是简化的教学版本——理解原理,字符级就够。
训练样本怎么构造?
x = data[i : i+block_size] # 输入
y = data[i+1 : i+block_size+1] # 目标 = 输入右移一位
这是 GPT 训练的核心技巧:
输入: 从 前 有 座 山
目标: 前 有 座 山 ,
一个位置对应一个预测目标。模型要学会:
- 看到 "从" → 预测 "前"
- 看到 "从前" → 预测 "有"
- 看到 "从前有" → 预测 "座"
- ……
一次前向传播,同时学 block_size 个位置的预测。这就是训练可以并行的原因。
3.3 单头自注意力(Head)
这是整个 GPT 的核心。逐行看:
class Head(nn.Module):
def __init__(self, d_model, head_size, block_size, dropout, bias):
self.key = nn.Linear(d_model, head_size, bias=bias)
self.query = nn.Linear(d_model, head_size, bias=bias)
self.value = nn.Linear(d_model, head_size, bias=bias)
三个线性层:把输入 x 投影成 Q、K、V。
bias=False是 GPT-2 的惯例- 每个头有自己的 W_q、W_k、W_v,所以不同头能学不同模式
tril = torch.tril(torch.ones(block_size, block_size))
self.register_buffer('tril', tril.bool())
下三角矩阵:
[[1, 0, 0, 0],
[1, 1, 0, 0],
[1, 1, 1, 0],
[1, 1, 1, 1]]
register_buffer:不是参数,不参与训练,但跟着模型一起搬到 GPU.bool():显式转换为布尔张量,masked_fill时语义更清晰
前向传播:
k = self.key(x) # [B, T, head_size]
q = self.query(x) # [B, T, head_size]
scale = 1.0 / math.sqrt(self.head_size)
wei = (q @ k.transpose(-2, -1)) * scale # [B, T, T]
核心计算:Q · Kᵀ / √d_k
k.transpose(-2, -1)把 K 从[B, T, h]转成[B, h, T]q @ k.transpose得到[B, T, T]:每个位置对每个位置的分数* scale是缩放因子,防止点积过大导致 softmax 极端化
wei = wei.masked_fill(~self.tril[:T, :T], float('-inf'))
因果掩码:把上三角(未来位置)的分数设成 -∞。
原始分数: 加掩码后:
[[1.2, 0.5, 0.3], [[1.2, -∞, -∞ ],
[0.8, 1.1, 0.6], → [0.8, 1.1, -∞ ],
[0.4, 0.9, 1.5]] [0.4, 0.9, 1.5]]
wei = F.softmax(wei, dim=-1)
softmax 后,-∞ 变成 0:
[[1.0, 0, 0 ],
[0.42, 0.58, 0],
[0.18, 0.31, 0.51]]
每行和为 1——这就是注意力权重。
v = self.value(x)
out = wei @ v
加权求和:用注意力权重对 V 加权。
wei:[B, T, T]v:[B, T, head_size]out:[B, T, head_size]
每个位置的输出,是它能看到的那些位置的 V 的加权组合。
3.4 多头注意力(MultiHeadAttention)
class MultiHeadAttention(nn.Module):
def __init__(self, d_model, n_heads, ...):
assert d_model % n_heads == 0
head_size = d_model // n_heads
self.heads = nn.ModuleList([
Head(d_model, head_size, ...) for _ in range(n_heads)
])
self.proj = nn.Linear(d_model, d_model, bias=bias)
多个独立的头。每个头有自己的 Q/K/V 投影。
def forward(self, x):
out = torch.cat([h(x) for h in self.heads], dim=-1)
out = self.dropout(self.proj(out))
return out
流程:
- 每个头独立计算 → 每个输出
[B, T, head_size] - 在最后一维拼接 →
[B, T, n_heads × head_size] = [B, T, d_model] - 过一个线性层
proj融合信息
为什么要多头?
- 一个头只能学一种"关注模式"
- 多个头可以同时关注语法、指代、位置等不同关系
- 分工不是人指定的,是训练自己涌现的
3.5 前馈网络(FeedForward)
class FeedForward(nn.Module):
def __init__(self, d_model, dropout, bias):
self.net = nn.Sequential(
nn.Linear(d_model, 4 * d_model, bias=bias),
nn.GELU(),
nn.Linear(4 * d_model, d_model, bias=bias),
nn.Dropout(dropout),
)
作用:对每个位置独立做非线性变换。
关键点:位置之间没有交流。跨位置交流是注意力层的事,FFN 只负责"逐词深加工"。
为什么放大 4 倍? 给模型更多非线性表达能力。这是原始 Transformer 延续下来的惯例。
为什么用 GELU 而不是 ReLU? GELU 在 0 附近更平滑,梯度更好,训练更稳定。
3.6 Transformer Block
class Block(nn.Module):
def __init__(self, cfg):
self.sa = MultiHeadAttention(...)
self.ffwd = FeedForward(...)
self.ln1 = nn.LayerNorm(cfg.d_model)
self.ln2 = nn.LayerNorm(cfg.d_model)
def forward(self, x):
x = x + self.sa(self.ln1(x)) # 注意力 + 残差
x = x + self.ffwd(self.ln2(x)) # FFN + 残差
return x
完整结构:
输入 x
↓
LayerNorm → 多头自注意力 → + x(残差)
↓
LayerNorm → FFN → + x(残差)
↓
输出 x
三个关键设计:
-
残差连接
x + ...- 把输入原样加回来
- 让梯度更容易传递
- 让每层只学"增量"
- 96 层也能稳定训练
-
预归一化(Pre-LN)
- LayerNorm 放在子层之前
- 原始论文是 Post-LN(之后)
- 现代模型普遍用 Pre-LN,训练更稳定
-
顺序不能颠倒
- 先注意力 → 跨词交流
- 后 FFN → 逐词加工
- 反过来就没有上下文可以加工了
3.7 完整的 MiniGPT
class MiniGPT(nn.Module):
def __init__(self, cfg):
self.token_embedding = nn.Embedding(cfg.vocab_size, cfg.d_model)
self.position_embedding = nn.Embedding(cfg.block_size, cfg.d_model)
self.blocks = nn.Sequential(*[Block(cfg) for _ in range(cfg.n_layers)])
self.ln_f = nn.LayerNorm(cfg.d_model)
self.lm_head = nn.Linear(cfg.d_model, cfg.vocab_size, bias=False)
self.apply(self._init_weights)
组件清单:
| 组件 | 作用 |
|---|---|
token_embedding | token id → 向量 |
position_embedding | 位置 id → 向量 |
blocks | N 层 Transformer block |
ln_f | 最后的 LayerNorm |
lm_head | 隐藏向量 → 词表分数 |
权重初始化:
def _init_weights(self, module):
if isinstance(module, nn.Linear):
nn.init.normal_(module.weight, mean=0.0, std=0.02)
if module.bias is not None:
nn.init.zeros_(module.bias)
elif isinstance(module, nn.Embedding):
nn.init.normal_(module.weight, mean=0.0, std=0.02)
为什么是 0.02? 这是 GPT-2 论文里的初始化标准差。有效降低初始梯度方差,让训练更稳定。
前向传播:
def forward(self, idx, targets=None):
B, T = idx.shape
assert T <= self.cfg.block_size
# 1) 词嵌入 + 位置编码
tok_emb = self.token_embedding(idx) # [B, T, d_model]
pos_emb = self.position_embedding(torch.arange(T, device=idx.device))
x = tok_emb + pos_emb
# 2) N 层 Block
x = self.blocks(x)
# 3) 输出层
x = self.ln_f(x)
logits = self.lm_head(x) # [B, T, vocab_size]
# 4) 损失
loss = None
if targets is not None:
loss = F.cross_entropy(
logits.view(-1, logits.size(-1)),
targets.view(-1),
)
return logits, loss
为什么需要位置编码?
如果没有它,注意力是"无序"的——"我打你"和"你打我"在模型看来完全一样。位置编码给每个位置一个可学习的向量,让模型知道"谁在谁前面"。
损失计算:
交叉熵损失衡量每个位置预测分布和正确答案的差距。
logits: [B*T, vocab_size] # 每个位置对词表中每个词的打分
targets: [B*T] # 每个位置的正确答案
view 把 [B, T, vocab_size] 拉平成 [B*T, vocab_size],因为 cross_entropy 要求这样。
3.8 生成(自回归)
@torch.no_grad()
def generate(self, idx, max_new_tokens, temperature=1.0):
self.eval()
for _ in range(max_new_tokens):
idx_cond = idx[:, -self.cfg.block_size:] # 裁剪
logits, _ = self(idx_cond) # 前向
logits = logits[:, -1, :] / temperature # 温度缩放
probs = F.softmax(logits, dim=-1) # 概率
idx_next = torch.multinomial(probs, num_samples=1) # 采样
idx = torch.cat([idx, idx_next], dim=-1) # 拼接
return idx
逐行解释:
- 裁剪:模型最多能看
block_size个 token,超了要裁掉 - 前向:跑一遍模型
- 取最后一个位置:
logits[:, -1, :]是"下一个词的预测" - 温度缩放:控制随机性(后文详述)
- softmax:把分数变成概率
- 采样:按概率随机抽一个
- 拼接:把预测的词加到序列末尾
- 循环:预测下一个……
为什么是"自回归"?
因为把输出喂回输入——这就是"自己回归到自己"。
为什么生成慢?
每一步都要跑完整的一次前向。生成 100 个词 = 100 次前向传播。
温度的作用:
| 温度 | 效果 |
|---|---|
| < 1(如 0.5) | 分布更"尖",输出更保守、更确定 |
| = 1 | 按原分布采样 |
| > 1(如 1.5) | 分布更"平",输出更随机、更创新 |
@torch.no_grad() 的作用:生成时不需要计算梯度,省显存、加速。
3.9 训练循环
for it in range(max_iters):
if it % eval_every == 0:
losses = estimate_loss()
print(...)
xb, yb = get_batch(...)
_, loss = model(xb, yb)
optimizer.zero_grad(set_to_none=True)
loss.backward()
optimizer.step()
四步走:
- 清空梯度:
zero_grad - 前向:
logits, loss = model(xb, yb) - 反向:
loss.backward()—— 计算所有参数的梯度 - 更新:
optimizer.step()—— 沿梯度方向微调参数
为什么用 AdamW?
AdamW 是 Adam 的改进版,自适应学习率 + 权重衰减分离,训练更稳。现代 Transformer 标配。
评估函数为什么需要 model.eval()?
评估时不该启用 dropout,所以要 model.eval();评估完要 model.train() 恢复。
set_to_none=True 的好处:比默认的设 0 略快、略省内存,是现代推荐做法。
四、数据流形状变化
跑一遍前向,每一层的形状:
| 步骤 | 张量 | 形状 | 说明 |
|---|---|---|---|
| 输入 | idx | [B, T] | token id |
| 词嵌入 | tok_emb | [B, T, d_model] | 每个 token 变向量 |
| 位置编码 | pos_emb | [T, d_model] | 每个位置一个向量 |
| 相加 | x | [B, T, d_model] | 广播相加 |
| Head:Q | q | [B, T, head_size] | |
| Head:K | k | [B, T, head_size] | |
| Head:分数 | wei | [B, T, T] | 每个位置对每个位置 |
| Head:输出 | out | [B, T, head_size] | 加权后的 V |
| 多头拼接 | cat | [B, T, d_model] | head_size × n_heads |
| FFN | x | [B, T, d_model] | 形状不变 |
| 最终 logits | logits | [B, T, vocab_size] | 每个位置对每个词 |
记牢这条链,任何 Transformer 变体都能套进去。
五、常见 bug 与陷阱
Bug 1:F.softmax 得到 NaN
症状:损失变成 NaN。
原因:如果一整行都是 -inf,exp(-inf) = 0,分母也是 0,得到 0/0 = NaN。
为什么我们的代码安全:因为下三角掩码保证对角线位置永远是可见的(tril[i][i] = 1),每行至少有一个非 -inf 值。
Bug 2:位置编码越界
症状:IndexError: index out of range。
原因:序列长度超过 block_size。
我们的修复:加断言 + 生成时裁剪。
Bug 3:忘记 zero_grad
症状:损失不下降,或越来越差。
原因:PyTorch 的梯度是累加的。不清零就 backward,梯度会一直加。
Bug 4:eval 时忘记 model.eval()
症状:验证损失远高于训练损失,或波动很大。
原因:dropout 在 eval 时不该生效。
Bug 5:view 和 transpose 顺序错误
症状:形状对但结果错。
正确流程:
wei = q @ k.transpose(-2, -1) # 先转置 K,再乘
六、你可以立刻做的三个实验
实验 1:改温度看效果
for temp in [0.5, 1.0, 1.5]:
out = model.generate(context, 100, temperature=temp)
print(f"温度 {temp}:", decode(out[0].tolist()))
低温:稳定重复;高温:混乱创新。
实验 2:改 n_layers
cfg = GPTConfig(vocab_size=vocab_size, n_layers=2) # 试试 2、8
看 loss 曲线和最终效果的差异。
实验 3:换语料
text = "你的文本内容..." * 100
换成一段歌词、一首诗、一段代码,看模型能学到什么。
七、核心收获
你学到的不是"一个模型",而是"一套心智模型":
- 注意力机制:Q、K、V + 掩码 + softmax + 加权求和
- 多头:多个视角并行,拼接融合
- Block:注意力 + FFN + 残差 + LayerNorm
- 堆叠:N 层,表示越来越抽象
- 训练:前向 → 损失 → 反向 → 更新
- 生成:自回归,一步一词
这套框架适用于:
- BERT(去掉因果掩码,加 MLM 训练)
- T5(加编码器 + 交叉注意力)
- LLaMA(加 RoPE、SwiGLU、RMSNorm)
- 任何新模型
你不需要记住每个模型的细节,只需要知道:它们都是这套框架的变体。
八、下一步学习路径
阶段 1:跑通本代码(你现在的状态)
- 完整运行一遍
- 修改超参数、语料,看效果
阶段 2:读经典源码
- HuggingFace
transformers的modeling_gpt2.py - Karpathy 的
nanoGPT - Meta 的
llama官方实现
阶段 3:读经典论文
- 《Attention Is All You Need》(原始 Transformer)
- 《BERT》
- 《GPT-2 / GPT-3》
- 《LLaMA》
阶段 4:深入工程
- KV Cache 实现
- FlashAttention
- 量化、LoRA、蒸馏
- 分布式训练
九、最后
纸上得来终觉浅,绝知此事要躬行。
跑通这个模型,你就真正入门了。接下来去改代码、做实验、踩坑、调试——你会在修复每一个 bug 的过程中,真正理解每个组件为什么存在。
下一次当你看到一个新模型(比如 Qwen、DeepSeek、Claude),你会发现:它们都是这套框架的变体,你已经能看懂它们的 80% 了。
剩下 20% 是工程细节——而工程细节,永远都可以查。