从零实现一个最小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最多能看多少个 tokenGPT-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)}
    ...

三步走:

  1. 建词表:把所有出现过的字符收集起来,排序 → 词表
  2. 建映射:字符 → id(stoi)和 id → 字符(itos)
  3. 编码:整个文本 → 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

流程:

  1. 每个头独立计算 → 每个输出 [B, T, head_size]
  2. 在最后一维拼接 → [B, T, n_heads × head_size] = [B, T, d_model]
  3. 过一个线性层 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

三个关键设计:

  1. 残差连接 x + ...

    • 把输入原样加回来
    • 让梯度更容易传递
    • 让每层只学"增量"
    • 96 层也能稳定训练
  2. 预归一化(Pre-LN)

    • LayerNorm 放在子层之前
    • 原始论文是 Post-LN(之后)
    • 现代模型普遍用 Pre-LN,训练更稳定
  3. 顺序不能颠倒

    • 先注意力 → 跨词交流
    • 后 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_embeddingtoken id → 向量
position_embedding位置 id → 向量
blocksN 层 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

逐行解释:

  1. 裁剪:模型最多能看 block_size 个 token,超了要裁掉
  2. 前向:跑一遍模型
  3. 取最后一个位置:logits[:, -1, :] 是"下一个词的预测"
  4. 温度缩放:控制随机性(后文详述)
  5. softmax:把分数变成概率
  6. 采样:按概率随机抽一个
  7. 拼接:把预测的词加到序列末尾
  8. 循环:预测下一个……

为什么是"自回归"?

因为把输出喂回输入——这就是"自己回归到自己"。

为什么生成慢?

每一步都要跑完整的一次前向。生成 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()

四步走:

  1. 清空梯度:zero_grad
  2. 前向:logits, loss = model(xb, yb)
  3. 反向:loss.backward() —— 计算所有参数的梯度
  4. 更新: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:Qq[B, T, head_size]
Head:Kk[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
FFNx[B, T, d_model]形状不变
最终 logitslogits[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

换成一段歌词、一首诗、一段代码,看模型能学到什么。


七、核心收获

你学到的不是"一个模型",而是"一套心智模型":

  1. 注意力机制:Q、K、V + 掩码 + softmax + 加权求和
  2. 多头:多个视角并行,拼接融合
  3. Block:注意力 + FFN + 残差 + LayerNorm
  4. 堆叠:N 层,表示越来越抽象
  5. 训练:前向 → 损失 → 反向 → 更新
  6. 生成:自回归,一步一词

这套框架适用于:

  • 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% 是工程细节——而工程细节,永远都可以查。