预训练 Pretraining
阶段 3 · 让模型学会"预测下一个字"——所有大语言模型最核心的能力
婴儿如何学语言
一个婴儿从零开始学会说话,经历三步:
一开始:什么都听不懂
只是不停地听大人说话。
听到海量的声音,毫无理解。
慢慢地:发现规律
"天"后面可能跟"气"或"空"。
"今天天气"后面大概率是好或差。
最终:能预测下文
根据前面的内容,预测接下来会说什么。
这不就是 ChatGPT 在做的事吗?
预训练做的正是这件事:让模型阅读海量文本,学会"根据前文预测下一个字"。这是所有 LLM 的基础能力,没有它,模型连一句完整的话都说不出来。
训练三阶段
大模型训练分三个阶段,就像培养一个人才:
图:大模型训练三阶段流程
【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:三个圆角矩形从左到右排列,用粗箭头依次连接。第一个"预训练 Pretraining"(橙色底,加粗边框),第二个"监督微调 SFT"(绿色底),第三个"RLHF / DPO"(蓝色底) - 各元素关系:从左到右单向箭头流转,箭头代表阶段递进关系,第一阶段用加粗边框突出"当前正在学习" - 关键标注:每个矩形下方标注类比——预训练="通识教育"、SFT="专业培训"、RLHF="品格塑造" - 配色建议:浅灰背景,预训练橙色(#FFE0B2)加粗边框突出,SFT绿色(#C8E6C9),RLHF蓝色(#BBDEFB) - 画面比例:16:9 - 避免:过度装饰、3D 效果、杂乱的颜色
| 阶段 | 类比 | 目标 | 数据 |
|---|---|---|---|
| 预训练 | 通识教育 | 学会语言规律 | 海量无标注文本 |
| 监督微调 | 专业培训 | 学会对话格式 | 问答对数据 |
| RLHF/DPO | 品格塑造 | 符合人类偏好 | 人类评分数据 |
你现在学的就是第一步——预训练。它消耗最多算力和数据,决定了模型的"天花板"。
Part 1: 核心思想
理解预训练的本质:Next Token Prediction。这是所有自回归语言模型的统一训练目标。
在写任何训练代码之前,我们必须先想清楚一个问题:语言模型到底在"学"什么?答案出人意料地简单——预测下一个 token。不管是 GPT-4 还是 MiniMind,训练目标都是同一个:给模型一段文字,让它猜下一个字是什么。这一部分的目标就是把这个核心思想吃透,理解"一次前向传播 = 所有位置同时在学习"这个关键洞察。
- Next Token Prediction —— 预测下一个字,自回归语言模型唯一的训练目标
- 滑动预测全景 —— 一次前向传播中,序列的每个位置都在同时练习预测下一个 token
- 输入与目标对齐 —— logits[:-1] 和 labels[1:] 的 shift 操作,确保预测和目标一一对应
- 对齐代码溯源 —— 回到模型代码中找到 shift 操作的具体实现,打通理论和代码
预测下一个字
模型的任务:给定前面的字,预测下一个字
定义:Next Token Prediction——给定一个 token 序列,模型对每个位置输出一个概率分布,预测下一个 token 是什么。
| 已知内容(输入) | 要预测的下一个字 |
|---|---|
| 今 | 天 |
| 今天 | 天 |
| 今天天 | 气 |
| 今天天气 | 真 |
| 今天天气真 | 好 |
白话:就像手机输入法的"联想输入"——你打了"今天天",它猜下一个字是"气"。但模型要同时在整个序列的每个位置做这件事。
模型并不是一次只看一个字。它同时处理整个序列,对每个位置都做一次"预测下一个字"的练习。一次前向传播 = N 次预测练习。
滑动预测全景
一次前向传播,所有位置都在练习
图:Next Token Prediction 滑动预测
【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:顶部一行"今 天 天 气 真 好",下方5个并排预测框 - 每个框左侧高亮已见部分(蓝色),右侧问号指向预测结果(橙色) - [今]→天? [今天]→天? [今天天]→气? [今天天气]→真? [今天天气真]→好? - 底部标注"一次前向传播 = 5次预测练习" - 右侧显示每个位置的交叉熵损失箭头指向正确答案(绿色) - 配色建议:已见token蓝色,预测目标橙色,正确答案绿色 - 画面比例:16:9 - 避免:过度装饰、3D 效果
关键洞察:序列长度为 N 时,一次前向传播产生 N-1 个训练信号。这就是 Transformer 高效的原因。
这里用"婴儿学说话"的比喻只是帮你建立直观。在计算机内部,它实际上是矩阵乘法 + softmax,对词表中每个 token 计算概率。
输入与目标对齐
logits 取前 N-1 个,labels 取后 N-1 个,逐位比较
输入: [你, 好, 世, 界] (input_ids)
预测: [好', 世', 界', ?' ] (logits)
取 [:-1] ↑ ↑ ↑
标签: [好, 世, 界, ! ] (labels)
取 [1:] ↑ ↑ ↑
交叉熵 = 比较每个位置的预测与真实值
白话:模型看了"你",要预测"好";看了"你好",要预测"世"。关键操作是——
| 操作 | 含义 |
|---|---|
logits[..., :-1, :] | 去掉最后一个位置的预测(没有对应标签) |
labels[..., 1:] | 去掉第一个位置的标签(不需要预测它) |
这不就是上一章 MiniMindForCausalLM.forward 里的 shift 操作吗?没错——模型架构已经写好了预测逻辑,现在要做的是:喂数据、算损失、更新参数。
对齐代码溯源
回顾模型 forward 中的 shift 操作
# model_minimind.py 中的 forward 方法(简化)
def forward(self, input_ids, labels=None):
# 1. 词嵌入 + 位置编码
x = self.model(input_ids)
# 2. Transformer 层
x = self.lm_head(x) # (batch, seq_len, vocab_size)
if labels is not None:
# 3. 关键:移位对齐
loss = F.cross_entropy(
x.view(-1, x.size(-1))[..., :-1, :],
labels.view(-1)[..., 1:]
)
return CausalLMOutput(loss=loss, logits=x)
三步走:
- 定义:shift 操作让预测和标签错位对齐。
- 白话:就像批改作业——第 i 个位置的学生答案(logits[i])对第 i+1 个位置的标准答案(labels[i+1])。
- 代码:
logits[..., :-1, :]与labels[..., 1:]逐位计算交叉熵。
Part 2: 数据处理
把原始文本变成模型能吃的数字。理解 PretrainDataset 的完整流水线。
模型再好,没有数据也白搭。这一部分我们来看 MiniMind 的数据是怎么从原始文本变成训练用的 tensor 的。整个流水线只有四步——分词、加起止标记、填充对齐、构造 labels——但每一步都有它的道理。特别是 labels 中为什么要用 -100 标记填充位置,这个细节直接关系到损失计算的正确性。我们会用一个完整的数字例子走一遍,让你看得清清楚楚。
- 数据文件格式 —— JSONL 格式的训练数据长什么样,每条数据的结构
- PretrainDataset 源码 —— 完整阅读数据集类的代码,理解 __getitem__ 的全流程
- 四步处理流水线 —— 分词 → 加 BOS/EOS → PAD 填充 → labels 中 PAD 位置填 -100
- 为什么是 -100 —— PyTorch 的 ignore_index 机制,让填充位置不参与损失计算
- max_length 的取舍 —— 序列长度如何影响显存、训练速度和上下文能力
数据文件格式
JSONL——每行一个 JSON 对象,简单直接
{"text": "今天天气真好,我们去公园玩吧。"}
{"text": "人工智能是计算机科学的一个分支。"}
{"text": "深度学习是机器学习的子领域,它使用多层神经网络……"}
| 要求 | 说明 |
|---|---|
| 格式 | JSONL(每行一个 JSON) |
| 字段 | 必须有 "text" 字段 |
| 编码 | UTF-8 |
| 数据量 | 越大越好,MiniMind 用了数百万行 |
预训练数据不需要任何标注。只要是大段高质量的文本就行——百科、新闻、书籍、代码都可以。
数据集类源码
PretrainDataset:把原始文本变成训练样本
class PretrainDataset(Dataset):
def __init__(self, data_path, tokenizer, max_length=512):
super().__init__()
self.tokenizer = tokenizer
self.max_length = max_length
# 用 HuggingFace load_dataset 加载 JSONL 文件
self.samples = load_dataset('json', data_files=data_path, split='train')
def __len__(self):
return len(self.samples)
def __getitem__(self, index):
sample = self.samples[index]
# 第1步:分词,留2个位置给 BOS 和 EOS
tokens = self.tokenizer(str(sample['text']),
add_special_tokens=False,
max_length=self.max_length - 2,
truncation=True).input_ids
# 第2步:加 BOS(开头)和 EOS(结尾)
tokens = [self.tokenizer.bos_token_id] + tokens + [self.tokenizer.eos_token_id]
# 第3步:PAD 填充到固定长度
input_ids = tokens + [self.tokenizer.pad_token_id] * (self.max_length - len(tokens))
input_ids = torch.tensor(input_ids, dtype=torch.long)
# 第4步:labels = input_ids 的副本,PAD 位置设为 -100
labels = input_ids.clone()
labels[input_ids == self.tokenizer.pad_token_id] = -100
return input_ids, labels
四步处理流水线
从原始文本到训练样本的完整路径
图:PretrainDataset 四步处理流水线
【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:从左到右4个圆角矩形,用粗箭头连接。①"原始文本"(示例:你好世界)→ ②"分词"(输出数字 [456, 789, 234])→ ③"加 BOS/EOS"(输出 [1, 456, 789, 234, 2])→ ④"PAD 填充"(输出 [1, 456, 789, 234, 2, 0, 0, 0]) - 各元素关系:从左到右依次处理,每步的输出作为下一步的输入 - 关键标注:BOS 标蓝色并标注"序列开始",EOS 标红色并标注"序列结束",PAD 标灰色并标注"填充" - 配色建议:浅蓝背景,BOS蓝色,EOS红色,PAD灰色,步骤编号用橙色圆圈标注 - 画面比例:16:9 - 避免:过度装饰、3D 效果、杂乱的颜色
图:PretrainDataset 处理流水线
【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:从左到右4步流水线:①原始文本→②分词[456,789,...]→③加BOS/EOS [1,...,2]→④PAD填充 [1,...,2,0,0] - 第4步下方两条输出箭头:input_ids 和 labels(PAD位置为-100) - BOS蓝色框标注"序列开始",EOS红色框标注"序列结束",PAD灰色框标注"填充",-100橙色框标注"交叉熵忽略" - 每步之间用粗箭头连接,箭头上标注操作名 - 配色建议:浅蓝背景,BOS蓝色,EOS红色,PAD灰色,-100橙色 - 画面比例:16:9 - 避免:过度装饰、3D 效果
每一步的目的:
- 分词:文字→数字,模型只认数字。
- 加 BOS/EOS:告诉模型"从这开始"和"到这结束"。
- PAD 填充:凑齐固定长度,方便组成 batch。
- labels 中 PAD→-100:让损失函数忽略填充部分。
完整数字例子
假设数据是 "你好",max_length = 8,BOS=1,EOS=2,PAD=0
| 步骤 | 内容 | 说明 |
|---|---|---|
| ① 原始文本 | "你好" | 从 JSONL 中取出 |
| ② 分词后 | [456, 789] | tokenizer 把文字变成数字 |
| ③ 加特殊标记 | [1, 456, 789, 2] | BOS=1 开头, EOS=2 结尾 |
| ④ 填充到长度 8 | [1, 456, 789, 2, 0, 0, 0, 0] | PAD=0 填满 |
| input_ids | [1, 456, 789, 2, 0, 0, 0, 0] | 送入模型 |
| labels | [1, 456, 789, 2, -100, -100, -100, -100] | PAD 变 -100 |
cross_entropy 函数默认 ignore_index=-100。所有 label 为 -100 的位置不参与损失计算。这样模型就不会在填充内容上浪费学习。
为什么是 -100?
深入理解 ignore_index 机制
# PyTorch 源码中 cross_entropy 的核心逻辑(简化)
def cross_entropy(input, target, ignore_index=-100):
loss = 0
count = 0
for i in range(len(target)):
if target[i] == ignore_index:
continue # 直接跳过,不计入损失
loss += -log(softmax(input[i])[target[i]])
count += 1
return loss / count # 只对有效位置求平均
| 位置 | input_ids | labels | 是否计入损失 |
|---|---|---|---|
| 0 | 1 (BOS) | 1 | 是 |
| 1 | 456 | 456 | 是 |
| 2 | 789 | 789 | 是 |
| 3 | 2 (EOS) | 2 | 是 |
| 4 | 0 (PAD) | -100 | 否 |
| 5 | 0 (PAD) | -100 | 否 |
如果不设 -100,模型会努力预测填充位置也是 0,浪费大量学习能力在无意义的数据上。这个机制确保模型只从真实文本中学习。
max_length 取舍
max_length 影响显存和训练效果
| max_length | 显存占用 | 训练信号 | 适用场景 |
|---|---|---|---|
| 256 | 低 | 短文本足够 | 快速实验 |
| 340(默认) | 中等 | 平衡 | MiniMind 默认 |
| 512 | 高 | 长文本更好 | 显存充裕时 |
| 1024 | 很高 | 完整长文 | SFT 阶段常用 |
MiniMind 默认 max_seq_len=340。中文约 1 token ≈ 1.5~1.7 个字符,340 token 约覆盖 500+ 字的段落。
max_length=self.max_length - 2,因为还要塞进 BOS 和 EOS。如果忘记减 2,最终序列会超长。
Part 3: 训练循环
精讲 train_epoch 函数的每一行。理解余弦调度、梯度累积、混合精度、梯度裁剪。
数据准备好了,接下来就是训练的核心环节。这一部分我们会逐行精讲 train_epoch 函数,搞懂训练循环中的六大阶段:前向传播 → 损失计算 → 反向传播 → 梯度累积 → 梯度裁剪 → 参数更新。除此之外,还有几个让训练又快又稳的关键技巧——余弦学习率调度让学习率从高到低平滑衰减、混合精度训练用 float16 省显存、梯度累积在显存不够时模拟更大的 batch size。每一个技巧都值得单独理解。
- 训练主循环源码 —— 逐行阅读 train_epoch 函数,理解每个变量和每步操作的含义
- 余弦学习率调度 —— 学习率从高到低按余弦曲线衰减,训练后期更精细地调参
- 梯度累积 —— 多个小 batch 的梯度累加后再更新,等效于更大的 batch size
- 混合精度训练 —— float32 / float16 / bfloat16 三种精度对比,GradScaler 如何防止梯度下溢
- 梯度裁剪与尾部处理 —— 限制梯度范数防止爆炸,以及累积最后一步残余梯度的细节
训练主循环源码
train_pretrain.py 中的 train_epoch 函数
def train_epoch(epoch, loader, iters, start_step=0, wandb=None):
start_time = time.time()
last_step = start_step
for step, (input_ids, labels) in enumerate(loader, start=start_step + 1):
input_ids = input_ids.to(args.device)
labels = labels.to(args.device)
last_step = step
# ① 计算当前学习率(余弦调度)
lr = get_lr(epoch * iters + step, args.epochs * iters, args.learning_rate)
for param_group in optimizer.param_groups:
param_group['lr'] = lr
# ② 前向传播(混合精度)
with autocast_ctx:
res = model(input_ids, labels=labels)
loss = res.loss + res.aux_loss # 主损失 + MoE 辅助损失
loss = loss / args.accumulation_steps # 梯度累积:除以累积步数
# ③ 反向传播
scaler.scale(loss).backward()
# ④ 每隔 accumulation_steps 步才真正更新参数
if step % args.accumulation_steps == 0:
scaler.unscale_(optimizer)
torch.nn.utils.clip_grad_norm_(model.parameters(), args.grad_clip)
scaler.step(optimizer)
scaler.update()
optimizer.zero_grad(set_to_none=True)
# ⑤ 日志记录
if step % args.log_interval == 0 or step == iters:
spend_time = time.time() - start_time
current_loss = loss.item() * args.accumulation_steps
Logger(f'Epoch:[{epoch+1}/{args.epochs}]({step}/{iters}), '
f'loss:{current_loss:.4f}, lr:{lr:.8f}')
# ⑥ 定期保存检查点
if (step % args.save_interval == 0 or step == iters) and is_main_process():
model.eval()
# ... 保存逻辑 ...
model.train()
循环流程图
六个阶段:取数据→调学习率→前向→反向→更新→保存
图:训练循环六阶段流程
【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:纵向流程图,从上到下6个阶段。①"取 batch 数据"→ ②"余弦调度学习率"→ ③"前向传播算 loss"→ ④"反向传播"→ 菱形判断"累积够了?"(否回到①,是继续)→ ⑤"梯度裁剪+更新参数"→ 菱形判断"到保存间隔?"(否继续,是执行⑥"保存检查点")→ 菱形判断"epoch结束?"(否回到①,是结束) - 各元素关系:纵向主流程,菱形判断节点产生回路箭头回到"取 batch 数据",形成循环 - 关键标注:每个阶段用序号圆圈①-⑥标注,菱形判断节点用问号标注 - 配色建议:浅蓝背景,处理步骤蓝绿色矩形,菱形判断橙色,回路箭头用虚线 - 画面比例:9:16(纵向) - 避免:过度装饰、3D 效果、节点太密
余弦学习率
来自 trainer_utils.py 的 get_lr 函数
定义:学习率按余弦曲线从初始值平滑衰减到终值。
def get_lr(current_step, total_steps, lr):
"""余弦学习率调度"""
return lr * (0.1 + 0.45 * (1 + math.cos(math.pi * current_step / total_steps)))
白话:学习率就像开车——起步时速度适中,中间平稳行驶,快到终点时慢慢减速。最低不会降到 0(保留 10%),让模型始终有微调能力。
| 训练进度 | current_step / total_steps | cos 值 | 学习率 |
|---|---|---|---|
| 开始 | 0 | 1.0 | lr × 1.0 = base_lr |
| 中间 | 0.5 | 0.0 | lr × 0.55 |
| 结束 | 1.0 | -1.0 | lr × 0.1 = 最低学习率 |
这个比喻只是为了帮你建立直观感受。在计算机内部,它实际上是余弦学习率调度,使用 math.cos 函数根据当前步数和总步数计算衰减系数。
余弦调度曲线
学习率随训练步数的变化
图:余弦学习率调度曲线
【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:坐标系图,横轴"训练步数",纵轴"学习率",绘制余弦衰减曲线 - 曲线从 lr=base_lr 开始,平滑下降到 lr=0.1×base_lr - 标注三个关键点:起始"lr = base_lr",中点"余弦下降中",终点"lr = 0.1×base_lr" - 曲线下方浅蓝色填充 - 右上角公式:lr = base_lr × (0.1 + 0.45 × (1 + cos(πt/T))) - 配色建议:白色背景,曲线深蓝色,填充浅蓝色,关键点橙色圆点 - 画面比例:16:9 - 避免:过度装饰、3D 效果
图:余弦学习率从初始到最终的衰减路径
【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:三个圆角矩形从左到右排列。"初始 lr = base_lr"(绿色,高位置)→ "余弦衰减"(中间带下降曲线箭头)→ "最终 lr = 0.1 × base_lr"(橙色,低位置) - 各元素关系:从左到右递减,中间用下降曲线箭头连接,表示学习率逐步降低 - 关键标注:起始值标注"base_lr",结束值标注"0.1 × base_lr",衰减幅度约10倍 - 配色建议:浅灰背景,起始绿色,结束橙色,中间渐变过渡 - 画面比例:16:9 - 避免:过度装饰、3D 效果
为什么不直接用固定学习率?固定学习率到后期会"震荡"——参数在一个点附近来回跳动,无法收敛到最优。余弦衰减让后期步子变小,精调参数。
梯度累积
小显存也能享受大批量的好处
想用 batch_size=256 训练,但显卡只够放 batch_size=32?
梯度累积:每次只放 32 条数据,但累积 8 次梯度(32 × 8 = 256),等效于 batch_size=256。
# 关键代码(train_pretrain.py)
loss = loss / args.accumulation_steps # ① loss 除以累积步数
scaler.scale(loss).backward() # ② 反向传播(梯度自动累加)
if step % args.accumulation_steps == 0: # ③ 每 8 步才更新一次
scaler.step(optimizer)
optimizer.zero_grad(set_to_none=True)
累积机制详解
梯度在内存中是如何一步步叠加的
图:梯度累积机制
【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:上方"理想"大方块(红色虚线框"显存不够");下方"梯度累积"8个小batch方块(绿色)→累加器漏斗→等效大方块 - 8个小batch编号①-⑧汇入累加器 - 累加器旁标注"loss ÷ 8 再backward" - 底部显示等效结果:32 × 8 = 256 等效 batch - 配色建议:浅灰背景,理想方块红色虚线,小方块绿色,累加器橙色,输出蓝色 - 画面比例:16:9 - 避免:过度装饰、3D 效果
| Step | 操作 | 梯度状态 |
|---|---|---|
| 1 | forward + backward | grad += grad_1 |
| 2 | forward + backward | grad += grad_2 |
| ... | ... | ... |
| 8 | forward + backward + update | grad += grad_8 → 更新 → 清零 |
PyTorch 的 backward() 默认行为是累加梯度,不是覆盖。所以只有调用 zero_grad() 时才清零。
混合精度训练
用更少的内存跑更大的模型
定义:混合精度 = 用低精度(16bit)做前向和反向,用高精度(32bit)维护参数主副本。
| 精度类型 | 占用 | 速度 | 数值范围 | 需 GradScaler |
|---|---|---|---|---|
| float32 | 高(4字节) | 慢 | ±3.4e38 | 不需要 |
| float16 | 低(2字节) | 快 | ±6.5e4 | 需要 |
| bfloat16 | 低(2字节) | 快 | ±3.4e38 | 不需要 |
# train_pretrain.py 中的设置
dtype = torch.bfloat16 if args.dtype == "bfloat16" else torch.float16
autocast_ctx = nullcontext() if device_type == "cpu" \
else torch.cuda.amp.autocast(dtype=dtype)
# GradScaler 仅 float16 需要(bfloat16 范围够大,不需要)
scaler = torch.cuda.amp.GradScaler(enabled=(args.dtype == 'float16'))
三种精度对比
bfloat16 为什么是最佳选择?
图:三种浮点精度格式的内存布局
【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:三行对比。float32:"1位符号|8位指数|23位尾数"(32位);float16:"1位符号|5位指数|10位尾数"(16位);bfloat16:"1位符号|8位指数|7位尾数"(16位) - float32和bfloat16指数位同色(都是8位,蓝色),float16指数位红色(只有5位) - 标注:float32"±3.4e38,4字节";float16"±6.5e4,需GradScaler";bfloat16"±3.4e38,无需Scaler" - 底部结论框:"bfloat16 = float16 的内存 + float32 的范围" - 配色建议:白色背景,符号位灰色,指数位蓝色或红色,尾数绿色 - 画面比例:16:9 - 避免:过度装饰、3D 效果
白话:float16 的"量程"太小(±6.5万),稍大的梯度就溢出了,需要 GradScaler 帮忙。bfloat16 和 float32 一样大(±3.4e38),但只要一半内存。MiniMind 默认用 bfloat16。
一句话记忆:bfloat16 = float16 的内存占用 + float32 的数值范围。能选 bfloat16 就选它。
GradScaler
float16 的"保镖"——防止梯度下溢
# GradScaler 的工作流程
# 1. 放大 loss,防止小梯度在 float16 下变成 0
scaler.scale(loss).backward()
# 2. 缩放回来,检查有没有 inf/nan
scaler.unscale_(optimizer)
# 3. 如果正常,更新参数;如果有 inf/nan,跳过这步
scaler.step(optimizer)
# 4. 调整缩放因子
scaler.update()
bfloat16 因为范围够大,不需要这个保镖。所以 MiniMind 的代码中 enabled=(args.dtype == 'float16')——只在 float16 时启用。
梯度裁剪
防止梯度爆炸的"限速器"
# train_pretrain.py 第 43 行
torch.nn.utils.clip_grad_norm_(model.parameters(), args.grad_clip)
# grad_clip 默认 1.0
# 如果所有参数梯度范数 > 1.0,就等比例缩小到 1.0
三步走:
- 定义:计算所有参数梯度的 L2 范数,超过阈值则等比缩放。
- 白话:给梯度设上限,就像汽车限速——不管多着急,最快 120。遇到"特别难"的数据时,梯度可能突然爆炸,裁剪防住它。
- 代码:
clip_grad_norm_(params, max_norm=1.0),超过 1.0 就缩到 1.0。
这个比喻只是为了帮你建立直观感受。在计算机内部,梯度裁剪实际上是计算所有参数梯度向量的 L2 范数,除以 max_norm 得到缩放系数。
裁剪位置很关键——必须在 unscale_ 之后、step 之前。因为要先还原真实梯度大小,才能正确判断是否超阈值。
尾部残余梯度
train_epoch 末尾的兜底处理
# train_epoch 函数的末尾(第 74-79 行)
if last_step > start_step and last_step % args.accumulation_steps != 0:
# 如果 epoch 结束时还有未更新的残余梯度
scaler.unscale_(optimizer)
torch.nn.utils.clip_grad_norm_(model.parameters(), args.grad_clip)
scaler.step(optimizer)
scaler.update()
optimizer.zero_grad(set_to_none=True)
假设 accumulation_steps=8,epoch 在 step=45 结束。45 % 8 = 5,还剩 5 步累积的梯度没有更新。
这一段代码确保不浪费任何梯度。否则每个 epoch 末尾最多会丢失 7 步的梯度信息,训练数据利用率降低。
Part 4: 工程实战
训练脚本 9 步总览、工程基础设施、端到端数据流。
理解了训练循环的内部逻辑,这一部分我们拉开视角,看完整的训练脚本是怎么组织的——从初始化分布式环境到最终清理资源,一共 9 个步骤。其中的检查点续训、多卡分布式等通用基础设施,所有训练器都共用同一套 trainer_utils.py,这里先建立直觉,深度实现留到阶段 11 系统讲解。
- 训练脚本 9 步结构 —— 从环境初始化到训练启动的完整流程,心中有全局
- 工程基础设施 —— 检查点续训、多卡分布式、原子保存,预训练视角的速览
- 端到端数据流 —— 从 JSONL 文件到梯度更新的完整数据变换链路
训练脚本 9 步
train_pretrain.py 的完整结构
图:train_pretrain.py 训练脚本九步结构
【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:纵向排列的9个圆角矩形,从上到下依次为:①"初始化分布式和随机种子"→②"配置目录模型参数"→③"设置混合精度"→④"配 SwanLab/WandB 日志"→⑤"定义模型、数据、优化器"→⑥"从 checkpoint 恢复状态"→⑦"编译和分布式包装"→⑧"开始训练循环"→⑨"清理分布进程" - 各元素关系:从上到下依次用箭头连接,表示执行顺序。每一步依赖前一步完成 - 关键标注:每步用序号圆圈①-⑨标注,⑤⑥⑦三步用浅色背景框起来标注"核心初始化" - 配色建议:浅蓝背景,步骤矩形用浅蓝色,序号用橙色圆圈 - 画面比例:9:16(纵向) - 避免:过度装饰、3D 效果、节点太密
从初始化到训练到清理,一共 9 步。每一步都不能跳过,否则训练可能静默出错。
步骤 1-3 初始化
分布式环境、模型配置、混合精度
# ① 初始化分布式环境和随机种子
local_rank = init_distributed_mode()
if dist.is_initialized():
args.device = f"cuda:{local_rank}"
setup_seed(42 + (dist.get_rank() if dist.is_initialized() else 0))
# ② 配置模型参数
lm_config = MiniMindConfig(
hidden_size=args.hidden_size, # 默认 768
num_hidden_layers=args.num_hidden_layers, # 默认 8
use_moe=bool(args.use_moe) # 默认 False
)
# ③ 设置混合精度
device_type = "cuda" if "cuda" in args.device else "cpu"
dtype = torch.bfloat16 if args.dtype == "bfloat16" else torch.float16
autocast_ctx = nullcontext() if device_type == "cpu" \
else torch.cuda.amp.autocast(dtype=dtype)
随机种子加了 rank 偏移:42 + rank。这确保每张 GPU 的数据增强不同,增加数据多样性。
步骤 4-5 定义组件
日志、模型、数据集、优化器
# ④ 配 SwanLab 日志(可选)
wandb = None
if args.use_wandb and is_main_process():
import swanlab as wandb
wandb_id = ckp_data.get('wandb_id') if ckp_data else None
wandb.init(project=args.wandb_project, id=wandb_id,
resume='must' if wandb_id else None)
# ⑤ 定义模型、数据、优化器
model, tokenizer = init_model(lm_config, args.from_weight, device=args.device)
train_ds = PretrainDataset(args.data_path, tokenizer, max_length=args.max_seq_len)
train_sampler = DistributedSampler(train_ds) if dist.is_initialized() else None
scaler = torch.cuda.amp.GradScaler(enabled=(args.dtype == 'float16'))
optimizer = optim.AdamW(model.parameters(), lr=args.learning_rate)
| 组件 | 默认值 | 说明 |
|---|---|---|
| model | MiniMind-768 | 64M 参数的小模型(minimind-3) |
| tokenizer | 模型目录下的分词器 | 基于 sentencepiece |
| optimizer | AdamW | 带权重衰减的 Adam |
| scaler | GradScaler | 仅 float16 启用 |
步骤 6-7 恢复与包装
检查点恢复、torch.compile、DDP 包装
# ⑥ 从 checkpoint 恢复状态
start_epoch, start_step = 0, 0
if ckp_data:
model.load_state_dict(ckp_data['model'])
optimizer.load_state_dict(ckp_data['optimizer'])
scaler.load_state_dict(ckp_data['scaler'])
start_epoch = ckp_data['epoch']
start_step = ckp_data.get('step', 0)
# ⑦ 编译和分布式包装
if args.use_compile == 1:
model = torch.compile(model) # PyTorch 2.0 加速
if dist.is_initialized():
model = DistributedDataParallel(model, device_ids=[local_rank])
恢复顺序很重要:先模型 → 再优化器 → 最后 scaler。因为优化器状态依赖模型参数结构。
步骤 8-9 训练与清理
训练循环与 DataLoader 构建
# ⑧ 开始训练循环
for epoch in range(start_epoch, args.epochs):
train_sampler and train_sampler.set_epoch(epoch)
setup_seed(42 + epoch)
indices = torch.randperm(len(train_ds)).tolist()
# 关键:断点续训时跳过已训练的 batch
skip = start_step if (epoch == start_epoch and start_step > 0) else 0
batch_sampler = SkipBatchSampler(
train_sampler or indices, args.batch_size, skip
)
loader = DataLoader(train_ds, batch_sampler=batch_sampler,
num_workers=args.num_workers, pin_memory=True)
if skip > 0:
Logger(f'跳过前{skip}个step,从step {skip+1}开始')
train_epoch(epoch, loader, len(loader) + skip, skip, wandb)
# ⑨ 清理分布进程
if dist.is_initialized():
dist.destroy_process_group()
SkipBatchSampler 是断点续训的关键——它跳过已经训练过的 batch,直接从断点处继续。不用它的话,恢复后会重复训练已见过的数据。
工程基础设施
检查点续训与多卡训练——这里先建立直觉,深度留给阶段 11
上面 9 步里出现了两个工程必备件:lm_checkpoint(检查点)和 init_distributed_mode(分布式)。它们的具体实现都在 trainer_utils.py 里,是所有训练器共享的基础设施——预训练、SFT、DPO、RL 用的是同一套代码。这里先记住它们解决什么问题,细节留到阶段 11 系统讲。
检查点(lm_checkpoint)
训练的"存档"机制。保存两类文件:pretrain_768.pth(纯权重,用于推理)和 pretrain_768_resume.pth(权重+优化器+epoch/step,用于断点续训)。配合 SkipBatchSampler,中断后能从断点无缝继续。
分布式(init_distributed_mode)
多卡并行训练。每张 GPU 处理不同数据切片,算出梯度后用 All-Reduce 取平均,再用相同梯度更新。4 张卡接近 4 倍速。DistributedSampler 自动分片,无需手动切。
原子保存
保存时先写 .tmp 临时文件,再用 os.replace 原子替换。即使保存中途断电,文件要么是旧的要么是新的,不会出现损坏的半成品。
深入这些机制的实现细节(GPU 数量变化时 step 自动转换、All-Reduce 的通信原理、SkipBatchSampler 的断点跳过逻辑、LMForRewardModel 封装等),请跳转到 阶段 11 · 训练工具与工程实践——那一章是 trainer_utils.py 的系统精讲。
端到端数据流
从文本到模型更新的完整路径
图:预训练端到端数据流完整闭环
【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:纵向循环流程图。顶部"原始文本 JSONL {text: 你好世界}"→"PretrainDataset 分词+BOS/EOS+PAD"→"DataLoader 组成batch搬到GPU"→"model.forward 词嵌入→Transformer→logits"→"loss = cross_entropy + aux_loss"→"loss/accumulation_steps backward()"→菱形"累积够了?"(否回路到DataLoader)→"unscale→clip_grad→step→zero_grad"→菱形"到保存间隔?"(否回路到DataLoader)→"保存 .pth 和 _resume.pth"→回路到DataLoader - 各元素关系:纵向主流程,多个菱形判断产生回路形成循环 - 关键标注:数据阶段用蓝色,模型阶段用绿色,优化阶段用橙色,保存阶段用紫色 - 配色建议:浅灰背景,数据流蓝色系,模型计算绿色系,优化器橙色系 - 画面比例:9:16(纵向) - 避免:过度装饰、3D 效果、节点太密
这就是训练的完整闭环。每一步你都已经在前面学过了,现在把它们串起来。
Part 5: 排错与练习
常见错误诊断和动手练习。训练出问题时,先来这里查。
训练不是一帆风顺的——Loss 不下降、显存爆了、突然出现 NaN……这些都是新手(和老手)都会遇到的问题。这一部分先帮你建一张"排错速查表",覆盖最常见的训练异常和对应的排查思路。然后留几道动手练习题,从手写 labels 到手动计算学习率,让你在纸上把关键概念过一遍,确保不只是"看懂了"而是"真的会了"。
- OOM 与显存 —— 显存不够怎么办?从 batch size、序列长度到精度,逐项排查
- Loss 异常诊断 —— Loss 不下降、突然飙升、震荡不稳,各种异常的常见原因和对策
- NaN 与恢复异常 —— 梯度爆炸导致 NaN、checkpoint 恢复后训练行为异常的排查方法
- 排查流程图 —— 一张图串联所有排错路径,出问题时按图索骥
- 动手练习 —— 手写 labels、计算等效 batch size、手动推算学习率、分析显存占用
OOM 与显存
CUDA Out of Memory
症状:RuntimeError: CUDA out of memory
原因:batch 太大或序列太长,显存放不下。
修复(按优先级):
- 减小
batch_size(32→16→8) - 减小
max_seq_len(340→256→128) - 增大
accumulation_steps补偿 batch
显存占用估算
粗略公式:
显存 ≈ 模型参数 × 4 (float32)
或 模型参数 × 2 (bfloat16)
+ batch_size × seq_len × hidden × 4
MiniMind-768 约 64M 参数,float32 时约 256MB。加上梯度和优化器状态,总共约 1-1.3GB。非常省显存。
Loss 异常诊断
Loss 不下降
症状:训练几百步 loss 没变化
排查清单:
- 学习率是否太小?(试试 1e-3)
- 数据量是否太少?(至少需要上万条)
- 数据质量是否太差?(大量乱码、重复)
- 模型结构是否正确?(检查 config)
Loss 突然飙升
症状:loss 正常下降后突然变大
排查清单:
- 学习率是否太大?(试试 1e-4)
- 是否有异常数据?(空行、超长行)
- 梯度裁剪是否生效?(检查
grad_clip) - 从最近的检查点重新开始
NaN 与恢复异常
Loss 变成 NaN
症状:训练几步后 loss 突然变成 NaN。
原因:学习率过高导致梯度爆炸,或混合精度下数值溢出。
修复:
- 降低学习率(5e-4 → 1e-4)
- 确认梯度裁剪生效
- 使用 bfloat16 而非 float16
- 检查数据是否有极端值
恢复训练后异常
症状:--from_resume 1 后 loss 跳到很大。
修复:
- 确认 resume 文件完整(大小合理?)
- 检查 optimizer 和 scaler 是否都恢复了
- 确认恢复前后模型配置一致
- 确认学习率调度器步数正确
排查流程图
遇到训练问题时,按此流程排查
图:训练问题排查决策树
【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:从顶部"训练出问题了"(红色圆角矩形)开始,向下经过菱形判断节点构成决策树。第一个判断"Loss 是 NaN?"(是→修复:降学习率、确认clip_grad、换bfloat16、从ckp重来),否→第二个判断"Loss 不下降?"(是→修复:检查数据量质量、增大学习率、检查模型结构),否→第三个判断"Loss 突然飙升?"(是→修复:降学习率、检查异常数据、确认clip_grad、从ckp重来),否→第四个判断"OOM?"(是→修复:减batch_size、减max_seq_len、增accumulation_steps) - 各元素关系:纵向决策树,每个菱形判断有"是"和"否"两条分支,"是"导向修复方案矩形,"否"导向下一个判断 - 关键标注:每个修复方案用浅红色背景,判断节点用菱形,问题类型用粗体标注 - 配色建议:浅灰背景,判断菱形橙色,修复方案浅红色(#FFE0E0),箭头深灰色 - 画面比例:9:16(纵向) - 避免:过度装饰、3D 效果、节点太密
动手练习
练习 1:写出 labels
假设 tokenized = [3, 8, 15, 2](BOS=3, EOS=2),max_length=6,PAD=0。请写出最终的 input_ids 和 labels。
参考
input_ids = [3, 8, 15, 2, 0, 0],labels = [3, 8, 15, 2, -100, -100]
练习 2:计算等效 batch size
如果 batch_size=16,accumulation_steps=4,等效 batch size 是多少?如果要等效 batch_size=256(batch_size=32),accumulation_steps 应该设多少?
参考
等效 = 16 × 4 = 64。256 / 32 = 8,设 accumulation_steps=8。
进阶练习
练习 3:手动计算学习率
假设 base_lr=5e-4,当前步数 current_step=500,总步数 total_steps=2000。请用 get_lr 公式计算当前学习率。
参考
cos(π × 500/2000) = cos(π/4) ≈ 0.707
lr = 5e-4 × (0.1 + 0.45 × (1 + 0.707)) = 5e-4 × (0.1 + 0.768) = 5e-4 × 0.868 ≈ 4.34e-4
练习 4:分析显存占用
MiniMind-768 有 64M 参数。使用 bfloat16 训练,batch_size=32,max_seq_len=340。估算最低显存需求。
参考
模型参数:64M × 2 bytes ≈ 128MB
梯度 + 优化器状态:约 3× 参数量 ≈ 384MB
激活值:batch_size × seq_len × hidden ≈ 32 × 340 × 768 × 2 ≈ 17MB(粗略)
总计约 529MB + CUDA 开销 ≈ 1~1.5GB。几乎所有 GPU 都能跑。
完整训练命令
从最简单到自定义到多卡
# 最简单的单卡训练(所有参数用默认值)
$ python trainer/train_pretrain.py
# 自定义参数
$ python trainer/train_pretrain.py \
--epochs 2 \
--batch_size 32 \
--learning_rate 5e-4 \
--max_seq_len 340 \
--accumulation_steps 8 \
--hidden_size 768 \
--num_hidden_layers 8
# 4卡训练 + SwanLab 日志
$ torchrun --nproc_per_node=4 trainer/train_pretrain.py \
--use_wandb --epochs 4 --batch_size 64
# 断点续训
$ python trainer/train_pretrain.py --from_resume 1
# 从已有权重继续预训练
$ python trainer/train_pretrain.py --from_weight pretrain
参数速查表
train_pretrain.py 所有参数一览
| 参数 | 默认值 | 说明 |
|---|---|---|
--epochs | 2 | 训练轮数 |
--batch_size | 32 | 每步数据量 |
--learning_rate | 5e-4 | 初始学习率 |
--max_seq_len | 340 | 最大序列长度 |
--accumulation_steps | 8 | 梯度累积步数 |
--grad_clip | 1.0 | 梯度裁剪阈值 |
--dtype | bfloat16 | 混合精度类型 |
--hidden_size | 768 | 隐藏层维度 |
--num_hidden_layers | 8 | Transformer 层数 |
--use_moe | 0 | 是否使用 MoE |
--save_interval | 1000 | 保存间隔(步) |
--log_interval | 100 | 日志间隔(步) |
--from_resume | 0 | 是否断点续训 |
--use_compile | 0 | 是否 torch.compile |
知识全景图
核心思想
Next Token Prediction——给定前文预测下一个字,logits[:-1] vs labels[1:] 对齐,一次前向传播 = N-1 次练习
数据处理
JSONL 格式 text 字段,分词 + BOS + EOS + PAD,labels 中 PAD = -100,max_length 影响显存
训练循环
余弦学习率调度、梯度累积等效大 batch、混合精度 bfloat16、GradScaler 保 float16、梯度裁剪防爆炸、尾部残余梯度处理
工程实战
9 步脚本结构、检查点原子保存、DDP 多卡 All-Reduce、SkipBatchSampler 续训、torch.compile 加速
阶段 3 完成
预训练完成后,模型已具备基本的语言能力。
它能"续写"文本,但还不能"对话"。
下一章:阶段 4 · 监督微调(SFT)——教模型如何听懂指令、进行对话。