# 第 17 课:从 Pretrain 权重到 Full SFT 权重
这一课只解决一个问题:MiniMind 如何把预训练阶段产出的权重,接到 Full SFT 阶段继续训练,并最终被推理脚本加载。
## 目录
- [0. 本节主线](#l17-mainline)
- [1. 原理讲解](#l17-principle)
- [2. 源码阅读顺序图](#l17-reading-order)
- [3. MiniMind 源码走读](#l17-source-walkthrough)
- [4. 本节必须会写 / 暂时不要求](#l17-must-write)
- [5. 手写模块](#l17-handwrite)
- [6. 对齐测试](#l17-alignment-test)
- [7. 阶段组装](#l17-stage-assembly)
- [8. 本节检查](#l17-check)
- [9. 下一课](#l17-next)
## 0. 本节主线
MiniMind 原项目的阶段切换是:
```text
train_pretrain.py
-> 保存 out/pretrain_768.pth
-> train_full_sft.py --from_weight pretrain
-> init_model 加载 out/pretrain_768.pth
-> 用 SFTDataset 继续训练
-> 保存 out/full_sft_768.pth
-> eval_llm.py --weight full_sft
-> 加载 out/full_sft_768.pth 推理
```
教学版实现的阶段切换应该是:
```text
train_pretrain_impl.py
-> 保存 out/course_pretrain_*.pth
-> train_sft_impl.py --from_weight course_pretrain
-> 加载 course_pretrain 权重
-> 用 CourseSFTDataset 继续训练
-> 保存 out/course_sft_*.pth
-> 用同一套 tokenizer + 模型结构做推理验证
```
一句话:
```text
from_weight 决定“从哪个已有权重开始训练”,save_weight 决定“这一阶段训练完保存成什么权重”。
```
## 1. 原理讲解
### 1.1 阶段训练不是重新开始
Pretrain 阶段学到的是基础语言建模能力:
```text
普通文本
-> next-token prediction
-> 学语言统计、知识片段、基本续写能力
```
SFT 阶段不是把模型随机初始化后重新学,而是在 pretrain 参数基础上继续优化:
```text
pretrain 参数
-> 对话格式数据
-> assistant 区域监督
-> 学会按用户指令回答
```
参数变化可以写成:
$$
\begin{aligned}
\theta_{\text{pretrain}}
&= \mathrm{Train}_{\text{pretrain}}(\theta_0, D_{\text{pretrain}}) \\
\theta_{\text{sft}}
&= \mathrm{Train}_{\text{sft}}(\theta_{\text{pretrain}}, D_{\text{sft}})
\end{aligned}
$$
其中:
| 符号 | 类型 | 含义 |
|---|---|---|
| $\theta_0$ | 参数集合 | 随机初始化参数 |
| $\theta_{\text{pretrain}}$ | 参数集合 | 预训练后参数 |
| $\theta_{\text{sft}}$ | 参数集合 | SFT 后参数 |
| $D_{\text{pretrain}}$ | 数据集 | 普通文本预训练数据 |
| $D_{\text{sft}}$ | 数据集 | 对话监督微调数据 |
这就是为什么 `train_full_sft.py` 默认 `from_weight='pretrain'`。
### 1.2 `from_weight` 和 `from_resume` 不是一回事
这两个参数很容易混。
`from_weight` 解决的是“阶段初始化”:
```text
从 out/pretrain_768.pth 初始化模型参数;
然后开始一个新的 SFT 阶段。
```
`from_resume` 解决的是“同一阶段中断后继续”:
```text
从 checkpoints/full_sft_768_resume.pth 恢复;
继续未完成的 Full SFT 阶段。
```
对比:
| 参数 | 读取文件 | 恢复内容 | 使用场景 |
|---|---|---|---|
| `from_weight='pretrain'` | `out/pretrain_768.pth` | 只恢复模型权重 | 从 pretrain 进入 SFT |
| `from_resume=1` | `checkpoints/full_sft_768_resume.pth` | model / optimizer / scaler / epoch / step | SFT 中断后继续 |
所以阶段切换时,通常是:
```text
from_weight='pretrain'
from_resume=0
```
如果 SFT 训练中断,再恢复时才用:
```text
from_resume=1
```
### 1.3 权重文件名是阶段接口
MiniMind 用权重名前缀表达阶段:
```text
pretrain_768.pth
full_sft_768.pth
dpo_768.pth
grpo_768.pth
```
文件名规则是:
$$
\begin{aligned}
\text{weight\_path}
&= \text{save\_dir}/(\text{weight}\_\text{hidden\_size}\text{moe\_suffix}.pth) \\
\text{moe\_suffix}
&=
\begin{cases}
\text{"\_moe"}, & \text{use\_moe=True} \\
\text{""}, & \text{use\_moe=False}
\end{cases}
\end{aligned}
$$
例子:
| 阶段 | `weight` | `hidden_size` | `use_moe` | 文件 |
|---|---|---:|---|---|
| Pretrain | `pretrain` | 768 | False | `out/pretrain_768.pth` |
| Full SFT | `full_sft` | 768 | False | `out/full_sft_768.pth` |
| MoE Pretrain | `pretrain` | 768 | True | `out/pretrain_768_moe.pth` |
| MoE SFT | `full_sft` | 768 | True | `out/full_sft_768_moe.pth` |
这一套命名让训练脚本和推理脚本能通过同一个 `weight` 参数找到对应权重。
### 1.4 SFT 为什么需要更小学习率
Pretrain 是从随机初始化开始,学习率可以大一些。
SFT 是在已有模型上微调,如果学习率太大,可能破坏 pretrain 学到的语言能力。MiniMind 默认:
```text
pretrain learning_rate = 5e-4
full_sft learning_rate = 1e-5
```
可以理解成:
$$
\eta_{\text{sft}} \ll \eta_{\text{pretrain}}
$$
这里 $\eta$ 表示学习率。
SFT 的目标不是让模型从零学语言,而是让参数在已有能力附近移动到“更会对话”的区域。
### 1.5 推理时为什么 `pretrain` 和 `full_sft` prompt 不同
`eval_llm.py` 里有一个分支:
```text
如果 weight 名里包含 pretrain:
输入 = bos_token + 用户原文
否则:
输入 = chat_template(conversation, add_generation_prompt=True)
```
原因是:
```text
pretrain 权重主要学普通文本续写,不一定学会 chat template;
full_sft 权重学过对话格式,推理时应该喂同样的 chat template。
```
这体现了一个重要原则:
```text
训练时用什么格式,推理时就尽量用什么格式。
```
### 1.6 阶段切换的变量流
从 pretrain 到 full_sft,关键变量是:
| 变量 | 类型/形状 | 阶段 | 含义 |
|---|---|---|---|
| `from_weight` | str | SFT 启动 | 要加载的旧阶段权重名前缀 |
| `save_weight` | str | SFT 保存 | 当前阶段保存的新权重名前缀 |
| `weight_path` | str | 加载 | `out/{from_weight}_{hidden_size}.pth` |
| `ckp` | str | 保存/推理 | `out/{save_weight}_{hidden_size}.pth` |
| `input_ids` | `[B, S]` | 训练 | SFTDataset 输出 |
| `labels` | `[B, S]` | 训练 | assistant 区域 label |
| `logits` | `[B, S, V]` | forward | 模型输出 |
| `loss` | 标量 | backward | SFT 训练损失 |
你要把这条线记住:
```text
from_weight 只影响训练开始;
save_weight 影响训练结束和后续推理。
```
## 2. 源码阅读顺序图
这节源码按这个顺序读:
```text
README 训练命令
-> train_pretrain.py 保存 pretrain 权重
-> train_full_sft.py 默认 from_weight / save_weight
-> trainer_utils.init_model 加载 from_weight
-> train_full_sft.py 保存 full_sft 权重
-> eval_llm.py 根据 --weight 加载推理权重
-> eval_llm.py 根据 weight 选择 prompt 格式
```
对应文件:
```text
README.md
trainer/train_pretrain.py
trainer/train_full_sft.py
trainer/trainer_utils.py
eval_llm.py
```
这节不要再陷进 `SFTDataset` 内部,第 16 课已经讲过。现在重点是阶段之间的权重如何流动。
## 3. MiniMind 源码走读
### 第 1 步:README 给出的阶段顺序
File: `README.md:322-340`
Read this to understand: 原项目推荐的最小训练顺序就是先 pretrain,再 full_sft,再 eval。
Code/config/template excerpt:
```bash
cd trainer && python train_pretrain.py
cd trainer && python train_full_sft.py
python eval_llm.py --weight full_sft
```
This shows:
- `train_pretrain.py` 先产出 pretrain 权重。
- `train_full_sft.py` 再基于 pretrain 权重训练。
- `eval_llm.py --weight full_sft` 用 SFT 后的权重推理。
### 第 2 步:Pretrain 默认保存成 `pretrain`
File: `trainer/train_pretrain.py:84-103`
Read this to understand: pretrain 阶段的默认保存名前缀。
Code/config/template excerpt:
```python
parser.add_argument('--save_weight', default='pretrain', type=str, help="保存权重的前缀名")
parser.add_argument('--from_weight', default='none', type=str, help="基于哪个权重训练,为none则从头开始")
parser.add_argument("--data_path", type=str, default="../dataset/pretrain_t2t_mini.jsonl", help="预训练数据路径")
```
This code shows:
- Pretrain 默认从头开始,`from_weight='none'`。
- Pretrain 默认保存名前缀是 `pretrain`。
- 默认会写出 `out/pretrain_768.pth`。
### 第 3 步:SFT 默认加载 `pretrain`,保存 `full_sft`
File: `trainer/train_full_sft.py:84-104`
Read this to understand: SFT 阶段如何接上 pretrain 阶段。
Code/config/template excerpt:
```python
parser.add_argument('--save_weight', default='full_sft', type=str, help="保存权重的前缀名")
parser.add_argument("--learning_rate", type=float, default=1e-5, help="初始学习率")
parser.add_argument("--data_path", type=str, default="../dataset/sft_t2t_mini.jsonl", help="训练数据路径")
parser.add_argument('--from_weight', default='pretrain', type=str, help="基于哪个权重训练,为none则不基于任何权重训练")
parser.add_argument('--from_resume', default=0, type=int, choices=[0, 1], help="是否自动检测&续训(0=否,1=是)")
```
This code shows:
- SFT 默认从 `pretrain` 权重初始化。
- SFT 默认保存成 `full_sft`。
- SFT 默认学习率是 `1e-5`。
- `from_resume` 是同一阶段续训,不是阶段初始化。
### 第 4 步:`init_model` 把 `from_weight` 变成文件路径
File: `trainer/trainer_utils.py:119-131`
Read this to understand: `from_weight='pretrain'` 如何定位到真实 `.pth` 文件。
Code/config/template excerpt:
```python
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)
```
This code shows:
- `from_weight` 只是前缀,不是完整路径。
- `save_dir` 默认是 `../out`。
- `hidden_size` 和 MoE 后缀会参与文件名。
- 训练脚本里的 `from_weight='pretrain'` 会加载 `../out/pretrain_768.pth`。
### 第 5 步:SFT 保存的是 `full_sft`
File: `trainer/train_full_sft.py:61-70`
Read this to understand: SFT 训练后会写出普通权重和 resume checkpoint。
Code/config/template excerpt:
```python
if (step % args.save_interval == 0 or step == iters) and is_main_process():
model.eval()
moe_suffix = '_moe' if lm_config.use_moe else ''
ckp = f'{args.save_dir}/{args.save_weight}_{lm_config.hidden_size}{moe_suffix}.pth'
raw_model = model.module if isinstance(model, DistributedDataParallel) else model
raw_model = getattr(raw_model, '_orig_mod', raw_model)
state_dict = raw_model.state_dict()
torch.save({k: v.half().cpu() for k, v in state_dict.items()}, ckp)
lm_checkpoint(lm_config, weight=args.save_weight, model=model, optimizer=optimizer, epoch=epoch, step=step, wandb=wandb, save_dir='../checkpoints', scaler=scaler)
```
This code shows:
- `ckp` 使用 `args.save_weight`。
- 默认 `args.save_weight='full_sft'`。
- 普通推理权重会保存到 `out/full_sft_768.pth`。
- resume checkpoint 会保存到 `checkpoints/full_sft_768_resume.pth`。
### 第 6 步:`eval_llm.py` 加载 native torch 权重
File: `eval_llm.py:12-30`
Read this to understand: 推理时 `--weight full_sft` 如何加载权重。
Code/config/template excerpt:
```python
tokenizer = AutoTokenizer.from_pretrained(args.load_from)
if 'model' in args.load_from:
model = MiniMindForCausalLM(MiniMindConfig(
hidden_size=args.hidden_size,
num_hidden_layers=args.num_hidden_layers,
use_moe=bool(args.use_moe),
inference_rope_scaling=args.inference_rope_scaling
))
moe_suffix = '_moe' if args.use_moe else ''
ckp = f'./{args.save_dir}/{args.weight}_{args.hidden_size}{moe_suffix}.pth'
model.load_state_dict(torch.load(ckp, map_location=args.device), strict=True)
else:
model = AutoModelForCausalLM.from_pretrained(args.load_from, trust_remote_code=True)
```
This code shows:
- `--load_from ./model` 会走 native torch 权重加载。
- `--weight full_sft` 会参与构造 `./out/full_sft_768.pth`。
- `hidden_size`、`num_hidden_layers`、`use_moe` 必须和权重对应。
- `--load_from ./minimind-3` 这类路径会走 HuggingFace transformers 格式。
### 第 7 步:推理 prompt 根据权重阶段变化
File: `eval_llm.py:71-78`
Read this to understand: pretrain 权重和 SFT 权重推理输入格式不同。
Code/config/template excerpt:
```python
conversation.append({"role": "user", "content": prompt})
if 'pretrain' in args.weight:
inputs = tokenizer.bos_token + prompt
else:
inputs = tokenizer.apply_chat_template(conversation, tokenize=False, add_generation_prompt=True, open_thinking=bool(args.open_thinking))
inputs = tokenizer(inputs, return_tensors="pt", truncation=True).to(args.device)
```
This code shows:
- `pretrain` 权重用普通文本续写式输入。
- `full_sft` 权重用 chat template。
- SFT 后的模型推理格式要和 SFT 训练格式保持一致。
## 4. 本节必须会写 / 暂时不要求
必须会写:
1. 阶段权重路径:
$$
\begin{aligned}
\text{pretrain\_path}
&= \text{out}/\text{pretrain}_{H}.pth \\
\text{sft\_path}
&= \text{out}/\text{full\_sft}_{H}.pth
\end{aligned}
$$
2. 阶段初始化关系:
$$
\theta_{\text{sft,start}} = \theta_{\text{pretrain}}
$$
3. 阶段训练更新:
$$
\theta_{\text{sft,end}}
= \mathrm{Train}_{\text{sft}}(\theta_{\text{sft,start}}, D_{\text{sft}})
$$
4. 推理加载关系:
```text
eval_llm.py --load_from ./model --weight full_sft
-> ./out/full_sft_768.pth
```
5. 区分:
```text
from_weight: 阶段开始时加载哪个普通权重
save_weight: 阶段结束时保存成哪个普通权重
from_resume: 当前阶段中断后恢复 optimizer/scaler/epoch/step
```
暂时不要求:
```text
1. 真正训练出高质量 full_sft 权重
2. HuggingFace 格式转换
3. WebUI / OpenAI API 部署
4. LoRA 合并
5. 多阶段自动流水线脚本
```
## 5. 手写模块
本节你要看的是:
```text
course/impl/train_sft_impl.py
```
这节已经给它补了阶段脚本参数骨架。你要理解每个默认值为什么这样设置:
```python
parser.add_argument("--data_path", default="course/labs/tiny_sft.jsonl")
parser.add_argument("--from_weight", default="course_pretrain")
parser.add_argument("--save_weight", default="course_sft")
parser.add_argument("--learning_rate", type=float, default=1e-5)
parser.add_argument("--accumulation_steps", type=int, default=1)
```
教学版不用直接复用 `pretrain/full_sft` 这两个名字,而是用:
```text
course_pretrain
course_sft
```
这样可以避免和原项目训练出来的正式权重混在一起。
### 5.1 本节你要补的不是训练逻辑
完整 `train_sft_impl.py` 还需要:
```text
CourseMiniMindForCausalLM
CourseSFTDataset
train_one_epoch
save_course_checkpoint
load_course_checkpoint
```
这些模块有些还没全部实现。所以第 17 课先只要求你把阶段参数理解清楚。
真正要你能写出来的是:
```text
from_weight 默认是 course_pretrain
save_weight 默认是 course_sft
data_path 默认是 tiny_sft.jsonl
learning_rate 默认比 pretrain 小
```
### 5.2 后续组装时的伪流程
后续实现 `main()` 时应该长这样:
```text
args = parse_args()
tokenizer = AutoTokenizer.from_pretrained(args.tokenizer_path)
model = CourseMiniMindForCausalLM(...)
load weights from args.from_weight
dataset = CourseSFTDataset(args.data_path, tokenizer, args.max_seq_len)
loader = DataLoader(dataset, ...)
optimizer = AdamW(model.parameters(), lr=args.learning_rate)
train_one_epoch(...)
save_course_checkpoint(..., weight=args.save_weight)
```
注意:这里是流程说明,不是让你现在把所有代码一次写完。
## 6. 对齐测试
本节新增一个轻量测试:
```text
course/impl/tests/test_sft_stage_args.py
```
运行命令:
```bash
cd /home/sun/minimind
python course/impl/tests/test_sft_stage_args.py
```
这个测试不训练模型,只检查 SFT 阶段脚本默认参数是否符合课程路线:
```text
data_path=course/labs/tiny_sft.jsonl
from_weight=course_pretrain
save_weight=course_sft
learning_rate=1e-05
sft_stage_args=passed
```
它的意义是:先把阶段接口固定,再逐步把数据、模型、训练循环接进去。
## 7. 阶段组装
这一课结束后,你应该能画出两条阶段链。
原项目链路:
```text
trainer/train_pretrain.py
-> out/pretrain_768.pth
-> trainer/train_full_sft.py --from_weight pretrain
-> out/full_sft_768.pth
-> eval_llm.py --load_from ./model --weight full_sft
```
教学版链路:
```text
course/impl/train_pretrain_impl.py
-> out/course_pretrain_*.pth
-> course/impl/train_sft_impl.py --from_weight course_pretrain
-> out/course_sft_*.pth
-> 教学版推理或原生加载验证
```
当前还缺的实现:
```text
1. CourseMiniMindForCausalLM 完整组装
2. CoursePretrainDataset
3. CourseSFTDataset
4. train_one_epoch 完整实现
5. save_course_checkpoint / load_course_checkpoint 完整实现
```
这几个补齐后,SFT 阶段就能真正跑起来。
## 8. 本节检查
1. `train_pretrain.py` 默认保存成什么权重名前缀?
2. `train_full_sft.py` 默认从哪个权重名前缀加载?
3. `train_full_sft.py` 默认保存成什么权重名前缀?
4. `from_weight` 和 `from_resume` 的区别是什么?
5. `eval_llm.py --load_from ./model --weight full_sft` 会加载哪个文件?
6. 为什么 `pretrain` 权重推理时不用 chat template,而 `full_sft` 权重推理时要用?
7. 教学版为什么用 `course_pretrain` 和 `course_sft`,而不是直接覆盖 `pretrain/full_sft`?
8. 如果 `hidden_size=512` 且 `use_moe=True`,`save_weight='full_sft'` 对应的权重文件名是什么?
## 9. 下一课
第 18 课进入 LoRA 原理和手写实现。
下一课要解决:
```text
为什么 LoRA 可以只训练低秩增量;
LoRA 如何插到 Linear 层;
哪些参数冻结,哪些参数训练;
LoRA 权重如何保存和加载;
教学版 core/lora.py 应该怎么实现。
```