[LLM 7/10] 模型蒸馏:教师的错误答案,才是最有价值的部分
第 6 章我们把"上下文"蒸馏进了同一个模型的权重里,这一章我们要把"整个模型"蒸馏进一个更小的模型。 这两章是刻意成对的:context distillation 改变的是模型知道什么,让你不必再告诉它—— 模型蒸馏(model distillation)改变的是模型的大小,并尽力不改变它能做的事。 而本章的核心强烈违反直觉:教师能传给学生的最有价值的东西,不是正确答案, 而是教师犯错的方式——那个叫温度(temperature)的旋钮,正是让我们看见它的东西。
Open in Colab07_model_distillation.ipynb
1. 问题(Problem statement)
设想你走完了前六章,得到一个在组织的泰语任务上表现令人满意的 Qwen3-1.7B。 然后某一天,infrastructure 团队问了一个模型自己答不上来的问题:"GPU 一个月要花多少钱?"
1.7B 模型占用的显存几乎是 0.6B 的 3 倍,回答速度慢约 2.5 倍。 在每秒 100 个请求的量级上,这个差距不是细节——它是系统整个生命周期里每个月都要多买的显卡数量。
| 选项 | 质量 | 推理时的成本 |
|---|---|---|
| 直接部署 1.7B 教师 | 最好 | 显存约 3 倍、慢约 2.5 倍,为系统整个生命周期买单 |
| 直接部署 0.6B 学生 | 明显下降 | 便宜且快 |
| 用标准答案对学生做 SFT | 略有提升 | 便宜且快 |
| 模型蒸馏 | 向教师靠拢 | 便宜且快,和学生分毫不差 |
问题是:最后一行知道什么 SFT 那一行不知道的东西——同样是训练,同一份数据,差别到底在哪。
答案在于每个 token 携带的信息量。硬标签(hard label)是一个 one-hot 向量: 只说"答案是 3",就没了。而被温度软化后的教师分布说的是: "答案是 3——但 8 也不是完全没可能,至于'猫'就纯属胡扯了。" 正是这种在所有错误答案之上的排序,被 Hinton 称为暗知识(dark knowledge)。 它编码了世界的相似性结构(数字 3 离 8 比离猫更近), 而只要你只保留 argmax,它就消失得一干二净。
在每个 token 位置上,教师都有 151,936 个数字可给(Qwen3 的 vocab 大小)——硬标签只留下了其中一个。
2. 我们要做什么(Solution)
我们会拿 Qwen/Qwen3-1.7B 当教师、Qwen/Qwen3-0.6B-Base 当学生, 用三个层级来做蒸馏,教师传递的信息一级比一级精细:
- SeqKD——让教师生成回答,再用这些回答对学生做 SFT(来自分布的样本)
- Logit KD——让学生逐 token 位置模仿教师的整个分布(分布本身)
- GKD(可选加餐)——让学生采样自己的回答,教师在这些 token 上给出 on-policy(同策略)的分布评分
还有一个不可或缺的东西:对照行——用标准答案在同一份数据上、以相同的 step 数对学生做 SFT。 没有这一行,我们根本分不清收益究竟来自"教师的分布",还是仅仅来自"多训练了一会儿"。
硬标签说"答案是 3"——教师分布说的是"3,但 8 也接近对,而'猫'不可能"。 在错误答案之上的排序就是暗知识,而温度是揭示它的旋钮。 在 T = 1 时这份知识被压得几乎看不见(教师置信 0.97);把 T 提上去,它才变成真正可训练的 signal。
这不是实验室里的花招——世界上大多数"小而强"的模型正是这样被造出来的。 我们整个系列一直在用的 Qwen3-0.6B,本身就是用它家族里更大的型号做 strong-to-weak distillation 训练出来的。 这一章我们做的是同一件事,只是缩到免费 Colab 装得下的规模。
3. 公式(Equation)
3.1 Hinton 的 KD loss
- = 学生与教师在同一 input、同一 token 位置上的 logits
- = 真实标准答案(hard label)——第一项就是普通的 cross-entropy,和 SFT 一模一样
- = 温度,在 softmax 之前同时除进两侧的 logits
- = soft 项的权重(我们用 0.9——以听教师为主,让标准答案兜底防跑偏)
注意 KL 的方向:教师在前。这是 forward KL,它强迫学生把概率铺开, 覆盖教师给了权重的每一个地方。把这一点记住,待会儿公式 3.4 会让它变成"一条线上的一个点"。
那么乘在 KL 前面的 是从哪来的?互联网上几乎每一份 KD 代码都带着这个系数, 但解释原因的少之又少——而如果不理解它,你会在不知不觉中用错误的方式调 T。
3.2 推导 的来历——把"理解"和"照抄"分开的两行推导
第 1 行——soft 项对学生单个 logit 的梯度,就是 softmax-CE 的标准梯度, 再经过 的 chain rule 吐出一个 :
第 2 行——当 很大时,softmax 会在 uniform 附近被摊平: (其中 = vocab 大小), 因此差值 又按 再缩一层:
soft 项的梯度按 缩放,而 hard CE 项完全不依赖 。 如果不把 乘回去,把 T 从 1 调到 4 就等于偷偷把 soft 项的学习率除以约 16。 你会得出"T 调高了不管用"的结论,而实际上你只是不小心把自己的 soft loss 关掉了而已。 乘上 让梯度的尺度几乎不随 T 变化——调好的 在每个 T 下都保持原来的含义。
第 2 行还有一份赠品:在同一极限下,soft loss 退化成对中心化 logits 的 MSE 匹配—— KD 就是一种"软化版的 logit regression",给分布头部的权重高于尾部。
3.3 SeqKD——便宜的 baseline(Kim & Rush, 2016)
读起来眼熟吗?——这就是在教师生成的回答上做的普通 SFT,仅此而已。 教师没有把整个分布送过来,而是送来一个从自己的分布里采出的"单个样本"。
一个常被忽视的优点:SeqKD 完全不在乎 tokenizer 是否一致,因为它传的是文本,不是 logits。 很多宣传"distilled from GPT-4"的开源模型,实际上就是纯 SeqKD—— 通过 API 收集教师回答,然后 SFT。这正是它成为 baseline 的原因:在上更贵的方法之前,必须先把它测出来。
3.4 GKD 与 generalized JSD——把本章和第 6 章连起来的那条线
公式 3.1 的 logit KD 有一个结构性弱点:学生是在别人写的句子上学习的(teacher forcing), 但真正使用时它必须接着自己的回答往下生成——这种不断累积的偏差叫作 exposure bias。 GKD(Agarwal et al., 2023)的解法是让学生自己采样回答,教师在这些 token 上打分, 并顺手把分布之间的距离推广为:
其中 = 教师、 = 学生。 从一个极端扫到另一个极端:
- → forward KL ——mass-covering:学生必须铺开覆盖教师的每一个 mode
- → reverse KL(反向 KL)——mode-seeking(模式寻找):学生选择守住自己扛得动的那几个 mode
把话说到最明白:第 6 章选用的 reverse KL 并不是来自另一个世界的怪东西——它就是这条线上 的那个点,而 Hinton 的经典 KD 是 的那个点。 这两章因此是同一个家族的成员, 差别只在"谁必须向谁靠拢"——比教师小得多的学生往往在 mode-seeking 一侧受益更多, 因为它的容量本来就不足以覆盖教师的所有 mode。
4. 把公式画出来(Visualize)
温度揭示暗知识
Figure 7.1教师在上下文 '7 × 8 = ' 之后的真实 10 维 logit 向量,分别在 T = 1、2、4、8 下做 softmax——正确答案('56')始终排第一,但错误答案之间的排序(54 ≻ 48 ≻ 63 ≻ … ≻ cat)只有把 T 提上去才显现出来
必须读出来的一点:温度没有添加任何信息——logits 一模一样, 它只是改变了已有信息的可见程度。在 T = 1 时,最好的那个错误答案概率只有 0.015—— 流过它的梯度几乎为零。在 T = 8 时,整排的排序变成了学生真正学得动的 signal, 而 "cat" 和 "!" 依然如它们应该的那样躺在底部。
缺失的 系数,一张图就能看见
Figure 7.2soft loss 对学生 logit 的梯度大小,直接用公式 (q−p)/T 在同一对 logit 向量上算出——不乘 T² 时梯度按 1/T² 下滑(红线),把 T² 乘回去后尺度在整个 T 区间保持平稳(绿线)
这就是公式 3.2 的肉眼版本:红线是你忘掉 时会发生的事—— 当你扫 T 找最优值时,你在不经意间同时扫了 soft loss 的学习率, 整张实验表都会变得没法读,因为两个变量缠在一起。绿线是我们能干净地调 T 的唯一理由。
教师和学生看同一个位置,差多少
来看真实数据:notebook 里一个泰语 token 位置上教师与学生的 top-5 对比 ("之前"视角是学生,"之后"视角是教师——这两个视角之间的差距,正是 logit KD 想要弥合的东西):
Promptทักทายเป็นภาษาไทย
สวัสดีครับ ผมชื่อโมเดลภาษาไทย
Showing the built-in sample.
5. 准备环境(Environment)
打开 Colab,选择 Runtime → Change runtime type → T4 GPU(免费额度够用, 但这一章的显存格外紧张,因为教师和学生必须同时待在卡上)。
Colab 的 T4 是 Turing 架构(SM 7.5),它不支持 bfloat16,也不支持 FlashAttention-2。
但 Qwen3 家族的 config.json 里写着 torch_dtype: bfloat16。
所以 torch_dtype="auto" 是个陷阱:代码会崩掉或者慢得离谱,而且不会告诉你原因。
torch_dtype=torch.float16 # 不是 bfloat16
attn_implementation="sdpa" # 不是 flash_attention_2
fp16=True # 在 TrainingArguments 里(不是 bf16=True)
cap = torch.cuda.get_device_capability(0)
print("compute capability:", cap) # T4 = (7, 5)
print("native bf16:", cap[0] >= 8) # T4 -> False
print("torch says :", torch.cuda.is_bf16_supported()) # T4 -> True(把 emulation 也算上了!)
is_bf16_supported() 在 T4 上会骗你较新的 torch 在 T4 上返回 True,因为它把**模拟(emulation)**也算作支持——而模拟比 fp16 慢得多。
请改为判断 compute capability ≥ 8.0(Ampere 及以上)。这是真正在 Colab 上跑才发现的 bug。
同时加载两个模型——全系列最紧张的显存预算
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
teacher = AutoModelForCausalLM.from_pretrained(
"Qwen/Qwen3-1.7B", # 教师:instruct 版,约 3.4 GB(fp16)
torch_dtype=torch.float16,
attn_implementation="sdpa",
).cuda().eval() # 永远 .eval()——教师什么都不学
student = AutoModelForCausalLM.from_pretrained(
"Qwen/Qwen3-0.6B-Base", # 学生:base 版,约 1.2 GB(fp16)
torch_dtype=torch.float16,
attn_implementation="sdpa",
).cuda()
两份权重合计约 4.6 GB,听起来很宽裕,但训练时还要给两个模型的 activations 留位置, 加上 LoRA 的 optimizer,再加上临时的 logits——在 16 GB 的 T4 上,batch size 只剩下 2 的空间, 再靠梯度累积补回来。这就是让教师一起坐在卡上的代价 (第 6 节会展示如何用离线 precompute logits 的方式"把教师请下卡")。
必须先于一切的 assert cell
tok_t = AutoTokenizer.from_pretrained("Qwen/Qwen3-1.7B")
tok = AutoTokenizer.from_pretrained("Qwen/Qwen3-0.6B-Base")
assert teacher.config.vocab_size == student.config.vocab_size == 151_936
assert tok_t.get_vocab() == tok.get_vocab()
print("vocab 每一格都一致——logit KD 可行")
公式 3.1 中的 KL 是逐维比较两个分布的:教师的第 维必须和学生的第 维 指同一个 token。如果 vocab 对不上,你就是在拿"曼谷"(กรุงเทพ)的概率去比较另一个 恰好占了同一格编号的 token 的概率——数字会漂亮流畅地算出来,但彻底没有意义。
更糟的是,同一个句子会被切成完全不同的 token 序列,教师和学生的第 个位置 指向的是文本里的不同地方——从时间轴上就已经没法对齐了。
Qwen3 全家族用同一个 tokenizer,我们因此逃过一劫。但如果你的教师是 GPT-4, 或者 vocab 和学生对不上的 Typhoon(一个泰语 LLM 家族),剩下的唯一出路就是 SeqKD(公式 3.3)——它传文本,不传 logits。
6. 准备数据(Data)
我们从 airesearch/wangchanx-seed-free-synthetic-instruct-thai-120k 取 3,000 条 prompt——
一个带完整参考答案的泰语 instruction 数据集:
from datasets import load_dataset
ds = load_dataset("airesearch/wangchanx-seed-free-synthetic-instruct-thai-120k",
split="train")
ds = ds.shuffle(seed=42).select(range(3000))
从这一份数据出发,我们准备三样原料:
第 1 样——标准答案:直接取自数据集,既是对照行(SFT)的训练数据, 也是训练 logit KD 所用的句子——刻意让这两行看到逐字符完全相同的文本, 让剩下的唯一差别只有"有没有教师的分布"。
第 2 样——教师的回答,给 SeqKD 用:让教师按每批 16 条 prompt 批量生成
(max_new_tokens=192, do_sample=False),耗时约 20–25 分钟,做一次就存进磁盘。
第 3 样——教师的 top-64 logits,给 logit KD 用:让教师 forward 第 1 样的句子, 每个位置只保留前 64 名。
为什么是 top-64——因为整个 vocab 是真的存不下。算一笔账看看:
Figure 7.3内存的算术:教师一个 batch 的全 vocab logits tensor(4 × 512 × 151,936 × fp16)是 622 MB——top-64 只剩 0.79 MB,小了 791 倍;如果离线保存全部 3,000 条样本:467 GB 对 0.59 GB
K = 64
@torch.no_grad() # 本 cell 最重要的一行——见第 9 节的陷阱 3
def teacher_topk(input_ids, attention_mask):
z = teacher(input_ids=input_ids,
attention_mask=attention_mask).logits # [B, L, 151936]
val, idx = z.topk(K, dim=-1) # [B, L, 64]
return val.half().cpu(), idx.int().cpu()
一个能教很多东西的小细节:index 必须用 int32,因为 vocab 151,936 比 uint16 的上限(65,535)高出一倍还多——存 index 的磁盘空间因此比 logits 值本身还大 (4 字节对 2 字节)。整套 3,000 条样本 × 512 个位置 × 64 名,合计约 590 MB 磁盘空间。
在 T = 2 时,教师的概率质量高度集中在分布头部。notebook 会把真实的 coverage 打印出来给你看(T = 2 下 softmax 后 top-64 的总质量——通常超过 99%)。 我们扔掉的是那条 151,872 个 token 的长尾,每个 token 分到的概率都微乎其微, 换来的是整个文件小 791 倍——这是一次可测量的近似,不是猜测。
7. 核心代码(Main code)
7.1 学生上的 LoRA + 第 1 章那个老 fp16 陷阱
from peft import LoraConfig, get_peft_model
student = get_peft_model(student, LoraConfig(
r=16, lora_alpha=32, lora_dropout=0.05, task_type="CAUSAL_LM",
target_modules=["q_proj", "k_proj", "v_proj", "o_proj",
"gate_proj", "up_proj", "down_proj"],
))
for p in student.parameters(): # 第 1 章 fp16 提示框的 LoRA 版本:
if p.requires_grad: # 只把 adapter 的参数 cast 成 fp32
p.data = p.data.float() # 否则会遇到 "Attempting to unscale FP16 gradients."
7.2 亲手把 KD loss 写出来——公式 3.1 逐行对照
import torch.nn.functional as F
def kd_loss(z_s, t_val, t_idx, labels, T=2.0, alpha=0.9):
"""z_s: [B, L, V] 学生的 logits —— t_val/t_idx: [B, L, 64] 教师的 top-64(从磁盘读取)"""
z_s, t_val, t_idx = z_s[:, :-1], t_val[:, :-1], t_idx[:, :-1]
tgt = labels[:, 1:] # 位置 t 预测的是第 t+1 个 token
mask = tgt.ne(-100) # 防止 prompt 和 padding 混进 loss
# ── hard 项:与标准答案的 cross-entropy,和 SFT 一模一样 ──
ce = F.cross_entropy(z_s.flatten(0, 1).float(), tgt.flatten(),
ignore_index=-100)
# ── soft 项:top-64 维度上的 KL(教师 ‖ 学生)——"两侧"都除以 T ──
p_t = F.softmax(t_val.float() / T, dim=-1) # 在 64 维上 renormalize
log_q = F.log_softmax(z_s.gather(-1, t_idx.long()).float() / T, dim=-1)
kl = (p_t * (p_t.clamp_min(1e-9).log() - log_q)).sum(-1) # 每个位置的 KL
kl = (kl * mask).sum() / mask.sum().clamp_min(1) # 只对回答 token 求平均
return (1 - alpha) * ce + alpha * (T ** 2) * kl # ← 来自公式 3.2 的 T²
整篇文章都压缩在最后那一行里:(1 - alpha) * ce 是防止学生跑偏的标准答案,
alpha * (T ** 2) * kl 是教师的暗知识,带着我们刚刚亲手推导出来的那个系数。
7.3 从磁盘喂教师 logits 的 Trainer
from transformers import TrainingArguments, Trainer
class KDTrainer(Trainer):
def compute_loss(self, model, inputs, return_outputs=False, **kwargs):
t_val = inputs.pop("teacher_val") # 来自磁盘,不是来自卡上的教师
t_idx = inputs.pop("teacher_idx")
out = model(input_ids=inputs["input_ids"],
attention_mask=inputs["attention_mask"])
loss = kd_loss(out.logits, t_val, t_idx, inputs["labels"])
return (loss, out) if return_outputs else loss
args = TrainingArguments(
output_dir="kd-out",
per_device_train_batch_size=2,
gradient_accumulation_steps=8, # effective batch = 16
num_train_epochs=1,
learning_rate=1e-4, # 学生上的 LoRA——只调 adapter
lr_scheduler_type="cosine",
warmup_ratio=0.05,
gradient_checkpointing=True,
fp16=True, # T4 没有 bf16
logging_steps=10,
remove_unused_columns=False, # ← 千万别忘,否则 Trainer 会把
) # teacher_val/teacher_idx 悄悄扔掉
remove_unused_columns=False 是本章最容易被漏掉的一行:默认情况下 Trainer
会丢弃模型 signature 不认识的列——其中就包括我们的教师 logits。
症状是第一个 step 就报 KeyError: 'teacher_val'。幸运的是它坏得很大声,不是悄无声息。
至于 SeqKD,完全不需要任何新东西——用普通 Trainer 配上第 2 样数据
(教师的回答),像普通 SFT 一样训练即可。对照行也是同一个 Trainer,
只是训练在标准答案上。两个主要方案(SeqKD + logit KD)在 T4 上合计训练约 17 分钟
(每行 8–9 分钟),对照行再多花约 8 分钟。
7.4 可选加餐(整节可跳过):用 TRL 做 on-policy 的 GKD
# 只在时间富余时运行——on-policy 比 offline 慢好几倍,因为学生
# 必须在训练途中 generate,而教师要一直坐在卡上
from trl import GKDConfig, GKDTrainer
cfg = GKDConfig(
output_dir="gkd-out",
beta=0.5, # 公式 3.4 中 JSD 线段的正中间
lmbda=0.5, # 一半的 batch 用学生自己采样的回答
max_new_tokens=128,
per_device_train_batch_size=1,
gradient_accumulation_steps=8,
learning_rate=1e-4,
max_steps=60, # 只是尝个味道(约 25 分钟),不是真正的训练
fp16=True,
)
trainer = GKDTrainer(model=student, teacher_model=teacher,
args=cfg, train_dataset=gkd_ds, processing_class=tok)
trainer.train()
8. 结果(Results)
notebook 会测四项指标并写入 results.json:
- 来自 KobEval-TH 评测集的 TH-INSTR——泰语指令跟随分数,训练前/后,附 Wilson 95% CI。 教师、原始学生、三个方案的学生,全部用同一套题来测。
- Gap closed——把整章总结成一个数字:
为什么不报裸分——因为如果不知道总共有多少差距可以弥合,"涨了 4 个点"毫无意义。 这个 metric 直接回答我们真正的问题:学生和教师之间的差距,已经弥合了百分之多少。 0% 是原地不动,100% 是恰好追平教师。 3. 蒸馏前后学生的 tok/sec——应当分毫不差,因为架构和参数量一个都没变。 而这正是整章的要点所在:质量向教师靠拢,latency 却一步也没挪——如果你想要一句话讲给团队听, 那句话就是"用学生的价格,买到(一部分)教师的质量"。 4. TH-SAFE spot check——教师通过分布把一切都传了过去,包括它的偏见和坏毛病。 所以我们把蒸馏后的学生放到泰语安全问题集上测,并如实报告它继承了什么 (详见文末的局限提示框)。
下一节表格里的真实数字留作 ?——它们必须来自你自己运行 notebook,而不是来自文章。
Promptอธิบายว่าทำไมท้องฟ้าถึงเป็นสีฟ้า แบบสั้น ๆbase
sft
Showing the built-in sample.
9. 对比(Comparison)
| 模型 | TH-INSTR(95% CI) | Gap closed | tok/sec | 训练耗时 |
|---|---|---|---|---|
| 教师 Qwen3-1.7B | ?(天花板) | 按定义为 100% | 约比学生慢 2.5 倍 | — |
| 学生 0.6B base | ?(地板) | 按定义为 0% | baseline | — |
| 学生 + 标准答案 SFT(对照) | ? | ? | 与 base 相同 | 约 8 分钟 |
| 学生 + SeqKD | ? | ? | 与 base 相同 | 约 8 分钟 |
| 学生 + logit KD(T=2, α=0.9) | ? | ? | 与 base 相同 | 约 9 分钟 |
没有"标准答案 SFT"这一行,这张表什么也证明不了,因为剩下的每一行 同时得到了"额外训练"和"来自教师的信息"——要宣称暗知识真的有效, 就必须把这两样东西拆开。
对照行和 logit KD 行训练在同样的句子、同样的 step 数、同样的超参数上, 只差一件事:没有教师的分布。所以这两行之间的差值就是暗知识的纯粹价值。 如果这两行打平,说明整套 KD 连一次 teacher forward 都不值—— 而你只有靠这张表才能知道这件事。
Figure 7.4gap closed 的读法:0% 是训练前的学生,100% 是教师——对照行(橙色)与 logit KD 行之间的距离,是只有教师分布才能解释的部分(图中数字为机制示意,并非实测结果——真实结果来自 notebook)
你应该看到的模式是:logit KD ≻ SeqKD ≻ SFT 对照 ≻ base,且下面四行的 tok/sec 全部相同。 如果看到的是别的情况,可以这样解读:
- 对照行和 logit KD 一样好 → KD 信号在这个任务上没有增益。试试更高的 T (暗知识仍被压着)、加大 α,或者教师和学生本来就靠得太近
- SeqKD 赢了 logit KD → 当数据集里的标准答案写得比教师的回答差时,这真的会发生 (我们的 logit KD 是在标准答案上训练的)——这是信息,不是失败,照实报告它
- 所有行都几乎没动 → 对这一对教师-学生的差距来说,3,000 条样本可能太少了。 下结论之前先看 loss 曲线,并读一读文末的局限提示框
需要提防的坑
1. 忘掉 系数 不会有任何报错,只是你的 T 扫描结果会变成一篇虚构作品,因为每次挪动 T 你都在偷偷挪动 soft loss 的学习率(图 7.2)——本章最安静的一个 bug。
2. 只在一侧加温度
写了 softmax(z_t / T) 却忘了在学生一侧也除 T——学生会被迫用自己尖锐的 logits
去模仿教师被摊平的分布。结果是它学会了"真的变平",
到了实际使用时(没有 T 了),回答会变得寡淡、分布异常。公式 3.1 永远是两侧都除 T。
3. 教师的 logits 没有 detach
如果 forward 教师时没有 torch.no_grad(),autograd 会把教师整个的 activation
留着等一个永远不会到来的 backward——显存悄悄膨胀直到 OOM,而报错还指向别的行。
我们的 offline 路线在结构上就是安全的(logits 在磁盘里,没有 graph 可存)。
这是 precompute 的第三个理由,排在时间和内存之后。
4. 让 padding 混进 KL
padding 位置同样有教师的分布——而那是垃圾。如果不 mask 掉
(7.2 节里的 mask = tgt.ne(-100) 那一行),KL 的平均值会被无意义的位置稀释,
而且稀释比例还随 batch 内句子长度不同而变化——loss 会以一种查不出原因的方式抖动。
5. 两个模型挤一张卡 = 消失的 batch 预算
3.4 GB 的教师坐在了原本属于大 batch 的位置上。如果 OOM,按这个顺序削减:
per_device_train_batch_size → max_length → 别再让教师待在卡上(先把 offline precompute
做完,然后 del teacher; torch.cuda.empty_cache())——最后这一步本来就是我们 notebook 的结构。
10. 小结(Summary)
- 暗知识藏在教师的错误答案里——在错误选项之上的排序编码了 hard label 永远说不出的 相似性结构,而温度是揭示它的旋钮
- 系数不是护身符——soft loss 的梯度按 缩放, 把它乘回去,调 T 才不会偷偷改掉自己的学习率
- SeqKD 就是在教师回答上做 SFT——最便宜的 baseline,也是 tokenizer 不一致时的唯一出路
- Logit KD 要求同一个 vocab——永远先 assert,因为 KL 是逐维比较的
- Top-64 是工程,不是理论——一个 batch 的全 vocab logits 是 622 MB 的 tensor, 只存前 64 名剩 0.79 MB,损失的 coverage 不到 1%
- forward KL、reverse KL 和 GKD 的 JSD 是同一条线——β 旋钮从 mass-covering(本章)一路扫到 mode-seeking(第 6 章)
- SFT 对照行是让结果表有意义的东西——没有它,你分不开"教师帮了忙"和 "只是多训练了一会儿"
- gap closed 是回答真正问题的 metric——在 tok/sec 分毫未动的前提下, 教师-学生的差距弥合了百分之几
1.7B → 0.6B 是一条很窄的差距。 我们的教师并没有把学生甩开很远, 可测得的收益自然也窄——不要拿这一对的 gap closed 数字去和 70B → 7B 那种差距宽好几倍的蒸馏工作比较。这个实验证明的是机制和测量方法,不是最终数字。
更大的教师塞不进 T4——7B 的 fp16 光自己就要吃约 14 GB,一个模型就几乎占满整张卡。 出路不总是更大的卡,而恰恰是我们这一章练习的东西:离线 precompute top-K logits, 在按小时租的机器上跑一次,然后拿着那份 590 MB 的文件在任何地方训练学生。 这个在 Colab 上看似妥协的 offline 结构,其实正是真实规模的工作的做法。
蒸馏会传递一切,包括教师的偏见和错误。 学生没有任何机制去分辨 分布里哪部分是知识、哪部分是坏毛病。教师讨厌简短回答,学生就会继承; 教师在某些语境下会在泰语句子中途蹦出英语,学生也倾向于照单全收。 所以 notebook 会把蒸馏后的学生放到 TH-SAFE 上测,并与教师直接对比—— 如果数字说你继承了什么,就把它写进报告,而不是删掉那一行。 这件事是通往下一章的桥:一个不加质疑地从教师那里全盘接收的模型,需要有自己的护栏。
下一章: Guardrails——我们的学生刚刚把教师的知识和习性照单全收。 下一章我们要在模型周围筑起护栏(guardrail):在危险的 input 到达模型之前拦住它, 在危险的 output 到达用户之前拦住它,并用真实数字度量安全与可用之间的 trade-off。
参考文献(References)
- Hinton et al. (2015). Distilling the Knowledge in a Neural Network — KD 原始论文:温度与 T² 因子
- Kim et al. (2016). Sequence-Level Knowledge Distillation — 序列级 KD——第 9 节中最省的基线
- Agarwal et al. (2023). On-Policy Distillation of Language Models: Learning from Self-Generated Mistakes — GKD:统一前向与反向 KL 的 JSD 框架
- Gu et al. (2023). MiniLLM: On-Policy Distillation of Large Language Models — MiniLLM:使用反向 KL 的理由
- Sanh et al. (2019). DistilBERT, a distilled version of BERT: smaller, faster, cheaper and lighter — 部署最广泛的蒸馏成果
- Chay-intr et al. (2025). LLaVAC: Fine-tuning LLaVA as a Multimodal Sentiment Classifier — LLaVAC:为泰语任务微调多模态模型
本系列的文章、代码与 notebook 均以 CC BY-NC-SA 4.0 授权 —— 可自由使用与改编,须署名、限非商业用途,并以相同方式共享。文中引用的第三方模型与数据集仍适用各自的许可证。
