手撕 GPT 系列第 9 篇。前 8 篇我们训练了一个 3M 模型,发现天花板在容量。上一篇最后选了知识蒸馏这条路——让大模型教小模型。
这篇记录蒸馏探索的完整过程:三个 teacher,三个方向,全部失败。还有一路上踩的工程坑。
失败不可怕。可怕的是失败了不知道为什么。
知识蒸馏听起来很美好:让一个 102M 的大模型当老师,把知识"传授"给我们的 3M 小模型。大模型知道的多,小模型学得快,双赢。
Industry 里确实有成功的案例——DistilBERT 把 BERT 压缩了 40%,只掉了 3% 的性能。DeepSeek-R1 蒸馏系列把 R1 的推理能力塞进了 1.5B 的小模型。
但那些都是 100M+ 级别的 student。3M 级别的小模型,蒸馏这条路走得通吗?
我们选了三个方向,逐一实验。
先解决一个问题:参数量是瓶颈吗?
第 7 篇说"瓶颈在容量",但那个结论是在 3M 模型上得出的。如果把模型从 3M 扩到 12M,容量翻 4 倍,情况会不会不一样?
这是蒸馏的起点——student 至少要够大才能接收 teacher 的知识。
▪ 实验
同一个 byte-level tokenizer (vocab=259),同一份 600 条训练数据,唯一变量是模型宽度:
配置 | n_embd | n_head | 参数量 | 训练时间 | 验证 loss |
|---|---|---|---|---|---|
原始 | 256 | 4 | 3.02M | ~20min | 0.005 |
加宽 | 512 | 8 | 12.22M | 8.6min | 0.0035 |
验证 loss 降低了 30%,训练反而更快(MPS 对大矩阵运算效率更高)。验收测试 6/6 通过。
但我用 15 个验收外的问题测试泛化能力:
Q: 什么是深度学习? → RoPE 是旋转位置编码... Q: CPU和GPU有什么区别? → RoPE 是旋转位置编码... Q: 地球到月球有多远? → RoPE 是旋转位置编码... Q: 什么是神经网络? → RoPE 是旋转位置编码...
15 个问题,全部返回同一个答案。置信度 100%。和 3M 模型一模一样的行为。
▪ 结论
参数量从 3M 增到 12M(4 倍),泛化能力零提升。
更大的模型只是把训练数据背得更熟了,并没有变得更"聪明"。泛化的瓶颈在数据多样性(只有 12 个主题),不在模型大小。
不过这个实验不白做——它告诉我们:如果要继续蒸馏,12M 是一个合理的 student 起点。 至少验收表现不比 3M 差。增大模型容量的方向已有结论,下面来看看看看另外两个方向。
方向一:让大模型生成训练数据
▪ 思路
最直觉的蒸馏方式:用 teacher 模型对每个问题生成答案,用这些答案替换原始手写答案,训练 student。
如果 teacher 的答案质量更高、覆盖更广,student 应该能学到更多。
▪ 踩坑一:选错了 teacher
第一次选了 uer/gpt2-chinese-cluecorpussmall(102M 参数)。理由很充分:中文 GPT-2,参数量是 student 的 30 倍。
结果它生成的答案:
问题 | teacher 的回答 |
|---|---|
RoPE 是什么? | 是我! |
太阳系有哪些行星? | .................................................................. |
什么是 Python? | 。。。。。。。。。。。。。。。。。。。。。。。。 |
15×6 等于多少? | 6 等于多少,多少乘以 6 等于多少 |
它不是在答题,是在续写文本。 问它"RoPE 是什么?",它从"是什么"后面续写,写出"是我!"。问它"太阳系有哪些行星?",它输出一堆点号——因为训练数据里点号后面经常跟着更多文本。
⚠️ 踩坑提醒:
做知识蒸馏,teacher 必须是指令微调模型(Instruct)。Base 模型只会 next-token prediction(续写),不会按 Q&A 格式答题。用它当 teacher 等于让一个不会数学的人教数学——不是教得不好,是根本不会。
▪ 换 teacher:Qwen2.5-1.5B-Instruct
第二次选了 Qwen2.5-1.5B-Instruct——真正的指令微调模型。
这次好多了:
问题 | Qwen 的回答 | 正确? |
|---|---|---|
15×6 等于多少? | 90 | ✓ |
太阳系有哪些行星? | 八大行星:水星、金星、地球、火星、木星、土星、天王星、海王星。 | ✓ |
什么是 Python? | Python是一种高级编程语言。 | ✓ |
RoPE 是什么? | 罗伯特·恩格尔系数 | ✗ |
RoPE 的全称是什么? | Robots, Operations, and Environments. | ✗ |
Qwen 答对了常识题,但对专业 ML 概念(RoPE = 旋转位置编码)完全无知。它不知道 RoPE 是 Rotary Position Embedding,以为是什么经济学名词。
这暴露了一个问题:1.5B 的模型,知识面有边界。 超出边界的内容,它会自信地胡说八道。
▪ 用 Qwen 数据训练 student
用 Qwen 生成的 171 条数据,训练 12M student(byte-level tokenizer)。
1.7 分钟,early stopping 触发——验证 loss 从第一步就不降。推理输出全是乱码。
▪ 为什么失败?
根因不在 teacher 质量,在 tokenizer 不匹配。
Qwen 用 tiktoken tokenizer(vocab=151,936)生成中文文本,每个汉字约 1-2 个 token。但这些文本被我们的 byte-level tokenizer(vocab=259)重新编码成 UTF-8 字节流,每个汉字变成 3 个字节。
"什么是深度学习?" Qwen tokenizer: [20002, 25, 106582, 102217, 100134, 94432] → 6 tokens Byte tokenizer: [233, 160, 168, 228, 185, 136, 230, 183, 177, 229, 186, 166, 229, 173, 166, 239, 189, 159] → 18 tokens
12M 模型要从这些字节流中学到有意义的中文模式——就像让你看摩尔斯电码学中文。信息还在,但密度太低了。
⚠️ 踩坑提醒:数据级蒸馏(teacher 生成文本 → student 学习)的前提是 student 的 tokenizer 能高效表示这些文本。byte-level tokenizer 每个中文字符 3 个 token,序列长度直接爆表,模型学不到任何有用的模式。
方向二:标准 logits 级蒸馏
▪ 思路
方向一失败的原因是 tokenizer 不匹配——teacher 生成文本,student 用另一个 tokenizer 重新编码,信息失真。
标准做法是:teacher 和 student 共享同一个 tokenizer,直接对齐输出层的概率分布。 这就是 Hinton 2015 年提出的经典知识蒸馏方法。
选 DeepSeek-R1-Distill-Qwen-1.5B 做 teacher。它本身就是 DeepSeek 从 R1 蒸馏出来的,推理能力强,而且和 Qwen 系列共享 tokenizer。
▪ 踩坑二:vocab_size 不一致
写代码时遇到了第一个工程坑。
tok = AutoTokenizer.from_pretrained("deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B") print(tok.vocab_size) # 151,936 print(model.config.vocab_size) # 151,643
tokenizer 报告的 vocab_size 和模型实际输出的维度不一样! tokenizer 包含一些特殊 token(比如 chat template 的控制 token),但这些 token 没有对应的模型权重。
用 tok.vocab_size 构建 student,计算 KL 散度时报错:
RuntimeError: The size of tensor a (151936) must match the size of tensor b (151643) at non-singleton dimension 2
正确做法是用 model.config.vocab_size,不是 tokenizer.vocab_size。
⚠️ 踩坑提醒:
HuggingFace 的 tokenizer.vocabsize 和 model.config.vocabsize 不一定相同。做 logits 级蒸馏时,一定要用 model.config.vocab_size 构建 student。
▪ 踩坑三:Python 的 min() 陷阱
训练数据编码时,需要把 prompt 部分的 target 设为 -100(不计算 loss)。代码:
# 计算 mask 长度 n = min(prompt_len - 1, len(y)) # 生成 masked target y_masked = [-100] * n + y[n:]
看起来没问题。但 Python 的 min() 在只传一个参数时,会尝试把那个参数当 iterable 来迭代。如果 len(y) 恰好等于 prompt_len - 1,你可能写出 min(prompt_len - 1) 这种单参数调用——直接报 TypeError: 'int' object is not iterable。
这个 bug 藏得很深,因为大多数情况下 min(a, b) 有两个参数没问题,只有边界条件才会触发。
▪ 实验
蒸馏损失 = α × CE(student, 硬标签) + (1-α) × KL(student 分布, teacher 分布)
配置 | 参数量 |
|---|---|
Teacher: DeepSeek-R1-Distill-Qwen-1.5B | 1777M |
Student: GPT (vocab=151,643) | 41.8M |
这是真正的标准知识蒸馏——teacher 和 student 输出维度完全一致,可以直接计算 KL 散度。
训练 loss 从 340 降到 6.5,验证 loss 也稳步下降。133 分钟训练完成。看起来很成功。
▪ 结果
Q: 什么是深度学习? A: 除以 除以 5 是多少,而不需要人工人工人工人工人工... Q: 什么是注意力机制? A: 分 分 分 分 分 分 分 分 分 分 分 分... Q: Python? A: Python 乘以 66 30,对对对对对对对对对对...
全是乱码和重复词。 没有一个问题回答正确。
▪ 为什么 loss 降了但输出是垃圾?
数字揭示了真相。41.8M 参数的 student 模型:
组件 | 参数量 | 占比 |
|---|---|---|
Embedding 层 | 38.9M | 93% |
Transformer 层(4层) | 2.9M | 7% |
93% 的参数用来记忆 151,643 个 token 的嵌入向量,只剩 7% 做实际的语言理解。
KL 散度确实在驱动 student 的 151,643 维输出去模仿 teacher——loss 在降不是假的。但 student 的 transformer 层只有 2.9M 参数,它在用 2.9M 的"大脑"去模仿 1.77B 的"大脑"输出的 15 万维概率分布。
它能做的只有一件事:学会哪些 token 出现频率高,然后反复输出它们。 "除以"、"人工"、"对"——这些是训练数据中的高频词。loss 下降是因为模型学会了输出高频 token 来"匹配" teacher 的概率分布,不是因为它理解了语言。
⚠️ 踩坑提醒:验证 loss 下降 ≠ 模型在学有用的东西。 当 embedding 占比超过 90% 时,loss 下降可能只是模型学会了"输出高频 token"。一定要跑推理验证,不要被 loss 曲线骗了。
为什么 DistilBERT 能成功?
你可能会问:DistilBERT 也做了蒸馏,凭什么它成功我们失败?
模型 | 参数量 | vocab | embedding 占比 | transformer 占比 |
|---|---|---|---|---|
DistilBERT | 66M | 30,522 | 36% | 64% |
我们的student | 41.8M | 151,643 | 93% | 7% |
DistilBERT 有 64% 的参数在做"理解"。
我们只有 7%。
不是蒸馏这个技术不行。是我们的 vocab(151K)太大了,embedding 把"理解"的空间全吃光了。DistilBERT 的 vocab 只有 30K,所以 embedding 只占 36%,剩下 64% 都给了 transformer。
三个失败指向同一个结论
方向 | 做法 | 结果 | 根因 |
|---|---|---|---|
增大容量 | 3M→12M | 6/6 验收,0/15 OOD | 瓶颈在数据多样性 |
数据级蒸馏 | Qwen 数据 + byte tokenizer | 0/6 全乱码 | tokenizer 不匹配,信息密度太低 |
logits 级蒸馏 | DeepSeek + 共享 vocab(151K) | 全乱码+重复词 | embedding占 93%,transformer 没容量 |
三个方向失败的原因各不相同,但指向同一个结论:
小模型做知识蒸馏,同时面临三个约束,无法同时满足。
小 vocab(方向一)→ embedding 小 → 有容量训练 → 但无法从 teacher 学习;
大 vocab(方向二)→ 能对齐 teacher → embedding 占满容量 → 没能力理解语言。
这就是小模型蒸馏的不可能三角。
工程踩坑清单
一路踩了不少代码层面的坑,记录下来:
坑 | 现象 | 解法 |
|---|---|---|
adapt_tokenizer返回未定义变量 | return tok_id 引用不存在的变量 | 改成 return tok |
tokenizer.vocabsize ≠ model.vocabsize | KL 散度维度不匹配 | 用 model.config.vocab_size |
min() 单参数传 int | TypeError: 'int' object is not iterable | 确保始终传两个参数 |
Python 后台跑训练输出缓冲 | 看不到实时输出 | PYTHONUNBUFFERED=1 |
MPS 间歇性暂停 | elapsed从1732s跳到 2693s | GPU 资源竞争,无解,等就好 |
base 模型当 teacher | 输出全是续写不是答题 | 只用Instruct版本 |
⚠️ 踩坑提醒:做蒸馏项目最常遇到的两个 bug——tokenizer 和 model 的 vocab 不对齐、teacher 类型选错(base vs instruct)。先把这两个确认了再开始训练,能省很多调试时间。
这些失败有什么价值?
三个全失败,有什么好写的?
三个失败各有各的价值:
你不是也这么想过:找个大模型当 teacher,小模型就能学会大模型的本事?
现在你知道了:知识蒸馏不是传功,是上课。student 太小,坐都坐不住,怎么听课?
下一步,我们准备换个思路——不依赖 teacher 的知识传递,而是从 student 自身的架构和 tokenizer 下手。看看能不能找到一条绕过不可能三角的路。
💡 一句话带走:小模型蒸馏的不可能三角——容量、词表、tokenizer 效率,三者不可兼得。不是蒸馏不行,是你的 student 还没大到能"听课"。
这是「手撕 GPT」系列第 9 篇。系列全部文章:
从0到1手搓484行代码,用普通CPU训练一个3.37M中文GPT模型,耗时不到20分钟,回答的效果很不错,欢迎各位老师检查作业
手撕 GPT#01:五分钟上手,手把手带你用CPU 原生训练中文GPT模型,“我没有 GPU”的问题解了!!!
手撕 GPT#03:GPT 的核心代码只有 100 行,并且还支持注意力、权重、loss可视化哦
手撕 GPT#04:我用CPU花20分钟训练了一个满分模型,问它一个问题,后悔了
手撕 GPT#05:316 万个参数、比照片还小的AI,为什么还能学会“说”中文?
手撕 GPT#06:手把手 30 分钟:零基础跑通你的第一个 GPT
手撕 GPT#07:我试了四个方向终于摸到了3M模型的天花板,最后还是成功了
手撕 GPT#08:你已经能训练模型了——接下来“撕”什么?
项目地址:https://github.com/helloworldtang/GPT_teacher-3.37M-cn