MiniMind 学习笔记 03:从 train_pretrain.py 看懂 LLM 训练循环

理解 LLM 训练,最好的入口不是一上来就看复杂的分布式训练框架,而是先看一个足够小、足够完整的训练脚本。

MiniMind 的 trainer/train_pretrain.py 就是这样的入口。它保留了现代 LLM 训练中最核心的流程:

准备参数
-> 初始化环境
-> 构造模型
-> 构造 tokenizer 和 dataset
-> 创建 optimizer
-> 前向计算 loss
-> 反向传播
-> 梯度累积
-> 梯度裁剪
-> 参数更新
-> 保存 checkpoint

这篇文章只抓主线,不陷入所有细枝末节。

1. 训练脚本先做什么

一个训练脚本的第一步通常是解析参数。

比如:

batch_size
learning_rate
epochs
max_seq_len
hidden_size
num_hidden_layers
accumulation_steps
grad_clip

这些参数决定了三件事:

模型有多大;
数据怎么喂;
训练怎么更新。

对于新手来说,最重要的是先分清:

参数类型例子作用
模型结构参数hidden_sizenum_hidden_layers决定模型形状。
数据参数max_seq_lenbatch_size决定输入长度和 batch。
优化参数learning_rateepochsgrad_clip决定如何训练。

如果训练和推理时模型结构参数不一致,权重就加载不上。

2. 构造模型配置

MiniMind 会先构造 MiniMindConfig

lm_config = MiniMindConfig(
    hidden_size=args.hidden_size,
    num_hidden_layers=args.num_hidden_layers,
    use_moe=args.use_moe
)

这个 config 决定了后面模型的主要结构:

hidden_size
num_hidden_layers
num_attention_heads
num_key_value_heads
vocab_size
intermediate_size
use_moe

可以把 MiniMindConfig 理解成模型的建筑图纸。

训练脚本不会手写每一层怎么连,而是把配置交给:

model = MiniMindForCausalLM(lm_config)

模型内部再根据 config 创建 embedding、Transformer blocks、lm_head 等组件。

3. Dataset 和 DataLoader

预训练阶段用的是 PretrainDataset

它把原始文本变成:

x = input_ids[:-1]
y = input_ids[1:]

训练时 DataLoader 会不断吐出 batch:

for step, (X, Y, loss_mask) in enumerate(train_loader):
    ...

这里可以简单理解为:

X:模型输入
Y:预测目标
loss_mask:哪些位置参与 loss

对于 causal LM,模型训练的本质是:

用 X 的每个位置预测 Y 的每个位置。

4. forward:loss 在哪里产生

训练循环里会调用模型:

res = model(X, labels=Y)
loss = res.loss

这一步做了几件事:

input_ids -> embedding
-> 多层 MiniMindBlock
-> final norm
-> lm_head
-> logits
-> CrossEntropyLoss

logits 的形状通常是:

[batch_size, seq_len, vocab_size]

也就是说,每个 token 位置都会输出一个词表大小的分布。

如果 vocab_size=6400,那么每个位置都会给出 6400 个 token 的分数。

loss 会比较:

当前位置预测的下一个 token 分布
vs
真实的下一个 token id

这就是 next-token prediction。

5. 一个 batch 是一次训练所有 token 吗

这是初学时很容易误解的地方。

推理时,模型通常一个 token 一个 token 生成:

生成第 1 个 token
-> 把它接回输入
-> 生成第 2 个 token
-> ...

但训练时不是这样。

训练时,一个样本长度为 N,模型会并行计算所有位置的预测:

位置 0 预测位置 1
位置 1 预测位置 2
位置 2 预测位置 3
...

所以一个 batch 的一次 forward,会同时训练很多 token 位置。

这也是为什么训练可以高效并行,而推理 decode 阶段必须自回归。

6. 梯度累积:为什么 loss = loss / accumulation_steps

MiniMind 训练代码里有一行:

loss = loss / args.accumulation_steps

这是梯度累积的标准写法。

假设显存只能放下小 batch:

micro batch size = 4

但我们希望等价于大 batch:

global batch size = 16

就可以累积 4 次梯度:

第 1 次 forward/backward:只累积梯度,不更新
第 2 次 forward/backward:只累积梯度,不更新
第 3 次 forward/backward:只累积梯度,不更新
第 4 次 forward/backward:optimizer.step()

如果每次 loss 不除以 4,那么累积后的梯度会变成原来的 4 倍。

所以要写:

loss = loss / accumulation_steps

这样 4 次 backward 累加起来,才等价于一个大 batch 的平均 loss。

7. 梯度裁剪:防止更新过猛

训练中还有一个参数:

grad_clip

它用于梯度裁剪。

深度模型训练时,偶尔会出现梯度过大的情况。如果直接用这个梯度更新参数,模型可能发生不稳定,loss 突然爆掉。

梯度裁剪的直觉是:

如果整体梯度范数超过阈值,就按比例缩小。

常见代码类似:

torch.nn.utils.clip_grad_norm_(model.parameters(), args.grad_clip)

它不是把每个梯度元素硬截断,而是控制整体梯度向量的范数。

可以理解为:

方向尽量保留;
步子不要迈太大。

8. aux_loss:MoE 的额外损失

如果模型启用了 MoE,forward 返回里可能有:

res.aux_loss

这个 loss 不是语言建模 loss,而是 MoE router 的负载均衡损失。

MoE 中有多个 expert。如果 router 总是把 token 分给同一个 expert,就会出现:

一个专家很忙;
其他专家几乎没训练;
模型容量被浪费。

aux loss 的作用是鼓励 token 更均衡地分配到不同 expert。

所以最终 loss 可能是:

语言建模 loss + MoE aux loss

如果没有启用 MoE,这部分通常就是 0。

9. checkpoint:训练产物是什么

训练一段时间后,脚本会保存权重:

out/pretrain_768.pth
out/full_sft_768.pth
out/agent_768.pth

这些文件只保存模型参数,不是完整工程。

推理时需要同时具备:

模型结构代码;
模型配置;
tokenizer;
权重文件。

所以用 eval_llm.py 加载 out 权重时,要保证:

hidden_size 一致;
num_hidden_layers 一致;
use_moe 一致;
tokenizer 路径正确。

否则就会出现权重 shape 不匹配。

10. 小结

train_pretrain.py 可以看成 LLM 训练主线的最小闭环:

Dataset 产出 X/Y
-> 模型 forward 得到 logits
-> logits 和 Y 计算 loss
-> backward 得到梯度
-> 梯度累积和裁剪
-> optimizer 更新参数
-> 保存 checkpoint

先看懂这个闭环,再去看 SFT、DPO、PPO、GRPO,会轻松很多。

因为后面的训练方法虽然目标更复杂,但都绕不开这条主线:

数据如何组织;
模型如何 forward;
loss 如何定义;
参数如何更新。

发表评论