Chapter 6 — step5 完整 GPT 模型

对应实践course/practice/labs/lab06-step5/
主要修改文件course/practice/labs/lab06-step5/framework/student.c
验证命令make clean && make test

到上一章为止,你已经做出了一个能独立工作的 Transformer block。它已经具备了非常核心的结构能力:先归一化,再读上下文,再做残差保留,再做逐位置的非线性加工。
但如果课程停在这里,学员仍然只是在反复观察“一个零件”。

真正的 GPT 模型不是一个 block,而是一套完整的装配关系。它要回答的是另一类问题:

  1. token 是怎样进入模型的;
  2. 多层 block 怎样被串起来;
  3. 最后的隐藏状态怎样变成词表 logits;
  4. 这些权重怎样保存到文件、再无损加载回来。

所以这一章的关键词不是“新数学”,而是“完整性”。
从现在开始,课程不再只是在讲局部结构,而是第一次把前五章已经学会的模块真正装配成一个可前向、可保存、可恢复的完整模型。

6.1 这一章为什么重要

很多人在第一次学小型 LLM 项目时,会在中间某几章误以为自己已经“差不多懂了”。
因为 attention 看过了,block 也拼过了,似乎离完整模型只差“再多写几行”。

但工程上的真实分水岭就在这里。

一个 block 和一个模型之间,看似只是多了一层数组和几个指针,实际上多出来的是三类能力:

  1. 组织能力:你要明确整个数据流到底从哪开始,到哪结束。
  2. 所有权能力:你要明确每一块权重由谁申请、谁释放、谁保存。
  3. 持久化能力:你要明确模型不是一次性内存对象,而是可以落盘、再加载、再复用的工程实体。

也就是说,本章不是为了“让代码量看起来更多”,而是为了把项目从“几个模块的集合”推进到“一个真正的模型系统”。

6.2 本章你要建立哪些判断

这章结束后,你应当能够独立说清楚下面几件事:

  1. GPTModel 里为什么至少需要 configembeddinglayersfinal_lnlm_head 这五类成员。
  2. 为什么最后的 lm_head 形状必须是 [hidden_dim, vocab_size],而不是反过来。
  3. model_forward 的主路径为什么可以概括成: embedding -> N 次 block -> final layernorm -> lm_head
  4. 模型保存文件为什么要有 magic number 和 version,而不能一上来直接写一堆 float。
  5. 为什么“保存顺序”和“加载顺序”必须严格一一对应。

这些判断都不是抽象要求。它们在 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_config
  • student_model_create
  • student_model_save

这三个函数的划分非常合理。因为它们刚好分别对应完整模型落地时最重要的三层责任:

  1. 模型长什么样:由配置决定。
  2. 模型怎样在内存里被组装出来:由 create 决定。
  3. 模型怎样从内存转成文件:由 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 == 2
  • shape[0] == hidden_dim
  • shape[1] == vocab_size

它不是在吹毛求疵,而是在守住整条前向传播的出口。

6.6 为什么模型文件不能只是一堆 float

如果只从“把权重写出来”这个角度看,最省事的做法似乎是:

  1. 打开文件;
  2. 把所有张量 data 直接 fwrite
  3. 结束。

但这样会有两个问题。

第一个问题是:别人拿到这个文件时,根本不知道它是不是模型文件。
第二个问题是:就算知道它是模型文件,也不知道它对应哪一版布局。

所以工程里通常会先写两类标识:

  1. magic number:这到底是不是我认识的那种文件。
  2. version:它是不是我当前这版代码能理解的布局。

miniLLM 在这章里采用的是一个非常教学化的最小格式:

[magic "MLLM"]
[version = 1]
[ModelConfig]
[各层权重,严格按固定顺序写出]

这套设计并不追求工业级兼容性,但它足够让学员理解“文件格式本身也是系统设计的一部分”。

6.7 保存与加载为什么必须像拉链一样对齐

很多课程在讲 save/load 时会一笔带过,好像“写出去再读回来”只是机械步骤。
但这一章最值得讲透的恰恰是:顺序就是协议。

假设你保存时写的是:

  1. token embedding
  2. position embedding
  3. 第 0 层 block
  4. final layernorm
  5. lm_head

那么加载时就必须用完全相同的顺序读回。
只要中间有一处偏移,例如某一层少读了一个张量,后面所有浮点都会错位。到那时,程序未必立刻崩,但前向结果已经没有任何可信度。

所以这一章不是在训练你“会不会用 fwrite”,而是在训练一种更重要的工程意识:

二进制格式的正确性,本质上依赖写方和读方共享同一个结构契约。

6.8 本章实践步骤

task 6.1:先读 student.cverify.c

进入:

cd course/practice/labs/lab06-step5

建议先读:

  • framework/student.h
  • framework/student.c
  • framework/verify.c

当前验证器会检查的重点包括:

  1. student_default_config 返回的 6 个字段是否合理;
  2. student_model_create 是否真的把 5 类子对象都建出来;
  3. student_model_save 是否真的写出了一个大于模型头部很多的非空文件;
  4. 生成的文件头是否是 "MLLM"version=1
  5. save -> load -> forward 之后,logits 是否逐位一致。

这说明本章不是“组装个对象能跑就算过”,而是在同时检查结构、文件和行为。

task 6.2:实现 student_default_config

这一题代码量很小,但最好不要把它当成纯抄数题。

你要填的是:

  • vocab_size
  • hidden_dim
  • num_heads
  • num_layers
  • ffn_dim
  • max_seq_len

这里最值得顺手建立的意识是:配置不是“注释”,它决定了后续所有对象的 shape 和容量。
如果配置本身就不一致,例如 hidden_dim 不能整除 num_heads,那后面很多模块根本没有合法解释。

task 6.3:实现 student_model_create

这是本章最重要的内存装配题。

你需要按顺序申请:

  1. GPTModel 本体;
  2. embedding
  3. layers 指针数组及每一层 block;
  4. final_ln
  5. lm_head

这里有一个非常关键的工程要求:任何一步失败,都要回收前面已经成功申请的对象。
这不是形式主义,而是因为模型构造是一个层层依赖的过程。如果你不在这一层养成“失败要清理”的习惯,后面写更大的系统会非常危险。

task 6.4:实现 student_model_save

这一题真正的主线只有两个:

  1. 文件头先写清楚;
  2. 后面的张量严格按既定顺序写出去。

建议你在实现时自己先写一张“保存顺序清单”,然后按清单逐项落 fwrite
不要边写边想。因为一旦你在中间某一层的权重顺序上前后犹豫,最后 save/load 不一致时会很难排查。

task 6.5:运行当前真实基线

在还没完成这三题之前,先跑一次:

make clean && make test

当前 Lab06 的真实初始状态是:

  • 能编译;
  • 但测试结果是 0 通过,16 失败

这组失败是有意义的。它说明:

  1. student_default_config 现在还没有提供有效配置;
  2. student_model_create 还没有真正装配任何子对象;
  3. student_model_save 也还没有写出可用文件。

换句话说,这一章当前的实验起点是“能进入练习,但核心功能完全空着”的状态。
这正是一个完整模型装配章应该有的起点。

task 6.6:完成后重新验证

当你补完三个函数后,再执行:

make clean && make test

如果一切正确,你应该看到:

  • 配置检查通过;
  • 5 类子对象都被建出来;
  • 文件头检查通过;
  • round-trip 逐位一致。

这一章的通过信号比前几章更强,因为它第一次证明:你写的不只是“当前进程里的一段逻辑”,而是一份能落盘、能恢复、能重复使用的模型。

6.9 常见错误与排查顺序

最常见的错误通常是这几类:

  1. lm_head shape 写反;
  2. layers 数组申请了,但里面每层没真正建出来;
  3. 某一步申请失败后没有清理;
  4. 保存顺序和加载顺序不一致;
  5. 文件头漏写 magic 或 version。

建议排查顺序也按这个层次来:

  1. 先看配置和 shape;
  2. 再看对象是否都非空;
  3. 再看文件有没有写出来、大小是否合理;
  4. 最后才看 round-trip 为什么不一致。

不要一开始就扑进 bit-exact 检查。
如果基本结构都没对齐,后面的逐位比较只会给你一堆噪声。

6.10 思考题

  1. 为什么完整模型的主 shape 在多层 block 中保持不变,而只在最后一层 lm_head 变成 vocab_size
  2. 如果把 ModelConfig 的字段顺序改了,但旧模型文件还按原顺序保存,会发生什么?
  3. 为什么说 save/load 的正确性本质上依赖“共享协议”,而不是单个函数写得够不够长?

6.11 本章小结

到这一章,miniLLM 课程第一次真正跨过了“局部模块练习”。

你现在要学会看的,不再只是某一层公式是否正确,而是:

  • 模型整体怎样被组织;
  • 权重怎样被管理;
  • 内存对象怎样变成可复用的文件格式。

这一步走通后,下一章才有意义。因为只有当模型已经完整存在,我们才可能讨论:它怎样通过损失、梯度和优化器开始真正学习。