# 第 20 课:Distillation 训练链路
这一课只解决一个问题:MiniMind 的 `train_distillation.py` 如何让 student 同时学习真实 assistant label 和 teacher 的 soft logits。
## 目录
- [0. 本节主线](#l20-mainline)
- [1. 本节要懂的 6 个原理](#l20-principles)
- [2. 蒸馏训练完整原理](#l20-complete-principle)
- [3. 源码阅读顺序图](#l20-reading-order)
- [4. MiniMind 源码走读](#l20-source-walkthrough)
- [5. 本节必须会写 / 暂时不要求](#l20-must-write)
- [6. 实验验证](#l20-experiment)
- [7. 阶段组装](#l20-stage-assembly)
- [8. 本节检查](#l20-check)
- [9. 下一课](#l20-next)
## 0. 本节主线
蒸馏训练流程是:
```text
加载 student 模型
-> 加载 teacher 模型
-> 冻结 teacher
-> 用 SFTDataset 得到 input_ids / labels
-> student 前向得到 student_logits
-> teacher no_grad 前向得到 teacher_logits
-> labels 生成 CE loss
-> teacher soft distribution 生成 KL distillation loss
-> 总 loss = alpha * CE + (1 - alpha) * distill
-> 只反传和更新 student
-> 保存 student 权重
```
这节课的核心不是“teacher 直接给答案”,而是:
```text
teacher 给每个位置、每个 token 的概率分布;
student 学真实 label,也学 teacher 对其它 token 的相对偏好。
```
一句话:
```text
SFT 只告诉 student 正确 token 是什么,蒸馏还告诉 student teacher 认为哪些 token 也接近合理。
```
## 1. 本节要懂的 6 个原理
| 原理 | 要理解什么 | 源码位置 |
|---|---|---|
| student/teacher 是两套模型 | student 被训练,teacher 只提供 soft target | `trainer/train_distillation.py:204-214` |
| teacher 必须冻结 | teacher 用 eval/no_grad/requires_grad_(False) 避免更新和节省显存 | `trainer/train_distillation.py:43-45`, `trainer/train_distillation.py:207-209` |
| 蒸馏仍用 SFTDataset | 数据、chat template、assistant-only labels 仍沿用 SFT | `trainer/train_distillation.py:211`, `dataset/lm_dataset.py:88-119` |
| CE loss 学真实 label | student logits 与 labels shift 后计算 token-level CE | `trainer/train_distillation.py:68-80` |
| KL loss 学 teacher 分布 | teacher softmax 后作为 target,student log_softmax 后做 KL | `trainer/train_distillation.py:25-36`, `trainer/train_distillation.py:82-90` |
| alpha/temperature 控制训练信号 | alpha 混合 CE/KL,temperature 平滑 teacher 分布 | `trainer/train_distillation.py:92-93`, `trainer/train_distillation.py:171-173` |
学完本节,你应该能说明:
```text
distillation_loss 的输入 shape 是什么;
teacher 为什么不 backward;
loss_mask 为什么来自 labels[..., 1:];
alpha 和 temperature 分别控制什么;
最终保存的是 student,不是 teacher。
```
## 2. 蒸馏训练完整原理
### 2.1 SFT 的监督信号太硬
普通 SFT 对每个位置只有一个目标 token。
假设当前位置的正确 token 是:
```text
北京
```
CE loss 看的是:
```text
模型给“北京”的概率够不够高
```
它不会告诉 student:
```text
“上海”是不是比“苹果”更接近合理;
“中国”是不是也有一定语义相关性;
标点、停用词、同义表达之间有什么相对偏好。
```
真实 label 是 hard target:
```text
正确 token = 1
其它 token = 0
```
蒸馏引入 teacher 的 soft target:
```text
teacher 对整个 vocab 的概率分布
```
这让 student 不只学习“唯一答案”,还学习 teacher 的分布形状。
### 2.2 teacher logits 是什么
teacher 和 student 都是 causal LM。
对同一个 `input_ids`,它们都会输出:
```text
student_logits: [batch, seq, vocab]
teacher_logits: [batch, seq, vocab]
```
第 10 课讲过 next-token shift,所以训练时真正使用:
```text
student_logits[..., :-1, :]
teacher_logits[..., :-1, :]
labels[..., 1:]
```
含义是:
```text
位置 i 的 logits 用来预测位置 i+1 的 label。
```
### 2.3 CE loss 和 distill loss 分工
MiniMind 的蒸馏总损失是:
```text
loss = alpha * CE + (1 - alpha) * Distill
```
CE loss 来自真实数据:
```text
student_logits -> labels
```
Distill loss 来自 teacher:
```text
student_logits -> teacher_logits
```
两者都只在 assistant label 区域上计算。这个区域由:
```python
labels[..., 1:] != -100
```
决定。
为什么不在 user/system 区域算 teacher KL?
因为 SFTDataset 把 user/system/prompt 区域设为 `-100`,表示这些 token 只是条件,不是训练目标。蒸馏训练沿用这个边界,避免 student 被训练去复述 prompt。
### 2.4 temperature 的作用
蒸馏时常把 logits 除以 temperature:
```text
softmax(logits / T)
```
当 `T > 1` 时,概率分布会更平滑:
```text
最大 token 的概率会降低;
次优 token 的概率会抬高;
student 更容易看到 teacher 的相对偏好。
```
MiniMind 默认:
```text
temperature = 1.5
```
源码最后乘回:
```text
temperature ** 2
```
这是常见蒸馏写法,用来补偿 temperature 对梯度尺度的影响。第 20 课只需要理解它保持 loss 尺度更稳定,不必推完整梯度公式。
### 2.5 teacher 为什么不更新
teacher 是训练目标的来源,不是被训练对象。
所以 teacher 必须:
```text
eval()
requires_grad_(False)
with torch.no_grad()
```
这三件事分别解决:
```text
eval(): 关闭 dropout 等训练态行为
requires_grad_(False): teacher 参数不需要梯度
no_grad(): teacher forward 不构建反向图
```
如果 teacher 也更新,目标本身会变化,student 学的分布不稳定;同时显存和计算都会浪费。
## 3. 源码阅读顺序图
建议按这个顺序读:
```text
1. train_distillation.py:146-176
看脚本参数:student/teacher 配置、alpha、temperature。
2. train_distillation.py:184-214
看 student 和 teacher 如何初始化、冻结、建 optimizer。
3. train_distillation.py:39-67
看一个 batch 里 student/teacher logits 如何取得。
4. train_distillation.py:68-93
看 CE loss、distill loss、总 loss 如何组合。
5. train_distillation.py:25-36
单独看 KL 蒸馏损失函数。
6. train_distillation.py:124-143
看保存 student 和尾批 gradient accumulation。
```
和前几课的关系:
```text
第 5 课:SFTDataset labels mask
第 10 课:logits/labels shift
第 14 课:lr、grad accumulation、AMP
第 16 课:Full SFT 训练入口
第 20 课:在 SFT 训练上额外加 teacher KL
```
## 4. MiniMind 源码走读
### 4.1 脚本参数决定 student、teacher、alpha 和 temperature
#### 源码证据 A:student/teacher 参数
文件:`trainer/train_distillation.py:146-176`
看它是为了理解:蒸馏脚本和普通 SFT 脚本相比多了哪些训练对象和超参数。
代码摘录:
```python
parser.add_argument('--student_hidden_size', default=768, type=int)
parser.add_argument('--student_num_layers', default=8, type=int)
parser.add_argument('--teacher_hidden_size', default=768, type=int)
parser.add_argument('--teacher_num_layers', default=8, type=int)
parser.add_argument('--student_use_moe', default=0, type=int, choices=[0, 1])
parser.add_argument('--teacher_use_moe', default=1, type=int, choices=[0, 1])
parser.add_argument('--from_student_weight', default='full_sft', type=str)
parser.add_argument('--from_teacher_weight', default='full_sft', type=str)
parser.add_argument('--alpha', default=0.5, type=float)
parser.add_argument('--temperature', default=1.5, type=float)
```
这段代码说明:
- student 和 teacher 可以有不同结构。
- 默认场景是 teacher 用 MoE,student 用 dense。
- 两者默认都从 `full_sft` 权重开始。
- `alpha` 控制 CE 和 KL 的权重。
- `temperature` 控制 teacher soft target 的平滑程度。
理解到这一步就够:
```text
蒸馏训练至少要同时关心两套模型配置,而不是只关心一个 lm_config。
```
暂时不要看:
```text
teacher 比 student 大多少效果最好;
alpha/temperature 搜参策略。
```
### 4.2 student 被训练,teacher 被冻结
#### 源码证据 A:构造两套 config
文件:`trainer/train_distillation.py:184-188`
看它是为了理解:student 和 teacher 在代码里是两套独立配置。
代码摘录:
```python
lm_config_student = MiniMindConfig(hidden_size=args.student_hidden_size, num_hidden_layers=args.student_num_layers, use_moe=bool(args.student_use_moe))
lm_config_teacher = MiniMindConfig(hidden_size=args.teacher_hidden_size, num_hidden_layers=args.teacher_num_layers, use_moe=bool(args.teacher_use_moe))
ckp_data = lm_checkpoint(lm_config_student, weight=args.save_weight, save_dir='../checkpoints') if args.from_resume==1 else None
```
这段代码说明:
- student 和 teacher 都用 `MiniMindConfig` 构建。
- resume checkpoint 按 student 配置保存。
- 最终被训练和恢复的是 student。
#### 源码证据 B:加载并冻结 teacher
文件:`trainer/train_distillation.py:204-214`
看它是为了理解:optimizer 为什么只更新 student。
代码摘录:
```python
model, tokenizer = init_model(lm_config_student, args.from_student_weight, device=args.device)
teacher_model, _ = init_model(lm_config_teacher, args.from_teacher_weight, device=args.device)
teacher_model.eval()
teacher_model.requires_grad_(False)
train_ds = SFTDataset(args.data_path, tokenizer, max_length=args.max_seq_len)
optimizer = optim.AdamW(model.parameters(), lr=args.learning_rate)
```
这段代码说明:
- `model` 是 student。
- `teacher_model` 单独加载。
- teacher 被设置为 eval 并冻结参数。
- optimizer 只接收 `model.parameters()`,也就是 student 参数。
- 数据仍然用 student tokenizer 构造。
### 4.3 一个 batch 同时跑 student 和 teacher
#### 源码证据 A:student 前向
文件:`trainer/train_distillation.py:47-60`
看它是为了理解:student logits 如何对齐 next-token 目标。
代码摘录:
```python
input_ids = input_ids.to(args.device)
labels = labels.to(args.device)
loss_mask = (labels[..., 1:] != -100).float()
with autocast_ctx:
res = model(input_ids)
student_logits = res.logits[..., :-1, :].contiguous()
```
这段代码说明:
- `loss_mask` 来自 shifted labels。
- student forward 没传 `labels`,因为脚本要手写 CE 和 KL。
- `student_logits[..., :-1, :]` 去掉最后一个位置。
- 这和第 10 课的 next-token shift 一致。
#### 源码证据 B:teacher no_grad 前向
文件:`trainer/train_distillation.py:61-67`
看它是为了理解:teacher logits 如何参与 loss 但不参与反向传播。
代码摘录:
```python
if teacher_model is not None:
with torch.no_grad():
teacher_logits = teacher_model(input_ids).logits[..., :-1, :].contiguous()
vocab_size_student = student_logits.size(-1)
teacher_logits = teacher_logits[..., :vocab_size_student]
```
这段代码说明:
- teacher forward 包在 `torch.no_grad()` 里。
- teacher logits 和 student logits 一样做 `[..., :-1, :]`。
- teacher vocab 会被截断到 student vocab 大小。
- KL loss 需要 student 和 teacher 最后一维一致。
注意这个截断只解决 vocab 维度对齐:
```text
teacher_logits[..., :vocab_size_student]
```
它不解决 tokenizer 不一致问题。真正训练时 student 和 teacher 应该使用兼容 tokenizer,否则同一个 token id 的含义可能不同。
### 4.4 CE loss 只在 assistant 区域上算
#### 源码证据:手写 CE loss
文件:`trainer/train_distillation.py:68-80`
看它是为了理解:为什么 distillation 脚本没有直接用 `model(input_ids, labels=labels)` 的 `res.loss`。
代码摘录:
```python
shift_labels = labels[..., 1:].contiguous()
loss_mask_flat = loss_mask.view(-1)
ce_loss = F.cross_entropy(
student_logits.view(-1, student_logits.size(-1)),
shift_labels.view(-1),
ignore_index=-100,
reduction='none'
)
ce_loss_raw = torch.sum(ce_loss * loss_mask_flat) / (loss_mask_flat.sum() + 1e-8)
if lm_config_student.use_moe: ce_loss = ce_loss_raw + res.aux_loss
else: ce_loss = ce_loss_raw
```
这段代码说明:
- CE loss 的 logits 和 labels 仍然 shift 对齐。
- `reduction='none'` 保留每个 token 的 loss。
- `loss_mask_flat` 只保留 assistant 训练区域。
- `ce_loss_raw` 是 active token 上的平均 CE。
- 如果 student 是 MoE,还要加 `res.aux_loss`。
为什么不用 `res.loss`?
因为蒸馏脚本后面还要对同一批 active token 计算 KL。手写 CE 能让 CE 和 KL 使用同一个 `loss_mask_flat`,日志里也能分别打印 `ce_loss` 和 `distill_loss`。
### 4.5 KL distillation loss 学 teacher 分布
#### 源码证据 A:distillation_loss 函数
文件:`trainer/train_distillation.py:25-36`
看它是为了理解:teacher logits 如何变成 student 的 soft target。
代码摘录:
```python
def distillation_loss(student_logits, teacher_logits, temperature=1.0, reduction='batchmean'):
with torch.no_grad():
teacher_probs = F.softmax(teacher_logits / temperature, dim=-1).detach()
student_log_probs = F.log_softmax(student_logits / temperature, dim=-1)
kl = F.kl_div(
student_log_probs,
teacher_probs,
reduction=reduction
)
return (temperature ** 2) * kl
```
这段代码说明:
- teacher logits 先除以 temperature,再 softmax 成概率。
- teacher 概率 detach,不参与梯度。
- student logits 变成 log probability。
- `F.kl_div` 的输入是 student log probs,target 是 teacher probs。
- 最后乘 `temperature ** 2`。
#### 源码证据 B:只在 active token 上算 KL
文件:`trainer/train_distillation.py:82-90`
看它是为了理解:teacher KL 和 SFT labels mask 如何结合。
代码摘录:
```python
distill_loss = distillation_loss(
student_logits.view(-1, student_logits.size(-1))[loss_mask_flat == 1],
teacher_logits.view(-1, teacher_logits.size(-1))[loss_mask_flat == 1],
temperature=temperature
)
```
这段代码说明:
- logits 被展平成 `[batch * (seq - 1), vocab]`。
- 只取 `loss_mask_flat == 1` 的 token。
- user/system/padding 区域不会进入 KL。
- CE 和 KL 使用同一批 active token。
### 4.6 总 loss 混合 CE 和 distill
#### 源码证据 A:loss 组合
文件:`trainer/train_distillation.py:92-102`
看它是为了理解:alpha 对最终训练目标的影响。
代码摘录:
```python
loss = (alpha * ce_loss + (1 - alpha) * distill_loss) / args.accumulation_steps
scaler.scale(loss).backward()
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)
```
这段代码说明:
- `alpha=1.0` 时接近普通 SFT。
- `alpha=0.0` 时只学 teacher 分布。
- 默认 `alpha=0.5`,CE 和 KL 各占一半。
- 梯度累积、AMP、clip、optimizer step 沿用普通训练脚本。
- optimizer 只管理 student,所以只更新 student。
#### 源码证据 B:日志同时打印 CE 和 distill
文件:`trainer/train_distillation.py:104-122`
看它是为了理解:训练时应该观察哪些指标。
代码摘录:
```python
Logger(f'Epoch:[{epoch + 1}/{args.epochs}]({step}/{iters}), loss: {current_loss:.4f}, ce: {current_ce_loss:.4f}, aux_loss: {current_aux_loss:.4f}, distill: {distill_loss.item():.4f}, learning_rate: {current_lr:.8f}, epoch_time: {eta_min:.3f}min')
```
这段代码说明:
- `loss` 是混合后的总损失。
- `ce` 是真实 label 的监督损失。
- `distill` 是 teacher 分布的 KL 损失。
- MoE student 时还要观察 `aux_loss`。
### 4.7 保存的是 student 权重
#### 源码证据:保存 checkpoint
文件:`trainer/train_distillation.py:124-143`
看它是为了理解:蒸馏训练产物是什么。
代码摘录:
```python
ckp = f'{args.save_dir}/{args.save_weight}_{lm_config_student.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_student, weight=args.save_weight, model=model, optimizer=optimizer, scaler=scaler, epoch=epoch, step=step, wandb=wandb, save_dir='../checkpoints')
```
这段代码说明:
- 保存路径使用 student config。
- 保存的是当前训练中的 `model`,也就是 student。
- teacher 不会被保存成训练产物。
- resume checkpoint 也按 student 权重名保存。
## 5. 本节必须会写 / 暂时不要求
必须会写:
```text
1. 说明 distillation_loss 的输入输出:
student_logits: [active_tokens, vocab]
teacher_logits: [active_tokens, vocab]
output: scalar KL loss
2. 手动写出 teacher soft target:
teacher_probs = softmax(teacher_logits / temperature)
3. 手动写出 student log probability:
student_log_probs = log_softmax(student_logits / temperature)
4. 说明 loss_mask 的来源:
loss_mask = labels[..., 1:] != -100
5. 说明总 loss:
loss = alpha * ce_loss + (1 - alpha) * distill_loss
6. 说明 teacher 为什么 eval/no_grad/frozen。
```
暂时不要求:
```text
1. 蒸馏效果评测。
2. teacher/student 架构搜索。
3. 多 teacher ensemble。
4. hidden states / attention map 蒸馏。
5. tokenizer 不一致时的 vocab 映射。
6. temperature 梯度尺度公式推导。
```
## 6. 实验验证
本节新增实验:
```text
course/labs/trace_distillation_loss.py
```
这个实验不需要真实 `full_sft` 权重。它用随机初始化的 tiny student 和 tiny teacher 跑一次前向,只验证:
```text
input_ids / labels shape
student_logits / teacher_logits shape
loss_mask active token 数量
CE loss
distill loss
alpha 混合后的 total loss
```
运行命令:
```bash
cd /home/sun/minimind
python course/labs/trace_distillation_loss.py \
--data_path course/labs/tiny_sft.jsonl \
--max_length 96 \
--alpha 0.5 \
--temperature 1.5
```
重点记录:
```text
student_logits.shape =
teacher_logits.shape =
active_tokens =
ce_loss_raw =
distill_loss =
total_loss =
```
你应该看到:
```text
student_logits.shape 和 teacher_logits.shape 的最后一维都是 vocab_size;
active_tokens 大于 0;
ce_loss_raw、distill_loss、total_loss 都是标量;
total_loss 约等于 alpha * ce_loss_raw + (1-alpha) * distill_loss。
```
这个实验验证的是计算路径,不验证模型效果。随机 teacher 不会教出好 student,但足够让你看懂源码里的张量流。
### 6.1 真实训练命令
如果本地已经有 student 和 teacher 权重,例如:
```text
out/full_sft_768.pth
out/full_sft_768_moe.pth
dataset/sft_t2t_mini.jsonl
```
可以跑原项目蒸馏训练:
```bash
cd /home/sun/minimind/trainer
python train_distillation.py \
--from_student_weight full_sft \
--from_teacher_weight full_sft \
--student_use_moe 0 \
--teacher_use_moe 1 \
--alpha 0.5 \
--temperature 1.5 \
--epochs 1
```
当前课程环境如果缺默认权重和数据,就不要把真实训练作为本节必跑验收。先跑 tiny trace 实验,确认 CE/KL 的张量和数值关系。
## 7. 阶段组装
第 20 课不新增 `course/impl/` 的手写训练阶段。原因是课程主线里的手写阶段已经覆盖:
```text
Pretrain
SFT
LoRA
DPO
PPO/GRPO
```
蒸馏在这里作为源码训练链路学习:
```text
理解 train_distillation.py
-> 用 lab 复现 CE + KL 的核心计算
-> 知道真实训练需要两套权重
```
如果后续要扩展教学版蒸馏,可以新增:
```text
course/impl/core/losses.py::distillation_loss
course/impl/train_distillation_impl.py
course/impl/tests/test_distillation_loss.py
```
但第 20 课先不强制做这件事,避免在 DPO 之前引入太多额外阶段。
### 7.1 和前面课程的连接
| 已学内容 | 在蒸馏里的作用 |
|---|---|
| SFTDataset | 提供 assistant-only labels 和 loss mask |
| loss shift | 让 logits[..., :-1, :] 对齐 labels[..., 1:] |
| MoE aux loss | student 是 MoE 时加到 CE loss |
| AMP / grad accumulation | 蒸馏训练循环继续复用 |
| checkpoint | 保存和恢复 student 训练状态 |
### 7.2 Portfolio 记录
完成本节后,可以在 `course/portfolio/experiments.md` 记录:
```text
Distillation trace:
- 构造 tiny student/teacher。
- 对同一批 SFT input_ids 计算 student_logits 和 teacher_logits。
- 用 labels[..., 1:] != -100 只保留 assistant active tokens。
- 计算 CE loss、KL distillation loss 和 alpha 混合总 loss。
```
在 `course/notes/mistakes.md` 记录容易错的点:
```text
1. 忘记 teacher no_grad,导致 teacher 也进入反向图。
2. loss_mask 用 labels 而不是 shifted labels。
3. CE 和 KL 使用的 token 区域不一致。
4. student/teacher vocab 维度不一致时直接做 KL。
5. 把 alpha 理解反了:源码里 alpha 是 CE 权重。
```
## 8. 本节检查
1. 蒸馏训练为什么要同时有 CE loss 和 distill loss?
2. `loss_mask = labels[..., 1:] != -100` 为什么要用 shifted labels?
3. teacher 为什么要 `eval()`、`requires_grad_(False)` 和 `torch.no_grad()`?
4. `temperature > 1` 对 teacher soft target 有什么影响?
5. 源码里的 `alpha` 是 CE 权重还是 distill 权重?
6. 为什么 KL 只在 assistant active token 上计算?
7. 蒸馏训练最终保存的是 student 还是 teacher?
## 9. 下一课
第 21 课进入 DPO 数据和 reference model:
- `21_dpo_dataset_and_reference.md`
下一课要解决:
```text
DPO 数据为什么需要 chosen/rejected;
policy model 和 reference model 分别是什么;
DPODataset 如何构造 chosen/rejected 的 input、label 和 mask;
reference model 为什么冻结;
DPO 和 SFT/Distillation 的训练信号有什么不同。
```