目录
- 一、「训练」在代码上是 24 行
- 二、loss:一个衡量「错得有多离谱」的数字
- 三、「预训练」的全部定义是四行代码
- 四、数据侧:labels 从哪来
- 五、梯度与学习率:往哪挪,挪多少
- 六、预训练之后,模型会写出什么
- 七、但「会写」不等于「答得对」
- 八、SFT:换掉的只是 labels
- 九、超参数上的两个信号
- 十、自检清单
- 附:本文的核查方式
- 附:延伸阅读
一、「训练」在代码上是 24 行
上一篇说到,训练就是”找了 6391 万个数字,一个一个挪到合适的位置上”。
这句话在代码里长什么样?下面是 MiniMind 预训练脚本的核心循环,逐字照抄,含缩进与空行(trainer/train_pretrain.py:24-47):
def train_epoch(epoch, loader, iters, start_step=0, wandb=None):
start_time = time.time()
for step, (input_ids, labels) in enumerate(loader, start=start_step + 1):
input_ids = input_ids.to(args.device)
labels = labels.to(args.device)
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
loss = loss / args.accumulation_steps
scaler.scale(loss).backward()
if step % args.accumulation_steps == 0 or step == iters:
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)
这 24 行就是「训练」这个词的全部含义。 后面的预训练、SFT、DPO、GRPO、Agentic RL,循环骨架都是这一段,只是换掉了 loss 怎么算。
画成图就是:
flowchart TD
A["取一批数据<br/>input_ids + labels"] --> B["model(input_ids, labels)<br/>前向传播"]
B --> C["得到 loss<br/>一个数字"]
C --> D["loss.backward()<br/>每个参数得到一个梯度"]
D --> E["optimizer.step()<br/>按梯度挪动参数一点点"]
E --> F["zero_grad()<br/>清空梯度"]
F -->|下一批| A
逐行拆一遍:
| 行 | 在干嘛 | 术语 |
|---|---|---|
for step, (input_ids, labels) | 取一批数据 | batch |
get_lr(...) → param_group['lr'] | 动态调整学习率 | warmup / 衰减 |
model(input_ids, labels=labels) | 前向传播,顺带算出 loss | forward |
loss = res.loss + res.aux_loss | 加上 MoE 的负载均衡损失 | 见 §5.3 |
loss / accumulation_steps | 梯度累积:攒几批再更新一次 | 显存不够时的等价技巧 |
scaler.scale(loss).backward() | 反向传播,算出每个参数的梯度 | backprop |
clip_grad_norm_(...) | 梯度裁剪,防止一步跨太大 | 数值稳定性 |
scaler.step(optimizer) | 按梯度更新参数 | optimizer step |
scaler.update() | 调整混合精度的缩放系数 | 防 fp16 下溢 |
zero_grad(set_to_none=True) | 清空梯度,准备下一批 | — |
表里有两个词补一下,它们后面会反复出现:
- batch:一次喂给模型的一小批数据。不是把全部数据一次喂进去——显存装不下那么大的 batch。所以循环里是”一批一批地取”。
- epoch:把整个数据集完整过一遍叫一个 epoch。上面函数名之所以叫
train_epoch,就是因为它跑完一个 epoch。
真正”改变模型”的只有 scaler.step(optimizer) 那一行。 其余全是在为它准备材料。
二、loss:一个衡量「错得有多离谱」的数字
要说清楚训练,先得说清楚那个要被”变小”的数字。
2.1 模型每一步输出的不是文字,是 6400 个分数
上一篇算过,MiniMind 的词表是 6400 个。模型在每一个位置上,输出的都是 6400 个数。这 6400 个原始分数在代码里叫 logits——分别对应”下一个词是词表里第 i 个”的把握程度。
打个比方:
logits就是一张”给 6400 个候选词打分”的表。分数有高有低,但它们加起来不等于 1,所以还不能当概率用。要变成真正的概率,得过一道叫 softmax 的换算——它把每个分数压到 0 到 1 之间,并且让 6400 个数加起来正好等于 1。所以你会在代码里看到
softmax和logits成对出现:logits是原料,softmax是把它变成概率的那道工序。
1200 亿参数的大模型也一样,只是词表可能大得多(Qwen2 是 151,643)。输出永远是”整个词表的分数表”。从里面挑一个(按概率抽签)就是采样;每次都挑分数最高的那个,叫贪心解码——那样输出最”稳”,但也最死板。(MiniMind 的生成循环见 model/model_minimind.py:261-293。)
2.2 loss 衡量的是:你把多少把握给了正确答案
比如原文是”秦始皇是中国历史上的第一位皇帝”,模型在”秦始皇”后面那个位置应该预测”是”。
- 如果模型给”是”这个 token 分了 90% 的把握 → loss 小
- 如果只给了 2%“是”、把 40% 给了”的” → loss 大
F.cross_entropy 算的就是这件事:-log(模型给正确答案分配的概率)。概率越接近 1,loss 越接近 0。
这个算法有个正式名字叫交叉熵(cross entropy)——本文开头那句”预训练和 SFT 共用同一行交叉熵”,指的就是这一行代码。名字不用记,记住它算的是什么就行:你给正确答案押的注越小,罚得越狠;押对了不罚,押得越准罚得越轻。
所以”训练”这个动作的完整含义是:
反复调整那 6391 万个数字,让模型给正确答案押的注越来越大。
2.3 loss 曲线的形状
MiniMind 的 README 给了预训练和 SFT 两条 loss 曲线图。它的典型形状是:开头陡降,然后越来越平——因为最容易学的规律(“的”后面常跟名词、标点该在哪里)很快就学完了,剩下的都是难啃的。
一个常被忽略的细节:loss 不是越低越好。 低到某个程度之后,模型开始”背”训练数据而不是”学”规律,这叫过拟合。判断依据是它在没见过的数据上的表现,而不只是训练 loss。
三、「预训练」的全部定义是四行代码
上一篇说过模型结构代码只有 292 行。这 292 行里最关键的四行,在 MiniMindForCausalLM.forward 的结尾(model/model_minimind.py:250-258,逐字照抄,含方法缩进):
def forward(self, input_ids, attention_mask=None, past_key_values=None, use_cache=False, logits_to_keep=0, labels=None, **kwargs):
hidden_states, past_key_values, aux_loss = self.model(input_ids, attention_mask, past_key_values, use_cache, **kwargs)
slice_indices = slice(-logits_to_keep, None) if isinstance(logits_to_keep, int) else logits_to_keep
logits = self.lm_head(hidden_states[:, slice_indices, :])
loss = None
if labels is not None:
x, y = logits[..., :-1, :].contiguous(), labels[..., 1:].contiguous()
loss = F.cross_entropy(x.view(-1, x.size(-1)), y.view(-1), ignore_index=-100)
return MoeCausalLMOutputWithPast(loss=loss, aux_loss=aux_loss, logits=logits, past_key_values=past_key_values, hidden_states=hidden_states)
(def forward 那一行在源码里就是没换行的一整行,这里保持原样。)
关键就这四行(下面这段去掉了缩进,以便单独看):
loss = None
if labels is not None:
x, y = logits[..., :-1, :].contiguous(), labels[..., 1:].contiguous()
loss = F.cross_entropy(x.view(-1, x.size(-1)), y.view(-1), ignore_index=-100)
3.1 那个 :-1 和 1: 是整件事的灵魂
x = logits[..., :-1, :] # 位置 0, 1, 2, ..., n-2 的输出
y = labels[..., 1:] # 位置 1, 2, 3, ..., n-1 的正确答案
错开一位。 用位置 t 的输出,去预测位置 t+1 的 token。
拿”秦始皇是中国历史上的第一位皇帝”举例:
| 输入到第 t 个位置 | 模型该预测的下一个 token |
|---|---|
秦 | 始 |
秦始皇 | 是 |
秦始皇是 | 中 |
| … | … |
这就叫 next token prediction(下一个词预测),也叫自回归语言模型。没有别的目标函数,就这一个。
(「目标函数」这个词后面还会出现,先说明白:它就是训练时用来当”考试标准”的那个量。模型所有的参数调整,都是为了让它在训练数据上把这个量做得更好。预训练的目标函数就是”预测下一个词准不准”——仅此而已,没有别的。)
3.2 为什么”只是猜下一个词”就能学会知识
这是所有人第一次听到都会卡住的地方:猜下一个词,跟”知道秦始皇是谁”有什么关系?
README 里作者自己的说法最简洁:
模型在这一阶段的核心目标就是学会高质量地词语接龙。
关键在于:要预测得准,就必须把文本背后的规律都吸收进去。
- 要猜对”秦始皇”后面跟什么,你得知道秦始皇是个人
- 要知道”首位皇帝”后面常接什么,你得知道中国历史的大致脉络
- 要判断”水煮鱼”该不该出现在”杭州特色美食”的答案里……这一条,它就没学会
知识的载体是”预测准确率”,不是某张知识表。 模型没有存一份”秦始皇 = 中国第一位皇帝”的记录,它是把”能让我把这题猜对”的东西压进了那 6391 万个数字里。
打个比方:读完整座图书馆的人,未必背得出任何一段原文,但你问他”秦始皇后面该接什么”,他答得出来。预训练做的就是这件事——读,然后能接。
四、数据侧:labels 从哪来
循环里的 (input_ids, labels) 是 DataLoader 给的。看预训练的数据集怎么造这两个东西(dataset/lm_dataset.py:50-58,逐字照抄,含类体缩进):
def __getitem__(self, index):
sample = self.samples[index]
tokens = self.tokenizer(str(sample['text']), add_special_tokens=False, max_length=self.max_length - 2, truncation=True).input_ids
tokens = [self.tokenizer.bos_token_id] + tokens + [self.tokenizer.eos_token_id]
input_ids = tokens + [self.tokenizer.pad_token_id] * (self.max_length - len(tokens))
input_ids = torch.tensor(input_ids, dtype=torch.long)
labels = input_ids.clone()
labels[input_ids == self.tokenizer.pad_token_id] = -100
return input_ids, labels
三件事:
① tokenizer 把文本变成编号。 tokenizer("秦始皇是中国...") 返回的是一串整数。MiniMind 的词表 6400,压缩比按 README 是中文约 1.5–1.7 字符/token、英文约 4–5 字符/token。
② 补上开头和结尾。 两头各加一个特殊 token:bos_token_id(begin of sequence,MiniMind 里是 <|im_start|>,id 为 1)和 eos_token_id(end of sequence,<|im_end|>,id 为 2)——作用是告诉模型”这段话从这儿开始、到这儿结束”。不够长的样本还要用 padding(填充符号)补齐,因为一批数据必须一样长才能一起算。
③ 最小化的那两行——labels = input_ids.clone()。 labels 就是原文自己。
这是预训练的全部设定:整段文本,每一个位置都是学习目标。 既然位置 t 要预测位置 t+1,而 t+1 就是原文的下一个 token——那 labels 当然就是原文错开一位。
那 labels[input_ids == pad_token_id] = -100 是干嘛的?padding 位不算 loss。 那些位置是硬凑出来的,学它们等于教模型输出垃圾。-100 是 PyTorch CrossEntropyLoss 的默认 ignore_index——填了这个值的标签,交叉熵直接跳过。
记住这个 -100,第八节它要出场,而且它是整篇的关键。
五、梯度与学习率:往哪挪,挪多少
5.1 backward() 到底做了什么
loss 是一个数字。loss.backward() 之后,每一个参数都得到了一个梯度——一个告诉你”这个数字往正方向调一点点,loss 会变大还是变小、变多少”的数。
这件事靠的是链式法则求导。手推几百层网络的导数对人类不现实,PyTorch 的 autograd 把整条计算链的导数自动算出来(上一篇 §4.2)。你不用知道导数长什么样,只要知道方向。
于是更新规则朴素到一句话:
参数 ← 参数 - 学习率 × 梯度
往梯度的反方向挪,挪的步长由学习率决定。
5.2 学习率是最重要的一个超参数
「超参数」指的是由人设定、而不是被训练出来的那些数——学习率、batch 大小、训练几轮,都是。区别很好记:权重是模型自己学出来的,超参数是你替它决定的。
学习率是其中最关键的一个:
- 太大:一步跨过头,loss 反复震荡甚至变成
NaN,训练直接崩 - 太小:挪得太慢,烧完预算还没走出起跑线
MiniMind 用的调度是”先热身再衰减”(循环里那个 get_lr):开始几步把学习率从很小慢慢升上来,然后逐步降低。前半段要快,后半段要稳。
开头那段”慢慢升上来”就叫 warmup(热身)。理由很实在:训练刚起步时参数是一团乱麻,此时用大学习率极容易一脚踩空、把训练搞崩;先小步试探几步,等方向大致对了再放大步子。
5.3 几个容易看懵的辅助机制
| 名字 | 解决的问题 | 一句话解释 |
|---|---|---|
grad_clip(梯度裁剪) | 某一步梯度突然特别大,一把把参数甩飞 | 超过阈值就按比例削下来 |
accumulation_steps(梯度累积) | 显存装不下大 batch | 连算几批,梯度攒着,再一次性更新 |
autocast / scaler(混合精度) | fp32 慢又占显存,fp16 又容易”下溢” | 见下方说明 |
aux_loss(负载均衡损失) | MoE 里所有 token 都挤到同一个专家 | 强迫专家使用率均衡 |
表里蹦出几个新词,逐个说清:
fp32 / fp16。数字在计算机里要用多少位来存。fp32 是 32 位,更精确也更占地方;fp16 是 16 位,空间省一半、精度低一些。
混合精度。意思是”两边都要”——用 fp16 做运算(图快图省),同时留一份 fp32 的权重(图准)。这个取舍你在上一篇见过一次:存权重时那个 .half() 就是转成 fp16。
下溢。fp16 能表示的最小数字有下限。如果某个中间结果小到比这个下限还小,它会被直接压成 0——而 0 乘以任何数都是 0,这个数携带的信息就彻底没了。scaler 的活是:先给 loss 乘一个缩放系数,把整体数值抬到安全区间里算,算完再除回来。
MoE / 专家。一种把前馈层拆成好几个”专家”、每个 token 只激活其中一两个的结构(minimind-3-moe 用的是 4 个专家、每次激活 1 个)。好处是参数量上去了、计算量没上去;坏处是可能所有 token 都挤到同一个专家——aux_loss 就是用来防这件事的。
这些都不影响”训练是什么”这个问题的答案,它们只是让它更稳、更省。第一次读的时候可以整段跳过。
六、预训练之后,模型会写出什么
预训练跑完,README 给的测试方式是 python eval_llm.py --weight pretrain。它保留的样例输出是:
💬: 为什么天空是蓝色的
🧠: 天空之所以看起来是蓝色的,主要是因为太阳光进入大气层后,短波长的蓝光更容易被空气分子散射,
因此人眼从各个方向接收到的蓝光会更多。
💬: 解释什么是机器学习
🧠: 机器学习是人工智能的一个重要分支,它通过数据训练模型,使系统能够自动学习规律,
并在分类、预测、推荐、自然语言处理等任务中持续改进效果。
(以上为项目 README 中的样例,为排版做了换行;原文见 README「预训练」一节。)
注意:这是”只做过预训练、没做过 SFT”的模型。它答得相当像样。
这件事值得停一下——它说明**“预训练模型只会乱接龙”是个过度简化的说法**。真实的预训练语料里本来就混着大量问答形态的文本,模型见过太多了,自然学会了”被提问时该长什么样”。
七、但「会写」不等于「答得对」
再看 README 里保留的另一个样例。这是更早版本、用 mini 数据集训出来的 Zero 模型(预训练 + SFT 各跑一遍):
👶: 请介绍一下自己。
🤖️: 作为人工智能,我没有实际的生活,也没有自我意识,所以没有自己的生活。我被设计成能够帮助
用户解答问题、提供信息、进行对话等。我的设计和功能是由计算机科学、机器学习、人工智能技术
和算法所构建的,我通过编程和算法实现。
👶: 推荐一些杭州的特色美食吧。
🤖️: 杭州有很多美味的特色美食,比如鸡肉串、鳗鱼头、水煮鱼、豆腐脑等。这些美食都非常美味,
有着独特的风味和口感,绝对是值得一试的美食。
👶: Introduce the history of the United States, please.
🤖️: 您提到的“Introok's the believeations of theument." 这个名字来源于中国古代的"groty
of of the change."
(以上为项目 README 中的样例。README 自己的评价是:“事实知识与泛化效果仍较有限”。这里两个词:事实知识 = 它到底知不知道这件事;泛化 = 在没见过的数据上表现如何——背下了训练集里的题、碰到新问法就不会了,就叫泛化差。)
请仔细看第二段。 它语气自信、格式工整、断句自然——而且一个杭州特色美食都没说对。水煮鱼是川菜,鳗鱼头、鸡肉串、豆腐脑都不是杭州的东西。它不是在检索,它是在接龙:在”杭州有很多美味的特色美食,比如”后面,什么词最可能接着出现。
第三段更直接:英文直接崩了。
这就是”下一个词预测”这台机器的真面目:
| 它学会了 | 它没学会 |
|---|---|
| 语言的形状:语法、语气、格式 | “这句话是不是真的” |
| “回答这类问题该用什么腔调” | “我不知道”该在什么时候说 |
因为 loss 里压根没有”对错”这一项。 loss 只问一件事:你给正确答案押的注够不够大? 而”正确答案”在训练数据里就是”文本里实际出现的下一个词”——一个流畅的谎言,和一个准确的答案,对 loss 而言没有区别。
幻觉不是一个 bug,它是这个目标的必然产物。 要让它”少编”,得换一个目标函数——这正是后训练(第三篇)要做的事。
(幻觉 = 模型一本正经地说出根本不存在的事实。这一段想说的就是:它不是偶尔抽风,而是训练目标本身留出的自由——既然”接得像”就算对,那编得流畅也就没被惩罚。)
八、SFT:换掉的只是 labels
SFT 全称 Supervised Fine-Tuning,有监督微调。听起来像是个新东西。
它的训练循环,和预训练逐字一致。
我对比过 train_pretrain.py:24-71 和 train_full_sft.py:24-70 两个 train_epoch 函数——唯一差异是 lm_checkpoint(...) 那一行的断行位置。连带调用的 model(...)、.backward()、optimizer.step(),一字不差。
换掉的只有喂进去的 labels。
8.1 SFT 的数据长这样
预训练数据是一行一行的纯文本({"text": "..."})。SFT 数据是多轮对话:
{
"conversations": [
{"role": "user", "content": "你好"},
{"role": "assistant", "content": "你好!"},
{"role": "user", "content": "再见"},
{"role": "assistant", "content": "再见!"}
]
}
(格式取自项目 README,字段名与 dataset/lm_dataset.py 中的定义一致。)
apply_chat_template 把它渲染成带 <|im_start|> / <|im_end|> 标记的一整串文本,然后照常 tokenize 成 input_ids。
到这一步为止,和预训练没区别。分岔发生在造 labels 的那一步。
8.2 造 labels:全填 -100,再把 assistant 的话挑回来
dataset/lm_dataset.py:91-107,逐字照抄(含类体缩进):
def generate_labels(self, input_ids):
labels = [-100] * len(input_ids)
i = 0
while i < len(input_ids):
if input_ids[i:i + len(self.bos_id)] == self.bos_id:
start = i + len(self.bos_id)
end = start
while end < len(input_ids):
if input_ids[end:end + len(self.eos_id)] == self.eos_id:
break
end += 1
for j in range(start, min(end + len(self.eos_id), self.max_length)):
labels[j] = input_ids[j]
i = end + len(self.eos_id) if end < len(input_ids) else len(input_ids)
else:
i += 1
return labels
这里的 bos_id / eos_id 不是普通的起止符(lm_dataset.py:68-69):
self.bos_id = tokenizer(f'{tokenizer.bos_token}assistant\n', add_special_tokens=False).input_ids
self.eos_id = tokenizer(f'{tokenizer.eos_token}\n', add_special_tokens=False).input_ids
bos_id 是 <|im_start|>assistant\n 这一整串的 token 序列。 所以这段代码在做的事情是:
labels = [-100] * len(input_ids)—— 先把所有位置都标成”不算 loss”- 在 token 序列里找
<|im_start|>assistant\n - 找到之后,从它后面开始,一路复制到
<|im_end|>为止,把这些位置的 label 恢复成真实 token - 剩下的一律保持
-100
8.3 一张图看清差别
一段 SFT 训练样本,token 序列和 labels 的对照:
| 位置 | token 内容 | label | 算不算 loss |
|---|---|---|---|
| 0 | <|im_start|> | -100 | ✗ |
| 1 | system | -100 | ✗ |
| … | “你是一个知识丰富的AI…” | -100 | ✗ |
| k | <|im_start|> | -100 | ✗ |
| k+1 | user | -100 | ✗ |
| … | “你好” | -100 | ✗ |
| m | <|im_start|> | -100 | ✗ |
| m+1 | assistant | assistant | ✓ |
| m+2 | “你好” | “你好” | ✓ |
| m+3 | ”!” | ”!” | ✓ |
| m+4 | <|im_end|> | <|im_end|> | ✓ |
system 的设定、user 的提问、以及 assistant 这个角色标记本身,全都标成 -100,不算 loss。
8.4 所以 SFT 到底做了什么
它没有换模型、没有换循环、没有换损失函数。它只是把”每个词都要预测”改成了”只有 assistant 该说的话要预测”。
用一句话概括两者的分工:
| 预训练 | SFT | |
|---|---|---|
| labels | input_ids.clone()(原文全部) | 只有 assistant 段,其余 -100 |
| 模型在学 | 整段文本怎么续写 | 轮到 assistant 时该说什么 |
| 数据量级 | pretrain_t2t_mini 1.2GB | sft_t2t_mini 1.6GB(按 README) |
| 类比 | 读完整座图书馆,学会接龙 | 拜师,学话术 |
这就是那四行 cross_entropy 里 ignore_index=-100 的全部含义。 预训练和 SFT,是同一个损失函数的两种 labels 填法。
8.5 顺带一提:-100 这个约定贯穿了整条链路
后面几个阶段还在用同一个把戏:
| 阶段 | mask 的形态 | 出处 |
|---|---|---|
| 预训练 | pad 位 → -100 | lm_dataset.py:57 |
| SFT | 非 assistant 段 → -100 | lm_dataset.py:92 |
| DPO | loss mask 用 0/1,再乘进 log_probs | lm_dataset.py:180-196 |
| GRPO / Agent | completion_mask,只对生成的部分算 | train_grpo.py:143 |
“哪些 token 该算 loss”是贯穿整条训练链路的一个持续问题,而每种方法给出的答案,恰好就是那种方法与其它方法的区别所在。
九、超参数上的两个信号
循环代码一样,那预训练和 SFT 在脚本层面还剩什么区别?对比两个脚本的参数区:
| 参数 | 预训练 | SFT | 说明 |
|---|---|---|---|
--save_weight | pretrain | full_sft | 产出文件名,决定下一个阶段读谁 |
--learning_rate | 5e-4 | 1e-5 | 差 50 倍 |
--batch_size | 32 | 16 | — |
--accumulation_steps | 8 | 1 | 预训练的等效 batch 更大 |
--max_seq_len | 340 | 768 | 按 README,中文约 1.5 字符/token |
--from_weight | none | pretrain | 从零开始 vs 接着上次 |
| 数据集类 | PretrainDataset | SFTDataset | 见 §八 |
9.1 学习率差 50 倍,这个数字本身在讲故事
预训练要大步走,SFT 要小步走。
预训练时模型是一张白纸,参数离”有用”很远,可以大步流星地往正确方向跑。到了 SFT,参数已经在一个不错的位置上了——这时候需要的是微调,不是重学。步子迈大了,会把预训练辛苦学到的东西一脚踢飞。
这也解释了 SFT 这个名字里”Fine”(精细)的意思。
9.2 from_weight 是训练链路的接缝
trainer/trainer_utils.py:121-133 里的 init_model(逐字照抄):
def init_model(lm_config, from_weight='pretrain', tokenizer_path='../model', save_dir='../out', device='cuda'):
tokenizer = AutoTokenizer.from_pretrained(tokenizer_path)
model = MiniMindForCausalLM(lm_config)
if from_weight!= 'none':
moe_suffix = '_moe' if lm_config.use_moe else ''
weight_path = f'{save_dir}/{from_weight}_{lm_config.hidden_size}{moe_suffix}.pth'
weights = torch.load(weight_path, map_location=device)
model.load_state_dict(weights, strict=False)
get_model_params(model, lm_config)
Logger(f'Trainable Params: {sum(p.numel() for p in model.parameters() if p.requires_grad) / 1e6:.3f}M')
return model.to(device), tokenizer
(from_weight!= 'none' 那个缺掉的空格是源码原文,不是排版失误。)
注意 weight_path 那个 f-string:{from_weight}_{hidden_size}.pth。
预训练传 none,因为它就是链的起点,没有”上一个”。SFT 传 pretrain,于是它去读 out/pretrain_768.pth,训完存成 out/full_sft_768.pth。后面 DPO 传 full_sft,GRPO 传它上一步产出……
flowchart TD
Z["随机初始化的<br/>6391 万个数字"] -->|train_pretrain.py| P["pretrain_768.pth"]
P -->|--from_weight pretrain| S["full_sft_768.pth"]
S -->|--from_weight full_sft| D["dpo_768.pth"]
D -->|--from_weight dpo| G["grpo_768.pth"]
G -->|--from_weight grpo| A["agent_768.pth"]
“训练链路”不是一个抽象概念,它就是文件接文件。 每个 .pth 就是一个存档点,下一个脚本读它、改它、存成新的。
所以严格说,“训练一个大模型”从来不是一次训练,而是一串训练,每一步都在改写上一步的产物。第三篇要讲的,就是这条链后面那几节各自在改什么。
十、自检清单
读完这篇,如果下面每一条你都能答上来,“训练”这个词对你就不再是个黑箱了:
- 训练循环里真正改变模型的,是哪一行?
- loss 衡量的是什么?为什么
-log(概率)能表达”错得有多离谱”? -
logits[..., :-1, :]和labels[..., 1:]为什么要错开一位? - “只是猜下一个词”,为什么就能把知识装进去?它的边界在哪?
- 预训练数据集的
labels为什么就等于input_ids本身? -
-100是什么?谁规定它在交叉熵里被跳过? - 预训练和 SFT 的差别,落到代码上究竟是哪几行不一样?
- SFT 里为什么连
assistant这个角色标记本身都不算 loss? - 为什么 SFT 的学习率要比预训练小 50 倍?调大了会怎样?
- “训练链路”在文件系统上是什么样子?
--from_weight none为什么特殊?
附:本文的核查方式
代码引用:本文所有代码块均取自 jingyaogong/minimind(2026-10-16 拉取的 master),标注了文件名与行号。
代码排版约定(与上一篇相同):标了「逐字照抄」的块,缩进与空行与源码完全一致,可以直接拿去比对。唯二的例外都在 §三——def forward 那行下面已说明”源码里就是没换行的一整行”,四行 loss 摘录去掉了缩进(也已就地说明)。除此之外没有第三处改动;源码里看着像笔误的地方保持原样,不做”顺手修一下”。
关于”两个 train_epoch 逐字一致”这个说法:这是把 train_pretrain.py:24-71 与 train_full_sft.py:24-70 两段代码逐字比对后得出的。差异只有一处——lm_checkpoint(...) 调用在同一行的换行位置不同(同一函数、同一组参数)。除此之外循环体无差异。
模型输出样例的来源:§六、§七中的对话样例均取自项目 README,不是我运行得到的。其中 §七的 Zero 模型样例,README 自身标注为”早期”且评价为”事实知识与泛化效果仍较有限”;它是 pretrain_t2t_mini + sft_t2t_mini 的组合产出。不同版本、不同数据的输出会有差异,请把它当作”这个量级会是什么样”的示意,而不是固定结果。
数据量级:1.2GB / 1.6GB 为项目 README 所述。压缩比(中文 1.5–1.7 字符/token)亦来自 README。
本文不涉及:self-attention 内部怎么算、位置编码(RoPE/YaRN)、MoE 路由细节、分布式训练(DDP/DeepSpeed)。这些要么在《AI 应用开发面试笔记》里有速查版,要么属于工程实现而非本系列主线。
附:延伸阅读
- 参照项目:jingyaogong/minimind ——
trainer/train_pretrain.py、dataset/lm_dataset.py、model/model_minimind.py是本文的直接来源 - 上一篇:《大模型训练笔记(一):模型下载下来到底是什么》
- 本号相关:
- 《AI 是如何识别一只猫的:最好懂的神经网络解释》 —— “训练就是调参”的最基础版本
- 《模型正在被「故意变笨」》 —— 从”目标函数决定模型行为”这个角度,和本文 §七 是同一个道理
- 下一篇:SFT 之后为什么还不够——LoRA、蒸馏与强化学习,以及你自己怎么上手