
这份指南面向完全没有深度学习/机器学习背景的读者。我会从最基础的概念讲起,用生活化的类比帮你理解代码背后的原理。
让 AI 自己当研究员,自动训练一个"小型 ChatGPT",不断实验、改进、迭代。
想象你有一个超级勤劳的实习生:
┌────────────────┐
人类编写 → │ program.md │ → "指挥手册",告诉 AI 怎么做实验
└────────────────┘
┌────────────────┐
AI 修改 → │ train.py │ → "实验代码",包含模型和训练逻辑
└────────────────┘
┌────────────────┐
不可修改 → │ prepare.py │ → "基础设施",数据准备和评估
└────────────────┘
LLM(Large Language Model)本质上就是一个超级强大的文字接龙机器:
输入: "今天天气真"
模型预测下一个字: "好" (概率 60%), "差" (概率 15%), "热" (概率 10%) ...
ChatGPT、Claude 等都是这样工作的 — 它们一个字一个字地"猜"下一个最合适的字,连起来就是你看到的完整回复。
训练就像教小孩学说话:
[!IMPORTANT]
这个项目训练的是一个很小的 GPT 模型(~50M 参数),而 ChatGPT 有几千亿参数。但原理完全一样!就像小孩和大人说同一种语言,只是词汇量不同。
项目用 val_bpb(validation bits per byte)来衡量模型好坏:
prepare.py 做两件事:
计算机不懂文字,只懂数字。分词器就是翻译官:
"Hello world" → [1542, 897] ← 编码(文字→数字)
[1542, 897] → "Hello world" ← 解码(数字→文字)
项目使用 BPE(Byte Pair Encoding) 分词器:
# prepare.py 第 30 行 - 关键常量
MAX_SEQ_LEN = 2048 # 模型一次能"看"多少个 token(上下文长度)
TIME_BUDGET = 300 # 训练时间预算:300秒 = 5分钟
VOCAB_SIZE = 8192 # 词表大小:8192 个 token
# prepare.py 第 41 行
BASE_URL = "https://huggingface.co/datasets/karpathy/climbmix-400b-shuffle/resolve/main"
数据来自 Karpathy 整理的 climbmix-400b 数据集,是大量混合的互联网文本(网页、书籍、代码等),分成了 6543 个 shard(分片)。
┌──────────────────────────────────────────────────────┐
│ DataLoader 工作流程 │
│ │
│ 原始文本 → 分词器编码 → 打包成批次 → 送入 GPU │
│ │
│ "Hello world" → [1542, 897] → ┐ │
│ "AI is great" → [203, 45, 78] → ├→ 一个批次(Batch)│
│ "Deep learning" → [92, 1034] → ┘ │
└──────────────────────────────────────────────────────┘
关键概念:
# prepare.py 第 276 行 - DataLoader 签名
def make_dataloader(tokenizer, B, T, split, buffer_size=1000):
# B = batch size(批次大小)
# T = sequence length(序列长度,即 MAX_SEQ_LEN = 2048)
# split = "train" 或 "val"(训练集或验证集)
┌─────────────┐
Token IDs → │ Embedding │ → 把数字变成向量
[1542, 897] └──────┬──────┘
↓
┌──────────────┐
│ Block × 8 │ → 8 层 Transformer 堆叠
│ ┌────────┐ │
│ │Attention│ │ → "看看其他位置的信息"
│ └────────┘ │
│ ┌────────┐ │
│ │ MLP │ │ → "思考、推理"
│ └────────┘ │
└──────┬──────┘
↓
┌──────────────┐
│ LM Head │ → 输出每个 token 的概率
└──────────────┘
↓
概率分布: [0.6, 0.15, 0.1, ...]
对应 token: ["好", "差", "热", ...]
# train.py 第 130 行
"wte": nn.Embedding(config.vocab_size, config.n_embd),
想象每个词是一个人,Embedding 就是给每个人一张身份证(一个向量/数组):
| Token | ID | Embedding 向量(简化示例) |
|---|---|---|
| "猫" | 42 | [0.3, -0.1, 0.8, ...] |
| "狗" | 57 | [0.4, -0.2, 0.7, ...] |
| "飞机" | 103 | [-0.5, 0.9, 0.1, ...] |
这里
n_embd = 768表示每个词的向量长度为 768维(项目中默认是 DEPTH × ASPECT_RATIO = 8 × 64 = 512,再向上对齐到 128的倍数 = 512)
# train.py 第 433-451 行 - 超参数
ASPECT_RATIO = 64 # model_dim = depth × 64
HEAD_DIM = 128 # 每个注意力头的维度
DEPTH = 8 # Transformer 层数(最重要的旋钮!)
DEVICE_BATCH_SIZE = 128 # 每次送入 GPU 的样本数
想象一个管弦乐团:
这是 Transformer 最核心的创新!
# train.py 第 61-96 行 - CausalSelfAttention
class CausalSelfAttention(nn.Module):
def __init__(self, config, layer_idx):
self.c_q = nn.Linear(...) # Query = "我在找什么?"
self.c_k = nn.Linear(...) # Key = "我有什么?"
self.c_v = nn.Linear(...) # Value = "如果你找的是我,这是我的信息"
self.c_proj = nn.Linear(...) # 输出投影
直觉类比 —— 图书馆查找:
你走进图书馆想找"如何做蛋糕"的信息:
Query (Q) = 你的问题:"蛋糕怎么做?"
Key (K) = 每本书封面的关键词:"烘焙指南"、"量子物理"、"法式甜点"...
Value (V) = 每本书的内容
注意力分数 = Q 和每个 K 的匹配度:
"烘焙指南" → 高匹配 → 多看这本书的内容
"量子物理" → 低匹配 → 几乎忽略
"法式甜点" → 中等匹配 → 看一点
最终输出 = 根据匹配度加权混合各本书的 Value 内容
"Causal"(因果) 的含义:每个位置只能看它前面的词,不能偷看后面的。因为生成时你是从左到右的,右边还没生成呢!
"今 天 天 气 真 好"
↑
这个位置只能看到 "今"
↑
这个位置能看到 "今 天"
↑
这个位置能看到 "今 天 天 气 真"
# train.py 第 40 行
window_pattern: str = "SSSL" # S=Short(半长), L=Long(全长)
不是每一层都需要看完所有前文。有些层只看最近的一半内容(S),有些看全部(L)。这样能节省计算量。
# train.py 第 52-58 行
def apply_rotary_emb(x, cos, sin):
...
模型怎么知道"第1个词"和"第100个词"的位置差异?RoPE 通过正弦/余弦函数旋转向量来编码位置信息。类比:就像在一条线上给每个座位编号一样。
# train.py 第 83-87 行
# Value residual (ResFormer): mix in value embedding with input-dependent gate per head
if ve is not None:
ve = ve.view(B, T, self.n_kv_head, self.head_dim)
gate = 2 * torch.sigmoid(self.ve_gate(x[..., :self.ve_gate_channels]))
v = v + gate.unsqueeze(-1) * ve
这是一个比较新的技巧:除了正常的 Value(来自上一层的计算结果),还额外查一张"备忘录"(直接从词嵌入得到的 Value),两者混合。这有助于信息在深层网络中更好地流动(避免"传话游戏"中信息丢失)。
# train.py 第 99-109 行
class MLP(nn.Module):
def __init__(self, config):
self.c_fc = nn.Linear(config.n_embd, 4 * config.n_embd, bias=False) # 扩展到 4 倍宽
self.c_proj = nn.Linear(4 * config.n_embd, config.n_embd, bias=False) # 压缩回去
def forward(self, x):
x = self.c_fc(x)
x = F.relu(x).square() # 激活函数:ReGLU 的变体(ReLU²)
x = self.c_proj(x)
return x
类比:Attention 是"收集信息",MLP 是"消化思考"。
relu().square() 进行非线性处理(类比:人脑中的"啊哈!"顿悟时刻,不是简单的线性运算)# train.py 第 112-121 行
class Block(nn.Module):
def forward(self, x, ve, cos_sin, window_size):
x = x + self.attn(norm(x), ve, cos_sin, window_size) # 残差连接
x = x + self.mlp(norm(x)) # 残差连接
return x
注意 x = x + ...,这叫残差连接(Residual Connection):
原始信号 ─────────────────────── + ──→ 输出
│ ↑
└→ [Attention] ─────┘
类比:你在背单词,每次不是从零开始,而是"在已知的基础上补充新信息"
norm(x) 是 RMS Norm(归一化),确保数值不会太大或太小,让训练更稳定。
# train.py 第 268-291 行 - GPT.forward()
def forward(self, idx, targets=None, reduction='mean'):
# idx = 输入的 token ID [B, T],B=批次大小,T=序列长度
x = self.transformer.wte(idx) # ① 词嵌入:[B,T] → [B,T,768]
x = norm(x)
x0 = x # 保存初始嵌入(后面要用)
for i, block in enumerate(self.transformer.h):
# ② 残差短路:每一层混合"当前信息"和"初始信息"
x = self.resid_lambdas[i] * x + self.x0_lambdas[i] * x0
# ③ 值嵌入(隔一层有一次)
ve = self.value_embeds[str(i)](idx) if str(i) in self.value_embeds else None
# ④ 通过 Transformer Block(Attention + MLP)
x = block(x, ve, cos_sin, self.window_sizes[i])
x = norm(x) # ⑤ 最终归一化
# ⑥ 输出层(预测下一个 token 的概率分布)
logits = self.lm_head(x) # [B,T,768] → [B,T,8192]
logits = logits.float()
logits = 15 * torch.tanh(logits / 15) # Softcap:防止极端值
# ⑦ 如果有标准答案,计算损失
if targets is not None:
loss = F.cross_entropy(logits.view(-1, logits.size(-1)), targets.view(-1))
return loss
return logits
一图胜千言:
Token IDs Embedding 8 × Block LM Head 概率
[42, 57] → [0.3, -0.1..] → [Attn+MLP]×8 → [logits] → softmax → [0.6, 0.1, ...]
↓
"好" (最可能)
① 模型预测下一个词 → 预测 "猫" (概率 30%)
② 正确答案其实是 "狗"
③ 计算 loss(损失)= 预测和真实答案的差距
④ 计算梯度 = loss 对每个参数的偏导数("往哪个方向调才能减小误差")
⑤ 更新参数 = 参数 - 学习率 × 梯度
重复几十万次... 模型就越来越准!
类比:你闭着眼睛走下山。
这个项目用了两种优化器组合使用,非常前沿:
| 对象 | 优化器 | 类比 |
|---|---|---|
| 嵌入层、输出层等(1D参数) | AdamW | 经典的、稳健的老司机 |
| Transformer 中间的矩阵参数(2D参数) | Muon | 新一代的、更高效的赛车手 |
# train.py 第 306-314 行
# 不只是跟着梯度走,而是:
# 1. 记住历史方向(momentum,惯性)—— 避免来回震荡
# 2. 根据每个参数的历史变化幅度自适应调整步长
# 3. 权重衰减(weight decay)—— 防止参数变得太大
Muon 是一种非常新的优化器,专门用于 2D 矩阵参数。核心思想是对梯度做正交化(Polar Express),这能更高效地利用梯度信息。
[!NOTE]
作为初学者,你只需要知道:Muon 比 AdamW 在矩阵参数上训练更快、更高效。它是 2024-2025 年才出现的前沿技术。
# train.py 第 518-525 行
WARMUP_RATIO = 0.0 # 前 0% 时间:学习率从 0 升到最大(这里没有 warmup)
WARMDOWN_RATIO = 0.5 # 后 50% 时间:学习率从最大降到 0
学习率
↑
1.0 ├─────────────────╲
│ ╲
│ ╲
0.0 └────────────────────────→ 训练进度
0% 50% 100%
←── 正常训练 ──→←─降温阶段─→
为什么?前期大步探索,后期小步微调。就像考试前复习:先快速过一遍内容,最后精细查漏补缺。
# train.py 第 543-604 行 - 简化理解版
while 训练时间 < 5分钟:
# ① 梯度累积(大批量 = 多个小批量的梯度相加)
for 每个小批次 in 梯度累积步数:
loss = 模型(输入, 标准答案) # 前向传播
loss.backward() # 反向传播(计算梯度)
加载下一个小批次数据
# ② 调整学习率
根据训练进度调整学习率
# ③ 更新参数
optimizer.step() # 用梯度更新模型参数
model.zero_grad() # 清空梯度,准备下一轮
# ④ 监控
打印(当前步数, 损失, 速度, 剩余时间)
# ⑤ 训练结束 → 在验证集上评估
val_bpb = evaluate_bpb(model, tokenizer, batch_size)
print(f"val_bpb: {val_bpb}")
# train.py 第 438 行
TOTAL_BATCH_SIZE = 2**19 # ~524K 个 token = 目标批量大小
DEVICE_BATCH_SIZE = 128 # GPU 一次只能放 128 个样本
# 自动计算:524288 / (128 × 2048) = 2 次梯度累积
grad_accum_steps = TOTAL_BATCH_SIZE // (DEVICE_BATCH_SIZE * MAX_SEQ_LEN)
类比:你想搬 100 箱货物,但车只能装 50 箱。那就跑两趟,效果一样!
step 00953 (100.0%) | loss: 3.421 | lrm: 0.00 | dt: 287ms | tok/sec: 1,826,484 | mfu: 39.8% | remaining: 0s
| 字段 | 含义 |
|---|---|
| step | 第几步 |
| loss | 当前训练损失(越低越好) |
| lrm | 学习率乘数(0~1) |
| dt | 每步耗时 |
| tok/sec | 每秒处理多少 token |
| mfu | 模型 FLOP 利用率(GPU 利用效率,39.8% 算不错) |
| remaining | 剩余训练时间 |
# prepare.py 第 344-365 行
def evaluate_bpb(model, tokenizer, batch_size):
...
return total_nats / (math.log(2) * total_bytes)
BPB 的直觉:
模型平均需要多少 "比特" 来编码文本中的每个字节?
为什么不直接用 loss?因为不同的词表大小会影响 loss 值,但 BPB 是 词表无关的,这样即使 AI 改变了词表大小,结果仍然可比。
┌────────────────────────────────────────────────────────┐
│ 人类的工作 │
│ │
│ 1. 编写 program.md(指挥手册) │
│ 2. 启动 AI 助手 + 提示它读 program.md │
│ 3. 去睡觉 😴 │
│ 4. 第二天看 results.tsv 里的实验结果 │
└────────────────────────────────────────────────────────┘
┌────────────────────────────────────────────────────────┐
│ AI 的工作 │
│ │
│ LOOP: │
│ 1. 想一个改进 idea │
│ 2. 修改 train.py │
│ 3. git commit │
│ 4. uv run train.py > run.log 2>&1 (训练 5 分钟) │
│ 5. 检查 val_bpb │
│ 6. 比之前好?→ keep(保留) │
│ 比之前差?→ git reset(回滚) │
│ 7. 记录到 results.tsv │
│ 8. 回到步骤 1 │
└────────────────────────────────────────────────────────┘
| 实验类型 | 具体例子 |
|---|---|
| 调超参数 | 调大/调小学习率、改批量大小 |
| 改架构 | 加深/减浅模型层数(DEPTH)、改注意力头数 |
| 改激活函数 | ReLU² → GELU, SiLU 等 |
| 改优化器设置 | 调 momentum、weight decay |
| 改窗口模式 | "SSSL" → "SL" → "L" |
| 加新技巧 | 新的归一化方法、不同的初始化策略 |
| 概念 | 一句话解释 | 类比 |
|---|---|---|
| Token | 文本的最小单位 | 拼图的一小块 |
| Embedding | 把 token 变成数字向量 | 给每个词发一张多维身份证 |
| Attention | 让每个位置看其他位置的信息 | 图书馆里查找相关的书 |
| MLP | 对收集到的信息做非线性变换 | 大脑的"思考"过程 |
| Residual | 跳跃连接,保留原始信息 | 背单词时在已知基础上补充 |
| Norm | 归一化,让数值保持稳定 | 音量调节器 |
| Loss | 预测和真实答案的差距 | 考试扣了多少分 |
| Gradient | loss 对参数的偏导数 | 下山时脚下的坡度方向 |
| Learning Rate | 每次更新的步长 | 下山时每步迈多大 |
| Batch Size | 一次处理多少样本 | 一次改几张试卷 |
| Epoch | 遍历完整个数据集一次 | 课本从头到尾看一遍 |
| val_bpb | 验证集上的 bits per byte | 最终考试成绩(越低越好) |
| Softcap | 限制 logits 最大值 | 给分数封顶,防止极端预测 |
| RoPE | 旋转位置编码 | 给每个座位编号 |
| Flash Attention | 高效注意力计算的 GPU 实现 | 高速公路 vs 乡间小路 |
Level 1(你现在的位置)
├─ ✅ 理解项目全貌
├─ 📖 3Blue1Brown 的《神经网络》系列视频
└─ 📖 Andrej Karpathy 的 "Neural Networks: Zero to Hero" YouTube 系列
Level 2
├─ 📖 读 "Attention Is All You Need" 论文(Transformer 原始论文)
├─ 🔧 尝试修改 train.py 中的超参数,看看效果
└─ 🔧 亲手运行一次训练(如果有 GPU 的话)
Level 3
├─ 📖 学习完整的 nanochat 仓库(本项目的简化版)
├─ 🔧 尝试自己写一个 program.md 来引导 AI 实验
└─ 📖 阅读 Muon 优化器的论文
[!TIP]
即使没有 GPU,阅读代码和理解原理本身就是极好的学习。这个项目代码极简(只有两个 Python 文件),是学习 GPT 训练的绝佳素材。