对应实践:
course/practice/labs/lab06-step5/
主要修改文件:course/practice/labs/lab06-step5/framework/student.c
验证命令:make clean && make test
到上一章为止,你已经做出了一个能独立工作的 Transformer block。它已经具备了非常核心的结构能力:先归一化,再读上下文,再做残差保留,再做逐位置的非线性加工。
但如果课程停在这里,学员仍然只是在反复观察“一个零件”。
真正的 GPT 模型不是一个 block,而是一套完整的装配关系。它要回答的是另一类问题:
- token 是怎样进入模型的;
- 多层 block 怎样被串起来;
- 最后的隐藏状态怎样变成词表 logits;
- 这些权重怎样保存到文件、再无损加载回来。
所以这一章的关键词不是“新数学”,而是“完整性”。
从现在开始,课程不再只是在讲局部结构,而是第一次把前五章已经学会的模块真正装配成一个可前向、可保存、可恢复的完整模型。
6.1 这一章为什么重要
很多人在第一次学小型 LLM 项目时,会在中间某几章误以为自己已经“差不多懂了”。
因为 attention 看过了,block 也拼过了,似乎离完整模型只差“再多写几行”。
但工程上的真实分水岭就在这里。
一个 block 和一个模型之间,看似只是多了一层数组和几个指针,实际上多出来的是三类能力:
- 组织能力:你要明确整个数据流到底从哪开始,到哪结束。
- 所有权能力:你要明确每一块权重由谁申请、谁释放、谁保存。
- 持久化能力:你要明确模型不是一次性内存对象,而是可以落盘、再加载、再复用的工程实体。
也就是说,本章不是为了“让代码量看起来更多”,而是为了把项目从“几个模块的集合”推进到“一个真正的模型系统”。
6.2 本章你要建立哪些判断
这章结束后,你应当能够独立说清楚下面几件事:
GPTModel里为什么至少需要config、embedding、layers、final_ln、lm_head这五类成员。- 为什么最后的
lm_head形状必须是[hidden_dim, vocab_size],而不是反过来。 model_forward的主路径为什么可以概括成:embedding -> N 次 block -> final layernorm -> lm_head- 模型保存文件为什么要有 magic number 和 version,而不能一上来直接写一堆 float。
- 为什么“保存顺序”和“加载顺序”必须严格一一对应。
这些判断都不是抽象要求。它们在 Lab06 的实践里都会被直接验证:要么模型根本建不出来,要么形状不对,要么 save/load 对不齐。
6.3 先看 practice target:这章改哪里
本章的实践目录是:
course/practice/labs/lab06-step5/
├── TASK.md
├── Makefile
└── framework/
├── student.c <- 主要修改这里
├── student.h
├── verify.c <- 自动验证,不改
└── verify.h
这一章真正需要你实现的函数只有三个:
student_default_configstudent_model_createstudent_model_save
这三个函数的划分非常合理。因为它们刚好分别对应完整模型落地时最重要的三层责任:
- 模型长什么样:由配置决定。
- 模型怎样在内存里被组装出来:由 create 决定。
- 模型怎样从内存转成文件:由 save 决定。
课程刻意没有让学员去写 model_load。原因不是加载不重要,而是当前章更关键的是先让你明确:一套模型如果没有清晰的写出顺序,就根本不可能有可靠的读回顺序。
6.4 从 block 到 model,到底多了什么
前一章的 block 其实已经包含了模型里最复杂的内部结构。
所以本章真正新增的,不是 attention 公式、不是 FFN 公式,而是“如何把这些现成零件装成一台完整机器”。
从输入到输出,完整 GPT 的主数据流可以写成:
input ids
-> token embedding + position embedding
-> layer 0 block
-> layer 1 block
-> ...
-> layer N-1 block
-> final layernorm
-> lm head
-> logits
这里最值得抓住的一个事实是:
中间多层 block 不改变主 shape,真正把
hidden_dim变成vocab_size的只有最后那次投影。
这意味着,如果你在实践里看到中间某一层 shape 已经跑偏,问题一定不是“最后输出层没写好”,而是前面的装配顺序或权重形状已经出了错。
6.5 为什么 lm_head 不是一个随便放的矩阵
初学者在这一章最容易犯的错误之一,就是把 lm_head 的形状写反。
如果最后一步是:
[seq_len, hidden_dim] @ [hidden_dim, vocab_size]
那么输出自然就是:
[seq_len, vocab_size]
这正是语言模型想要的结果:对序列中每个位置,都给出一个完整词表上的 logits。
但如果你把 lm_head 反过来写成 [vocab_size, hidden_dim],整个矩阵乘法关系就不成立了。
这不是一个“小错位”,而是会直接破坏模型的主输出语义。
这也是为什么 Lab06 的验证器会专门检查:
lm_head->ndim == 2shape[0] == hidden_dimshape[1] == vocab_size
它不是在吹毛求疵,而是在守住整条前向传播的出口。
6.6 为什么模型文件不能只是一堆 float
如果只从“把权重写出来”这个角度看,最省事的做法似乎是:
- 打开文件;
- 把所有张量 data 直接
fwrite; - 结束。
但这样会有两个问题。
第一个问题是:别人拿到这个文件时,根本不知道它是不是模型文件。
第二个问题是:就算知道它是模型文件,也不知道它对应哪一版布局。
所以工程里通常会先写两类标识:
- magic number:这到底是不是我认识的那种文件。
- version:它是不是我当前这版代码能理解的布局。
miniLLM 在这章里采用的是一个非常教学化的最小格式:
[magic "MLLM"]
[version = 1]
[ModelConfig]
[各层权重,严格按固定顺序写出]
这套设计并不追求工业级兼容性,但它足够让学员理解“文件格式本身也是系统设计的一部分”。
6.7 保存与加载为什么必须像拉链一样对齐
很多课程在讲 save/load 时会一笔带过,好像“写出去再读回来”只是机械步骤。
但这一章最值得讲透的恰恰是:顺序就是协议。
假设你保存时写的是:
- token embedding
- position embedding
- 第 0 层 block
- …
- final layernorm
- lm_head
那么加载时就必须用完全相同的顺序读回。
只要中间有一处偏移,例如某一层少读了一个张量,后面所有浮点都会错位。到那时,程序未必立刻崩,但前向结果已经没有任何可信度。
所以这一章不是在训练你“会不会用 fwrite”,而是在训练一种更重要的工程意识:
二进制格式的正确性,本质上依赖写方和读方共享同一个结构契约。
6.8 本章实践步骤
task 6.1:先读 student.c 和 verify.c
进入:
cd course/practice/labs/lab06-step5
建议先读:
framework/student.hframework/student.cframework/verify.c
当前验证器会检查的重点包括:
student_default_config返回的 6 个字段是否合理;student_model_create是否真的把 5 类子对象都建出来;student_model_save是否真的写出了一个大于模型头部很多的非空文件;- 生成的文件头是否是
"MLLM"和version=1; save -> load -> forward之后,logits 是否逐位一致。
这说明本章不是“组装个对象能跑就算过”,而是在同时检查结构、文件和行为。
task 6.2:实现 student_default_config
这一题代码量很小,但最好不要把它当成纯抄数题。
你要填的是:
vocab_sizehidden_dimnum_headsnum_layersffn_dimmax_seq_len
这里最值得顺手建立的意识是:配置不是“注释”,它决定了后续所有对象的 shape 和容量。
如果配置本身就不一致,例如 hidden_dim 不能整除 num_heads,那后面很多模块根本没有合法解释。
task 6.3:实现 student_model_create
这是本章最重要的内存装配题。
你需要按顺序申请:
GPTModel本体;embedding;layers指针数组及每一层 block;final_ln;lm_head。
这里有一个非常关键的工程要求:任何一步失败,都要回收前面已经成功申请的对象。
这不是形式主义,而是因为模型构造是一个层层依赖的过程。如果你不在这一层养成“失败要清理”的习惯,后面写更大的系统会非常危险。
task 6.4:实现 student_model_save
这一题真正的主线只有两个:
- 文件头先写清楚;
- 后面的张量严格按既定顺序写出去。
建议你在实现时自己先写一张“保存顺序清单”,然后按清单逐项落 fwrite。
不要边写边想。因为一旦你在中间某一层的权重顺序上前后犹豫,最后 save/load 不一致时会很难排查。
task 6.5:运行当前真实基线
在还没完成这三题之前,先跑一次:
make clean && make test
当前 Lab06 的真实初始状态是:
- 能编译;
- 但测试结果是 0 通过,16 失败。
这组失败是有意义的。它说明:
student_default_config现在还没有提供有效配置;student_model_create还没有真正装配任何子对象;student_model_save也还没有写出可用文件。
换句话说,这一章当前的实验起点是“能进入练习,但核心功能完全空着”的状态。
这正是一个完整模型装配章应该有的起点。
task 6.6:完成后重新验证
当你补完三个函数后,再执行:
make clean && make test
如果一切正确,你应该看到:
- 配置检查通过;
- 5 类子对象都被建出来;
- 文件头检查通过;
- round-trip 逐位一致。
这一章的通过信号比前几章更强,因为它第一次证明:你写的不只是“当前进程里的一段逻辑”,而是一份能落盘、能恢复、能重复使用的模型。
6.9 常见错误与排查顺序
最常见的错误通常是这几类:
lm_headshape 写反;layers数组申请了,但里面每层没真正建出来;- 某一步申请失败后没有清理;
- 保存顺序和加载顺序不一致;
- 文件头漏写 magic 或 version。
建议排查顺序也按这个层次来:
- 先看配置和 shape;
- 再看对象是否都非空;
- 再看文件有没有写出来、大小是否合理;
- 最后才看 round-trip 为什么不一致。
不要一开始就扑进 bit-exact 检查。
如果基本结构都没对齐,后面的逐位比较只会给你一堆噪声。
6.10 思考题
- 为什么完整模型的主 shape 在多层 block 中保持不变,而只在最后一层
lm_head变成vocab_size? - 如果把
ModelConfig的字段顺序改了,但旧模型文件还按原顺序保存,会发生什么? - 为什么说 save/load 的正确性本质上依赖“共享协议”,而不是单个函数写得够不够长?
6.11 本章小结
到这一章,miniLLM 课程第一次真正跨过了“局部模块练习”。
你现在要学会看的,不再只是某一层公式是否正确,而是:
- 模型整体怎样被组织;
- 权重怎样被管理;
- 内存对象怎样变成可复用的文件格式。
这一步走通后,下一章才有意义。因为只有当模型已经完整存在,我们才可能讨论:它怎样通过损失、梯度和优化器开始真正学习。