大模型训练笔记(二):预训练与 SFT,从词语接龙到听指令

目录


一、「训练」在代码上是 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)前向传播,顺带算出 lossforward
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 序列。 所以这段代码在做的事情是:

  1. labels = [-100] * len(input_ids) —— 先把所有位置都标成”不算 loss”
  2. 在 token 序列里找 <|im_start|>assistant\n
  3. 找到之后,从它后面开始,一路复制到 <|im_end|> 为止,把这些位置的 label 恢复成真实 token
  4. 剩下的一律保持 -100

8.3 一张图看清差别

一段 SFT 训练样本,token 序列和 labels 的对照:

位置token 内容label算不算 loss
0<|im_start|>-100✗
1system-100✗
…“你是一个知识丰富的AI…”-100✗
k<|im_start|>-100✗
k+1user-100✗
…“你好”-100✗
m<|im_start|>-100✗
m+1assistantassistant✓
m+2“你好”“你好”✓
m+3”!””!”✓
m+4<|im_end|><|im_end|>✓

system 的设定、user 的提问、以及 assistant 这个角色标记本身,全都标成 -100,不算 loss。

8.4 所以 SFT 到底做了什么

它没有换模型、没有换循环、没有换损失函数。它只是把”每个词都要预测”改成了”只有 assistant 该说的话要预测”。

用一句话概括两者的分工:

预训练SFT
labelsinput_ids.clone()(原文全部)只有 assistant 段,其余 -100
模型在学整段文本怎么续写轮到 assistant 时该说什么
数据量级pretrain_t2t_mini 1.2GBsft_t2t_mini 1.6GB(按 README)
类比读完整座图书馆,学会接龙拜师,学话术

这就是那四行 cross_entropy 里 ignore_index=-100 的全部含义。 预训练和 SFT,是同一个损失函数的两种 labels 填法。

8.5 顺带一提:-100 这个约定贯穿了整条链路

后面几个阶段还在用同一个把戏:

阶段mask 的形态出处
预训练pad 位 → -100lm_dataset.py:57
SFT非 assistant 段 → -100lm_dataset.py:92
DPOloss mask 用 0/1,再乘进 log_probslm_dataset.py:180-196
GRPO / Agentcompletion_mask,只对生成的部分算train_grpo.py:143

“哪些 token 该算 loss”是贯穿整条训练链路的一个持续问题,而每种方法给出的答案,恰好就是那种方法与其它方法的区别所在。


九、超参数上的两个信号

循环代码一样,那预训练和 SFT 在脚本层面还剩什么区别?对比两个脚本的参数区:

参数预训练SFT说明
--save_weightpretrainfull_sft产出文件名,决定下一个阶段读谁
--learning_rate5e-41e-5差 50 倍
--batch_size3216—
--accumulation_steps81预训练的等效 batch 更大
--max_seq_len340768按 README,中文约 1.5 字符/token
--from_weightnonepretrain从零开始 vs 接着上次
数据集类PretrainDatasetSFTDataset见 §八

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 应用开发面试笔记》里有速查版,要么属于工程实现而非本系列主线。


附:延伸阅读