动手主线:从零预训练一个小模型
一句话定位:这是本篇唯一的动手教程——用约 75 行 PyTorch,从空模型开始预训练一个小到能在笔记本上跑的 GPT,走完 预训练 → SFT → RLHF 全流程里最烧钱的第一段,亲眼看它炼出来。前置:Attention 与 Transformer——本篇的模型就是那篇架构的最小实现;也建议先读那篇流程篇,知道每一步在全局里的位置。
先说清楚这个教程给你什么、不给你什么
读了很多讲训练的文章。
「预训练」三个字依然像黑话:数据怎么进去、损失长什么样、训练循环里每一行在干嘛——没有亲手跑过一轮,概念永远悬在空中。
这些概念到底能不能在一张桌面上跑通一次?
这个教程把「炼成」的全过程摊开在你面前:用约 75 行 PyTorch,从空模型开始预训练一个小到能在笔记本上跑的 GPT,全程不租 GPU、不花一分钱。
同时把丑话说在前面:本文炼的是一个 11 万参数的玩具(真旗舰是千亿参数),词表只有 65 个字符(真旗舰是十几万 token),训练时长约 9 秒(真旗舰以月计)。你学的是流程与手感,不是规模——但流程与手感恰恰是所有规模共享的部分。后续的 SFT、RLHF、蒸馏三段(见流程篇与后文各篇)的动手可以复用同一套骨架。
照做即可成功;如果某一步结果与本文对不上,先检查是不是漏了固定随机种子那一行。开始前先看全程地图:
图:从零预训练五步地图——备料(数据/分词/模型)、引擎(训练循环)、验收(评估与采样),单机约 9 秒全程
环境与数据(步骤①②)
装好 PyTorch(本文实测 CPU 版 2.8.0 即可,无需显卡),下载 1.1 MB 的莎士比亚戏剧选段(nanoGPT 的经典入门数据,Karpathy 整理的戏剧合集):
pip install torch # CPU 版即可跑通本教程
curl -O https://raw.githubusercontent.com/karpathy/char-rnn/master/data/tinyshakespeare/input.txt
①②两步在这一节一起做:把文本读进来、建字符级词表(这就算分词了)、按 9:1 切出训练集与验证集:
import torch, torch.nn as nn, torch.nn.functional as F
torch.manual_seed(1337) # 固定种子,保证你能复现本文全部数字
text = open('input.txt', encoding='utf-8').read()
chars = sorted(set(text)) # 全文去重后的 65 个字符(含大小写、标点、换行)
stoi = {c: i for i, c in enumerate(chars)} # 字符 → 编号
data = torch.tensor([stoi[c] for c in text], dtype=torch.long)
n = int(0.9 * len(data))
train_data, val_data = data[:n], data[n:] # 前 90% 训练,后 10% 验证
block_size = 64 # 模型每次回看的上下文长度(字符数)
def get_batch(split):
d = train_data if split == 'train' else val_data
ix = torch.randint(len(d) - block_size - 1, (32,)) # 随机抽 32 个起点
x = torch.stack([d[i:i+block_size] for i in ix])
y = torch.stack([d[i+1:i+block_size+1] for i in ix]) # 标签 = 输入右移一位
return x, y
为什么留着 10% 不给模型看:训练集上的损失只说明「背得怎么样」,验证集上的损失才说明「学得怎么样」——模型没见过的文本上表现如何,是判断「背会还是学会」的唯一公正考官。本教程 500 步里两者的差距很小(2.343 对 2.280,见步骤⑤的账本),说明还没过拟合;差距持续拉大,就是在背训练集,该停了。
为什么用字符级起步:真模型用 BPE 分词、十几万 token 词表(见分词篇),字符级只有一个 65 项的查找表——分词器的复杂性会淹没训练流程本身,而流程才是本篇的主角。代价是同样的文本要吃更多步数、生成的词会拼错,这恰好也是后文的学习素材。
搭模型(步骤③)
两层的 Decoder-only Transformer,是 Attention 与 Transformer篇那套「打分→缩放→掩码→加权」的最小实现。先写单头注意力,再组块:
class Head(nn.Module):
def __init__(self, n_embd, head_size):
super().__init__()
self.key = nn.Linear(n_embd, head_size, bias=False)
self.query = nn.Linear(n_embd, head_size, bias=False)
self.value = nn.Linear(n_embd, head_size, bias=False)
self.register_buffer('tril', torch.tril(torch.ones(block_size, block_size)))
def forward(self, x):
k, q = self.key(x), self.query(x)
wei = q @ k.transpose(-2, -1) * q.shape[-1] ** -0.5 # 打分并按键向量维度缩放
wei = wei.masked_fill(self.tril[:x.shape[1], :x.shape[1]] == 0, float('-inf'))
wei = F.softmax(wei, dim=-1) # 掩码后归一化,只许看过去
return wei @ self.value(x)
class Block(nn.Module):
def __init__(self, n_embd=64, n_head=4):
super().__init__()
self.heads = nn.ModuleList([Head(n_embd, n_embd // n_head) for _ in range(n_head)])
self.proj = nn.Linear(n_embd, n_embd)
self.mlp = nn.Sequential(nn.Linear(n_embd, 4 * n_embd), nn.GELU(), nn.Linear(4 * n_embd, n_embd)) # GELU 激活引入非线性;4x 是 FFN 常规膨胀
self.ln1, self.ln2 = nn.LayerNorm(n_embd), nn.LayerNorm(n_embd)
def forward(self, x):
x = x + self.proj(torch.cat([h(self.ln1(x)) for h in self.heads], dim=-1)) # 4 头拼接
x = x + self.mlp(self.ln2(x))
return x
再包上嵌入层与输出层,就是一个完整的语言模型:
class MiniGPT(nn.Module):
def __init__(self, vocab_size, n_embd=64, n_head=4, n_layer=2):
super().__init__()
self.tok_emb = nn.Embedding(vocab_size, n_embd) # 字符 → 向量
self.pos_emb = nn.Embedding(block_size, n_embd) # 位置 → 向量
self.blocks = nn.Sequential(*[Block(n_embd, n_head) for _ in range(n_layer)])
self.ln_f = nn.LayerNorm(n_embd)
self.lm_head = nn.Linear(n_embd, vocab_size) # 向量 → 65 个候选的打分(logits)
def forward(self, idx):
B, T = idx.shape
x = self.tok_emb(idx) + self.pos_emb(torch.arange(T))
return self.lm_head(self.ln_f(self.blocks(x)))
model = MiniGPT(len(chars))
print(sum(p.numel() for p in model.parameters())) # 112193
代码里有两个小设计值得对照架构篇看:每个块里的 x = x + … 是残差连接——原始信息直通,块只往上加增量,层数叠深了梯度也能从捷径流回去;两个 LayerNorm 分别立在注意力与 MLP 加工之前(加工前的质检)。4 个头各管 64÷4=16 维,「拆维度不拆计算」,让不同头各自盯不同的模式(有的盯韵脚、有的盯角色名格式)。模型总参数 11.2 万——真旗舰是它的数百万倍,但逐部件对照,结构一样。
训练循环(步骤④)
训练的全部引擎就六行:取一批数据、前向、算损失、清零梯度、反向、更新——「预测下一个字符」的交叉熵损失(衡量猜得准不准的标准损失)驱动一切:
opt = torch.optim.AdamW(model.parameters(), lr=1e-3)
for step in range(500):
x, y = get_batch('train')
logits = model(x)
loss = F.cross_entropy(logits.view(-1, len(chars)), y.view(-1))
opt.zero_grad(set_to_none=True)
loss.backward()
opt.step()
if step % 100 == 0:
with torch.no_grad(): # 评估时不积累梯度
vx, vy = get_batch('val')
vloss = F.cross_entropy(model(vx).view(-1, len(chars)), vy.view(-1))
print(f"step {step}: train {loss.item():.3f} val {vloss:.3f}")
# 循环结束后再各取一批做终评(得到账本里 step 500 那一行)
with torch.no_grad():
tx, ty = get_batch('train'); vx, vy = get_batch('val')
print(f"step 500: train {F.cross_entropy(model(tx).view(-1, len(chars)), ty.view(-1)):.3f}",
f" val {F.cross_entropy(model(vx).view(-1, len(chars)), vy.view(-1)):.3f}")
注意评估那几行包在 torch.no_grad() 里——验证集只用来「看成绩」,不该在它上面积累梯度。
这六行在真旗舰上就是万卡集群跑几个月的那件事——「预测下一个 token」的损失、梯度、更新,从 11 万参数到千亿参数引擎结构完全一样,变的只是数据量、模型尺寸和工程包装。
真训练还会在开头加预热(前几百步用小学习率起步,防随机初始化阶段的坏梯度带偏方向)与调度(随训练推进逐步降学习率,后期细调)——本教程 500 步太短,用不上,但它们是下一节那张学习率对照表在真实尺度上的延伸:预热先小后大,调度见顶回落。
验收:困惑度与采样(步骤⑤)
损失是交叉熵,对它取指数就是困惑度——「模型在每个位置平均在多少个候选里犹豫」。随机初始化时模型对 65 个字符一无所知,困惑度应接近 65(实测 78.3,略高于 65 的原因见自测);训练后实测降到 10.4。困惑度的局限也值得记一笔:它衡量「模仿文本的统计规律像不像」,不衡量「答案对不对」——本篇模型困惑度再低也只会续写戏剧,不会答数学题;比较两个模型时只有在同一份词表与数据上才有意义。
import math
# 实测记录(固定种子下,各步数值随每次抽批小幅浮动,看量级与趋势即可):
# step 0: train 4.361 val 4.381 → 困惑度 78.3 / 79.9
# step 500: train 2.343 val 2.280 → 困惑度 10.4 / 9.8
print(math.exp(2.343)) # 10.4
再让模型自由生成一段,眼见为实。同一个「新生模型」(训练 0 步)只会输出均匀乱抽的乱码;500 步后的模型已经学出了戏剧的格式。提醒一点:下面这段生成代码要跑两次——训练循环前跑一遍留作对照,训练后再跑一遍:
idx = torch.zeros((1, 1), dtype=torch.long) # 从换行符(编号 0)开始
for _ in range(200):
logits = model(idx[:, -block_size:]) # 上下文最多回看 64 个字符
probs = F.softmax(logits[:, -1, :], dim=-1)
idx = torch.cat([idx, torch.multinomial(probs, 1)], dim=1) # 按概率抽下一个字符
print(''.join(chars[i] for i in idx[0].tolist()))
实测输出(节选)。训练前:
KWpvutIKgS!RYiwQgJnW$Afiz3Rc pyUVFF!z$YnMav3fZISp.TK$LiW-Xu;
eP-UOiLu:CIIInu,SuUh-uMiAUOuIzIC:dqpDW?&;eriiPowDmZPnf,ffVD
训练后:
HARI,d cco peeaves$, buld fre Tue.
SERNIUSEAW:
D wingh, thutuks whilf the are, I my peanelf th, hameatl
Pugos sow my th, cathebeforreat we tord?
读这份输出能读出模型学到了什么、还差什么:冒号与换行的戏剧排版已到位,角色名格式(全大写开头)也学到了,单词是「像英语的拼错词」(peeaves、hameatl)——65 字符词表 + 11 万参数 + 约 9 秒(机型相关)训练,就到这里的性价比顶点了。要拼对词,得更大模型、更多步数、更好的分词——那些是规模的事,不是流程的事。
调参实战:亲手撞一次「学习率」的墙
固定其他条件(每组都从同一随机种子重新初始化模型),只动学习率跑五档,各看 200 步内验证集损失,实测:
| 学习率 | 200 步内实测 | 诊断 |
|---|---|---|
| 0.1 | 3.03 → 3.13,卡住震荡 | 太大:步子太大,在谷底两侧来回跳 |
| 0.05 | 2.66 → 2.67,卡住震荡 | 偏大:同样卡住 |
| 0.01 | 2.34 → 2.14(最快) | 本教程里恰好快 |
| 0.001 | 2.61 → 2.48(标准) | 甜点位 |
| 0.0001 | 3.50 → 3.26(偏慢) | 太小:步子太碎,同样的步数走不到 |
两个值得记下的真实观察:
- 「学习率大会崩」在玩具上不成立——0.01 比 0.001 还快,0.1 也没有爆炸成 NaN,只是卡住震荡;「过大」的真实形态是「loss 不降了」,不是报错。
- 判断方法就藏在前 100 步:降得快还能继续降 → 合适;卡住震荡 → 调大过头;稳步下降但斜率太小 → 太小。
真实预训练的坑多在数据(这是规模放大后的事,下一节展开),但超参坑恰恰是最容易提前发现的一类——每几百步看一眼验证集损失,曲线就是仪表盘。
图:学习率五档实测曲线——过大卡住震荡、标准稳步下降、过小斜率不足;诊断窗口就在前 100 步
边界与取舍:这个教程不能给你什么
规模差三个以上数量级时,真正的新问题出在数据
本文的「数据管线」是一行读文件;真预训练的数据管线是采集、清洗、去重、配比的全套工程(见流程篇),而且坑多在数据不在超参:配比决定偏科、去重不足让模型背题、低质数据拉低上限——这些坑在玩具上根本暴露不出来,因为 1 MB 干净文本没有这些病。提前发现的手段随规模换装:超参坑看曲线(本篇方法通用于任何规模),数据坑靠抽样人检与验证集行为差异(玩具用不上,真训练的第一天就该做)。
从笔记本到万卡集群,中间还隔着三件工程
三件都不改变训练引擎的六行代码——它们是「把同一引擎放大」的代价:
- 数据并行:模型拷贝到几百张卡、各吃各的数据批,每步同步一次梯度。本教程的「一批 32 条」在集群上是「每张卡一批、全局几千条」,具体切法见并行训练篇。
- 检查点:训练以天或月计,必须每隔几分钟把权重与优化器状态存盘,机器挂了从存档续跑。流程篇讲过的优化器状态为什么占显存,在这里就是为什么检查点那么大。
- 监控与容错:损失曲线、梯度范数、硬件健康全要进仪表盘,坏卡要能自动摘除。
再往上走的路线图
想继续升级:把字符级换 BPE 分词(见分词篇的动手);把 2 层换 6~12 层并上 GPU;数据换成领域语料——这就是一条生产级小模型的预训练线,市面上「PyTorch 预训练亿级 GPT」类的实战教程走的就是这条路,本篇的五步地图在亿级参数上同样适用,只是每一格都变大。Karpathy 的 nanoGPT 与其配套课程是这条路线的标准路标(见参考)。
「每一格都变大」有个震撼的参照:2019 年 OpenAI 训练 GPT-2 花了约 4 万美元;2024 年 Karpathy 的开源项目 llm.c 用一台 8 卡 H100 机器、24 小时、672 美元把 15 亿参数的满血版 GPT-2 复现到位——数据管线、硬件、训练软件三头进步,训练成本跌到原来的约 1.7%。你本文盯着的那条 loss 下降曲线,和大厂训练监控里看的是同一种东西,只是格子的数量级不同。
坏味道预警:拿验证集调参调到过拟合
验证集是「考试卷」,看它调超参没问题,但盯着同一个验证集优化几十轮,它就变成了训练集的一部分——真正的新考卷要留到最后(或者干脆从未见过)。本文 500 步没有这个问题,规模一大就容易犯。
自测
合上文章先自己答,再展开对照:
从零预训练最大的坑在数据还是在超参?怎么提前发现?
按规模分层回答。玩具级(本教程):坑几乎全在超参,尤其学习率——实测 0.1 卡在 3.0~3.1 震荡不降、0.05 卡在 2.67、0.0001 降得慢(3.50→3.26),判断只看前 100 步曲线:卡住震荡=太大、斜率太小=太小,曲线就是仪表盘。生产级:真正的新问题多在数据(配比偏科、去重不足、低质拉低上限),因为超参经验与监控手段已经成熟而数据病在玩具上暴露不出来;提前发现靠两件事——训练第一天就建立抽样人检(肉眼读数据)与验证集行为监控(验证集与训练集行为差异拉大时先怀疑数据),再加上每几百步看验证集损失的仪表盘习惯(这条通用于任何规模)。所以答案是:坑的重心随规模从超参移向数据,但「看曲线」这个发现手段两端通用。
困惑度 10.4 是什么意思?为什么初始是 78.3 而不是恰好的 65?
困惑度 = 交叉熵损失的指数,含义是「模型在每个位置平均在多少个候选里犹豫」——10.4 表示模型平均在约 10 个候选里做选择,比 65 个字符的瞎猜好很多。初始实测 78.3 而不是 65 的原因:65 是「完全均匀分布」对应的困惑度(损失 ln 65 ≈ 4.17),但交叉熵量的是「真实字符在模型分布下的负对数似然」,不是模型自己分布的熵——随机初始化的打分高低与真实答案错位,概率常常押在错误候选上,押错处的重罚把期望损失推到 ln 65 之上(实测 4.361),取指数就是 78.3。验证集与训练集的困惑度在 500 步内都徘徊在 10 上下、差距小于估计噪声,是正常现象;两者差距持续拉大才是过拟合信号。
训练 500 步后的模型学到了什么、还差什么?依据是什么?
学到的:戏剧的排版格式(冒号后换行)、角色名的全大写格式、英语的字符级统计规律——依据是实测生成文本「SERNIUSEAW: / D wingh, thutuks…」的结构。还差的:词的正确拼写(peeaves、hameatl 这类「像英语的拼错词」)与真实的语义。差的部分不是流程缺失而是规模不足:65 字符词表迫使模型逐字符拼写、11 万参数记不住词形、1 MB 数据约 9 秒训练喂不饱语义。要补齐得换 BPE 分词、加大模型与数据——流程不变,规模变大。
参考与延伸
- Karpathy《nanoGPT》(GitHub,2022 起):本篇五步地图的生产级放大版,300 行内可读完整实现 — github.com/karpathy/nanoGPT
- Karpathy《Let’s build GPT: from scratch, in code, spelled out》(视频,2023):与本篇同路线的完整跟做课(约 2 小时) — www.youtube.com/watch?v=kCc8FmEb1nY
- Karpathy《Neural Networks: Zero to Hero》系列课程页:从反向传播到 GPT 的完整学习路径 — karpathy.ai/zero-to-hero.html
- Karpathy 的 llm.c 讨论帖《Let’s reproduce GPT-2 (1.6B)》(2024-07):8 卡 H100、24 小时、672 美元复现满血版 GPT-2 的原始记录 — github.com/karpathy/llm.c/discussions/677
- tiny shakespeare 数据集(本篇所用,1.1 MB) — github.com/karpathy/char-rnn
下一站:推理优化——模型炼成之后,怎么把它跑得又快又便宜:KV Cache、Continuous Batching 与 PagedAttention 的推理优化三件套。