miniLLM 课程主页
这门课的目标很直接:带你从零写出一个纯 C 语言的小型语言模型。你不会只停在“能编译几个算子”这一步,而是会一路把张量、Tokenizer、Embedding、Attention、Transformer Block、完整 GPT、训练、生成、对话、HTTP 接口、KV Cache 和 BPE 串起来,最后做出一个真正能聊天的程序。
如果你以前看过很多大模型材料,却总觉得它们停在概念图和公式上,没有真正落到代码里,那么这门课就是为这个问题准备的。这里不会先把你扔进一个庞大框架,也不会让你先背一堆抽象定义。课程的节奏很固定:先讲清楚这章要解决的具体问题,再进入对应 lab,把这一章真正该写的代码补出来,然后用验证程序确认结果。
你会学到什么
学完整条主线后,你应当能独立回答下面这些问题:
- 一个张量在 C 语言里究竟是什么,它为什么离不开
shape和stride。 - 为什么字符级 Tokenizer 足够作为第一版模型入口,它的局限又在哪里。
- Embedding 和位置编码为什么能把“离散 token”变成可计算的向量。
- Attention 为什么需要
Q、K、V,为什么还要做缩放和 mask。 - Transformer Block 为什么不是“attention 写完就结束了”,而还需要残差、LayerNorm 和 FFN。
- 一个 GPT 模型从结构上怎样由前面这些部件拼起来。
- 为什么训练不是“再加几个循环”这么简单,而要同时关心 loss、梯度和优化器。
- 文本生成、多轮对话、HTTP 服务、KV Cache、BPE 分词各自解决的到底是哪一层问题。
更重要的是,你不会只在纸面上知道这些东西,而是会在 lab 里亲手把它们逐章补出来。
怎么开始
第一次进入课程时,不要急着跳到后面的章节。按这个顺序走:
如果你只想记一句最短路径,那就是:
Chapter 0 -> Chapter 1 -> Lab01 -> Chapter 2 -> Lab02 -> ...
主线章节
| 阶段 | Chapter | 对应 Lab | 你会解决的问题 |
|---|---|---|---|
| 起步 | Chapter 0 | 准备阶段 | 跑通环境,进入第一章前的真实工作状态 |
| 基础 | Chapter 1 | lab01-step0 |
张量、stride、softmax |
| 基础 | Chapter 2 | lab02-step1 |
字符级 Tokenizer |
| 基础 | Chapter 3 | lab03-step2 |
Embedding 与位置编码 |
| 模型 | Chapter 4 | lab04-step3 |
多头自注意力 |
| 模型 | Chapter 5 | lab05-step4 |
Transformer Block |
| 模型 | Chapter 6 | lab06-step5 |
完整 GPT 模型 |
| 训练 | Chapter 7 | lab07-step6 |
损失、反向传播、优化器 |
| 应用 | Chapter 8 | lab08-step7 |
文本生成 |
| 应用 | Chapter 9 | lab09-step8 |
多轮对话 |
| 工程 | Chapter 10 | lab10-step9 |
HTTP 服务 |
| 优化 | Chapter 11 | lab11-step10 |
KV Cache |
| 工程 | Chapter 12 | lab12-step11 |
BPE 分词 |
| 综合 | Chapter 13 | lab13-end-to-end |
把 BPE 对话链路真正跑起来 |
附录
附录不是主线的一部分,只有在你遇到具体问题时再回来看:
现在就开始
如果你已经准备打开第一章,那么下一步就是:
Chapter 0 — 出发前的准备
预计时间:45 ~ 90 分钟
本章目标:跑通课程的第一次 smoke test,并进入Lab01的真实工作目录
正式课程从下一章开始,但在那之前,你需要先把起跑线摆正。这里的“准备”不是为了让你多看几页导言,而是为了确保你接下来读到的每一章、写下的每一行代码、看到的每一次测试结果,都发生在正确的工作环境里。
这一章做完以后,你不需要已经懂张量,也不需要已经懂 attention。你只需要处于一个清楚、稳定、可继续推进的状态:你的机器能编译后面的 lab,你知道正式课程从哪里开始,你能看到第一章对应实验的初始输出。
0.1 这一章结束后,你应该达到什么状态
本章完成时,你应当同时满足下面四条:
- 你的机器上有可用的
gcc和make。 - 你能进入
course/practice/labs/lab01-step0/。 - 你能执行
make clean && make test。 - 你知道第一次看到
3 FAIL + 1 PASS是正常现象,不是环境损坏。
这四条里,最后一条尤其重要。因为这门课不是让你一上来运行一个已经写完的程序,而是让你从一个可编译、可验证、但尚未完成的骨架出发,逐章把项目做出来。
0.2 你现在还不需要懂什么
零基础课程最容易犯的错误,就是在开始之前先把读者压进一大堆还用不上的概念里。这里先把边界说清楚。
在这一章,你还不需要理解:
- 张量的 stride 公式;
- softmax 为什么要减最大值;
- attention 的
Q/K/V; - 反向传播如何算梯度;
- BPE 为什么能把字符序列压缩成更长的子词。
这些内容都会在后面按顺序出现。你现在真正需要做的,是保证自己已经站在正式课程入口上。
0.3 检查最小工具链
先打开终端,执行:
gcc --version
make --version
如果两条命令都能打印版本信息,说明你的机器已经具备最基本的起步条件。
如果这里直接报 command not found,先不要继续往下做。优先修工具链:
- macOS:
xcode-select --install - Debian / Ubuntu:
sudo apt install build-essential - Windows:优先使用 WSL2,再在 WSL 内安装
build-essential
你不需要在这一章深入理解编译器原理。此刻只要记住一件事:后面的每个 lab 都建立在“make 能驱动 gcc 编译这些 .c 文件”的前提上。
0.4 进入课程目录
从仓库根目录开始,执行:
pwd
ls
你应该能看到仓库根下的 course/ 目录。继续进入:
cd course
ls
这里你会看到主线章节、附录,以及接下来要进入的 lab 目录。
到这一步为止,还只是“进入课程”。真正的起点在下面这一步:跑第一次 smoke test。
0.5 运行第一次 smoke test
请直接执行:
cd practice
bash scripts/bootstrap-practice.sh
这个脚本会帮你完成三件事:
- 检查
gcc和make是否存在; - 进入
labs/lab01-step0/; - 自动执行一次
make clean && make test。
如果你想手动确认它最后到底做了什么,也可以继续执行:
cd labs/lab01-step0
make clean && make test
0.6 第一次看到 FAIL,为什么反而是对的
第一次进入 Lab01 时,你看到的通常不是全通过,而是类似下面这种结果:
[TEST 1] ... [FAIL]
[TEST 2] ... [FAIL]
[TEST 3] ... [FAIL]
[TEST 4] ... [PASS]
3 test(s) FAILED.
这不是坏事。它恰恰说明三件事已经成立:
- 代码能编译;
- 验证程序能运行;
- 第一章需要你补的几个函数还没有写,所以失败被清楚地暴露出来了。
这门课的正式起点,不是“你已经什么都做完了”,而是“你已经进入一个可以开始做第一章的状态”。
0.7 如果这里跑不起来,先查什么
如果 smoke test 没有跑到上面的结果,先按这个顺序排查:
| 现象 | 先检查什么 |
|---|---|
gcc: command not found |
编译器没有装好 |
make: command not found |
构建工具没有装好 |
No such file or directory |
当前目录不对 |
make 阶段失败 |
先回看编译输出,确认是编译错误还是链接错误 |
| 输出和预期差很多 | 先执行 git status --short,确认工作区是不是已经被改过 |
如果你只是第一次看到 3 FAIL + 1 PASS,那不属于故障,不需要排错,直接继续下一章。
0.8 本章小结
这一章没有教你模型结构,也没有让你开始写核心算法。它只完成了一件更基础的事:把你准确送到正式课程入口。
现在你已经知道:
- 机器可以运行这门课的 lab;
- 第一章对应的实验目录在哪里;
- smoke test 的正常起始现象是什么;
- 接下来应该去哪里写第一批代码。
这就够了。后面的解释,从下一章开始才真正进入知识本身。
0.9 下一步
现在直接进入:
从这里开始,课程才真正进入代码。
Lab 与实践说明
从 Chapter 1 开始,这门课就不再只是阅读材料,而是要求你真正动手写代码。后面的每一章都会配一个对应的 lab。chapter 负责把知识讲清楚,lab 负责把这一章真正要做的代码落下来。
如果你把这门课想成“先看讲义,再做实验”,这里就是实验区的总入口。
你应该怎样使用这里
最推荐的节奏非常固定:
- 先读对应 chapter,明白这章为什么需要这个新概念。
- 再进入对应 lab,读
TASK.md。 - 打开
framework/student.c,只做这一章留给你的部分。 - 运行
make clean && make test,检查结果。
不要反过来一上来就直接改 student.c。因为这门课不是纯题单,每一章前面的解释会告诉你为什么现在轮到这个问题、为什么代码恰好改在这个位置。
目录长什么样
在 course/practice/ 下面,最重要的是 labs/:
labs/
├── lab01-step0/
├── lab02-step1/
├── ...
└── lab13-end-to-end/
每个 lab 都是一章正式课程对应的动手部分。
以 lab01-step0/ 为例,它通常包含:
lab01-step0/
├── Makefile
├── TASK.md
└── framework/
├── student.c
├── student.h
├── verify.c
└── verify.h
这里几份文件的作用很简单:
TASK.md:告诉你这一章到底要完成什么。framework/student.c:你主要修改的地方。framework/verify.c:自动验证程序。Makefile:本章编译和测试入口。
第一次进入 lab 时会看到什么
如果你刚完成 Chapter 0,那么第一站就是:
cd course/practice/labs/lab01-step0
make clean && make test
第一次运行时,看到部分测试失败是正常的。因为 student.c 里本来就留着待完成的函数,课程就是要你把这些空缺一步步补起来。
对 Lab01 而言,当前正常的起始现象是:
[TEST 1] ... [FAIL]
[TEST 2] ... [FAIL]
[TEST 3] ... [FAIL]
[TEST 4] ... [PASS]
3 test(s) FAILED.
这不是环境坏了,而是说明第一章对应的练习边界已经准备好了。
推荐工作流
从 Lab01 开始,后面每一章都尽量保持同样的做法:
- 读 chapter。
- 读
TASK.md。 - 改
framework/student.c。 - 跑
make clean && make test。 - 对照输出、常见错误和思考题,把这一章真正做完。
这个顺序看起来朴素,但非常有效。它能避免两种最常见的问题:
- 只看讲义,不真正写代码;
- 只盯着代码改,却不知道为什么要这样改。
现在从哪里开始
如果你刚跑完 Chapter 0,下一步就是:
- 读 Chapter 1
- 打开 Lab01 的任务说明
- 开始修改
labs/lab01-step0/framework/student.c
Chapter 1 — step0 张量与数学运算
对应实践:
course/practice/labs/lab01-step0/
主要修改文件:course/practice/labs/lab01-step0/framework/student.c
验证命令:make clean && make test
到这一章,课程才算真正进入代码。
在 Chapter 0 里,你已经完成了一件非常重要但又容易被忽略的事:确认自己接下来真正要改的是 lab 里的实验代码,而不是项目里已经写好的那套实现。你已经看到过 Lab01 的初始输出是“3 个 FAIL + 1 个 PASS”。这意味着练习边界已经准备好了,接下来就该真正填第一批代码。
本章之所以从张量开始,不是因为张量“理论上最基础”,而是因为 miniLLM 的后面所有模块,本质上都在反复做同一件事:对一块连续的浮点内存,按照某个 shape 和 stride 规则去读、写、重排、相乘、归一化。只要你对这一层没有建立直觉,后面的 embedding、attention、FFN、甚至训练和缓存,看起来都会像不同风格的魔法;但一旦这一层清楚了,后面的复杂模块就会开始显得“不过是更大一点的张量操作组合”。
所以这章真正要建立的,不只是三个函数会不会写,而是一种观察方式:以后你每看到一个模块,都先问自己三件事。
- 输入和输出分别是什么 shape?
- 这些 shape 在内存里怎样排布?
- 这一段代码到底是在“按什么规则读数据、算数据、再写回数据”?
1.1 本章你要完成什么
这章结束后,你应当能做到下面几件事:
- 用自己的话解释
Tensor结构体里data、shape、strides、ndim、size各自表示什么。 - 看着一个二维张量,手算出任意元素的一维偏移。
- 明白为什么
student_tensor_get和student_tensor_set是第一批最值得亲手写的函数。 - 明白 softmax 为什么不能直接对原始输入做
expf,以及“先减最大值”到底解决了什么问题。 - 在
Lab01的实践框架中,把这三个函数补完,并看到全部测试转成[PASS]。
这五点里,前四点是理解,第五点是实践。缺一不可。只读懂不写,你的理解会很虚;只写出来不懂,你后面一到 attention 就会失速。
1.2 先看 practice target:你这章到底改哪里
正式进入概念之前,先把实践边界说清楚。
这一章不要去改 step0/src/tensor.c,也不要去改根目录 step0/src/math_ops.c。那些文件是项目里现成的实现,不是你本章的主工作区。
你这章真正应该进入的是:
course/practice/labs/lab01-step0/
├── TASK.md
├── Makefile
└── framework/
├── student.c <- 主要修改这里
├── student.h
├── verify.c <- 自动验证,不改
└── verify.h
当前 lab 的设计非常克制。student.c 里只留了三个待完成函数:
student_tensor_getstudent_tensor_setstudent_softmax_stable
这三个函数之所以被选出来,不是偶然。它们刚好覆盖了 miniLLM 后面几乎所有运算的三个基本动作:
- 按索引规则读数据;
- 按索引规则写数据;
- 对一段数做数值稳定的归一化。
当你把这三个动作理解透,后面很多“高级模块”的底层直觉其实已经提前出现了。
1.3 为什么张量在工程里不是抽象名词,而是内存规则
很多初学者第一次听到“张量”,会下意识把它理解成一种很高级的数学对象。这个理解当然没错,但如果你现在停在这里,它对写 C 代码几乎没有帮助。
在工程里,尤其在像 miniLLM 这样纯 C 的项目里,张量首先不是一个抽象名词,而是一个非常朴素的问题:
我有一块连续的
float内存。我想把它看成 1 维、2 维、3 维甚至更多维的数据。那我该如何记录“第几个元素属于哪一行哪一列”,以及“给我一个多维坐标,我怎么快速找到它在这块线性内存里的位置”?
这就是 Tensor 结构体存在的理由。
看一下参考接口 step0/src/tensor.h,你会发现它的核心字段并不神秘:
typedef struct {
float* data;
int* shape;
int* strides;
int ndim;
int size;
} Tensor;
把这几个字段翻译成人话,大致是:
data:真正存数值的连续内存;shape:每一维有多长;strides:沿某一维走一步,在线性内存里要跳过多少个元素;ndim:总共有几维;size:所有元素加起来一共多少个。
这套设计非常重要,因为它把“多维世界”压扁成了“一个数组 + 一套索引规则”。而神经网络代码里几乎所有计算,本质上都建立在这套压扁规则之上。
1.4 从二维矩阵开始理解 stride
现在不要急着考虑高维。先把二维情况吃透。
假设你有一个 shape 为 [2, 3] 的张量,也就是两行三列。我们把它写成矩阵,看起来像这样:
[[a, b, c],
[d, e, f]]
但在内存里,它不是按“行和列”分开存的,而是按一条线排开:
[a, b, c, d, e, f]
这就叫 row-major,也就是行优先存储。对 C 语言背景的同学来说,这其实并不陌生,因为普通二维数组在底层也是类似思路。
问题来了:如果我现在问你要元素 [1, 2],也就是第二行第三列的那个值,你怎样从线性内存里定位它?
这时 stride 就登场了。
对 [2, 3] 这样的二维张量,stride 是 [3, 1]。它表示:
- 沿第 0 维,也就是“换一行”,你要在
data里跳过 3 个元素; - 沿第 1 维,也就是“换一列”,你只要往后走 1 个元素。
于是坐标 [i, j] 对应的一维偏移就是:
offset = i * strides[0] + j * strides[1]
对 [1, 2] 来说,就是:
offset = 1 * 3 + 2 * 1 = 5
而 data[5] 正好就是 f。
这一步必须理解得非常扎实。因为本章的前两个待实现函数,本质上都是同一件事:
get:算出 offset,再去读data[offset]set:算出 offset,再去写data[offset] = value
1.5 为什么这两个函数值得你亲手写一遍
你可能会想:这种函数看起来很简单,为什么不直接给学员现成答案?
原因恰恰在于它简单,但又“极具代表性”。
对于项目型课程来说,第一章最好不要让学员同时承受三种负担:
- 新概念太多;
- 代码范围太大;
- 验证链太长。
student_tensor_get 和 student_tensor_set 的好处是,它们在概念上足够小,在代码上足够短,在验证上又非常直接。你能非常快地看到:自己是不是算对了 offset,是不是理解了 row-major,是不是知道读和写其实共享同一套索引规则。
这会给后续章节一个很好的心理起点:原来所谓“写神经网络底层代码”,第一步也不过是把一个结构体和索引公式想清楚。
1.6 softmax 为什么会出现在第一章
看到 student_softmax_stable 时,很多人会有点意外:前两个函数都在讲张量索引,为什么第三个突然变成了 softmax?
这不是跳跃,而是刻意安排。
因为 stride 公式解决的是“如何访问数据”,但 miniLLM 后面还有另一个会反复出现的问题:如何在浮点数世界里稳定地计算概率分布。softmax 正是这个问题最经典、也最早出现的例子。
如果你以后实现 attention,会不断遇到“先算一堆分数,再把它们变成和为 1 的权重”。那套变换的核心就是 softmax。因此,把它提早放在第一章,有两个好处:
- 让你尽早建立“数值稳定性不是优化项,而是正确性的一部分”的意识;
- 让你第一次在一个很小的练习里,接触后面真正模型计算里会反复出现的模式。
1.7 softmax 的公式没有问题,问题出在数值
softmax 的数学定义很短:
softmax(x_i) = exp(x_i) / sum_j exp(x_j)
如果你只看纸面公式,会觉得这再自然不过。先把每个数做指数,再归一化成概率分布。
但工程实现的问题从来不在“公式看起来对不对”,而在“它在真实机器上的浮点表示里会不会炸”。
例如,假设输入是:
[1000, 1001, 1002]
从数学上看这完全合法,甚至很普通:第三个数最大,所以它的概率应该最大。
但从计算机执行 expf(1000) 的角度看,这就危险了。因为指数函数增长太快,直接 expf 很容易溢出,产生 inf,后续再做除法,就会把整个结果污染成 NaN 或其它无意义数值。
这就是为什么稳定实现要先减最大值。
把上面的输入变成:
[1000 - 1002, 1001 - 1002, 1002 - 1002]
= [-2, -1, 0]
再去做 expf,数值就安全得多了。而且 softmax 的最终结果不会变,因为分子分母同时乘上了同一个常数因子。
你可以把它理解成:我们没有改变这组三个数“谁比谁更大”的相对关系,只是把它们整体平移到了一个更适合浮点运算的坐标系里。
这就是 student_softmax_stable 真正要你学的东西。不是会背一个技巧,而是知道:
数学上等价,不代表数值上同样安全。工程实现必须同时尊重公式和机器。
1.8 先看一眼验证器,它到底在检查什么
在动手之前,建议你读一遍 course/practice/labs/lab01-step0/framework/verify.c。不需要逐行背,但要知道它在验证哪些事实。
当前 Lab01 的四个测试大致在检查:
- 你能不能从二维张量里按坐标正确读到值;
- 你能不能按坐标正确把值写回去;
- 你写的 softmax 输出是不是能归一化成和为 1 的分布;
- 你的 softmax 在大数输入下会不会溢出。
你会注意到,这些检查都非常“局部”,但非常有针对性。它们不是在问“你会不会写完整神经网络”,而是在问“本章最核心的三个动作,你到底会不会”。
这就是这种 lab 结构的价值:它让验证结果直接对准本章教学目标。
1.9 本章实践步骤
现在开始进入真正的操作。建议你边看这一节,边在终端里跟着做。
task 1.1:重新确认 baseline
进入实践目录:
cd course/practice/labs/lab01-step0
make clean && make test
如果你前面已经做过一次,这里看到的现象应当还是一样:
TEST 1FAILTEST 2FAILTEST 3FAILTEST 4PASS
这一步的作用不是浪费时间,而是确认你接下来每一次变化,都能和这次 baseline 对比。
task 1.2:实现 student_tensor_get
打开:
course/practice/labs/lab01-step0/framework/student.c
找到:
float student_tensor_get(Tensor* t, int* indices)
这里不要急着写很多保护逻辑。先把主干想清楚:
t和indices至少不能是空;- 对当前 lab,可以按二维张量来处理;
- 用
indices[0] * t->strides[0] + indices[1] * t->strides[1]算 offset; - 返回
t->data[offset]。
如果你写完以后 TEST 1 还没过,先别怀疑 softmax,也别怀疑 Makefile。先回到纸上,再手算一遍 [1, 2] 对应的 offset。
task 1.3:实现 student_tensor_set
第二个函数其实是第一个函数的镜像版本。
同样先算 offset,然后执行写入:
t->data[offset] = value;
这里最容易犯的错,不是公式写错,而是思维没有对齐:你在读数据时承认 stride 规则,在写数据时又试图用别的索引方式。这个 lab 正是在训练你形成一个一致的意识:
读和写不是两套规则,它们共享同一套内存定位逻辑。
task 1.4:实现 student_softmax_stable
最后一个函数建议分三段写,而不是一口气压成一团:
- 先扫描输入,找最大值;
- 再计算
expf(in[i] - max)并累加总和; - 最后做一次归一化,把每个
out[i]除以总和。
这类函数非常适合写得稍微“啰嗦一点”,因为可读性本身就是稳定性的一部分。你以后会越来越发现:在数值代码里,清晰的分步实现,往往比花哨的压缩写法更可靠。
task 1.5:重新验证
完成后重新执行:
make clean && make test
理想结果是:
[TEST 1] ... [PASS]
[TEST 2] ... [PASS]
[TEST 3] ... [PASS]
[TEST 4] ... [PASS]
All tests passed!
当你第一次看到这里全部转成 PASS,虽然代码量很小,但它的意义很大。因为这说明你已经完成了这门课的第一块真正可验证的工程积木。
1.10 常见错误与排查顺序
如果结果不对,按下面顺序排:
| 现象 | 更可能的问题 | 优先检查 |
|---|---|---|
TEST 1 失败 |
offset 公式错了,或没处理输入 | student_tensor_get |
TEST 2 失败 |
写回逻辑没用同一套 offset | student_tensor_set |
TEST 3 失败 |
没归一化,或输出顺序不对 | student_softmax_stable |
TEST 4 失败 |
没有先减最大值 | student_softmax_stable 第一步 |
这里最重要的排错原则是:不要同时改三处再赌一次结果。
一旦验证器已经把失败点切得这么细,你就应该一处一处地定位。学会尊重这种细粒度反馈,是后面做更复杂章节时很关键的工程习惯。
1.11 思考题
- 如果把
[2, 3]张量的 stride 错写成[2, 1],哪些坐标会最先读错?为什么? student_tensor_get和student_tensor_set现在只按二维张量写。如果以后要支持任意维度,你觉得最自然的推广方式是什么?- softmax 先减最大值后,为什么最终概率分布不变?这里真正保持不变的量是什么?
TEST 4只证明“不会溢出”,它有没有证明“概率一定正确”?如果没有,它没有覆盖哪一类错误?
这些问题不是装饰。它们会决定你后面看到 attention、layernorm、cross entropy 时,是把它们当作新名词,还是把它们看成“旧模式的新组合”。
1.12 本章小结
这一章你做的事情看起来不大,但它在课程结构里非常关键。
你第一次真正进入了本章实验代码,第一次修改了需要自己完成的文件,第一次通过自动验证器获得了高信噪比反馈。更重要的是,你开始建立了这门课后面最重要的三个底层直觉:
- 多维结构最终要落成线性内存;
- 正确的索引规则比表面 API 更重要;
- 数值计算要同时尊重数学等价和浮点稳定性。
这三点以后会反复出现。只是下一次,它们会披上更复杂的外衣。
1.13 通往下一章
下一章我们会从“如何在内存里放一个矩阵”走到“如何把文本放进模型”。也就是从张量基础走向 tokenizer。
在概念上,这是第一次把“人类能读的字符串”翻译成“模型能处理的离散 ID”;在实践上,这意味着你会第一次接触词表、特殊 token、编码和解码的一致性问题。
Chapter 2 — step1 字符级 Tokenizer
对应实践:
course/practice/labs/lab02-step1/
主要修改文件:course/practice/labs/lab02-step1/framework/student.c
验证命令:make clean && make test
在 Chapter 1 里,你第一次建立了一个很底层但非常关键的认识:神经网络代码最终都要落实成“怎么从内存里把数取出来,再按规则写回去”。那一章解决的是“数字如何在张量里存在”的问题。
但模型真正要处理的输入并不是矩阵,也不是浮点向量,而是人写出来的文本。文本和张量之间隔着一道很硬的边界:计算机当然可以把 'H'、'i'、空格这些字符存在内存里,但模型并不会直接对“字符”这个概念做矩阵乘法。模型只接受数字,尤其只接受离散 ID 序列,再由 embedding 把这些 ID 变成浮点向量。
这就是 tokenizer 出场的原因。
所以这一章要回答的问题是:为什么 "Hello" 这样一个字符串,必须先变成 [76, 105, 112, ...] 这样的整数序列,后面的 embedding、attention、loss 才有工作对象。
2.1 本章真正要建立什么直觉
如果只从练习代码看,这一章似乎很简单:写几个字符和 token ID 之间的转换函数,再做一次 encode/decode 的 roundtrip。
但从课程推进上看,这一章非常关键,因为它把“人类文本”第一次送入了“模型可处理的数据管道”。
这章结束后,你应当具备下面几个判断:
- 知道 miniLLM 当前字符级 tokenizer 的词汇表为什么是
4 + 256 = 260。 - 知道
<PAD>、<UNK>、<BOS>、<EOS>为什么必须预留在最前面。 - 明白字符级 tokenizer 不是在理解语言,而是在建立一种稳定、可逆、可喂给模型的离散编码方式。
- 明白“编一次再解一次能拿回原串”为什么是 tokenizer 最基础也最重要的正确性检查。
- 能在本章实验代码中完成
student_encode_char、student_decode_id和student_roundtrip。
和上一章一样,这里前四点是理解,第五点是实践。没有前四点,后面的练习会沦为背规则;没有第五点,这一章又会停留在纸面上。
2.2 先看 practice target:这一章改哪里
这一章的工作区同样不在 step1/src/tokenizer.c 里,而在本章 lab 目录中:
course/practice/labs/lab02-step1/
├── TASK.md
├── Makefile
└── framework/
├── student.c <- 主要修改这里
├── student.h
├── verify.c <- 自动验证,不改
└── verify.h
这一章真正需要你改的文件依然只有一个:framework/student.c。这说明本章仍然在坚持同一个原则:让你在一个边界明确、反馈直接的最小工作区里完成任务。
当前这个 lab 只要求你实现 3 个函数:
student_encode_charstudent_decode_idstudent_roundtrip
看起来都不长,但它们恰好构成了一条完整数据链:
字符 -> token ID -> 字符
这条链一旦稳定,下一章 embedding 才有可输入的整数序列。
2.3 为什么模型不能直接吃字符
这个问题对初学者很值得认真解释,因为它决定你会不会把 tokenizer 当成一个“多余前处理”。
模型之所以不能直接吃字符,不是因为字符“不够高级”,而是因为模型所有核心运算都是数值运算。一个 decoder-only 模型要做的是:
- 查表;
- 线性变换;
- attention;
- 归一化;
- 采样。
这些操作都要求输入先变成一个稳定的离散编号系统。你必须先告诉模型:“H 是哪个 ID,空格是哪个 ID,句子结束又是哪个 ID。”如果没有这层编号,后面 embedding 就不知道该查哪一行,loss 也不知道目标 token 对应哪一维。
从这个角度说,tokenizer 的工作不是“理解语言意义”,而是“把文本搬运进模型计算图”。
2.4 当前 miniLLM 的字符级词表为什么是 260
miniLLM 当前这一阶段还没有上 BPE,所以 tokenizer 采用的是最直接的字符级设计。
它的基本想法很简单:
- ASCII 普通字符一共有 256 个可能值(0 到 255);
- 但在正式字符之前,还要预留几个特殊 token;
- 因此最终词表大小 =
4 + 256 = 260。
这四个特殊 token 分别是:
TOKEN_PAD = 0TOKEN_UNK = 1TOKEN_BOS = 2TOKEN_EOS = 3
然后从 4 开始,才轮到普通字符:
- ASCII 0 -> token 4
- ASCII 1 -> token 5
- …
- ASCII 72 (
'H') -> token 76 - ASCII 105 (
'i') -> token 109
这里最关键的不是背这些数字,而是理解这个设计背后的秩序:
先把协议性、控制性的 token 预留出来,再把普通字符整体往后平移一个固定偏移量。
一旦你理解了“固定偏移量”这个想法,这一章的编码和解码逻辑其实就很直接了。
2.5 特殊 token 为什么必须单独存在
这一步很容易被初学者当成“为了好看加的常量”,其实不是。
特殊 token 的存在,是为了让模型和训练流程能表达普通字符之外的结构信号。
<PAD>
它负责填充。以后当不同样本长度不一致、需要凑成固定长度 batch 时,就需要这个 token 占位。
<UNK>
它负责兜底。遇到当前词表无法表示的内容时,至少要有一个合法 ID 可以退回,而不是直接崩掉。
<BOS>
它告诉模型“一个序列从这里开始”。对生成模型来说,起点并不是无意义的,很多训练和推理流程都需要明确知道序列边界。
<EOS>
它告诉模型“一个序列到这里结束”。没有它,模型在训练阶段就很难学习“何时停”,在推理阶段也很难自然结束输出。
即便这一章的 roundtrip 暂时可以不引入 BOS/EOS,你也应该知道它们为什么在词表里提前占了位置。因为这说明 tokenizer 并不是单纯的字符映射表,它还是整个序列协议的一部分。
2.6 编码和解码到底是在做什么
这一章最容易让人误以为“逻辑太简单,不值得讲”。但越简单的规则,越适合讲清楚它的本质。
编码
编码做的事是:
char -> int
对普通字符来说,规则就是:
id = (unsigned char)c + NUM_SPECIAL_TOKENS
这条公式有两个细节值得注意。
第一,为什么要转成 (unsigned char)?因为 char 在 C 里是否带符号,和编译器实现有关。如果你不先转成无符号范围,某些高位字符就可能被解释成负数。
第二,为什么要加 NUM_SPECIAL_TOKENS?因为前 0 到 3 已经留给特殊 token 了。普通字符不能再占这些位置。
解码
解码做的事是反过来:
int -> char
对普通字符来说,就是:
char = id - NUM_SPECIAL_TOKENS
但这里不能简单地对所有 ID 都这么做。因为前 0 到 3 根本不是普通字符;超出词表上界的 ID 也不能乱转。所以这一章的 student_decode_id 实际上是在做两件事:
- 先过滤非法或特殊输入;
- 再对合法普通字符做逆映射。
这种“先判边界,再做主逻辑”的写法,以后几乎每章都会出现。
2.7 为什么 roundtrip 是 tokenizer 的最低正确性标准
这一章第三个函数 student_roundtrip 看上去只是个小工具,但它实际上在验证 tokenizer 最重要的性质之一:可逆性。
如果一套 tokenizer 连最基本的 decode(encode(text)) == text 都做不到,那后面的 embedding、训练、生成就没有可靠输入输出边界。你甚至无法确认模型看到的到底是不是你以为的那串文本。
当然,真实世界里的 tokenizer 不一定对所有情况都完全严格可逆,尤其涉及规范化、空白折叠或更复杂分词时会有例外。但对当前这个字符级最小实现来说,roundtrip 就是最应该被牢牢守住的基线。
这也是为什么 Lab02 把第三个 learner function 设计成 student_roundtrip:它迫使你把前两个单点规则连成一条真正的数据路径,而不是停留在“单步看起来都对”。
2.8 这一章和下一章的关系是什么
如果说当前章解决的是“文本怎么变成离散编号”,那下一章要解决的就是“离散编号怎么变成浮点向量”。
这两个步骤不能调换。
embedding 从来不是直接作用在字符上,而是作用在 token ID 上。你可以把它理解成一张矩阵,每个 token ID 对应矩阵里的一行。
那么自然地,只有先有 token ID,embedding 才知道该取哪一行。
也就是说,这一章不是一个独立的小插曲,而是把输入管道铺到 embedding 门口。
2.9 本章实践步骤
task 2.1:先看一遍 student.c 和 verify.c
进入 practice 目录:
cd course/practice/labs/lab02-step1
先读:
framework/student.cframework/student.hframework/verify.c
你会发现这一章的验证器很直白,它主要检查四件事:
- 词表大小是不是 260;
'H'和'i'编码后是不是 76 和 109;- 76 和 109 解码后是不是
'H'和'i'; "Hello"做 roundtrip 后能不能完整回来。
这种验证器的价值在于,它把“这一章真正要会什么”写得非常明确。
task 2.2:实现 student_encode_char
这个函数的核心逻辑非常短,但不要因为短就草率。
你需要处理两类情况:
tok == NULL或输入字符异常时,返回TOKEN_UNK;- 普通字符时,返回
(unsigned char)c + NUM_SPECIAL_TOKENS。
这里建议你刻意留意“异常路径”和“主路径”是如何分开的。后面做更复杂模块时,这种结构会一直出现。
task 2.3:实现 student_decode_id
这个函数的关键不是减去偏移本身,而是知道哪些 ID 根本不该被当成普通字符解码。
优先检查:
tok是否为空;id < NUM_SPECIAL_TOKENS是否成立;id >= tok->vocab_size是否成立。
只有这些边界都过了,才去做:
(char)(id - NUM_SPECIAL_TOKENS)
如果你没有先做这些边界处理,很多输入在“数学上看似有值”,但在协议上是无意义的。
task 2.4:实现 student_roundtrip
这一章最完整的实践就在这里。你需要把前两个局部规则串起来。
推荐的思路是:
- 先对
text、out、out_size做边界处理; - 调
tokenizer_encode(tok, text, &len, 0, 0)得到 ID 数组; - 分配一个临时字符缓冲;
- 逐个 ID 调
student_decode_id写回字符; - 末尾补
'\0'; - 再复制到
out; - 释放中间内存。
这一步有一个很典型的工程点:即使逻辑很短,也不能忘记内存释放。课程越往后走,这类“不是算法主体、但会决定程序是否健康”的细节会越来越重要。
task 2.5:运行验证
完成后执行:
make clean && make test
当前理想结果应当是:
[TEST 1] ... [PASS]
[TEST 2] ... [PASS]
[TEST 3] ... [PASS]
[TEST 4] ... [PASS]
All tests passed!
如果还没有全部通过,不要在多个函数里同时乱改。先对照哪一条测试失败,再回到它对应的那一小段逻辑。
2.10 常见错误与排查顺序
| 现象 | 更可能的问题 | 优先检查 |
|---|---|---|
TEST 1 失败 |
词表大小或框架状态异常 | tokenizer_vocab_size 与框架状态 |
TEST 2 失败 |
编码时偏移量没加对 | student_encode_char |
TEST 3 失败 |
特殊 token / 上界判断有问题 | student_decode_id |
TEST 4 失败 |
roundtrip 路径没有完整串起来 | student_roundtrip |
| 编译不过 | 当前 lab 框架或你本地修改有问题 | 先看 make 第一处报错 |
这里有一个很值得养成的习惯:先分清“编译错误”和“验证失败”。
- 编译错误说明程序根本还没进入语义层;
- 验证失败说明程序能跑,但行为和预期不一致。
这两种问题的处理方式完全不同。不要把它们混在一起看。
2.11 思考题
- 为什么字符级 tokenizer 在教学上很合适,但在真实大模型里通常不够高效?
- 如果没有
<EOS>,模型在生成时还能靠什么机制停下来?这种停法有什么局限? student_decode_id遇到特殊 token 返回'\0'。这只是一个最小课程选择。如果以后你想保留特殊 token 的文本形态,更合理的接口应该长什么样?- 这一章里“固定偏移量 + 可逆映射”的思想,和上一章“stride + 可逆定位”有什么共通点?
最后这个问题尤其重要。因为课程真正想训练的,不是你背住两个局部技巧,而是看到它们背后同一类设计结构。
2.12 本章小结
这一章你第一次把“人类文本”接进了模型管道。
你看到 tokenizer 并不神秘,它首先是在建立一个稳定的离散编号系统;你也看到特殊 token 并不是装饰,而是整个序列协议的一部分。更重要的是,你开始接触一类非常常见的工程模式:一条数据路径不仅要能正向走通,还要能通过 roundtrip 或其它方式证明它在边界上足够稳定。
下一章,离散 ID 将不再停留在整数层面,而会进入浮点表示世界。也就是:embedding。
Chapter 3 — step2 Embedding 与位置编码
对应实践:
course/practice/labs/lab03-step2/
主要修改文件:course/practice/labs/lab03-step2/framework/student.c
验证命令:make clean && make test
前两章里,输入已经经历了两次关键翻译。
在 Chapter 1,你学会了如何把“多维结构”落实成一块连续内存加一套索引规则。
在 Chapter 2,你又把“人能读的文本”翻译成了“模型能处理的离散 ID”。
但对模型来说,这还不够。因为注意力、前馈网络、层归一化这些后续模块都工作在浮点向量空间里,而不是工作在离散整数空间里。76 这个 token ID 对人来说可以表示 'H',对模型来说却只是一个整数。模型不会直接“乘以一个 token ID”,它只能读取一个向量、变换一个向量、再输出另一个向量。
这就是 embedding 的职责:把离散编号系统,接到连续向量空间上。
3.1 本章你真正要建立的直觉
如果只看表面,这章的代码比 attention、transformer block 都简单很多。它无非是在做“查表”和“相加”。
但从结构上说,这章非常重要,因为它回答了一个贯穿整个模型的问题:
一个 token 到底是怎样第一次变成模型内部的数值表示的?
完成本章后,你应当具备下面这些判断:
- 明白 token embedding 本质上就是一张
vocab_size × hidden_dim的查表矩阵。 - 明白 position embedding 不是装饰,而是为了让模型知道“同一个 token 出现在第几个位置”。
- 能解释为什么
embedding_forward不是一种神秘操作,而是“取 token 向量 + 加位置向量”。 - 知道 sinusoidal position embedding 的基本公式和最容易验证的边界情形:
pos = 0。 - 能在本章实验代码中完成
student_pe_sinusoidal和student_embedding_forward。
和前两章一样,这里既不是纯理论,也不是纯填空。它真正要你建立的是一种组合直觉:一层模型经常并不做“复杂算法”,而是在把前面已经准备好的几块结构,按有意义的方式拼起来。
3.2 先看 practice target:这章改哪里
本章的主工作区仍然在本章 lab 目录里,而不是在 step2/src/embedding.c。
你应该进入:
course/practice/labs/lab03-step2/
├── TASK.md
├── Makefile
└── framework/
├── student.c <- 主要修改这里
├── student.h
├── verify.c <- 自动验证,不改
└── verify.h
当前 student.c 里只要求你完成两个函数:
student_pe_sinusoidalstudent_embedding_forward
这两个函数的组合非常有代表性。第一个函数负责“位置编码的单元素公式”,第二个函数负责“把 token embedding 和 position embedding 装配成整层前向传播”。
也就是说,这一章不是在让你实现完整 Embedding 模块的所有初始化和管理逻辑,而是在让你抓住它最核心的两步:
- 位置编码这一行上的某个值到底怎么算;
- 一个 token 序列怎样被逐位置写成
[seq_len, hidden_dim]的输出矩阵。
3.3 为什么光有 token ID 还不够
上一章结束时,你已经有了 "Hi" 对应的一组整数 ID。看起来模型离“能处理输入”已经很近了,但其实还差一个关键层次。
token ID 的本质只是标签,不是表示。
例如,76 和 109 这两个数字并不天然带有“字符之间的相似性”或“语义接近性”。从模型角度看,如果你直接把这些整数当作数值去算,会产生非常奇怪的含义:难道 109 比 76 大,就说明 'i' 比 'H' 更重要吗?显然不是。
这就是 embedding 层存在的原因。它要做的不是保留这些整数的算术大小,而是为每个 token 分配一个可学习的连续向量表示。
你可以把它想成一张很大的表:
token_embedding[vocab_size][hidden_dim]
其中每一行对应一个 token,每一列对应这个 token 表示向量的某个维度。给定 token ID 后,embedding 做的事情非常朴素:直接去取那一行。
这就是为什么 embedding 通常被叫做“查表层”。它在最基本的意义上,确实就是查表。
3.4 但仅有 token embedding,模型仍然分不清顺序
到这里会出现一个新的问题。
如果你把一句话里的每个 token 都变成一个向量,那么模型确实有了连续输入表示;但同一个 token 在不同位置,查出来的 token embedding 是完全相同的。
这就意味着,对模型来说,序列 "AB" 和 "BA" 在某种意义上会非常像:它看到了同样两种 token,只是换了位置,而单独的 token embedding 并不会告诉它“这个 token 现在在第 0 位还是第 1 位”。
这件事对 RNN 来说不那么致命,因为 RNN 的递推结构天然带有顺序。
但对 Transformer 来说,它后面依赖的是 attention,而 attention 本身并不会自动记住“先后次序”。如果没有额外位置信息,模型看到的会更像是一袋 token,而不是一个有顺序的序列。
所以 position embedding 不是为了“锦上添花”,而是为了把序列顺序重新带回模型里。
3.5 sinusoidal position embedding 到底在做什么
本章默认关注的是 sinusoidal 位置编码,也就是一组固定的、由公式直接生成的位置向量。
最常见的写法是:
PE(pos, 2i) = sin(pos / 10000^(2i / d))
PE(pos, 2i+1) = cos(pos / 10000^(2i / d))
这里:
pos表示当前位置;i表示第几对维度;d表示整个隐藏维度,也就是hidden_dim。
这个公式第一次看时很像“数学人为构造”,但你可以先抓住它最直观的一层意义:
- 不同位置会得到不同向量;
- 不同维度变化的快慢不同;
- 因此模型能同时从某些维度里感知短距离位移,也从另一些维度里感知长距离位移。
你现在还不需要深入相对位置的推导,也不需要比较 RoPE、ALiBi 等变种。对本章来说,更重要的是:你能先把这个公式作为一个可验证、可写进代码、可实际打印出来的结构去理解。
3.6 为什么 pos = 0 是最好的第一个检查点
课程里最应该优先利用的,不是复杂公式本身,而是它们最简单的边界情况。
在 sinusoidal PE 里,pos = 0 恰好就是这样一个边界点。因为无论分母是什么,只要分子是 0,就有:
sin(0) = 0cos(0) = 1
因此对任意维度,pos = 0 这一整行的位置编码都会呈现出非常规则的形状:
[0, 1, 0, 1, 0, 1, ...]
这非常适合作为第一层验证。因为你不需要先理解整张位置编码矩阵,只要先确认第 0 个位置的偶数维是不是 0、奇数维是不是 1,就能知道自己的公式主干有没有跑偏。
这也是为什么 Lab03 的第一个测试就盯住这个现象。教学上最稳的方式,就是先从最容易手算、最不容易被误解的边界点入手。
3.7 embedding_forward 本质上只是两件事叠加
很多初学者第一次看到 embedding forward,会把它想得很复杂。其实把结构拆开后,它非常直接。
对序列里第 pos 个 token,embedding forward 做的事情就是:
- 用
token_ids[pos]去 token embedding 表里查一行; - 再取当前位置
pos的 position embedding 那一行; - 把这两行逐元素相加,写到输出的第
pos行。
换句话说:
output[pos, d] = token_embedding[token_id, d] + position_embedding[pos, d]
或者在本 lab 的 student 实现里:
output[pos, d] = token_embedding[token_id, d] + student_pe_sinusoidal(pos, d, hidden_dim)
这类前向传播最值得学的,不是“会不会写循环”,而是看懂结构:
- token embedding 负责“这个 token 是谁”;
- position embedding 负责“它现在在第几个位置”;
- forward 把这两种信息压进同一个向量。
这个模式以后会反复出现。后面的很多层,都是把“不同来源的信息”加到一起,或者按某种结构组合起来。
3.8 本章实践步骤
task 3.1:先看 student.c 和 verify.c
进入:
cd course/practice/labs/lab03-step2
先读三份文件:
framework/student.hframework/student.cframework/verify.c
你会发现这一章的测试在问四个非常明确的问题:
pos = 0时,位置编码是否呈现0/1/0/1的交替;PE(1, 0)和PE(1, 1)是否接近sin(1)和cos(1);- 相同 token 放在不同位置时,输出向量是否真的不同;
- 不同 token 放在相同位置时,输出向量是否也不同。
这四个问题很有代表性,因为它们分别覆盖了:
- 公式对不对;
- 数值算得像不像;
- 位置信息有没有真的加进去;
- token 信息有没有真的保留下来。
task 3.2:实现 student_pe_sinusoidal
先做最小单位,也就是“位置编码的一个值”。
推荐先把逻辑拆成三步:
- 计算当前维度对应的
i = dim / 2; - 计算分母
10000^(2i / hidden_dim); - 决定当前维度是用
sin还是cos。
这里最值得留心的是:你不是在“背公式”,而是在把一个可验证的数值结构落到代码里。写完以后,第一时间就该拿 pos = 0 去检查,而不是先赌大样本行为。
task 3.3:实现 student_embedding_forward
这个函数比上一章的 roundtrip 更像一个真正的“层前向传播”。
你要处理的核心事情有:
- NULL 和长度边界;
- 越界 token 用 UNK 兜底;
- 外层按
pos遍历; - 内层按
d遍历; - 把 token embedding 和 position encoding 写进输出。
这里建议你刻意体会一个工程细节:
本章虽然在讲“embedding”,但 student 实现并没有让你直接调用完整 embedding_forward。课程故意把最核心的结构拆给你自己写,就是为了让你亲手经历一次“这一层前向传播其实只是怎么组织几块数据”的过程。
task 3.4:运行当前基线并理解它
在你还没完成这两个函数时,执行:
make clean && make test
当前这个 lab 的真实基线是:
TEST 1FAILTEST 2FAILTEST 3FAILTEST 4FAIL
这不是坏消息,反而说明 lab 现在正处在正确的“待学员完成”状态。
因为当前 student_pe_sinusoidal 永远返回 0.0f,student_embedding_forward 也还什么都没写,所以四个测试都必然失败。
你应该把这次输出当作 Chapter 3 的 baseline,而不是当作环境异常。
task 3.5:完成后重新验证
当你完成两个函数后,再执行:
make clean && make test
理想结果应当是:
[TEST 1] ... [PASS]
[TEST 2] ... [PASS]
[TEST 3] ... [PASS]
[TEST 4] ... [PASS]
All tests passed!
如果 TEST 1 和 TEST 2 过了,但 TEST 3、TEST 4 没过,通常说明你的单元素 PE 算对了,但 forward 里没有正确把 token 信息和位置信息组合起来。
3.9 常见错误与排查顺序
| 现象 | 更可能的问题 | 优先检查 |
|---|---|---|
TEST 1 失败 |
pos = 0 的边界没对齐 |
student_pe_sinusoidal |
TEST 2 失败 |
sin/cos 公式或分母实现有误 |
student_pe_sinusoidal |
TEST 3 失败 |
没把位置信息真正加进去 | student_embedding_forward |
TEST 4 失败 |
token embedding 读取或写回逻辑有误 | student_embedding_forward |
| 全部仍为 0 | forward 根本没有写 output | student_embedding_forward 主循环 |
这一章最重要的排查思路是:先把“单元素公式”查清,再查“整层装配”。不要一上来就同时怀疑所有地方。
3.10 思考题
- 如果彻底关掉 position embedding,模型为什么会更难区分
"AB"和"BA"? - 为什么
pos = 0这么适合作为 sinusoidal PE 的第一个验证点?它揭示的是哪一类工程思路? student_embedding_forward里为什么要对越界 token ID 回退到 UNK,而不是直接崩溃?- 这一章的“查表 + 相加”,和上一章的“编码 + 解码”相比,虽然形式不同,但都体现了哪种“从协议层过渡到计算层”的结构?
3.11 本章小结
这一章的关键,不只是你第一次写出了 embedding forward,而是你第一次看见模型内部表示是如何生成的。
tokenizer 负责建立离散编号系统;embedding 则把这个离散系统投影到连续向量空间里。再加上 position encoding,模型终于拥有了一种既知道“这个 token 是谁”、又知道“它现在在哪”的输入表示。
从后面的角度看,这正是 attention 的起点。因为 attention 不会直接处理字符,也不会直接处理 token ID,它处理的正是这一章得到的 [seq_len, hidden_dim] 向量序列。
Chapter 4 — step3 多头自注意力
对应实践:
course/practice/labs/lab04-step3/
主要修改文件:course/practice/labs/lab04-step3/framework/student.c
验证命令:make clean && make test
前一章结束时,你已经拿到了模型真正会处理的输入形式:一个 shape 为 [seq_len, hidden_dim] 的浮点向量序列。
这是 embedding 层交出来的结果。每一行代表一个位置的向量表示,其中既包含“这个 token 是谁”,也包含“它出现在第几个位置”。
但到此为止,这些位置之间仍然是彼此孤立的。
第 0 个位置并不知道第 1 个位置写了什么,第 5 个位置也不会自动去参考第 2 个位置的内容。换句话说,你现在只有一串“带位置的局部表示”,还没有“位置之间互相读取信息”的机制。
attention 正是为这个问题而生。
4.1 这一章真正要解决的问题
这一章最值得先说清楚的,不是公式,而是动机。
假设你现在要让模型处理这样一段序列。第 6 个 token 想判断自己更应该参考前面的哪个位置:是第 1 个位置里的主语,还是第 4 个位置里的限定词,还是刚刚出现的另一个关键词?
如果模型没有一种“读别人”的能力,它就只能把每个位置当成独立样本,根本无法建立上下文依赖。
attention 做的事,就是给每个位置一套主动查阅其它位置的机制。
更准确地说,它允许当前位置:
- 发出一个“我现在在找什么”的查询;
- 用这个查询去和其它位置做匹配;
- 按匹配强弱,从其它位置收集信息。
这三步,正是 Q、K、V 背后的直觉来源。
4.2 本章你要建立哪些判断
这一章完成后,你应当能够:
- 用工程语言解释 Q、K、V:不是抽象字母,而是“查询”“标签”“内容”三种不同角色。
- 明白 attention 分数矩阵为什么是
Q @ K^T,以及它的 shape 为什么是[seq_len, seq_len]。 - 明白为什么要乘
1 / sqrt(head_dim)这个缩放因子。 - 明白因果掩码为什么必须在 softmax 之前加,而不是之后再处理。
- 在本章实验代码中实现单头 attention 的三个关键步骤:
student_attention_scoresstudent_apply_maskstudent_softmax
和前三章一样,这里真正训练的不是“你见过 attention 公式”,而是“你能把这个公式拆成几段可观察、可验证、可落代码的步骤”。
4.3 先看 practice target:这章改哪里
你这章的主工作区是:
course/practice/labs/lab04-step3/
├── TASK.md
├── Makefile
└── framework/
├── student.c <- 主要修改这里
├── student.h
├── verify.c <- 自动验证,不改
└── verify.h
本章 student 文件里留出的待实现函数有三个:
student_attention_scoresstudent_apply_maskstudent_softmax
这套拆法非常有教学意义。因为完整 attention 虽然听起来像一个“大模块”,但它最核心的数学动作其实只有三段:
- 先算相似度分数;
- 再把不该看的位置屏蔽掉;
- 最后把分数归一化成概率。
课程没有一上来让你实现整套 attention_forward 的多头重排和输出投影,而是先把最本质的三段拆出来。这是非常合理的,因为如果你连这三段都没有建立清晰直觉,多头、拼接、输出线性层只会显得更乱。
4.4 Q、K、V 的直觉到底是什么
Q、K、V 这三个字母第一次出现时,很容易被初学者当成“又一套新名词”。
但如果只记名词,你很快就会混乱。更好的方式是先记住它们各自承担的角色。
Query
当前位置发出的“问题”。
你可以把它理解成:我现在在找哪类信息?
Key
每个位置携带的“标签”。
它回答的是:如果别人来查我,我属于哪一类、我适合在哪些问题下被匹配上?
Value
真正被读取出来的“内容”。
它回答的是:一旦当前位置决定要参考我,那它究竟从我这里拿走什么信息?
从这个角度看,attention 的第一步 Q @ K^T 根本不神秘。它只是在问:
当前位置的问题,和其它位置的标签,匹配得有多强?
如果你把这个直觉建立起来,后面的矩阵乘法就不再只是符号操作,而会开始有明确语义。
4.5 为什么 Q @ K^T 的 shape 是 [seq_len, seq_len]
这一点值得专门讲,因为它是很多人第一次真正“看懂 attention 里面矩阵维度”的转折点。
假设:
Q的 shape 是[seq_len, head_dim]K的 shape 也是[seq_len, head_dim]
那么 K^T 的 shape 就是 [head_dim, seq_len]。
所以:
Q @ K^T : [seq_len, head_dim] @ [head_dim, seq_len]
-> [seq_len, seq_len]
这个结果矩阵的第 i, j 个元素,表示的是:
第
i个位置发出的 query,与第j个位置的 key 的匹配分数。
也就是说,这张矩阵本质上不是“内容矩阵”,而是一张“谁更值得看”的关系表。
一旦你接受这一点,后面 mask 和 softmax 的意义就会立刻清楚很多:
- mask 决定“哪些位置根本不能看”;
- softmax 决定“在可看的位置里,各自分到多少权重”。
4.6 为什么还要缩放 1 / sqrt(head_dim)
如果没有这一步,随着 head_dim 变大,Q 和 K 的点积规模也会越来越大。分数一旦过大,softmax 就会很容易变得极端:最大的那一项几乎独占全部概率,其它项接近 0。
从某种角度说,这会让 attention 过早进入“近似 one-hot”的状态。
而一旦 softmax 太尖锐,梯度、数值稳定性和训练行为都会变得更难处理。
所以 attention 里引入:
scale = 1 / sqrt(head_dim)
它的作用不是改变排序,而是把分数范围压回一个更适合 softmax 的数值尺度。
在本章实验代码里,这一步会直接体现在 student_attention_scores 的 scale 参数上。课程把它显式留给你,是为了让你知道:这不是公式里可有可无的小系数,而是 attention 计算能否稳定工作的关键一部分。
4.7 为什么因果掩码必须在 softmax 之前加
这一章还有一个非常容易“看懂文字却没真正想明白”的点:mask 为什么要提前加?
对于 decoder-only 模型来说,位置 i 不应该看到未来位置 j > i。
实现这个约束的常见方式是:在这些未来位置上加一个极大的负数,比如 -1e9f。
这样一来,softmax 之前的分数矩阵里,未来位置就会变成几乎不可能被选中的候选项。
经过 softmax 之后,它们的权重自然会接近 0。
如果你把 mask 放到 softmax 之后再处理,会有两个问题:
- softmax 已经把这些非法位置也纳入概率归一化;
- 你后面再“清零”它们,会破坏一整行权重和为 1 的性质。
这就是为什么本章把 student_apply_mask 和 student_softmax 拆成两个步骤,并且明确要求顺序是:
scores -> mask -> softmax
不是别的顺序。
4.8 -1e9f 为什么比 -INFINITY 更常见
这也是一个很好的“工程实现和数学理想并不完全相同”的例子。
从数学上说,被屏蔽位置的分数当然可以理解成负无穷,这样 softmax 后它的概率正好就是 0。
但在真实浮点实现里,直接混入 -INFINITY 有时会和某些后续运算、某些编译器行为、某些数值路径形成更脆弱的组合,尤其是在你自己手写不同风格的 softmax 或调试时。
而 -1e9f 这种“足够小的大负数”在实践里通常就已经够用:
- 它仍然会让
expf(x)近似 0; - 但在工程上往往更容易和普通浮点流程兼容。
这就是为什么 create_causal_mask 在当前框架里返回的是大负数,而不是严格的 -INFINITY。
4.9 本章实践步骤
task 4.1:先读 student.c 和 verify.c
进入:
cd course/practice/labs/lab04-step3
建议先读:
framework/student.hframework/student.cframework/verify.c
你会发现当前 lab 的验证器并不是只做 4 个粗粒度 PASS/FAIL,而是在每个大测试里还拆出很多细项。
这非常适合 attention 这种容易“哪里都像有点对、但整体就是不对”的模块。
例如它会单独检查:
out[0][0]是否等于 0.5;out[0][1]是否等于 0;- mask 后某个位置是不是小于
-1e8f; - softmax 后每一行和是不是 1。
这意味着你做这章时,应该利用这些局部反馈,而不是只盯住最后“结果对不对”。
task 4.2:实现 student_attention_scores
这是 attention 公式的第一步,也是最容易通过一个极小例子验证的部分。
建议用三层循环思考:
- 外层枚举 query 位置
i; - 中层枚举 key 位置
j; - 内层沿
head_dim做点积累加。
然后再乘以 scale。
这里一个很关键的意识是:你并不需要真的去构造 K^T 这个新张量。
只要你在读 K 时按照“第 j 行、第 d 列”访问,它在语义上就已经等价于 Q @ K^T 里的那个转置读取了。
task 4.3:实现 student_apply_mask
这一段反而是最短的。
它做的事情就是把 mask 逐元素加到 scores 上,而且是原地修改。
从代码量上看可能只有一两个循环,但从概念上看它很重要,因为它把“模型结构约束”显式地注入到了分数矩阵里。
以后你会不断遇到这种模式:真正决定模型行为的,并不总是复杂算子,有时恰恰是这些在关键节点插入的约束张量。
task 4.4:实现 student_softmax
这一步会把“匹配分数”变成“注意力权重”。
推荐仍然按数值稳定版的标准流程来写:
- 按行找最大值;
- 计算
exp(x - max); - 求和;
- 归一化。
这一章最有价值的一点是:你会再次看到 Chapter 1 里 softmax 稳定性那套思想,但它这次不再是孤立技巧,而是 attention 里真正的核心组成部分。
这正说明前面学的东西不是学完就丢,而是会不断进入更大的结构里。
task 4.5:运行当前真实基线
在 student 实现还没写完之前,执行:
make clean && make test
当前这个 lab 的真实基线表现是:
TEST 1里部分断言已经 PASS,但关键数值断言 FAIL;TEST 2里主对角项可能 PASS,但被 mask 的位置 FAIL;TEST 3中 softmax 相关断言大多 FAIL;TEST 4会出现一部分 PASS、一部分 FAIL。
这和前几章不太一样。前几章的基线更像“全部失败”。而这一章的基线更像“框架已经帮你做了输入检查和结构检查,但核心数学逻辑还没实现,所以局部现象对、关键结果不对”。
这类基线对 attention 反而很有帮助,因为它能把错误位置缩得更细。
task 4.6:完成后重新验证
当你补完三个函数后,再执行:
make clean && make test
理想结果应当是:
TEST 1的所有局部断言都 PASS;TEST 2的 mask 相关断言都 PASS;TEST 3的行和与均分断言都 PASS;TEST 4的端到端断言都 PASS。
这时你不只是“写过一个 attention 公式”,而是已经把 attention 最核心的三段拆开、落地、验证了一次。
4.10 常见错误与排查顺序
| 现象 | 更可能的问题 | 优先检查 |
|---|---|---|
| 对角项不对 | 点积或 scale 没乘对 |
student_attention_scores |
| 上三角没有被压到极小 | mask 没有真正加进 scores | student_apply_mask |
| 每行和不是 1 | softmax 归一化逻辑有误 | student_softmax |
| 全部是 0 | softmax 结果没写进 out,或 sum 路径错误 | student_softmax |
| 端到端只剩零星 PASS | 某一步局部逻辑对,但顺序或组合错了 | 检查 scores -> mask -> softmax 顺序 |
这一章最重要的排查原则是:不要从“完整 attention 为什么不对”开始想,而要从“分数、mask、softmax 三段里,究竟哪一段先开始偏了”开始想。
4.11 思考题
- 如果去掉
1 / sqrt(head_dim)的缩放,softmax 权重为什么更容易变得极端? - 为什么因果掩码必须放在 softmax 之前,而不是之后?
student_softmax再次用到了“先减最大值”的技巧。它和 Chapter 1 里的 softmax 有什么共同本质?- 这一章只让你实现单头 attention 的核心三段,没有让你直接写完整多头前向。为什么这是更适合教学的拆分?
4.12 本章小结
这一章你第一次真正让序列里的位置彼此“看见了对方”。
embedding 解决的是“每个位置如何拥有自己的表示”;attention 解决的则是“每个位置如何读取别人的表示”。
从模型结构上看,这是一道很关键的分界线:从这里开始,序列不再只是排成一列的向量,而变成了一个会互相交换信息的系统。
后面的 Transformer block,正是在 attention 外面再包上层归一化、前馈网络和残差连接,让这套读取机制能稳定叠很多层。
Chapter 5 — step4 Transformer Block
对应实践:
course/practice/labs/lab05-step4/
主要修改文件:course/practice/labs/lab05-step4/framework/student.c
验证命令:make clean && make test
到上一章为止,你已经让每个位置具备了读取上下文的能力。
这意味着模型终于不再只是“一串并排的向量”,而开始形成真正的上下文交互。
但 attention 还不是 Transformer block。
如果你只把 attention 一层一层硬堆起来,模型很快会遇到两个非常现实的问题:
- 数值越来越不稳;
- 表达能力还不够完整。
第一个问题需要 LayerNorm 和残差来稳住。
第二个问题需要一个逐位置的前馈网络(FFN)来补足。
本章真正要做的,就是把前面已经学过的几种结构第一次装进一个标准的、能被重复堆叠的 block 里。
5.1 这一章为什么是“组装”,但又不是简单拼接
从表面看,这一章很像组装题。
你已经见过:
- 张量和 softmax;
- embedding;
- attention;
- 一些基础数值结构。
那么把它们装进一个 block,似乎只是把几段 API 连起来而已。
但真正的难点恰恰在于:
这些模块不只是要能连起来,还要按正确的顺序连起来,并且在数值上保持稳定。
Transformer block 的价值,不在于它发明了新的基本算子,而在于它找到了一个非常高效的组织方式,让:
- 每个位置既能读上下文;
- 又能保留原始信息;
- 还可以通过逐位置 MLP 做更强的非线性变换;
- 最后还能稳定堆很多层。
所以这一章真正要建立的,是“结构编排能力”。这会是你后面读完整 GPT 模型时最重要的前置直觉。
5.2 本章你要建立哪些判断
完成本章后,你应当能够:
- 用自己的话口述 Pre-LN block 的主数据流:
h = h + Attn(LN(h)),再h = h + FFN(LN(h))。 - 明白 LayerNorm 解决的是哪类数值问题,以及它为什么总是和残差一起出现。
- 明白 FFN 不是“attention 的重复”,而是给每个位置增加更强的逐位置非线性变换能力。
- 知道为什么 block 不是“attention 后直接结束”,而必须把这几种结构串在一起。
- 在本章实验代码中完成:
student_layernormstudent_residual_addstudent_block_forward
你会发现,这一章和上一章有一个很强的延续关系。上一章是把 attention 拆成三段;这一章则是把整个 block 看成一个更高层的组合结构。
5.3 先看 practice target:这章改哪里
本章的主工作区是:
course/practice/labs/lab05-step4/
├── TASK.md
├── Makefile
└── framework/
├── student.c <- 主要修改这里
├── student.h
├── verify.c <- 自动验证,不改
└── verify.h
当前 student 文件里要求你完成三个函数:
student_layernormstudent_residual_addstudent_block_forward
这三个函数覆盖的正是 block 最核心的三层组织逻辑:
- 先把输入归一化;
- 再把子层输出加回原输入;
- 最后按 Pre-LN 的顺序把 attention 和 FFN 都串起来。
从教学上说,这种拆法很合理。因为如果一上来就让学员直接照着 transformer_block_forward 抄一遍,很容易只看见“很多函数调用”,却看不见其中哪一层是在解决什么问题。
5.4 LayerNorm 为什么会在这里出现
Attention 已经能让位置之间交互了,那为什么还要在它前后引入归一化?
原因在于,一旦网络开始堆叠,多层输出的分布会越来越容易漂移。
如果每一层都在上一层已经发生漂移的数值上继续做线性变换、softmax、非线性激活,那么模型很快就会出现:
- 某些维度值越来越大;
- 某些维度梯度越来越不稳定;
- 激活范围越来越难控制。
LayerNorm 做的事情很直接:对一个位置的整行向量,先减均值,再除以标准差,把它拉回一个更稳定的尺度。
最基础的形式就是:
y_i = (x_i - mean) / sqrt(var + eps)
本 lab 里的 student_layernorm 甚至还刻意简化了:
它不让你先处理可学习的 gamma / beta,而是先把“归一化本身”搞明白。
这是一种非常好的课程切法。因为对当前阶段来说,真正要先学会的是:
如何把一个向量拉回均值约为 0、方差约为 1 的尺度。
而不是一开始就把所有参数化细节都压进来。
5.5 残差连接为什么不是装饰
残差连接有时会被初学者误解成“就是多写一个加号”。
但这个加号在深模型里极其关键。
如果没有残差,当前层就必须完全依赖子层输出,原始输入一旦在中间被破坏或放大,很难有稳定路径保留下去。
而有了:
h = h + F(h)
模型就获得了一条非常重要的结构特性:
- 子层可以负责“增量修正”;
- 原始输入不会轻易丢失;
- 堆层数时,信息和梯度都更容易保持可传递性。
这就是为什么本章会单独把 student_residual_add 作为一个 learner function 留出来。
它从代码量看很小,但从结构意义看非常大。因为它代表的是 Transformer 这类深层模型之所以能稳定工作的核心设计之一。
5.6 FFN 在 block 里到底补了什么
很多人第一次学 Transformer 时,会误以为 attention 已经“足够强”,FFN 只是一个附带模块。
其实不是。
attention 主要擅长做的是:
在不同位置之间建立信息流动和加权聚合。
但它并不擅长做每个位置自己的更强非线性变换。
FFN 正是在补这个空缺。
它对每个位置单独地做两层 MLP:
FFN(x) = W2 * GELU(W1 * x + b1) + b2
常见结构里,它会先把维度从 hidden_dim 扩到更大的 ffn_dim,再压回来。当前 miniLLM 的典型设置就是扩到 4 * hidden_dim。
这一步的意义在于:
- 给每个位置更高维的中间表示空间;
- 引入更强的逐位置非线性;
- 让模型不只是“会看别人”,也会“自己加工已经读到的信息”。
所以 Transformer block 的直觉不应该是“attention + 一些收尾逻辑”,而应该是:
先让位置之间交换信息,再让每个位置自己消化这些信息。
5.7 为什么是 Pre-LN,而不是别的顺序
这一章明确要求的是 Pre-LN 结构:
h = h + Attn(LN(h))
h = h + FFN(LN(h))
这和另一种常见写法 Post-LN 的区别,不在于模块有没有变,而在于 LayerNorm 放在了子层之前还是之后。
当前课程阶段不需要你完整比较两者所有理论差异,但至少要记住一个非常实用的判断:
Pre-LN 通常更容易在深层训练中保持稳定。
所以这章不是在给你一个任意顺序的拼装练习,而是在让你熟悉一个后来在实际实现里非常常见的稳定结构。
5.8 本章实践步骤
task 5.1:先读 student.c 和 verify.c
进入:
cd course/practice/labs/lab05-step4
建议先读:
framework/student.hframework/student.cframework/verify.c
你会看到这一章的验证器分别检查:
student_layernorm输出均值是否接近 0;student_layernorm输出方差是否接近 1;student_residual_add是否真的把a += b做对;student_block_forward是否保持 shape;- 输出是不是全零;
- 输出里是否含 NaN 或 Inf。
这说明本章验证器既看结构,也看数值状态。它不是只问“函数调没调用”,而是在问“你装出来的 block 是否真的保留了最基本的数值健康性”。
task 5.2:实现 student_layernorm
这一章最适合作为第一步的,仍然是最小功能单元。
你要做的是对一维向量:
- 计算均值;
- 计算方差;
- 再按
sqrt(var + eps)做归一化。
这里建议你刻意体会上一章和这一章之间的联系:
- 上一章 softmax 里你已经学过“数值稳定”;
- 这一章 LayerNorm 则是在学“数值尺度控制”。
两者虽然不一样,但都属于“模型正确工作不只靠公式,还靠数值状态被约束在合理范围内”。
task 5.3:实现 student_residual_add
这是本章代码量最小、但结构意义最强的一步。
实践里它可能就是调用一次 tensor_add_inplace(a, b)。
但你不应该因此低估它。
因为这里这个小函数代表的是整个 block 保留主路径信息的机制。
以后你再看到更大模块里的残差,最好都能第一时间意识到:这不是语法上的“加一下”,而是网络结构上的信息保留和梯度通路设计。
task 5.4:实现 student_block_forward
这是本章真正的主任务。
你需要把 LayerNorm、Attention、FFN 和两次残差串起来,顺序必须正确。
一个简单但有用的思考方式是:不要把它当成“一个长函数”,而是当成两段平行结构的重复:
第一段:
LN -> Attention -> residual add
第二段:
LN -> FFN -> residual add
当你这样看,Transformer block 就不再像一团混合逻辑,而会显得很有节奏感。
task 5.5:运行当前真实基线
在 student 实现还没补完之前,执行:
make clean && make test
当前这个 lab 的真实基线是:
student_layernorm的返回值和均值检查可能 PASS;- 但方差接近 1 的检查 FAIL;
student_residual_add的返回值可能 PASS;- 但实际数值加法行为 FAIL;
student_block_forward的 shape 检查和部分健康性检查可能 PASS;- 但 output 非零等关键行为 FAIL。
这说明当前基线不是“整块都坏”,而是“框架把调用壳子和部分结构线已经搭好,但核心数学行为还没有真正成立”。
这种基线很适合 block 这种组合模块,因为它会提醒你:
- 框架级对象和缓存已经创建好了;
- 函数签名和调用路径也是通的;
- 但你还没有把 block 的真正行为建立起来。
task 5.6:完成后重新验证
补完三个函数后再执行:
make clean && make test
理想结果应当是:
student_layernorm的均值和方差检查都 PASS;student_residual_add的两次求和检查都 PASS;student_block_forward的 shape、非零、非 NaN 等检查都 PASS。
到这时,课程前五章就形成了一个非常清晰的累积链:
- Chapter 1:你会处理底层张量和 softmax;
- Chapter 2:你把文本变成 token ID;
- Chapter 3:你把 token ID 变成向量;
- Chapter 4:你让位置之间彼此读取信息;
- Chapter 5:你把这些部件装成一个可堆叠的 Transformer block。
5.9 常见错误与排查顺序
| 现象 | 更可能的问题 | 优先检查 |
|---|---|---|
| 均值接近 0,但方差不接近 1 | LayerNorm 方差或除法路径不对 | student_layernorm |
| 残差测试 sum 不对 | 没真正原地加,或 shape 路径错 | student_residual_add |
| block 输出全零 | 子层输出没写回,或残差没加进去 | student_block_forward |
| block 输出含 NaN | LayerNorm / FFN / Attention 组合顺序或数值路径有误 | student_block_forward |
| shape 对,但行为不对 | 调用了模块,但没有正确组织数据流 | student_block_forward |
这一章最重要的排查原则是:
先确认每个基础积木自己单独成立,再去查 block 级组装。不要在 student_block_forward 里盲目猜所有问题。
5.10 思考题
- 为什么 attention 之后还需要 FFN?如果没有 FFN,block 的表达能力会缺什么?
- 为什么残差连接和 LayerNorm 往往一起出现?它们分别在稳定性上承担什么角色?
- 本章的 Pre-LN 结构和上一章 attention 的三段式拆分相比,体现了哪种“先理解局部,再理解组合”的学习顺序?
- 如果把
student_block_forward看成一个“更大粒度的前向传播模板”,你觉得下一章完整 GPT 模型会在这个模板外面再包哪几层结构?
5.11 本章小结
这一章最关键的收获,不是“会写一个 block 函数”,而是第一次看清 Transformer block 为什么是一种稳定、可堆叠的模块单位。
attention 解决的是上下文读取;FFN 解决的是逐位置非线性加工;LayerNorm 和残差则保证这些子层组合后仍然足够稳定。
当这几种结构按正确顺序组织在一起时,模型才真正拥有了反复堆叠的基本积木。
下一章,课程会把“单个 block”进一步提升成“完整 GPT 模型”:embedding 在前,若干 block 在中间,最后再接上输出头和保存加载逻辑。
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,而是一套完整的装配关系。它要回答的是另一类问题:
- 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 课程第一次真正跨过了“局部模块练习”。
你现在要学会看的,不再只是某一层公式是否正确,而是:
- 模型整体怎样被组织;
- 权重怎样被管理;
- 内存对象怎样变成可复用的文件格式。
这一步走通后,下一章才有意义。因为只有当模型已经完整存在,我们才可能讨论:它怎样通过损失、梯度和优化器开始真正学习。
Chapter 7 — step6 训练
对应实践:
course/practice/labs/lab07-step6/
主要修改文件:course/practice/labs/lab07-step6/framework/student.c
验证命令:make clean && make test
上一章你已经把完整 GPT 模型装起来了。
这意味着模型终于有了一个清晰的前向路径,也能被保存和重新加载。
但现在还有一个更关键的问题没有解决:
模型虽然会吐 logits,但它还不会学习。
这就是训练章存在的原因。
从工程角度看,训练并不神秘。它无非是在做一个固定闭环:
- 前向得到 logits;
- 根据正确答案计算损失;
- 从损失对 logits 的误差反推梯度;
- 用优化器拿梯度去更新参数。
真正的困难不在于这四步的口号你会不会背,而在于:
你能不能把这四步拆成最小可验证的数学动作。
Lab07 的实践方式非常克制。它没有要求你一口气写完整训练框架,而是只把三件最关键的局部能力留给你:
- 交叉熵损失;
- softmax + cross-entropy 的梯度;
- Adam 对单个参数向量的一次更新。
这三件事一旦真正写明白,后面的完整训练循环就不再像魔法。
7.1 为什么训练章必须这样拆
如果课程在这里直接让学员读完整的 train.c、backward.c 和 optimizer.c,绝大多数初学者会被三件事同时压住:
- 张量很多;
- 中间状态很多;
- 几乎每一步看起来都像“又一个新公式”。
所以最务实的教学策略,不是先把系统复杂度全部摊给学员,而是先抓住训练里最核心、最不可绕过的三段数学关系:
- 损失到底在度量什么;
- logits 的梯度到底长什么样;
- 参数更新到底怎样发生。
这就是 Lab07 的切法。它不是“缩减版训练”,而是把真正最核心的三颗齿轮先拆出来给你看。
7.2 本章你要建立哪些判断
这一章结束后,你应当能够独立说清楚下面这些事:
- 为什么交叉熵损失在模型很确定时接近 0,而在瞎猜时接近
log(vocab_size)。 - 为什么 softmax 和 cross-entropy 放在一起时,梯度能简化成
softmax - one_hot。 - 为什么梯度在目标位置应该是负数,而在非目标位置应该是正数。
- Adam 为什么同时维护一阶矩
m和二阶矩v。 - 为什么学习率过大时,loss 可能直接跑成
NaN。
如果这些判断还没有建立,即使你把完整训练程序“跑绿”,后面也很难真正理解训练过程出了什么问题。
7.3 先看 practice target:这章改哪里
本章的实践目录是:
course/practice/labs/lab07-step6/
├── TASK.md
├── Makefile
└── framework/
├── student.c <- 主要修改这里
├── student.h
├── verify.c <- 自动验证,不改
└── verify.h
当前需要你实现的函数有三个:
student_cross_entropy_lossstudent_softmax_ce_gradstudent_adam_step
这个切法很合理,因为它刚好对应训练闭环中的三个核心接口:
- 用损失把“预测”和“正确答案”接上;
- 用梯度把“损失”传回 logits;
- 用优化器把“梯度”变成参数更新。
7.4 交叉熵为什么是“预测错误程度”
在语言模型里,每个位置的目标都很简单:
模型应该给“正确下一个 token”更高概率。
交叉熵写成最简单的形式就是:
L = -log P(target)
它之所以合适,是因为这个量的行为非常直观:
- 如果模型给正确 token 的概率接近 1,那么
-log(1)接近 0; - 如果模型给正确 token 的概率很小,那么
-log(P)就会迅速变大。
这意味着交叉熵天然把“越确定越好、越不确定越差”编码进了一个标量里。
更重要的是,它还给出了一个非常强的基线直觉:
如果模型还什么都没学会,词表上几乎均匀瞎猜,那么损失大约就是:
log(vocab_size)
这个判断在调试时非常好用。因为你不需要等完整训练结束,就能先看当前 loss 是不是还停留在“瞎猜水平”。
7.5 为什么梯度会简化成 softmax - one_hot
很多初学者第一次真正接触反向传播时,会以为必须沿着 softmax 和 log 的内部公式一层层手推,才可能得到 logits 的梯度。
在数学上当然可以这么做,但工程里更重要的是:知道哪些组合已经有非常干净的闭式结果。
对于 softmax 和 cross-entropy 的组合,最终梯度正好会简化成:
grad[i] = softmax(logits)[i] - (i == target ? 1 : 0)
这条式子的意义非常强:
- 目标位置会被减 1,所以通常是负的;
- 非目标位置只是保留 softmax 概率,所以是正的;
- 整个梯度向量求和接近 0。
这几个性质都能直接变成实践里的验证条件。
也正因为如此,Lab07 会分别检查:
- 梯度数值是否合理;
- 梯度和是否接近 0;
- target 位置是否为负;
- 非 target 位置是否为正。
这不是多余检查,而是在帮学员把公式和行为一一对应起来。
7.6 Adam 为什么不是“多写几个变量”
如果只看代码表面,Adam 和 SGD 的区别好像只是多了几个状态数组。
但从优化行为上看,它做了两件非常重要的事。
第一件事是:它保留了历史梯度的一阶统计,也就是动量。
这会让参数更新不至于每一步都只看当前这一拍的局部波动。
第二件事是:它还保留了梯度平方的移动平均,也就是二阶统计。
这会让每个参数维度拥有更自适应的更新尺度。
所以 Adam 的核心直觉不是“更复杂的 SGD”,而是:
既看方向,又看这个方向在历史上有多稳定、有多大。
这也是为什么 Lab07 不要求你一上来写完整模型上的 Adam,而是先在一个单独的一维参数数组上把这套更新逻辑写对。
7.7 为什么训练里最怕的不是公式错,而是数值炸掉
到了训练这一章,课程里会第一次大规模遇到数值问题。
不是因为前面没有数值稳定性,而是因为训练把很多小误差和大尺度变化都放大了。
最典型的现象就是:
- 学习率过大;
- 某一步更新把参数推得过猛;
- 下次前向里某些指数或归一化直接溢出;
- loss 变成
NaN或inf。
所以这一章最应该建立的工程意识是:
训练失败不一定是逻辑错,也可能是数值状态已经失控。
这也是为什么 Chapter 7 的讲义一定要把“loss 为什么会发散”和“梯度裁剪为什么是保命装置”专门讲出来,而不是只在优化器参数表里随手带过。
7.8 本章实践步骤
task 7.1:先读 student.c 和 verify.c
进入:
cd course/practice/labs/lab07-step6
建议先读:
framework/student.hframework/student.cframework/verify.c
当前验证器主要检查:
- 均匀 logits 时,交叉熵是否接近
log(vocab_size); - 正确目标 logit 明显更大时,loss 是否显著下降;
- 梯度方向和大小是否符合预期;
- 梯度的符号和和是否满足预期;
- Adam 一步后,
m、v、参数值是否真的变化。
这些检查非常有价值,因为它们都对应训练直觉里最关键的局部现象。
task 7.2:实现 student_cross_entropy_loss
这一步建议你先从“单个位置的 loss”想起,再推广到整个序列平均。
重点不是把公式背出来,而是清楚:
- 为什么要先做数值稳定的
log_sum_exp; - 为什么最终要对
seq_len取平均。
前者解决数值问题,后者解决不同序列长度下的可比性问题。
task 7.3:实现 student_softmax_ce_grad
这一步最适合边写边对照“性质”。
你可以一边实现,一边想这几个问题:
- softmax 后概率和是不是 1;
- target 位置减 1 之后为什么会变成负数;
- 非 target 位置为什么仍然是正数;
- 整个向量为什么总和接近 0。
如果这些性质你在脑子里是清楚的,代码就不容易写偏。
task 7.4:实现 student_adam_step
这一题最好先把循环外和循环内的责任分开。
循环外先算:
1 - beta1^t1 - beta2^t
循环内再按每个参数维度依次更新:
mv- 偏差校正后的
m_hat - 偏差校正后的
v_hat - 参数本身
这能让代码更清楚,也更符合 Adam 的数学结构。
task 7.5:运行当前真实基线
在还没补完三个函数前,先执行:
make clean && make test
当前 Lab07 的真实初始状态是:
- 3 通过,11 失败。
已经通过的部分主要来自:
- 某些“空实现也不会崩”的边界检查;
- 一些结构性返回值检查。
但真正和训练数学直接相关的部分,目前大多还失败。这正说明:
- 框架已经可以正常运行;
- 训练核心逻辑仍然留给学员完成。
task 7.6:完成后重新验证
当你补完三个函数后,再执行:
make clean && make test
如果实现正确,你应当看到:
- 交叉熵数值是否落在预期范围内;
- logits 梯度满足符号和总和条件;
- Adam 真的改变了
m、v和参数。
这一章一旦跑通,课程就真正跨进“模型会学习”的阶段了。
7.9 常见错误与排查顺序
最常见的错误通常是:
cross_entropy_loss忘了做数值稳定版log_sum_exp;softmax_ce_grad忘了归一化或忘了在 target 位置减 1;- Adam 偏差校正写错;
sqrt用成双精度版本或参数顺序写反;weight_decay和主更新顺序混乱。
建议排查顺序是:
- 先看 loss 数量级对不对;
- 再看梯度符号和总和;
- 最后再看 Adam 状态更新。
因为如果前两层已经错了,优化器层面的任何现象都不再可信。
7.10 思考题
- 为什么均匀分布下的交叉熵大约是
log(vocab_size)? - 为什么 softmax + cross-entropy 的梯度总和接近 0?
- 为什么学习率过大时,训练更容易出现
NaN?
7.11 本章小结
Chapter 7 的意义,不只是“开始训练”,而是第一次把模型、目标和参数更新真正接起来。
从这一章开始,你不再只是看模型怎样算输出,而是开始看:
- 模型怎样知道自己错了;
- 错误怎样变成梯度;
- 梯度怎样真正改写参数。
这一步打通后,下一章才顺理成章。因为模型只有学会之后,文本生成和采样策略才会变得真正有意义。
Chapter 8 — step7 文本生成
对应实践:
course/practice/labs/lab08-step7/
主要修改文件:course/practice/labs/lab08-step7/framework/student.c
验证命令:make clean && make test
到了这里,模型已经具备了一个重要前提:
它不再只是随机把一堆模块拼起来,而是已经有了“通过 logits 表达偏好”的能力。
但 logits 还不是文本。
一个语言模型真正“开口说话”的瞬间,不在 model_forward 结束的时候,而在你决定:
这一拍到底选哪一个 token 作为下一个输出。
这就是生成章的核心。
本章不会再引入新的大模型结构。它要处理的是另一类非常工程化、但又直接决定模型表现的问题:
- 是永远选分数最高的那个 token,还是允许一点随机性?
- 如果允许随机性,随机性的范围该怎样控制?
- 如果词表很大,是否应该只在最有希望的少数候选里抽样?
这些问题的答案,最终都会落到三个需要你实现的函数上:
student_sample_greedystudent_sample_temperaturestudent_sample_top_k
这三种策略加在一起,就构成了后面所有生成行为的基本决策层。
8.1 为什么生成章不是“附属技巧”
很多初学者会把采样策略误看成模型主体之外的一点“调味料”,好像只是在前向后面多接几行代码。
但从用户实际看到的效果来说,采样几乎直接决定了模型表现的性格。
同样一组 logits:
- 用 greedy,模型会更稳定,也更容易复读;
- 用高温度采样,模型会更发散,也更容易出怪话;
- 用 top-k,模型会只在高分候选里活动,表现更受约束。
所以这一章不是在教一个外围技巧,而是在教:
模型内部的概率分布,怎样被转化成外部可见的文本行为。
8.2 本章你要建立哪些判断
这一章结束后,你应当能够清楚说出:
- greedy 为什么本质上就是
argmax; - 温度为什么等价于“先把 logits 除以一个系数,再做 softmax”;
- 为什么温度越小,分布越尖锐;温度越大,分布越平坦;
- top-k 为什么能够砍掉大量低概率尾部候选;
- 为什么“未训练模型生成乱码”是预期,而不是 bug。
如果这些直觉建立起来,后面无论是 REPL、HTTP 服务还是 BPE 对话,你都能更清楚地判断“问题到底出在模型学得差,还是出在采样策略不合适”。
8.3 先看 practice target:这章改哪里
本章实践目录是:
course/practice/labs/lab08-step7/
├── TASK.md
├── Makefile
└── framework/
├── student.c <- 主要修改这里
├── student.h
├── verify.c <- 自动验证,不改
└── verify.h
这一章需要你实现的函数,正好对应三种采样策略:
student_sample_greedystudent_sample_temperaturestudent_sample_top_k
它们的关系不是三道孤立小题,而是一层层往上搭:
- greedy 是最简单的基线;
- temperature 是在完整词表上按概率抽样;
- top-k 是先缩小候选范围,再调用温度采样。
这也是一个很好的工程设计范例:更复杂的策略最好建立在更简单的策略之上,而不是每一题都从头另写一遍。
8.4 greedy 为什么是最好的第一步
greedy 采样的定义非常直接:
选 logits 中最大的那个索引
也就是 argmax。
它的好处是确定性强、最容易调试,也最适合作为采样逻辑的起点。
因为只要 greedy 都选不对,后面的 temperature 和 top-k 就更没有可信度。
这也是为什么 Lab08 的第一组测试会优先检查:
- 最大值位置是否被正确选出;
- 平局时如何处理;
- logits 本身是否被无意修改。
这些看起来简单,但正是在守住最基本的决策层。
8.5 温度真正改变的是什么
温度不是“直接改概率”,而是先改 logits 的尺度。
如果写成公式,就是:
scaled_logits[i] = logits[i] / temperature
然后再对 scaled_logits 做 softmax。
这里最值得讲清楚的是直觉:
temperature < 1时,所有差距都被放大,分布更尖;temperature = 1时,保持原始分布;temperature > 1时,差距被压缩,分布更平。
所以温度本质上是在调“模型有多犹豫”。
温度越低,模型越像在说“我只认最可能那个”;温度越高,模型越像在说“几个候选都可以试试”。
8.6 为什么 top-k 是对尾部分布的约束
语言模型的词表通常很大。即使前几个 token 概率已经远高于其它候选,完整 softmax 仍然会给大量低概率 token 留下一点点质量。
如果你让抽样直接在完整词表上发生,就会出现一个问题:
那些几乎不可能、但仍不为零的候选,仍然有机会偶尔被抽中。
top-k 的思路就是先粗暴地做一层裁剪:
- 只保留 logits 最高的前
k个; - 其余候选全部视作不可选;
- 再在剩下的这些候选里做 temperature 抽样。
它的作用不是让模型“更聪明”,而是让生成空间更受约束、更不容易掉进词表尾部噪声。
8.7 为什么“模型学得差”和“采样策略差”要分开看
本章一个非常重要的课程目标,是让学员别把所有生成问题都归因给模型本身。
如果模型没训练好,那么:
- 不管你怎么调采样,输出都可能很差。
但反过来,如果模型其实已经学到一点东西,而采样策略选得极端,例如:
- 温度过高;
- top-k 太大;
- 没有任何约束;
那么输出同样可能显得混乱。
所以从 Chapter 8 开始,课程要学员建立一个新的调试分层:
- 先问 logits 有没有学到东西;
- 再问采样策略是不是把好分布抽坏了。
8.8 本章实践步骤
task 8.1:先读 student.c 和 verify.c
进入:
cd course/practice/labs/lab08-step7
建议先读:
framework/student.hframework/student.cframework/verify.c
当前验证器会分别检查:
- greedy 是否真的选出最大位置;
- 温度采样在低温和高温下的统计行为是否合理;
- top-k 是否真的只在前
k个候选里抽样; - 一些边界条件,例如
temperature <= 0、k <= 0或k >= vocab_size时怎样退化。
这说明本章不是只看单次输出,而是在看抽样行为是不是符合概率直觉。
task 8.2:实现 student_sample_greedy
这一题建议完全手写一次 argmax。
重点不是代码长短,而是形成习惯:
采样函数不应该偷偷改输入 logits,本质上它只是读 logits,然后做决策。
task 8.3:实现 student_sample_temperature
这一题的关键步骤有三个:
- 按温度缩放 logits;
- 做 softmax;
- 按累积分布抽样。
建议你在写的时候不断提醒自己:
temperature 的真正作用发生在 softmax 之前,而不是 softmax 之后。
task 8.4:实现 student_sample_top_k
这一题的重点不是排序技巧,而是清楚:
- 哪些候选应该保留;
- 哪些候选应该彻底屏蔽;
- 筛完以后,仍然要回到 temperature 抽样那套逻辑。
也就是说,top-k 的本质不是另一种全新抽样,而是“先裁剪,再抽样”。
task 8.5:运行当前真实基线
在还没补完三个函数前,先运行:
make clean && make test
当前 Lab08 的真实初始状态是:
- 8 通过,12 失败。
已经通过的部分,主要来自一些边界测试和“空实现也不会破坏输入”的检查。
失败的部分则集中在真正与采样决策相关的逻辑上,例如:
- greedy 没有选对最大值;
- 温度采样没有体现出预期的分布趋势;
- top-k 没有把采样范围约束住。
这正说明这章当前的实验起点是健康的:
框架可以跑,但采样核心逻辑还在等学员完成。
task 8.6:完成后重新验证
当你补完三个函数后,再执行:
make clean && make test
如果实现正确,你应该看到:
- greedy 的位置选择正确;
- 低温时几乎总选最大项;
- 高温时非最大项获得更多采样质量;
- top-k 只在规定候选里活动。
到这一章结束,模型才真正具备“把内部概率转成外部文本选择”的能力。
8.9 常见错误与排查顺序
最常见的错误通常是:
- greedy 写成了取最小值或平局规则错误;
- temperature 忘了除温度,或者温度为 0 时没有退化到 greedy;
- softmax 概率没正确归一化;
- top-k 没有真正屏蔽词表尾部候选;
- 抽样函数修改了输入 logits。
建议排查顺序是:
- 先看 greedy;
- 再看 temperature 分布;
- 最后看 top-k 的筛选范围。
因为 top-k 本质上依赖前面两层直觉。
8.10 思考题
- 为什么温度趋近于 0 时,采样行为越来越像 greedy?
- 为什么温度升高时,低分 token 会获得更多机会?
- 为什么 top-k 能抑制尾部噪声,但也可能让模型更容易陷入复读?
8.11 本章小结
Chapter 8 把课程重心从“模型内部怎样算”推到了“模型外部怎样表现”。
从现在开始,学员应该逐渐形成一个重要判断:
模型输出的文本,不只是由权重决定,也同样由采样策略决定。
这一步铺好以后,下一章才有意义。因为多轮对话本质上就是把“单次生成”放进带上下文的连续交互里。
Chapter 9 — step8 多轮对话
对应实践:
course/practice/labs/lab09-step8/
主要修改文件:course/practice/labs/lab09-step8/framework/student.c
验证命令:make clean && make test
到了上一章,模型已经会做一件重要的事:
给定一个 prompt,继续往后生成 token。
但“会接龙”和“会聊天”仍然是两回事。
聊天之所以比单轮生成更复杂,不是因为模型突然多会了一种数学,而是因为交互场景多了两层结构约束:
- 模型必须分清楚谁在说话;
- 模型必须从历史中提取出“现在该接哪一句”的位置。
这两件事看起来像产品逻辑,实际上会直接影响输入给模型的原始字符串长什么样。
也就是说,多轮对话的核心并不是先去改模型,而是先把“对话历史如何被组织成 prompt”这件事讲清楚。
Lab09 也正是这样切的。它没有一上来就让学员处理完整对话状态机,而是只把两个最关键、最不可绕过的字符串操作留出来:
student_format_promptstudent_extract_response
这两个函数看似简单,但它们正好定义了多轮 chat 系统里“输入怎样拼”和“输出怎样截”的边界。
9.1 为什么多轮对话首先是一个格式问题
很多初学者在第一次接触 chat 系统时,会下意识把重点放在“模型要更聪明”。
但从工程实现角度看,第一件更基本的事其实是:
你到底怎样把多轮消息表示成一段模型可读的字符串?
因为 decoder-only 模型天生并不认识“消息对象”“角色字段”“历史列表”这些抽象概念。
它只认识 token 序列。
这意味着,所有多轮对话能力在进入模型之前,都要先被压平到一种统一模板里,例如:
<|user|>
你好
<|assistant|>
你好,请问有什么可以帮助你?
<|user|>
今天天气怎么样?
<|assistant|>
这套模板不是装饰,而是“谁在说什么、现在轮到谁继续说”的唯一信号。
9.2 本章你要建立哪些判断
这一章结束后,你应当能够说清楚:
- 为什么 prompt 末尾必须额外追加一个
<|assistant|>标签。 - 为什么历史消息必须带 role,而不能只把内容简单拼在一起。
- 为什么提取回复时要找“最后一个”
<|assistant|>,而不是第一个。 - 为什么下一条
<|user|>、<|assistant|>、<|system|>或<|end|>会自然成为回复截断边界。 - 为什么多轮 chat 的第一步其实是字符串协议,而不是模型结构改造。
只要这些判断建立起来,后面的 HTTP 封装、REPL 和对话历史管理都会清晰很多。
9.3 先看 practice target:这章改哪里
本章实践目录是:
course/practice/labs/lab09-step8/
├── TASK.md
├── Makefile
└── framework/
├── student.c <- 主要修改这里
├── student.h
├── verify.c <- 自动验证,不改
└── verify.h
当前需要你实现的函数只有两个:
student_format_promptstudent_extract_response
这两个函数对应的正是多轮对话中最本质的两个边界动作:
- 把消息对象压平成模型可读 prompt;
- 把模型原始输出再还原成“真正算助手回复的那一段”。
课程这里的切法很克制,也很正确。因为如果这两个动作都还没有讲清楚,继续往上堆 chat loop 或服务化接口,只会放大混乱。
9.4 为什么角色标签是对话模板的骨架
如果你只是把多轮内容简单拼起来,例如:
你好
你好,请问有什么可以帮助你?
今天天气怎么样?
那么模型虽然能看到历史文本,但它并不知道:
- 哪一句是用户说的;
- 哪一句是助手已经回答过的;
- 现在轮到谁继续说。
角色标签的作用,就是把这个结构显式写进输入串里。
所以 <|user|>、<|assistant|>、<|system|> 这类标签不是花哨包装,而是整个多轮 prompt 的语法骨架。
没有它们,历史内容虽然还在,但“对话结构”已经丢了。
9.5 为什么 prompt 末尾一定要再放一个 <|assistant|>
这一点特别值得讲清楚,因为它决定了模型是继续说“用户的话”,还是开始说“助手的话”。
当你把历史拼完之后,如果最后只是停在用户内容后面,例如:
<|user|>
今天天气怎么样?
那么模型只看到了历史,却没有一个明确的“现在轮到谁接”的起始标记。
而如果你在末尾补上:
<|assistant|>
语义就清楚了:
上面是历史;从这里开始,模型应该扮演 assistant 来续写。
所以 student_format_prompt 这一题的关键,不是机械地 strcat 几次,而是要真正明白:
末尾那个标签其实是在指定“生成起点的角色”。
9.6 为什么提取回复时要找“最后一个” <|assistant|>
模型输出原始字符串时,并不保证只出现一次 <|assistant|>。
尤其是在生成行为不稳定时,它完全可能把前面的模板片段也模仿出来。
如果你总是从第一个 <|assistant|> 开始截,那么一旦输出里前面混进了旧标签,你就会把大量无关历史也错误地当成新回复的一部分。
所以更稳的做法是:
- 找最右边、也就是最后一次出现的
<|assistant|>; - 从它后面开始取内容;
- 一旦遇到下一个角色标签或
<|end|>,立即截断。
这套逻辑背后的直觉其实很简单:
我们要的是“最近一次 assistant 开始说话后,到下一个结构边界之前”的那一段。
9.7 为什么这一章故意只做字符串,不碰模型
你会注意到 Lab09 的实践并不加载真实模型,也不要求你去改生成函数。
这不是因为对话和模型无关,而是因为这章真正的教学重点是协议边界。
如果你在这一章同时处理:
- prompt 模板;
- 多轮历史;
- 模型推理;
- 采样;
- 回复截断;
那学员很难知道问题到底出在哪一层。
所以课程把它压缩成两个字符串函数,是在刻意降低排查复杂度。
这能让学员先把“多轮对话的输入输出协议”建立清楚,再去接下一章的服务化接口。
9.8 本章实践步骤
task 9.1:先读 student.c 和 verify.c
进入:
cd course/practice/labs/lab09-step8
建议先读:
framework/student.hframework/student.cframework/verify.c
当前验证器会检查:
- 单轮 prompt 中 user 和 assistant 标签数量是否合理;
- 多轮 prompt 中内容和末尾标签是否完整;
- 没有 assistant 标签时,是否会退化成整段 trim;
- 有多个 assistant 标签时,是否取最后一个;
- 遇到下一条 user 消息时,是否正确截断。
也就是说,这章的测试都不是在问“你会不会聊天”,而是在问“你有没有把 chat 的字符串边界写对”。
task 9.2:实现 student_format_prompt
这一题最值得注意的,不是拼接 API 选哪一个,而是格式语义。
你要保证每条消息都是:
<tag>
content
并且在所有历史消息结束后,再额外追加一行:
<|assistant|>
只有这样,模型才知道接下来该由助手继续说。
task 9.3:实现 student_extract_response
这一步的思路应该非常稳定:
- 找最后一个
<|assistant|>; - 从它后面开始看;
- 一旦碰到下一条角色标签或
<|end|>,立刻停; - 最后 trim 两端空白。
如果你在实现时始终围绕这个结构边界去想,代码就不容易写偏。
task 9.4:运行当前真实基线
在还没补完两个函数前,先执行:
make clean && make test
当前 Lab09 的真实初始状态是:
- 0 通过,6 失败。
这组结果非常干净,说明当前实验代码里的两个核心函数都还是空的。
这其实很适合教学,因为它把“这个 lab 的责任边界”体现得很明确:
只要你把这两个函数补对,测试就会整体翻绿。
task 9.5:完成后重新验证
当你补完两个函数后,再运行:
make clean && make test
如果实现正确,你应该看到:
- prompt 里的标签数量、内容顺序和末尾 assistant 起点都正确;
- 回复提取能够在多种边界条件下稳定工作。
到这里,多轮对话最核心的模板层就真正建立起来了。
9.9 常见错误与排查顺序
最常见的错误一般是:
- 忘了在 prompt 末尾补
<|assistant|>; - role 对应标签映射错;
- 提取回复时找了第一个 assistant,而不是最后一个;
- 遇到下一个角色标签时没有截断;
- 没有做 trim,导致结果前后多出换行或空格。
建议排查顺序是:
- 先看 prompt 格式;
- 再看 assistant 起点;
- 最后看回复截断逻辑。
因为提取逻辑本质上依赖前面模板结构是否清晰。
9.10 思考题
- 为什么 prompt 末尾一定要落在
<|assistant|>,而不是<|user|>或纯空字符串? - 为什么提取回复时要找最后一个
<|assistant|>? - 如果把多轮消息只按内容拼起来,不带任何标签,会丢掉哪类信息?
9.11 本章小结
Chapter 9 把课程从“单次生成”推进到了“带历史结构的生成”。
但更重要的是,它让学员第一次明确看到:
对话系统并不是模型之外的一层壳,而是一套会直接进入 token 序列的输入输出协议。
这一步走通后,下一章自然就会接上。因为一旦 prompt 模板和回复边界都稳定了,我们就可以把这套 chat 能力封装成 HTTP 服务,对外暴露给其它程序调用。
Chapter 10 — step9 HTTP 服务
对应实践:
course/practice/labs/lab10-step9/
主要修改文件:course/practice/labs/lab10-step9/framework/student.c
验证命令:make clean && make test
到上一章为止,课程已经把“多轮对话”里的字符串协议拆清楚了。
你已经知道 prompt 怎样组织,回复又该怎样从模型输出里截出来。
但只停在命令行程序里,还谈不上真正的服务化。
一旦你想让前端、脚本或别的程序来调这个模型,就需要把 chat 能力封装成一个可通信的接口。
在当前课程阶段,这个接口最自然的形态就是 HTTP。
需要特别说明的是:
这一章并不要求学员自己从头写 socket 服务器。课程把范围压得很小,只让你实现 HTTP 协议层两个最小积木:
- 解析请求行;
- 组装响应帧。
也就是说,Chapter 10 的核心问题不是“网络编程有多复杂”,而是:
当一个模型能力要被外部程序调用时,最基础的请求/响应协议长什么样?
10.1 为什么服务化首先是协议问题
初学者第一次接触“把模型做成服务”时,很容易把注意力都放在端口、线程、监听循环上。
这些当然都重要,但它们还不是服务化的第一层边界。
第一层边界其实是协议:
- 客户端发来什么文本;
- 服务端如何把它拆成 method / path / body;
- 服务端回去的内容该怎样拼成合法 HTTP 响应。
如果这一层都没弄清楚,那么就算 socket 已经连上,客户端也不知道你在说什么。
所以本章故意把实践缩到两道协议题里,是为了让学员先掌握:
- 请求帧的最小结构;
- 响应帧的最小结构。
10.2 本章你要建立哪些判断
本章结束后,你应当能够清楚说出:
- 一个 HTTP 请求行为什么通常长成
METHOD PATH HTTP/1.1。 - 为什么 headers 和 body 之间必须有一个空行。
Content-Length为什么是必须的,而不是“可选信息”。- 为什么响应行的
\r\n不是普通换行细节,而是协议要求。 - 为什么 OpenAI 兼容接口的价值,本质上是“客户端和服务端共享了一套 JSON 契约”。
这些判断一旦建立起来,后面真正去跑 curl 或前端调用时,你就不会只把服务端当黑盒。
10.3 先看 practice target:这章改哪里
本章实践目录是:
course/practice/labs/lab10-step9/
├── TASK.md
├── Makefile
└── framework/
├── student.c <- 主要修改这里
├── student.h
├── verify.c <- 自动验证,不改
└── verify.h
当前需要你实现的函数有两个:
student_http_parse_request_linestudent_http_build_response
这个切法非常合理,因为它刚好对应 HTTP 协议里最基础的两个动作:
- 从客户端来的第一行里拆出“请求意图”;
- 把服务端结果重新打包成客户端能理解的响应文本。
10.4 为什么请求行解析是“结构恢复”
请求行看起来只是一行字符串,例如:
GET /health HTTP/1.1
但对服务器来说,它不是普通文本,而是一段被压平的结构数据。
解析请求行做的事情,本质上是在把这段压平字符串重新恢复成三个字段:
- method
- path
- version
这跟前一章“从模型原始输出里截出 assistant 回复”的思路其实非常像。
两者都是在做一件事:从一段线性文本里恢复协议结构。
10.5 为什么响应构建是“主动声明边界”
响应构建和请求解析正好反过来。
这里服务器不是在恢复结构,而是在主动声明结构。
你要明确告诉客户端:
- 这是
HTTP/1.1响应; - 状态码是多少;
- 内容类型是什么;
- body 有多长;
- headers 到哪里结束,body 从哪里开始。
这里最不能忽略的就是 Content-Length。
因为 HTTP 是字节流协议,客户端如果不知道 body 的长度,就无法稳妥判断响应到底什么时候结束。
所以本章最应该建立的不是“能拼出一串文本”,而是:
一次合法响应,必须把自己的边界写清楚。
10.6 为什么课程现在只做协议骨架,不让你写完整服务
如果在这一章同时让学员:
- 管 socket;
- 管请求解析;
- 管 JSON;
- 管模型调用;
- 管并发或阻塞行为;
那教学负担会一下子跳得非常高。
课程当前的选择更务实:
先把“协议骨架”单独拿出来练。
因为只要请求行解析和响应组装是清楚的,后面的完整服务化路径就已经有了坚实基础。
反过来,如果这两层还混乱,直接跑完整 server 只会让错误来源更加难找。
10.7 本章实践步骤
task 10.1:先读 student.c 和 verify.c
进入:
cd course/practice/labs/lab10-step9
建议先读:
framework/student.hframework/student.cframework/verify.c
当前验证器主要检查:
GET /health HTTP/1.1能否被正确拆成三段;POST /v1/chat/completions HTTP/1.1能否被正确拆成三段;- 非法请求行是否会被拒绝;
- 200 OK 响应是否包含正确的 header 与 body;
- 404 Not Found 响应是否仍然是合法 HTTP 帧。
task 10.2:实现 student_http_parse_request_line
这一步建议你始终记住目标不是“做复杂解析器”,而是从最小协议格式里拿出三段字段。
实现时最值得关注的是:
- 找两个空格;
- 段长不要越界;
- 任何结构异常都应该返回失败,而不是悄悄截断。
这是一个很典型的协议解析习惯:
宁可明确报错,也不要默默吞掉坏输入。
task 10.3:实现 student_http_build_response
这一题最值得在脑子里保持一张“响应骨架图”:
status line
headers
空行
body
只要这张骨架没有乱,你再逐项补:
Content-TypeContent-LengthConnection: close
代码就不容易写偏。
task 10.4:运行当前真实基线
在还没补完两个函数前,先执行:
make clean && make test
当前 Lab10 的真实初始状态是:
- 1 通过,4 失败。
已经通过的那一项通常只是非法请求行失败这类保底路径。
真正与协议骨架直接相关的部分,例如:
- 请求行拆分;
- 200/404 响应组装;
目前都还没有完成。
这说明当前 lab 已经能运行,但 HTTP 协议层的核心责任仍然留给你自己补完。
task 10.5:完成后重新验证
当你补完两个函数后,再运行:
make clean && make test
如果实现正确,你应当看到:
- 常见请求行都能被稳定解析;
- 非法输入被拒绝;
- 响应文本包含合法头部、空行和 body。
到这里,chat 系统就已经具备了向外暴露接口的最小协议层。
10.8 常见错误与排查顺序
最常见的错误通常是:
- 请求行没有正确按两个空格切开;
- 缓冲区长度检查不严;
- 响应行用了
\n而不是\r\n; Content-Length算错;- headers 和 body 之间忘了空行。
建议排查顺序是:
- 先看请求行三段是否拆对;
- 再看响应状态行;
- 最后看 header/body 边界是否正确。
10.9 思考题
- 为什么
Content-Length对 HTTP/1.1 响应如此关键? - 为什么协议里要求
\r\n,而不是普通的\n? - 为什么说 OpenAI 兼容接口的价值,本质上是一种共享协议而不是某个具体模型实现?
10.10 本章小结
Chapter 10 把课程从“命令行里的模型能力”推进到了“可被外部程序调用的模型能力”。
更重要的是,它让学员第一次明确看到:
服务化的第一层不是 socket 技巧,而是协议边界。
下一章继续往前走时,这层边界就会变得非常有用。因为一旦模型开始被频繁调用,推理效率问题就会立刻浮现出来,而 KV cache 正是为那个问题准备的。
Chapter 11 — step10 KV Cache
对应实践:
course/practice/labs/lab11-step10/
主要修改文件:course/practice/labs/lab11-step10/framework/student.c
验证命令:make clean && make test
到上一章为止,模型已经不仅能生成文本,还已经可以被包装成一个对外服务。
这时新的问题自然会出现:
如果同一个请求在生成过程中不断追加 token,模型是不是每一拍都在把整段历史重新算一遍?
答案是:如果没有缓存,那确实会这样。
这就是 KV cache 章节存在的原因。
它不改变模型的数学输出目标,而是优化模型在自回归推理时的重复工作量。
需要注意的是,Lab11 的实践范围依然控制得很小。课程没有要求学员从头重写整套带 cache 的 attention,而是先把最小接口层留出来:
student_kv_cache_allocstudent_kv_cache_appendstudent_kv_cache_len
也就是说,这章真正要学的第一步不是“我已经能写高性能推理”,而是:
我先得清楚 cache 在结构上到底是什么,它保存什么,它怎样被追加和读取。
11.1 为什么 KV cache 解决的是“重复计算”
在自回归生成里,第 t 次生成时,模型已经知道前面 0..t-1 个位置的历史。
如果没有缓存,模型通常会把整段历史从头到尾再跑一次 attention。
但注意力里有一类量有一个非常关键的性质:
- 历史位置的 K 和 V 一旦算出来,在后续新 token 到来时并不会改变。
这意味着,真正每一步都必须重新算的,主要是新位置相关的那一小部分。
而历史 K/V 完全可以缓存下来复用。
所以 KV cache 的核心价值并不是“变了一个更聪明的公式”,而是:
把本来会重复做的历史计算结果留下来,下次别再重算。
11.2 本章你要建立哪些判断
这一章结束后,你应当能够清楚说出:
- 为什么 K 和 V 可以缓存,而 Q 不适合用同样方式处理。
- KV cache 的容量为什么与
num_layers、max_seq_len、hidden_dim成正比。 - 为什么 append 一个新位置,本质上是在每层的 cache 里写入一行 K 和一行 V。
- 为什么
current_len是 cache 状态的一部分,而不是临时变量。 - 为什么 KV cache 是典型的“用内存换时间”策略。
这些判断建立之后,后面你读完整 step10 实现时,才会知道哪些是主逻辑,哪些只是包装。
11.3 先看 practice target:这章改哪里
本章实践目录是:
course/practice/labs/lab11-step10/
├── TASK.md
├── Makefile
└── framework/
├── student.c <- 主要修改这里
├── student.h
├── verify.c <- 自动验证,不改
└── verify.h
本章需要你实现的函数有三个:
student_kv_cache_allocstudent_kv_cache_appendstudent_kv_cache_len
课程这里故意没有要求你直接碰到底层 stride 或 attention 核心公式,而是先把 cache 生命周期最关键的三个接口做清楚:
- 先能建出来;
- 再能往里写;
- 最后能知道自己写到了哪里。
11.4 为什么 cache 首先是一个数据布局问题
很多人第一次接触 KV cache 时,会直接想到“推理加速”。
这当然没错,但如果只盯住速度,往往反而看不清它在代码里到底长什么样。
从数据结构角度看,KV cache 最核心的事情其实很朴素:
- 对每一层,都留一块能按序列位置存 K 的区域;
- 再留一块同样按序列位置存 V 的区域;
- 记录当前已经写到了第几个位置。
这说明 KV cache 不是一个抽象“开关”,而是一套明确的内存布局。
11.5 为什么 append 这一题有教学价值
student_kv_cache_append 看起来可能只是包一层调用,代码并不长。
但它的教学价值很高,因为它让学员亲手经历一个非常重要的推理流程:
- 新 token 来了;
- 当前层的新 K/V 算出来了;
- 要把它们放进 cache 的哪一层、哪一个位置。
只要这件事在脑子里清楚了,后面去理解 prefill、decode、cache 命中这些概念时,就会容易很多。
11.6 为什么 current_len 不是可有可无
cache 如果只是有一大块内存,却不知道已经写到了哪里,那它就没有办法安全参与下一轮生成。
所以 current_len 不是装饰字段,而是 cache 状态的一部分。
它回答的是:
现在这个 cache 里,前多少个位置已经是有效历史。
这也是为什么 student_kv_cache_append 不只是写数据,还要记得同步更新长度。
11.7 本章实践步骤
task 11.1:先读 student.c 和 verify.c
进入:
cd course/practice/labs/lab11-step10
建议先读:
framework/student.hframework/student.cframework/verify.c
当前验证器会检查:
- cache 是否能被成功创建;
- cache 占用字节数是否符合公式;
- append 进去的 K/V 能否从对应位置正确读回;
current_len是否和追加次数一致。
这说明本章虽然没有直接测完整推理速度,但它已经抓住了 KV cache 最重要的结构行为。
task 11.2:实现 student_kv_cache_alloc
这一题最重要的不是代码量,而是理解:
- cache 不是普通临时数组;
- 它和模型配置直接相关;
- 一旦创建失败,就不应该继续假装后面的状态存在。
task 11.3:实现 student_kv_cache_append
这一题最值得建立的直觉是:
append 一个位置,实际上就是在 cache 的“层 + 序列位置”二维坐标上写入一行。
当前课程为了降低负担,已经把底层 stride 和写入逻辑封装好了。
你现在真正要抓住的是“接口语义”,也就是:
- 我往哪层写;
- 我往第几个位置写;
- 写完后长度该怎样更新。
task 11.4:实现 student_kv_cache_len
这一题最短,但不要忽略它的意义。
它其实是在把内部状态暴露成一个稳定接口,方便验证器和后续代码去观察 cache 当前走到哪里。
task 11.5:运行当前真实基线
在还没补完三个函数前,先执行:
make clean && make test
当前 Lab11 的真实初始状态是:
- 0 通过,4 失败。
这说明:
- 框架和链接已经可以跑起来;
- 但 cache 的最小生命周期接口还没有真正实现。
这是一种很好的教学起点,因为当前失败都集中在本章自己的实现范围里,没有混进过多外围噪声。
task 11.6:完成后重新验证
当你补完三个函数后,再运行:
make clean && make test
如果实现正确,你应该看到:
- cache 创建成功;
- 容量公式匹配;
- append 后读回数据正确;
current_len正确推进。
到这里,KV cache 在课程里的第一层结构直觉就建立起来了。
11.8 常见错误与排查顺序
最常见的错误通常是:
- 创建 cache 时参数没传对;
- append 时没有做空指针保护;
- 追加成功后忘了更新长度;
- 写入了错误的层或错误的位置。
建议排查顺序是:
- 先看 alloc 是否成功;
- 再看 memory size 是否符合公式;
- 再看 append 后的具体读回值;
- 最后看
current_len。
11.9 思考题
- 为什么 K 和 V 适合缓存,而 Q 不适合照搬同样模式?
- 为什么说 KV cache 是典型的“空间换时间”?
- 如果
num_layers或max_seq_len翻倍,cache 占用会怎样变化?
11.10 本章小结
Chapter 11 把课程带进了推理优化这个层次。
它的重点不是让学员马上写出最快的实现,而是先把 cache 作为一个明确数据结构看清楚:
- 它存什么;
- 它怎样扩展历史;
- 它怎样让后续推理避免重复工作。
这一步打通后,下一章就顺理成章了。因为一旦推理路径更像真实系统,词表和分词方案的问题就会变得更加显眼,而 BPE 正是下一步要解决的输入表示升级。
Chapter 12 — step11 BPE 分词
对应实践:
course/practice/labs/lab12-step11/
主要修改文件:course/practice/labs/lab12-step11/framework/student.c
验证命令:make clean && make test
前面的课程一直默认在一个比较小、比较稳定的字符级词表上工作。
这种设计在教学初期非常合理,因为它简单、直观,而且几乎不会遇到 OOV 之类的问题。
但字符级表示也有很明显的代价:
一句普通文本会被拆成很多很碎的小 token,模型不得不在更长的序列上工作。
BPE 章节要解决的,就是这个输入表示层的问题。
它并不直接改变 GPT 结构,也不直接改变训练算法,而是在更上游的地方改变:
文本在进入模型之前,到底被切成怎样的 token 单位。
在当前课程里,Lab12 同样采用了非常克制的实践切法。
它没有要求学员从头写完整 BPE 系统,而是只把两个最核心的算子拆出来:
student_bpe_count_pairsstudent_bpe_apply_merge
这意味着本章真正要你掌握的是 BPE 的核心循环直觉,而不是记住一整个工程实现的所有细节。
12.1 为什么字符级表示会变得不够用
字符级 tokenizer 的最大优点是稳:
只要字符集定义好了,任何文本都能被拆开。
但它的最大问题也很明显:
很多本来可以作为整体被理解的常见片段,会被拆得过碎。
比如:
hello
在字符级里会被拆成 5 个 token。
而对于一个已经在语料中反复出现过的常见词,这种拆法会让模型承担额外的序列长度和学习负担。
BPE 的出发点正是:
如果一对相邻 token 总是一起出现,那不如把它们合成一个新的、更大的 token。
12.2 本章你要建立哪些判断
这一章结束后,你应当能够清楚说出:
- BPE 的一个训练循环为什么可以概括成“统计相邻 pair -> 合并最常见 pair”。
- 为什么只有频次足够高的 pair 才值得合并。
- 为什么一次合并会让序列长度变短,而词表大小变大。
- 为什么 BPE 既能压缩常见词,又仍然保留退回字符级的能力。
- 为什么 BPE 的真正核心并不在“花哨数据结构”,而在那两个基本算子。
这些判断一旦建立起来,你后面看 bpe_train 这种完整实现时,就不会只看到一团循环和数组。
12.3 先看 practice target:这章改哪里
本章实践目录是:
course/practice/labs/lab12-step11/
├── TASK.md
├── Makefile
└── framework/
├── student.c <- 主要修改这里
├── student.h
├── verify.c <- 自动验证,不改
└── verify.h
本章需要你实现的函数有两个:
student_bpe_count_pairsstudent_bpe_apply_merge
这个切法非常好,因为它恰好把 BPE 的训练循环拆成了最核心的两半:
- 先看当前序列里哪一对最常一起出现;
- 再把这一对在序列里替换成新的 token。
你只要把这两步真正想明白,BPE 的整体直觉就已经建立起来了。
12.4 为什么“找最高频 pair”是 BPE 的核心观察
如果把一段文本先看成字符序列,那么相邻两个 token 组成的 pair 就是 BPE 最基础的观察单位。
例如在:
ababab
里,(a, b) 显然反复出现。
这说明对当前语料来说,a 后面很常跟着 b,它们作为整体出现的频率很高。
于是 BPE 的想法非常朴素:
既然这对经常一起出现,那就把它们焊成一个新的 token。
这一步不是在猜语义,而是在做统计压缩。
也正因为如此,本章非常适合先把“统计最频繁 pair”的动作单独拿出来实现。
12.5 为什么“频次至少 2”是一个自然阈值
如果一对相邻 token 只出现过一次,那把它们合并通常没有太大意义。
因为这更像一次偶然相邻,而不是语料中稳定存在的局部模式。
所以 BPE 会天然偏向那些至少重复出现过几次的 pair。
在当前教学框架里,验证器就采用了一个最小可理解阈值:
- 最高频如果连 2 次都不到,就返回 0,表示没有值得合并的 pair。
这有很好的教学价值。因为它把“什么时候该停止合并”这件事也一起讲清楚了。
12.6 为什么“应用 merge”不是简单替换字符串
student_bpe_apply_merge 的任务,看起来像是在做替换。
但它和普通字符串替换又不完全一样。
它处理的是 token 序列,因此更准确地说,是在做:
- 从左到右扫描;
- 一旦看到
(first, second)这个相邻 pair; - 就写入一个新的
new_id,并跳过下一个位置。
这里最值得建立的直觉是:
一次 merge 会把两个旧 token 折叠成一个新 token,所以序列长度只会变短或不变,不会变长。
这也是 BPE 能压缩输入长度的直接原因。
12.7 为什么本章故意不用完整系统来起步
如果一上来就让学员去读完整 bpe_tokenizer.c,大多数人会先被各种工程细节包住:
- 词表存储;
- merges 列表;
- encode / decode 路径;
- 文件保存加载。
这些当然都是真实系统的一部分,但它们不适合做第一观察点。
当前课程更务实的策略是:
先把“统计 pair”和“应用 merge”这两个核心动作亲手写出来。
这样你后面再看完整 BPE 系统时,就会知道哪些代码是在服务这两个动作,哪些只是外围管理。
12.8 本章实践步骤
task 12.1:先读 student.c 和 verify.c
进入:
cd course/practice/labs/lab12-step11
建议先读:
framework/student.hframework/student.cframework/verify.c
当前验证器会检查:
- 在
ababab里能否找到最高频 pair; - 没有重复 pair 时,是否正确返回 0;
- 把
(a, b)合并成新 id 后,序列内容和长度是否正确; - 在一段更长文本上,重复做合并时,序列是否真的变短。
这些检查很适合作为 BPE 入门,因为它们全都对准“局部统计”和“局部替换”这两个核心动作。
task 12.2:实现 student_bpe_count_pairs
这一题建议你不要一开始就追求复杂数据结构。
当前 lab 的输入规模完全允许你先用简单暴力的方式,把逻辑写清楚。
最重要的是建立顺序:
- 先枚举所有相邻 pair;
- 再统计每个 pair 的次数;
- 选出最高频那个;
- 如果频次不到 2,就明确返回 0。
task 12.3:实现 student_bpe_apply_merge
这一题要特别注意扫描时的位置推进。
一旦你把 (first, second) 合成了 new_id,就意味着:
- 当前 pair 已经被消耗;
- 下一轮扫描不能再把
second当成新的起点重复处理。
所以这题本质上是在训练一种非常基础但非常重要的序列处理意识:
当输入和输出的步长不一致时,扫描指针该怎样推进。
task 12.4:运行当前真实基线
在还没补完两个函数前,先执行:
make clean && make test
当前 Lab12 的真实初始状态是:
- 1 通过,3 失败。
已经通过的通常是“没有可合并 pair 时正确返回 0”这类保底路径。
真正和 BPE 核心相关的部分,例如:
- 找最高频 pair;
- 正确合并;
- 多轮合并后长度变化;
目前都还没完成。
这说明当前实验代码已经把问题范围压得很小,而且失败点高度集中,非常适合作为 BPE 核心算子的第一站。
task 12.5:完成后重新验证
当你补完两个函数后,再运行:
make clean && make test
如果实现正确,你应该看到:
- 最高频 pair 被正确找出;
- 合并后序列长度和内容都正确;
- 多轮合并后序列真的缩短。
到这一章结束,词表和输入表示层就不再只是“固定字符切分”了。
12.9 常见错误与排查顺序
最常见的错误一般是:
- pair 次序写反;
- 统计次数时漏掉某些位置;
- 合并时没有正确跳过已经消费的第二个 token;
out_len忘写;- 没处理
len < 2这种边界情况。
建议排查顺序是:
- 先看简单样例
ababab; - 再看“无重复 pair”边界;
- 最后看多轮合并长度变化。
12.10 思考题
- 为什么频次只有 1 的 pair 往往不值得合并?
- 为什么一次 merge 会让序列缩短、词表变大?
- 为什么 BPE 既是压缩手段,又仍然保留退回细粒度字符的能力?
12.11 本章小结
Chapter 12 把课程视角再次往前移了一层,从模型内部运算回到了输入表示本身。
你现在应该能明确看到:
文本进入模型之前怎样被切分,会直接影响序列长度、训练负担和生成表现。
这一步打通之后,课程的最后一章就有了清晰目标:把 BPE、模型前向、采样和对话循环重新拼成一条完整路径。
Chapter 13 — BPE 对话整合
对应实践:
course/practice/labs/lab13-end-to-end/
主要修改文件:course/practice/labs/lab13-end-to-end/framework/student.c
验证命令:make clean && make test
到了这里,课程已经把后半段需要的几个关键部件都讲过了:
- 采样;
- 对话模板;
- HTTP 协议边界;
- KV cache;
- BPE 核心算子。
如果这些章节只各自孤立存在,学员会很容易形成一种错觉:
自己“每章都懂一点”,但还没有真正把它们重新接成一条完整路径。
Chapter 13 的任务就是解决这件事。
需要特别澄清的是,当前这个 lab 的真实角色并不是“重新训练一个大模型”。
它更接近一次后半段组件整合实验:把 BPE 编码 + 模型前向 + 采样 + 解码 + 简单 chat loop 串起来。
所以这一章最准确的名字,不是“端到端训练”,而是:
用 BPE 输入路径把对话最小闭环重新接通。
13.1 为什么最后一章还要再做一次整合
项目型课程做到后期,最常见的问题不是学员完全不会某一章,而是学员的理解停留在“部件级”。
比如:
- 知道 BPE 会输出 token id;
- 知道模型会吐 logits;
- 知道采样能从 logits 里挑下一个 token;
- 知道 chat loop 会读输入、打输出;
但这些知识如果没有重新装成一条完整数据流,就很容易仍然是散的。
所以课程最后一章最应该做的,不是再引入一个更大的新主题,而是把这些后半段组件重新接起来,让学员明确看到:
- 文本怎样进来;
- token 怎样流经模型;
- 采样怎样把模型输出变回 token;
- token 又怎样被解码成可读文本;
- 这一切怎样放进一个最小 REPL 循环里。
13.2 本章你要建立哪些判断
这一章结束后,你应当能够清楚说出:
- BPE 对话闭环里最小数据流是什么。
- 为什么输入侧要先编码,而输出侧要再解码。
- 为什么单轮
one_turn和外层chat_loop最好分开实现。 - 为什么 cache 让“后续 token 只喂增量输入”成为可能。
- 为什么 BPE 词表和模型权重必须配套使用。
如果这些判断建立起来,课程后半程就真正从“很多局部功能”变成了一条完整系统路径。
13.3 先看 practice target:这章改哪里
本章实践目录是:
course/practice/labs/lab13-end-to-end/
├── TASK.md
├── Makefile
└── framework/
├── student.c <- 主要修改这里
├── student.h
├── verify.c <- 自动验证,不改
└── verify.h
本章需要你实现的函数有四个:
student_bpe_chat_tokenizestudent_bpe_chat_decodestudent_bpe_chat_one_turnstudent_chat_loop
这个切法非常好,因为它刚好把最终整合路径拆成四层:
- 输入编码;
- 输出解码;
- 单轮生成;
- 外层交互循环。
也就是说,这章不是让你面对一个巨大函数,而是把完整路径重新拆回可以一段段观察的形态。
13.4 为什么编码和解码必须单独成为接口
在教学上,把编码和解码拆成独立函数有一个非常大的好处:
它把“文本世界”和“token 世界”的边界明确暴露出来了。
如果你把它们直接写死在单轮对话函数里,学员很容易只看到一条很长的流程,而看不清哪些动作发生在:
- 模型之外;
- 模型之内;
- 模型输出之后。
单独拆成:
student_bpe_chat_tokenizestudent_bpe_chat_decode
就能非常明确地告诉学员:
模型从不直接理解字符串,它只处理 token 序列;字符串和 token 之间的翻译必须明确存在。
13.5 为什么 one_turn 才是整合章节的真正核心
外层 REPL 当然重要,但本章真正的核心还是 student_bpe_chat_one_turn。
因为它正好承接了前面所有后半段主题:
- 用 BPE 把用户输入变成 token;
- 用模型前向得到 logits;
- 用采样策略选出新 token;
- 利用 cache 继续增量生成;
- 再把输出 token 解码回文本。
如果你把这一条链真正写通了,那前面几章学过的内容才第一次全部进入同一个函数视野。
13.6 为什么外层 chat_loop 仍然值得单独写
有些人会觉得外层循环只是“读一行、打一句”,没有什么技术含量。
但课程把它单独拿出来,恰恰是为了让学员看到:系统整合并不总是出在最复杂数学上,有时也出在很朴素的交互边界上。
一个能工作的最小 REPL,至少要明确:
- 输入从哪里读;
- 什么时候退出;
- 回复由谁生成;
- 生成后的内存由谁释放。
这正是很多小系统第一次真正“像个系统”的地方。
13.7 为什么当前 Lab13 不是“训练型端到端”
这里需要把定位讲清楚,不然学员和助教很容易被目录名误导。
当前 lab13-end-to-end 的真实内容,并不是从语料重新开始训练一整条大流水。
它更准确地说,是:
- 在已有 framework 能力之上,
- 把 BPE 输入路径和简单对话回路整合起来。
所以如果你在这一章里期待看到完整训练 epoch、模型文件落盘和复训逻辑,那就会和实际 lab 内容错位。
课程文稿必须把这一点写清楚,避免学员带着错误预期进入最后一章。
13.8 本章实践步骤
task 13.1:先读 student.c 和 verify.c
进入:
cd course/practice/labs/lab13-end-to-end
建议先读:
framework/student.hframework/student.cframework/verify.c
当前验证器会检查:
- 编码接口是否能返回非空 token 序列;
- 解码接口是否能返回非空字符串;
- 单轮生成是否至少能走完一轮;
- 外层 chat loop 是否能读入一轮再退出;
- 空指针防护是否存在。
也就是说,这章并不要求“生成内容很好”,而是优先要求“整条接口链能走通”。
task 13.2:实现 student_bpe_chat_tokenize
这一题最适合先做,因为它最短,而且能把输入边界先稳住。
重点是:
- 明确空指针守护;
- 明确是否加 BOS;
- 明确是否加 EOS。
这会直接决定后面模型前向看到的序列起点长什么样。
task 13.3:实现 student_bpe_chat_decode
这一步和编码刚好相反。
它的意义不仅是“把 token 变回字符串”,更是在提醒你:
生成结束时,模型世界必须重新回到用户世界。
task 13.4:实现 student_bpe_chat_one_turn
这是本章最重要的函数。
建议你在实现时始终围绕一条清晰的数据流思考:
- 先把输入编码;
- 再跑前向;
- 再从最后一行 logits 采样;
- 再把新 token 继续喂回模型;
- 最后解码输出。
如果你把它写成“很多杂乱的小步骤”,很容易丢失主线。
但如果你始终守住这条数据流,整个函数其实并不难理解。
task 13.5:实现 student_chat_loop
这一题的重点不是花哨界面,而是交互闭环:
- 打印欢迎语;
- 读一行;
- 判断
/quit; - 调
one_turn; - 打印回复;
- 释放资源。
到这里,后半段所有组件才第一次真正进入一个面向用户的最小系统。
task 13.6:运行当前真实基线
在还没补完四个函数前,先执行:
make clean && make test
当前 Lab13 的真实初始状态是:
- 2 通过,3 失败。
已经通过的部分主要是:
student_bpe_chat_one_turn当前占位返回了一个非空字符串,因此“不崩”测试能过;chat_loop当前也能打印占位信息后返回,因此“一轮 stdin 后退出”的测试能过。
而失败的部分主要集中在:
- 真实编码;
- 真实解码;
- 空指针守护。
这组结果很有代表性,因为它说明当前 lab 已经能把整条框架链拉起来,但真正的接口细节还没有补全。
task 13.7:完成后重新验证
当你补完四个函数后,再执行:
make clean && make test
如果实现正确,你应该看到:
- token 化和解码都可用;
- 单轮生成能稳定返回字符串;
- chat loop 能完成一次真实回合;
- 空指针边界不会崩。
到这里,课程后半段组件就真正重新接成了一条完整路径。
13.9 常见错误与排查顺序
最常见的错误一般是:
- 编码接口没正确处理 BOS/EOS;
- 解码接口在空输入上行为不明;
one_turn没有把最后一行 logits 正确拿出来;- cache 增量使用方式不清楚;
chat_loop忘了在 EOF 或/quit时退出;- 临时内存释放不完整。
建议排查顺序是:
- 先看 tokenize/decode;
- 再看
one_turn; - 最后看外层 loop。
因为外层 loop 的正确性依赖前面三个接口都已经稳住。
13.10 思考题
- 为什么当前一章最适合先追求“链路通”,而不是先追求“回复质量好”?
- 为什么 BPE 词表和模型权重必须配套使用?
- 为什么把
one_turn和chat_loop分开,会让整合更清晰?
13.11 本章小结
Chapter 13 的意义,不在于再引入一个更新的主题,而在于把后半段已经学过的部件真正重新装起来。
从这一章结束时开始,学员应该能明确看到一条完整路径:
- 文本输入;
- BPE 编码;
- 模型前向;
- 采样生成;
- BPE 解码;
- 外层对话循环。
这条路径一旦真正走通,课程才算把“讲明白”和“做出来”完整闭合。