训练与对齐 · 从零预训练
学习舱 / 训练与对齐 / 从零预训练

动手主线:从零预训练一个小模型

整理中 深度 · 应用 更新 2026-09-09

前置 预训练全流程、Attention

动手主线:从零预训练一个小模型

一句话定位:这是本篇唯一的动手教程——用约 75 行 PyTorch,从空模型开始预训练一个小到能在笔记本上跑的 GPT,走完 预训练 → SFT → RLHF 全流程里最烧钱的第一段,亲眼看它炼出来。前置:Attention 与 Transformer——本篇的模型就是那篇架构的最小实现;也建议先读那篇流程篇,知道每一步在全局里的位置。

先说清楚这个教程给你什么、不给你什么

读了很多讲训练的文章。

「预训练」三个字依然像黑话:数据怎么进去、损失长什么样、训练循环里每一行在干嘛——没有亲手跑过一轮,概念永远悬在空中。

这些概念到底能不能在一张桌面上跑通一次?

这个教程把「炼成」的全过程摊开在你面前:用约 75 行 PyTorch,从空模型开始预训练一个小到能在笔记本上跑的 GPT,全程不租 GPU、不花一分钱。

同时把丑话说在前面:本文炼的是一个 11 万参数的玩具(真旗舰是千亿参数),词表只有 65 个字符(真旗舰是十几万 token),训练时长约 9 秒(真旗舰以月计)。你学的是流程与手感,不是规模——但流程与手感恰恰是所有规模共享的部分。后续的 SFT、RLHF、蒸馏三段(见流程篇与后文各篇)的动手可以复用同一套骨架。

照做即可成功;如果某一步结果与本文对不上,先检查是不是漏了固定随机种子那一行。开始前先看全程地图:

从零预训练的五步地图(本篇编号系统) ① 数据管线 读入 1.1 MB 文本 9:1 切训练/验证 ② 分词 字符级起步 65 字符词表 ③ 模型 2 层 Transformer 11.2 万参数 ④ 训练循环 预测下一字符 AdamW × 500 步 ⑤ 评估 困惑度 采样对比 ④的循环内容:取一批数据 → 前向 → 算交叉熵损失 → 清零梯度 → 反向 → 更新——六行就是「预训练」的全部引擎 实测账单:笔记本 CPU 约 9 秒跑完 500 步;loss 4.361 → 2.343;困惑度 78.3 → 10.4 读法:①②③是备料,④是引擎,⑤是验收——五步走完你就见过「预训练」这个词的全部含义

图:从零预训练五步地图——备料(数据/分词/模型)、引擎(训练循环)、验收(评估与采样),单机约 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.13.03 → 3.13,卡住震荡太大:步子太大,在谷底两侧来回跳
0.052.66 → 2.67,卡住震荡偏大:同样卡住
0.012.34 → 2.14(最快)本教程里恰好快
0.0012.61 → 2.48(标准)甜点位
0.00013.50 → 3.26(偏慢)太小:步子太碎,同样的步数走不到

两个值得记下的真实观察:

  • 「学习率大会崩」在玩具上不成立——0.01 比 0.001 还快,0.1 也没有爆炸成 NaN,只是卡住震荡;「过大」的真实形态是「loss 不降了」,不是报错。
  • 判断方法就藏在前 100 步:降得快还能继续降 → 合适;卡住震荡 → 调大过头;稳步下降但斜率太小 → 太小。

真实预训练的坑多在数据(这是规模放大后的事,下一节展开),但超参坑恰恰是最容易提前发现的一类——每几百步看一眼验证集损失,曲线就是仪表盘。

学习率五档实测(前 200 步验证集损失) 训练步数 → 验证集损失 lr=0.01 → 2.14 lr=0.001 → 2.48 lr=0.0001 → 3.26 lr=0.1 → 卡住震荡 lr=0.05 → 卡在 2.67 读法:红线卡住不降是「过大」的真实形态(不是报错);蓝实线是甜点;黄虚线降得慢是「过小」——判断只看前 100 步

图:学习率五档实测曲线——过大卡住震荡、标准稳步下降、过小斜率不足;诊断窗口就在前 100 步

边界与取舍:这个教程不能给你什么

规模差三个以上数量级时,真正的新问题出在数据

本文的「数据管线」是一行读文件;真预训练的数据管线是采集、清洗、去重、配比的全套工程(见流程篇),而且坑多在数据不在超参:配比决定偏科、去重不足让模型背题、低质数据拉低上限——这些坑在玩具上根本暴露不出来,因为 1 MB 干净文本没有这些病。提前发现的手段随规模换装:超参坑看曲线(本篇方法通用于任何规模),数据坑靠抽样人检与验证集行为差异(玩具用不上,真训练的第一天就该做)。

从笔记本到万卡集群,中间还隔着三件工程

三件都不改变训练引擎的六行代码——它们是「把同一引擎放大」的代价:

  1. 数据并行:模型拷贝到几百张卡、各吃各的数据批,每步同步一次梯度。本教程的「一批 32 条」在集群上是「每张卡一批、全局几千条」,具体切法见并行训练篇。
  2. 检查点:训练以天或月计,必须每隔几分钟把权重与优化器状态存盘,机器挂了从存档续跑。流程篇讲过的优化器状态为什么占显存,在这里就是为什么检查点那么大。
  3. 监控与容错:损失曲线、梯度范数、硬件健康全要进仪表盘,坏卡要能自动摘除。

再往上走的路线图

想继续升级:把字符级换 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 分词、加大模型与数据——流程不变,规模变大。

参考与延伸

下一站:推理优化——模型炼成之后,怎么把它跑得又快又便宜:KV Cache、Continuous Batching 与 PagedAttention 的推理优化三件套。

本节点大纲与自测题(4 个子点 · 1 问)

大纲

  • nanoGPT 路线(Karpathy zero-to-hero)
  • 数据管线:采集、清洗、配比、分词
  • 训练循环、超参与调参、困惑度评估
  • 参照:「PyTorch 预训练亿级 GPT」实战线

自测——答不上来就是缺口

  • 从零预训练最大的坑在数据还是在超参?怎么提前发现?