miniLLM 课程连续阅读

miniLLM 课程主页

这门课的目标很直接:带你从零写出一个纯 C 语言的小型语言模型。你不会只停在“能编译几个算子”这一步,而是会一路把张量、Tokenizer、Embedding、Attention、Transformer Block、完整 GPT、训练、生成、对话、HTTP 接口、KV Cache 和 BPE 串起来,最后做出一个真正能聊天的程序。

如果你以前看过很多大模型材料,却总觉得它们停在概念图和公式上,没有真正落到代码里,那么这门课就是为这个问题准备的。这里不会先把你扔进一个庞大框架,也不会让你先背一堆抽象定义。课程的节奏很固定:先讲清楚这章要解决的具体问题,再进入对应 lab,把这一章真正该写的代码补出来,然后用验证程序确认结果。

你会学到什么

学完整条主线后,你应当能独立回答下面这些问题:

  • 一个张量在 C 语言里究竟是什么,它为什么离不开 shapestride
  • 为什么字符级 Tokenizer 足够作为第一版模型入口,它的局限又在哪里。
  • Embedding 和位置编码为什么能把“离散 token”变成可计算的向量。
  • Attention 为什么需要 QKV,为什么还要做缩放和 mask。
  • Transformer Block 为什么不是“attention 写完就结束了”,而还需要残差、LayerNorm 和 FFN。
  • 一个 GPT 模型从结构上怎样由前面这些部件拼起来。
  • 为什么训练不是“再加几个循环”这么简单,而要同时关心 loss、梯度和优化器。
  • 文本生成、多轮对话、HTTP 服务、KV Cache、BPE 分词各自解决的到底是哪一层问题。

更重要的是,你不会只在纸面上知道这些东西,而是会在 lab 里亲手把它们逐章补出来。

怎么开始

第一次进入课程时,不要急着跳到后面的章节。按这个顺序走:

  1. 先读 Chapter 0,把环境和第一轮 smoke test 跑通。
  2. 再读 Chapter 1,进入正式课程。
  3. 读完一章,就去对应 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 对话链路真正跑起来

附录

附录不是主线的一部分,只有在你遇到具体问题时再回来看:

现在就开始

如果你已经准备打开第一章,那么下一步就是:

  1. 进入 Chapter 0
  2. 跑通里面的 smoke test
  3. 继续进入 Chapter 1

Chapter 0 — 出发前的准备

预计时间:45 ~ 90 分钟
本章目标:跑通课程的第一次 smoke test,并进入 Lab01 的真实工作目录

正式课程从下一章开始,但在那之前,你需要先把起跑线摆正。这里的“准备”不是为了让你多看几页导言,而是为了确保你接下来读到的每一章、写下的每一行代码、看到的每一次测试结果,都发生在正确的工作环境里。

这一章做完以后,你不需要已经懂张量,也不需要已经懂 attention。你只需要处于一个清楚、稳定、可继续推进的状态:你的机器能编译后面的 lab,你知道正式课程从哪里开始,你能看到第一章对应实验的初始输出。

0.1 这一章结束后,你应该达到什么状态

本章完成时,你应当同时满足下面四条:

  1. 你的机器上有可用的 gccmake
  2. 你能进入 course/practice/labs/lab01-step0/
  3. 你能执行 make clean && make test
  4. 你知道第一次看到 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

这个脚本会帮你完成三件事:

  1. 检查 gccmake 是否存在;
  2. 进入 labs/lab01-step0/
  3. 自动执行一次 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.

这不是坏事。它恰恰说明三件事已经成立:

  1. 代码能编译;
  2. 验证程序能运行;
  3. 第一章需要你补的几个函数还没有写,所以失败被清楚地暴露出来了。

这门课的正式起点,不是“你已经什么都做完了”,而是“你已经进入一个可以开始做第一章的状态”。

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 下一步

现在直接进入:

  1. Chapter 1
  2. Lab01 的任务说明

从这里开始,课程才真正进入代码。

Lab 与实践说明

Chapter 1 开始,这门课就不再只是阅读材料,而是要求你真正动手写代码。后面的每一章都会配一个对应的 lab。chapter 负责把知识讲清楚,lab 负责把这一章真正要做的代码落下来。

如果你把这门课想成“先看讲义,再做实验”,这里就是实验区的总入口。

你应该怎样使用这里

最推荐的节奏非常固定:

  1. 先读对应 chapter,明白这章为什么需要这个新概念。
  2. 再进入对应 lab,读 TASK.md
  3. 打开 framework/student.c,只做这一章留给你的部分。
  4. 运行 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 开始,后面每一章都尽量保持同样的做法:

  1. 读 chapter。
  2. TASK.md
  3. framework/student.c
  4. make clean && make test
  5. 对照输出、常见错误和思考题,把这一章真正做完。

这个顺序看起来朴素,但非常有效。它能避免两种最常见的问题:

  • 只看讲义,不真正写代码;
  • 只盯着代码改,却不知道为什么要这样改。

现在从哪里开始

如果你刚跑完 Chapter 0,下一步就是:

  1. Chapter 1
  2. 打开 Lab01 的任务说明
  3. 开始修改 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、甚至训练和缓存,看起来都会像不同风格的魔法;但一旦这一层清楚了,后面的复杂模块就会开始显得“不过是更大一点的张量操作组合”。

所以这章真正要建立的,不只是三个函数会不会写,而是一种观察方式:以后你每看到一个模块,都先问自己三件事。

  1. 输入和输出分别是什么 shape?
  2. 这些 shape 在内存里怎样排布?
  3. 这一段代码到底是在“按什么规则读数据、算数据、再写回数据”?

1.1 本章你要完成什么

这章结束后,你应当能做到下面几件事:

  1. 用自己的话解释 Tensor 结构体里 datashapestridesndimsize 各自表示什么。
  2. 看着一个二维张量,手算出任意元素的一维偏移。
  3. 明白为什么 student_tensor_getstudent_tensor_set 是第一批最值得亲手写的函数。
  4. 明白 softmax 为什么不能直接对原始输入做 expf,以及“先减最大值”到底解决了什么问题。
  5. 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_get
  • student_tensor_set
  • student_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_getstudent_tensor_set 的好处是,它们在概念上足够小,在代码上足够短,在验证上又非常直接。你能非常快地看到:自己是不是算对了 offset,是不是理解了 row-major,是不是知道读和写其实共享同一套索引规则。

这会给后续章节一个很好的心理起点:原来所谓“写神经网络底层代码”,第一步也不过是把一个结构体和索引公式想清楚。

1.6 softmax 为什么会出现在第一章

看到 student_softmax_stable 时,很多人会有点意外:前两个函数都在讲张量索引,为什么第三个突然变成了 softmax?

这不是跳跃,而是刻意安排。

因为 stride 公式解决的是“如何访问数据”,但 miniLLM 后面还有另一个会反复出现的问题:如何在浮点数世界里稳定地计算概率分布。softmax 正是这个问题最经典、也最早出现的例子。

如果你以后实现 attention,会不断遇到“先算一堆分数,再把它们变成和为 1 的权重”。那套变换的核心就是 softmax。因此,把它提早放在第一章,有两个好处:

  1. 让你尽早建立“数值稳定性不是优化项,而是正确性的一部分”的意识;
  2. 让你第一次在一个很小的练习里,接触后面真正模型计算里会反复出现的模式。

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 的四个测试大致在检查:

  1. 你能不能从二维张量里按坐标正确读到值;
  2. 你能不能按坐标正确把值写回去;
  3. 你写的 softmax 输出是不是能归一化成和为 1 的分布;
  4. 你的 softmax 在大数输入下会不会溢出。

你会注意到,这些检查都非常“局部”,但非常有针对性。它们不是在问“你会不会写完整神经网络”,而是在问“本章最核心的三个动作,你到底会不会”。

这就是这种 lab 结构的价值:它让验证结果直接对准本章教学目标。

1.9 本章实践步骤

现在开始进入真正的操作。建议你边看这一节,边在终端里跟着做。

task 1.1:重新确认 baseline

进入实践目录:

cd course/practice/labs/lab01-step0
make clean && make test

如果你前面已经做过一次,这里看到的现象应当还是一样:

  • TEST 1 FAIL
  • TEST 2 FAIL
  • TEST 3 FAIL
  • TEST 4 PASS

这一步的作用不是浪费时间,而是确认你接下来每一次变化,都能和这次 baseline 对比。

task 1.2:实现 student_tensor_get

打开:

course/practice/labs/lab01-step0/framework/student.c

找到:

float student_tensor_get(Tensor* t, int* indices)

这里不要急着写很多保护逻辑。先把主干想清楚:

  1. tindices 至少不能是空;
  2. 对当前 lab,可以按二维张量来处理;
  3. indices[0] * t->strides[0] + indices[1] * t->strides[1] 算 offset;
  4. 返回 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

最后一个函数建议分三段写,而不是一口气压成一团:

  1. 先扫描输入,找最大值;
  2. 再计算 expf(in[i] - max) 并累加总和;
  3. 最后做一次归一化,把每个 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 思考题

  1. 如果把 [2, 3] 张量的 stride 错写成 [2, 1],哪些坐标会最先读错?为什么?
  2. student_tensor_getstudent_tensor_set 现在只按二维张量写。如果以后要支持任意维度,你觉得最自然的推广方式是什么?
  3. softmax 先减最大值后,为什么最终概率分布不变?这里真正保持不变的量是什么?
  4. TEST 4 只证明“不会溢出”,它有没有证明“概率一定正确”?如果没有,它没有覆盖哪一类错误?

这些问题不是装饰。它们会决定你后面看到 attention、layernorm、cross entropy 时,是把它们当作新名词,还是把它们看成“旧模式的新组合”。

1.12 本章小结

这一章你做的事情看起来不大,但它在课程结构里非常关键。

你第一次真正进入了本章实验代码,第一次修改了需要自己完成的文件,第一次通过自动验证器获得了高信噪比反馈。更重要的是,你开始建立了这门课后面最重要的三个底层直觉:

  • 多维结构最终要落成线性内存;
  • 正确的索引规则比表面 API 更重要;
  • 数值计算要同时尊重数学等价和浮点稳定性。

这三点以后会反复出现。只是下一次,它们会披上更复杂的外衣。

1.13 通往下一章

下一章我们会从“如何在内存里放一个矩阵”走到“如何把文本放进模型”。也就是从张量基础走向 tokenizer。

在概念上,这是第一次把“人类能读的字符串”翻译成“模型能处理的离散 ID”;在实践上,这意味着你会第一次接触词表、特殊 token、编码和解码的一致性问题。

继续阅读:Chapter 2
对应实践:Lab02

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。
但从课程推进上看,这一章非常关键,因为它把“人类文本”第一次送入了“模型可处理的数据管道”。

这章结束后,你应当具备下面几个判断:

  1. 知道 miniLLM 当前字符级 tokenizer 的词汇表为什么是 4 + 256 = 260
  2. 知道 <PAD><UNK><BOS><EOS> 为什么必须预留在最前面。
  3. 明白字符级 tokenizer 不是在理解语言,而是在建立一种稳定、可逆、可喂给模型的离散编码方式。
  4. 明白“编一次再解一次能拿回原串”为什么是 tokenizer 最基础也最重要的正确性检查。
  5. 能在本章实验代码中完成 student_encode_charstudent_decode_idstudent_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_char
  • student_decode_id
  • student_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 = 0
  • TOKEN_UNK = 1
  • TOKEN_BOS = 2
  • TOKEN_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 实际上是在做两件事:

  1. 先过滤非法或特殊输入;
  2. 再对合法普通字符做逆映射。

这种“先判边界,再做主逻辑”的写法,以后几乎每章都会出现。

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.cverify.c

进入 practice 目录:

cd course/practice/labs/lab02-step1

先读:

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

你会发现这一章的验证器很直白,它主要检查四件事:

  1. 词表大小是不是 260;
  2. 'H''i' 编码后是不是 76 和 109;
  3. 76 和 109 解码后是不是 'H''i'
  4. "Hello" 做 roundtrip 后能不能完整回来。

这种验证器的价值在于,它把“这一章真正要会什么”写得非常明确。

task 2.2:实现 student_encode_char

这个函数的核心逻辑非常短,但不要因为短就草率。

你需要处理两类情况:

  1. tok == NULL 或输入字符异常时,返回 TOKEN_UNK
  2. 普通字符时,返回 (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

这一章最完整的实践就在这里。你需要把前两个局部规则串起来。

推荐的思路是:

  1. 先对 textoutout_size 做边界处理;
  2. tokenizer_encode(tok, text, &len, 0, 0) 得到 ID 数组;
  3. 分配一个临时字符缓冲;
  4. 逐个 ID 调 student_decode_id 写回字符;
  5. 末尾补 '\0'
  6. 再复制到 out
  7. 释放中间内存。

这一步有一个很典型的工程点:即使逻辑很短,也不能忘记内存释放。课程越往后走,这类“不是算法主体、但会决定程序是否健康”的细节会越来越重要。

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 思考题

  1. 为什么字符级 tokenizer 在教学上很合适,但在真实大模型里通常不够高效?
  2. 如果没有 <EOS>,模型在生成时还能靠什么机制停下来?这种停法有什么局限?
  3. student_decode_id 遇到特殊 token 返回 '\0'。这只是一个最小课程选择。如果以后你想保留特殊 token 的文本形态,更合理的接口应该长什么样?
  4. 这一章里“固定偏移量 + 可逆映射”的思想,和上一章“stride + 可逆定位”有什么共通点?

最后这个问题尤其重要。因为课程真正想训练的,不是你背住两个局部技巧,而是看到它们背后同一类设计结构。

2.12 本章小结

这一章你第一次把“人类文本”接进了模型管道。

你看到 tokenizer 并不神秘,它首先是在建立一个稳定的离散编号系统;你也看到特殊 token 并不是装饰,而是整个序列协议的一部分。更重要的是,你开始接触一类非常常见的工程模式:一条数据路径不仅要能正向走通,还要能通过 roundtrip 或其它方式证明它在边界上足够稳定。

下一章,离散 ID 将不再停留在整数层面,而会进入浮点表示世界。也就是:embedding。

继续阅读:Chapter 3
对应实践:Lab03

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 到底是怎样第一次变成模型内部的数值表示的?

完成本章后,你应当具备下面这些判断:

  1. 明白 token embedding 本质上就是一张 vocab_size × hidden_dim 的查表矩阵。
  2. 明白 position embedding 不是装饰,而是为了让模型知道“同一个 token 出现在第几个位置”。
  3. 能解释为什么 embedding_forward 不是一种神秘操作,而是“取 token 向量 + 加位置向量”。
  4. 知道 sinusoidal position embedding 的基本公式和最容易验证的边界情形:pos = 0
  5. 能在本章实验代码中完成 student_pe_sinusoidalstudent_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_sinusoidal
  • student_embedding_forward

这两个函数的组合非常有代表性。第一个函数负责“位置编码的单元素公式”,第二个函数负责“把 token embedding 和 position embedding 装配成整层前向传播”。

也就是说,这一章不是在让你实现完整 Embedding 模块的所有初始化和管理逻辑,而是在让你抓住它最核心的两步:

  1. 位置编码这一行上的某个值到底怎么算;
  2. 一个 token 序列怎样被逐位置写成 [seq_len, hidden_dim] 的输出矩阵。

3.3 为什么光有 token ID 还不够

上一章结束时,你已经有了 "Hi" 对应的一组整数 ID。看起来模型离“能处理输入”已经很近了,但其实还差一个关键层次。

token ID 的本质只是标签,不是表示。

例如,76109 这两个数字并不天然带有“字符之间的相似性”或“语义接近性”。从模型角度看,如果你直接把这些整数当作数值去算,会产生非常奇怪的含义:难道 10976 大,就说明 '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) = 0
  • cos(0) = 1

因此对任意维度,pos = 0 这一整行的位置编码都会呈现出非常规则的形状:

[0, 1, 0, 1, 0, 1, ...]

这非常适合作为第一层验证。因为你不需要先理解整张位置编码矩阵,只要先确认第 0 个位置的偶数维是不是 0、奇数维是不是 1,就能知道自己的公式主干有没有跑偏。

这也是为什么 Lab03 的第一个测试就盯住这个现象。教学上最稳的方式,就是先从最容易手算、最不容易被误解的边界点入手。

3.7 embedding_forward 本质上只是两件事叠加

很多初学者第一次看到 embedding forward,会把它想得很复杂。其实把结构拆开后,它非常直接。

对序列里第 pos 个 token,embedding forward 做的事情就是:

  1. token_ids[pos] 去 token embedding 表里查一行;
  2. 再取当前位置 pos 的 position embedding 那一行;
  3. 把这两行逐元素相加,写到输出的第 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.cverify.c

进入:

cd course/practice/labs/lab03-step2

先读三份文件:

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

你会发现这一章的测试在问四个非常明确的问题:

  1. pos = 0 时,位置编码是否呈现 0/1/0/1 的交替;
  2. PE(1, 0)PE(1, 1) 是否接近 sin(1)cos(1)
  3. 相同 token 放在不同位置时,输出向量是否真的不同;
  4. 不同 token 放在相同位置时,输出向量是否也不同。

这四个问题很有代表性,因为它们分别覆盖了:

  • 公式对不对;
  • 数值算得像不像;
  • 位置信息有没有真的加进去;
  • token 信息有没有真的保留下来。

task 3.2:实现 student_pe_sinusoidal

先做最小单位,也就是“位置编码的一个值”。

推荐先把逻辑拆成三步:

  1. 计算当前维度对应的 i = dim / 2
  2. 计算分母 10000^(2i / hidden_dim)
  3. 决定当前维度是用 sin 还是 cos

这里最值得留心的是:你不是在“背公式”,而是在把一个可验证的数值结构落到代码里。写完以后,第一时间就该拿 pos = 0 去检查,而不是先赌大样本行为。

task 3.3:实现 student_embedding_forward

这个函数比上一章的 roundtrip 更像一个真正的“层前向传播”。

你要处理的核心事情有:

  1. NULL 和长度边界;
  2. 越界 token 用 UNK 兜底;
  3. 外层按 pos 遍历;
  4. 内层按 d 遍历;
  5. 把 token embedding 和 position encoding 写进输出。

这里建议你刻意体会一个工程细节:
本章虽然在讲“embedding”,但 student 实现并没有让你直接调用完整 embedding_forward。课程故意把最核心的结构拆给你自己写,就是为了让你亲手经历一次“这一层前向传播其实只是怎么组织几块数据”的过程。

task 3.4:运行当前基线并理解它

在你还没完成这两个函数时,执行:

make clean && make test

当前这个 lab 的真实基线是:

  • TEST 1 FAIL
  • TEST 2 FAIL
  • TEST 3 FAIL
  • TEST 4 FAIL

这不是坏消息,反而说明 lab 现在正处在正确的“待学员完成”状态。
因为当前 student_pe_sinusoidal 永远返回 0.0fstudent_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 1TEST 2 过了,但 TEST 3TEST 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 思考题

  1. 如果彻底关掉 position embedding,模型为什么会更难区分 "AB""BA"
  2. 为什么 pos = 0 这么适合作为 sinusoidal PE 的第一个验证点?它揭示的是哪一类工程思路?
  3. student_embedding_forward 里为什么要对越界 token ID 回退到 UNK,而不是直接崩溃?
  4. 这一章的“查表 + 相加”,和上一章的“编码 + 解码”相比,虽然形式不同,但都体现了哪种“从协议层过渡到计算层”的结构?

3.11 本章小结

这一章的关键,不只是你第一次写出了 embedding forward,而是你第一次看见模型内部表示是如何生成的。

tokenizer 负责建立离散编号系统;embedding 则把这个离散系统投影到连续向量空间里。再加上 position encoding,模型终于拥有了一种既知道“这个 token 是谁”、又知道“它现在在哪”的输入表示。

从后面的角度看,这正是 attention 的起点。因为 attention 不会直接处理字符,也不会直接处理 token ID,它处理的正是这一章得到的 [seq_len, hidden_dim] 向量序列。

继续阅读:Chapter 4
对应实践:Lab04

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 做的事,就是给每个位置一套主动查阅其它位置的机制。
更准确地说,它允许当前位置:

  1. 发出一个“我现在在找什么”的查询;
  2. 用这个查询去和其它位置做匹配;
  3. 按匹配强弱,从其它位置收集信息。

这三步,正是 Q、K、V 背后的直觉来源。

4.2 本章你要建立哪些判断

这一章完成后,你应当能够:

  1. 用工程语言解释 Q、K、V:不是抽象字母,而是“查询”“标签”“内容”三种不同角色。
  2. 明白 attention 分数矩阵为什么是 Q @ K^T,以及它的 shape 为什么是 [seq_len, seq_len]
  3. 明白为什么要乘 1 / sqrt(head_dim) 这个缩放因子。
  4. 明白因果掩码为什么必须在 softmax 之前加,而不是之后再处理。
  5. 在本章实验代码中实现单头 attention 的三个关键步骤:
    • student_attention_scores
    • student_apply_mask
    • student_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_scores
  • student_apply_mask
  • student_softmax

这套拆法非常有教学意义。因为完整 attention 虽然听起来像一个“大模块”,但它最核心的数学动作其实只有三段:

  1. 先算相似度分数;
  2. 再把不该看的位置屏蔽掉;
  3. 最后把分数归一化成概率。

课程没有一上来让你实现整套 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_scoresscale 参数上。课程把它显式留给你,是为了让你知道:这不是公式里可有可无的小系数,而是 attention 计算能否稳定工作的关键一部分。

4.7 为什么因果掩码必须在 softmax 之前加

这一章还有一个非常容易“看懂文字却没真正想明白”的点:mask 为什么要提前加?

对于 decoder-only 模型来说,位置 i 不应该看到未来位置 j > i
实现这个约束的常见方式是:在这些未来位置上加一个极大的负数,比如 -1e9f

这样一来,softmax 之前的分数矩阵里,未来位置就会变成几乎不可能被选中的候选项。
经过 softmax 之后,它们的权重自然会接近 0。

如果你把 mask 放到 softmax 之后再处理,会有两个问题:

  1. softmax 已经把这些非法位置也纳入概率归一化;
  2. 你后面再“清零”它们,会破坏一整行权重和为 1 的性质。

这就是为什么本章把 student_apply_maskstudent_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.cverify.c

进入:

cd course/practice/labs/lab04-step3

建议先读:

  • framework/student.h
  • framework/student.c
  • framework/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 公式的第一步,也是最容易通过一个极小例子验证的部分。

建议用三层循环思考:

  1. 外层枚举 query 位置 i
  2. 中层枚举 key 位置 j
  3. 内层沿 head_dim 做点积累加。

然后再乘以 scale

这里一个很关键的意识是:你并不需要真的去构造 K^T 这个新张量。
只要你在读 K 时按照“第 j 行、第 d 列”访问,它在语义上就已经等价于 Q @ K^T 里的那个转置读取了。

task 4.3:实现 student_apply_mask

这一段反而是最短的。

它做的事情就是把 mask 逐元素加到 scores 上,而且是原地修改。
从代码量上看可能只有一两个循环,但从概念上看它很重要,因为它把“模型结构约束”显式地注入到了分数矩阵里。

以后你会不断遇到这种模式:真正决定模型行为的,并不总是复杂算子,有时恰恰是这些在关键节点插入的约束张量。

task 4.4:实现 student_softmax

这一步会把“匹配分数”变成“注意力权重”。

推荐仍然按数值稳定版的标准流程来写:

  1. 按行找最大值;
  2. 计算 exp(x - max)
  3. 求和;
  4. 归一化。

这一章最有价值的一点是:你会再次看到 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. 如果去掉 1 / sqrt(head_dim) 的缩放,softmax 权重为什么更容易变得极端?
  2. 为什么因果掩码必须放在 softmax 之前,而不是之后?
  3. student_softmax 再次用到了“先减最大值”的技巧。它和 Chapter 1 里的 softmax 有什么共同本质?
  4. 这一章只让你实现单头 attention 的核心三段,没有让你直接写完整多头前向。为什么这是更适合教学的拆分?

4.12 本章小结

这一章你第一次真正让序列里的位置彼此“看见了对方”。

embedding 解决的是“每个位置如何拥有自己的表示”;attention 解决的则是“每个位置如何读取别人的表示”。
从模型结构上看,这是一道很关键的分界线:从这里开始,序列不再只是排成一列的向量,而变成了一个会互相交换信息的系统。

后面的 Transformer block,正是在 attention 外面再包上层归一化、前馈网络和残差连接,让这套读取机制能稳定叠很多层。

继续阅读:Chapter 5
对应实践:Lab05

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 一层一层硬堆起来,模型很快会遇到两个非常现实的问题:

  1. 数值越来越不稳;
  2. 表达能力还不够完整。

第一个问题需要 LayerNorm 和残差来稳住。
第二个问题需要一个逐位置的前馈网络(FFN)来补足。

本章真正要做的,就是把前面已经学过的几种结构第一次装进一个标准的、能被重复堆叠的 block 里。

5.1 这一章为什么是“组装”,但又不是简单拼接

从表面看,这一章很像组装题。

你已经见过:

  • 张量和 softmax;
  • embedding;
  • attention;
  • 一些基础数值结构。

那么把它们装进一个 block,似乎只是把几段 API 连起来而已。

但真正的难点恰恰在于:
这些模块不只是要能连起来,还要按正确的顺序连起来,并且在数值上保持稳定。

Transformer block 的价值,不在于它发明了新的基本算子,而在于它找到了一个非常高效的组织方式,让:

  • 每个位置既能读上下文;
  • 又能保留原始信息;
  • 还可以通过逐位置 MLP 做更强的非线性变换;
  • 最后还能稳定堆很多层。

所以这一章真正要建立的,是“结构编排能力”。这会是你后面读完整 GPT 模型时最重要的前置直觉。

5.2 本章你要建立哪些判断

完成本章后,你应当能够:

  1. 用自己的话口述 Pre-LN block 的主数据流:h = h + Attn(LN(h)),再 h = h + FFN(LN(h))
  2. 明白 LayerNorm 解决的是哪类数值问题,以及它为什么总是和残差一起出现。
  3. 明白 FFN 不是“attention 的重复”,而是给每个位置增加更强的逐位置非线性变换能力。
  4. 知道为什么 block 不是“attention 后直接结束”,而必须把这几种结构串在一起。
  5. 在本章实验代码中完成:
    • student_layernorm
    • student_residual_add
    • student_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_layernorm
  • student_residual_add
  • student_block_forward

这三个函数覆盖的正是 block 最核心的三层组织逻辑:

  1. 先把输入归一化;
  2. 再把子层输出加回原输入;
  3. 最后按 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.cverify.c

进入:

cd course/practice/labs/lab05-step4

建议先读:

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

你会看到这一章的验证器分别检查:

  1. student_layernorm 输出均值是否接近 0;
  2. student_layernorm 输出方差是否接近 1;
  3. student_residual_add 是否真的把 a += b 做对;
  4. student_block_forward 是否保持 shape;
  5. 输出是不是全零;
  6. 输出里是否含 NaN 或 Inf。

这说明本章验证器既看结构,也看数值状态。它不是只问“函数调没调用”,而是在问“你装出来的 block 是否真的保留了最基本的数值健康性”。

task 5.2:实现 student_layernorm

这一章最适合作为第一步的,仍然是最小功能单元。

你要做的是对一维向量:

  1. 计算均值;
  2. 计算方差;
  3. 再按 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 思考题

  1. 为什么 attention 之后还需要 FFN?如果没有 FFN,block 的表达能力会缺什么?
  2. 为什么残差连接和 LayerNorm 往往一起出现?它们分别在稳定性上承担什么角色?
  3. 本章的 Pre-LN 结构和上一章 attention 的三段式拆分相比,体现了哪种“先理解局部,再理解组合”的学习顺序?
  4. 如果把 student_block_forward 看成一个“更大粒度的前向传播模板”,你觉得下一章完整 GPT 模型会在这个模板外面再包哪几层结构?

5.11 本章小结

这一章最关键的收获,不是“会写一个 block 函数”,而是第一次看清 Transformer block 为什么是一种稳定、可堆叠的模块单位。

attention 解决的是上下文读取;FFN 解决的是逐位置非线性加工;LayerNorm 和残差则保证这些子层组合后仍然足够稳定。
当这几种结构按正确顺序组织在一起时,模型才真正拥有了反复堆叠的基本积木。

下一章,课程会把“单个 block”进一步提升成“完整 GPT 模型”:embedding 在前,若干 block 在中间,最后再接上输出头和保存加载逻辑。

继续阅读:Chapter 6
对应实践:Lab06

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 课程第一次真正跨过了“局部模块练习”。

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

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

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

Chapter 7 — step6 训练

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

上一章你已经把完整 GPT 模型装起来了。
这意味着模型终于有了一个清晰的前向路径,也能被保存和重新加载。

但现在还有一个更关键的问题没有解决:
模型虽然会吐 logits,但它还不会学习。

这就是训练章存在的原因。

从工程角度看,训练并不神秘。它无非是在做一个固定闭环:

  1. 前向得到 logits;
  2. 根据正确答案计算损失;
  3. 从损失对 logits 的误差反推梯度;
  4. 用优化器拿梯度去更新参数。

真正的困难不在于这四步的口号你会不会背,而在于:
你能不能把这四步拆成最小可验证的数学动作。

Lab07 的实践方式非常克制。它没有要求你一口气写完整训练框架,而是只把三件最关键的局部能力留给你:

  • 交叉熵损失;
  • softmax + cross-entropy 的梯度;
  • Adam 对单个参数向量的一次更新。

这三件事一旦真正写明白,后面的完整训练循环就不再像魔法。

7.1 为什么训练章必须这样拆

如果课程在这里直接让学员读完整的 train.cbackward.coptimizer.c,绝大多数初学者会被三件事同时压住:

  1. 张量很多;
  2. 中间状态很多;
  3. 几乎每一步看起来都像“又一个新公式”。

所以最务实的教学策略,不是先把系统复杂度全部摊给学员,而是先抓住训练里最核心、最不可绕过的三段数学关系:

  1. 损失到底在度量什么;
  2. logits 的梯度到底长什么样;
  3. 参数更新到底怎样发生。

这就是 Lab07 的切法。它不是“缩减版训练”,而是把真正最核心的三颗齿轮先拆出来给你看。

7.2 本章你要建立哪些判断

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

  1. 为什么交叉熵损失在模型很确定时接近 0,而在瞎猜时接近 log(vocab_size)
  2. 为什么 softmax 和 cross-entropy 放在一起时,梯度能简化成 softmax - one_hot
  3. 为什么梯度在目标位置应该是负数,而在非目标位置应该是正数。
  4. Adam 为什么同时维护一阶矩 m 和二阶矩 v
  5. 为什么学习率过大时,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_loss
  • student_softmax_ce_grad
  • student_adam_step

这个切法很合理,因为它刚好对应训练闭环中的三个核心接口:

  1. 用损失把“预测”和“正确答案”接上;
  2. 用梯度把“损失”传回 logits;
  3. 用优化器把“梯度”变成参数更新。

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 变成 NaNinf

所以这一章最应该建立的工程意识是:

训练失败不一定是逻辑错,也可能是数值状态已经失控。

这也是为什么 Chapter 7 的讲义一定要把“loss 为什么会发散”和“梯度裁剪为什么是保命装置”专门讲出来,而不是只在优化器参数表里随手带过。

7.8 本章实践步骤

task 7.1:先读 student.cverify.c

进入:

cd course/practice/labs/lab07-step6

建议先读:

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

当前验证器主要检查:

  1. 均匀 logits 时,交叉熵是否接近 log(vocab_size)
  2. 正确目标 logit 明显更大时,loss 是否显著下降;
  3. 梯度方向和大小是否符合预期;
  4. 梯度的符号和和是否满足预期;
  5. Adam 一步后,mv、参数值是否真的变化。

这些检查非常有价值,因为它们都对应训练直觉里最关键的局部现象。

task 7.2:实现 student_cross_entropy_loss

这一步建议你先从“单个位置的 loss”想起,再推广到整个序列平均。

重点不是把公式背出来,而是清楚:

  1. 为什么要先做数值稳定的 log_sum_exp
  2. 为什么最终要对 seq_len 取平均。

前者解决数值问题,后者解决不同序列长度下的可比性问题。

task 7.3:实现 student_softmax_ce_grad

这一步最适合边写边对照“性质”。

你可以一边实现,一边想这几个问题:

  • softmax 后概率和是不是 1;
  • target 位置减 1 之后为什么会变成负数;
  • 非 target 位置为什么仍然是正数;
  • 整个向量为什么总和接近 0。

如果这些性质你在脑子里是清楚的,代码就不容易写偏。

task 7.4:实现 student_adam_step

这一题最好先把循环外和循环内的责任分开。

循环外先算:

  • 1 - beta1^t
  • 1 - beta2^t

循环内再按每个参数维度依次更新:

  1. m
  2. v
  3. 偏差校正后的 m_hat
  4. 偏差校正后的 v_hat
  5. 参数本身

这能让代码更清楚,也更符合 Adam 的数学结构。

task 7.5:运行当前真实基线

在还没补完三个函数前,先执行:

make clean && make test

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

  • 3 通过,11 失败

已经通过的部分主要来自:

  • 某些“空实现也不会崩”的边界检查;
  • 一些结构性返回值检查。

但真正和训练数学直接相关的部分,目前大多还失败。这正说明:

  1. 框架已经可以正常运行;
  2. 训练核心逻辑仍然留给学员完成。

task 7.6:完成后重新验证

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

make clean && make test

如果实现正确,你应当看到:

  • 交叉熵数值是否落在预期范围内;
  • logits 梯度满足符号和总和条件;
  • Adam 真的改变了 mv 和参数。

这一章一旦跑通,课程就真正跨进“模型会学习”的阶段了。

7.9 常见错误与排查顺序

最常见的错误通常是:

  1. cross_entropy_loss 忘了做数值稳定版 log_sum_exp
  2. softmax_ce_grad 忘了归一化或忘了在 target 位置减 1;
  3. Adam 偏差校正写错;
  4. sqrt 用成双精度版本或参数顺序写反;
  5. weight_decay 和主更新顺序混乱。

建议排查顺序是:

  1. 先看 loss 数量级对不对;
  2. 再看梯度符号和总和;
  3. 最后再看 Adam 状态更新。

因为如果前两层已经错了,优化器层面的任何现象都不再可信。

7.10 思考题

  1. 为什么均匀分布下的交叉熵大约是 log(vocab_size)
  2. 为什么 softmax + cross-entropy 的梯度总和接近 0?
  3. 为什么学习率过大时,训练更容易出现 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 作为下一个输出。

这就是生成章的核心。

本章不会再引入新的大模型结构。它要处理的是另一类非常工程化、但又直接决定模型表现的问题:

  1. 是永远选分数最高的那个 token,还是允许一点随机性?
  2. 如果允许随机性,随机性的范围该怎样控制?
  3. 如果词表很大,是否应该只在最有希望的少数候选里抽样?

这些问题的答案,最终都会落到三个需要你实现的函数上:

  • student_sample_greedy
  • student_sample_temperature
  • student_sample_top_k

这三种策略加在一起,就构成了后面所有生成行为的基本决策层。

8.1 为什么生成章不是“附属技巧”

很多初学者会把采样策略误看成模型主体之外的一点“调味料”,好像只是在前向后面多接几行代码。
但从用户实际看到的效果来说,采样几乎直接决定了模型表现的性格。

同样一组 logits:

  • 用 greedy,模型会更稳定,也更容易复读;
  • 用高温度采样,模型会更发散,也更容易出怪话;
  • 用 top-k,模型会只在高分候选里活动,表现更受约束。

所以这一章不是在教一个外围技巧,而是在教:

模型内部的概率分布,怎样被转化成外部可见的文本行为。

8.2 本章你要建立哪些判断

这一章结束后,你应当能够清楚说出:

  1. greedy 为什么本质上就是 argmax
  2. 温度为什么等价于“先把 logits 除以一个系数,再做 softmax”;
  3. 为什么温度越小,分布越尖锐;温度越大,分布越平坦;
  4. top-k 为什么能够砍掉大量低概率尾部候选;
  5. 为什么“未训练模型生成乱码”是预期,而不是 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_greedy
  • student_sample_temperature
  • student_sample_top_k

它们的关系不是三道孤立小题,而是一层层往上搭:

  1. greedy 是最简单的基线;
  2. temperature 是在完整词表上按概率抽样;
  3. 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 的思路就是先粗暴地做一层裁剪:

  1. 只保留 logits 最高的前 k 个;
  2. 其余候选全部视作不可选;
  3. 再在剩下的这些候选里做 temperature 抽样。

它的作用不是让模型“更聪明”,而是让生成空间更受约束、更不容易掉进词表尾部噪声。

8.7 为什么“模型学得差”和“采样策略差”要分开看

本章一个非常重要的课程目标,是让学员别把所有生成问题都归因给模型本身。

如果模型没训练好,那么:

  • 不管你怎么调采样,输出都可能很差。

但反过来,如果模型其实已经学到一点东西,而采样策略选得极端,例如:

  • 温度过高;
  • top-k 太大;
  • 没有任何约束;

那么输出同样可能显得混乱。

所以从 Chapter 8 开始,课程要学员建立一个新的调试分层:

  1. 先问 logits 有没有学到东西;
  2. 再问采样策略是不是把好分布抽坏了。

8.8 本章实践步骤

task 8.1:先读 student.cverify.c

进入:

cd course/practice/labs/lab08-step7

建议先读:

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

当前验证器会分别检查:

  1. greedy 是否真的选出最大位置;
  2. 温度采样在低温和高温下的统计行为是否合理;
  3. top-k 是否真的只在前 k 个候选里抽样;
  4. 一些边界条件,例如 temperature <= 0k <= 0k >= vocab_size 时怎样退化。

这说明本章不是只看单次输出,而是在看抽样行为是不是符合概率直觉。

task 8.2:实现 student_sample_greedy

这一题建议完全手写一次 argmax

重点不是代码长短,而是形成习惯:
采样函数不应该偷偷改输入 logits,本质上它只是读 logits,然后做决策。

task 8.3:实现 student_sample_temperature

这一题的关键步骤有三个:

  1. 按温度缩放 logits;
  2. 做 softmax;
  3. 按累积分布抽样。

建议你在写的时候不断提醒自己:
temperature 的真正作用发生在 softmax 之前,而不是 softmax 之后。

task 8.4:实现 student_sample_top_k

这一题的重点不是排序技巧,而是清楚:

  1. 哪些候选应该保留;
  2. 哪些候选应该彻底屏蔽;
  3. 筛完以后,仍然要回到 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 常见错误与排查顺序

最常见的错误通常是:

  1. greedy 写成了取最小值或平局规则错误;
  2. temperature 忘了除温度,或者温度为 0 时没有退化到 greedy;
  3. softmax 概率没正确归一化;
  4. top-k 没有真正屏蔽词表尾部候选;
  5. 抽样函数修改了输入 logits。

建议排查顺序是:

  1. 先看 greedy;
  2. 再看 temperature 分布;
  3. 最后看 top-k 的筛选范围。

因为 top-k 本质上依赖前面两层直觉。

8.10 思考题

  1. 为什么温度趋近于 0 时,采样行为越来越像 greedy?
  2. 为什么温度升高时,低分 token 会获得更多机会?
  3. 为什么 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。

但“会接龙”和“会聊天”仍然是两回事。

聊天之所以比单轮生成更复杂,不是因为模型突然多会了一种数学,而是因为交互场景多了两层结构约束:

  1. 模型必须分清楚谁在说话;
  2. 模型必须从历史中提取出“现在该接哪一句”的位置。

这两件事看起来像产品逻辑,实际上会直接影响输入给模型的原始字符串长什么样。
也就是说,多轮对话的核心并不是先去改模型,而是先把“对话历史如何被组织成 prompt”这件事讲清楚。

Lab09 也正是这样切的。它没有一上来就让学员处理完整对话状态机,而是只把两个最关键、最不可绕过的字符串操作留出来:

  • student_format_prompt
  • student_extract_response

这两个函数看似简单,但它们正好定义了多轮 chat 系统里“输入怎样拼”和“输出怎样截”的边界。

9.1 为什么多轮对话首先是一个格式问题

很多初学者在第一次接触 chat 系统时,会下意识把重点放在“模型要更聪明”。
但从工程实现角度看,第一件更基本的事其实是:

你到底怎样把多轮消息表示成一段模型可读的字符串?

因为 decoder-only 模型天生并不认识“消息对象”“角色字段”“历史列表”这些抽象概念。
它只认识 token 序列。

这意味着,所有多轮对话能力在进入模型之前,都要先被压平到一种统一模板里,例如:

<|user|>
你好
<|assistant|>
你好,请问有什么可以帮助你?
<|user|>
今天天气怎么样?
<|assistant|>

这套模板不是装饰,而是“谁在说什么、现在轮到谁继续说”的唯一信号。

9.2 本章你要建立哪些判断

这一章结束后,你应当能够说清楚:

  1. 为什么 prompt 末尾必须额外追加一个 <|assistant|> 标签。
  2. 为什么历史消息必须带 role,而不能只把内容简单拼在一起。
  3. 为什么提取回复时要找“最后一个” <|assistant|>,而不是第一个。
  4. 为什么下一条 <|user|><|assistant|><|system|><|end|> 会自然成为回复截断边界。
  5. 为什么多轮 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_prompt
  • student_extract_response

这两个函数对应的正是多轮对话中最本质的两个边界动作:

  1. 把消息对象压平成模型可读 prompt;
  2. 把模型原始输出再还原成“真正算助手回复的那一段”。

课程这里的切法很克制,也很正确。因为如果这两个动作都还没有讲清楚,继续往上堆 chat loop 或服务化接口,只会放大混乱。

9.4 为什么角色标签是对话模板的骨架

如果你只是把多轮内容简单拼起来,例如:

你好
你好,请问有什么可以帮助你?
今天天气怎么样?

那么模型虽然能看到历史文本,但它并不知道:

  • 哪一句是用户说的;
  • 哪一句是助手已经回答过的;
  • 现在轮到谁继续说。

角色标签的作用,就是把这个结构显式写进输入串里。

所以 <|user|><|assistant|><|system|> 这类标签不是花哨包装,而是整个多轮 prompt 的语法骨架。
没有它们,历史内容虽然还在,但“对话结构”已经丢了。

9.5 为什么 prompt 末尾一定要再放一个 <|assistant|>

这一点特别值得讲清楚,因为它决定了模型是继续说“用户的话”,还是开始说“助手的话”。

当你把历史拼完之后,如果最后只是停在用户内容后面,例如:

<|user|>
今天天气怎么样?

那么模型只看到了历史,却没有一个明确的“现在轮到谁接”的起始标记。

而如果你在末尾补上:

<|assistant|>

语义就清楚了:

上面是历史;从这里开始,模型应该扮演 assistant 来续写。

所以 student_format_prompt 这一题的关键,不是机械地 strcat 几次,而是要真正明白:
末尾那个标签其实是在指定“生成起点的角色”。

9.6 为什么提取回复时要找“最后一个” <|assistant|>

模型输出原始字符串时,并不保证只出现一次 <|assistant|>
尤其是在生成行为不稳定时,它完全可能把前面的模板片段也模仿出来。

如果你总是从第一个 <|assistant|> 开始截,那么一旦输出里前面混进了旧标签,你就会把大量无关历史也错误地当成新回复的一部分。

所以更稳的做法是:

  1. 找最右边、也就是最后一次出现的 <|assistant|>
  2. 从它后面开始取内容;
  3. 一旦遇到下一个角色标签或 <|end|>,立即截断。

这套逻辑背后的直觉其实很简单:

我们要的是“最近一次 assistant 开始说话后,到下一个结构边界之前”的那一段。

9.7 为什么这一章故意只做字符串,不碰模型

你会注意到 Lab09 的实践并不加载真实模型,也不要求你去改生成函数。
这不是因为对话和模型无关,而是因为这章真正的教学重点是协议边界

如果你在这一章同时处理:

  • prompt 模板;
  • 多轮历史;
  • 模型推理;
  • 采样;
  • 回复截断;

那学员很难知道问题到底出在哪一层。

所以课程把它压缩成两个字符串函数,是在刻意降低排查复杂度。
这能让学员先把“多轮对话的输入输出协议”建立清楚,再去接下一章的服务化接口。

9.8 本章实践步骤

task 9.1:先读 student.cverify.c

进入:

cd course/practice/labs/lab09-step8

建议先读:

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

当前验证器会检查:

  1. 单轮 prompt 中 user 和 assistant 标签数量是否合理;
  2. 多轮 prompt 中内容和末尾标签是否完整;
  3. 没有 assistant 标签时,是否会退化成整段 trim;
  4. 有多个 assistant 标签时,是否取最后一个;
  5. 遇到下一条 user 消息时,是否正确截断。

也就是说,这章的测试都不是在问“你会不会聊天”,而是在问“你有没有把 chat 的字符串边界写对”。

task 9.2:实现 student_format_prompt

这一题最值得注意的,不是拼接 API 选哪一个,而是格式语义。

你要保证每条消息都是:

<tag>
content

并且在所有历史消息结束后,再额外追加一行:

<|assistant|>

只有这样,模型才知道接下来该由助手继续说。

task 9.3:实现 student_extract_response

这一步的思路应该非常稳定:

  1. 找最后一个 <|assistant|>
  2. 从它后面开始看;
  3. 一旦碰到下一条角色标签或 <|end|>,立刻停;
  4. 最后 trim 两端空白。

如果你在实现时始终围绕这个结构边界去想,代码就不容易写偏。

task 9.4:运行当前真实基线

在还没补完两个函数前,先执行:

make clean && make test

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

  • 0 通过,6 失败

这组结果非常干净,说明当前实验代码里的两个核心函数都还是空的。
这其实很适合教学,因为它把“这个 lab 的责任边界”体现得很明确:
只要你把这两个函数补对,测试就会整体翻绿。

task 9.5:完成后重新验证

当你补完两个函数后,再运行:

make clean && make test

如果实现正确,你应该看到:

  • prompt 里的标签数量、内容顺序和末尾 assistant 起点都正确;
  • 回复提取能够在多种边界条件下稳定工作。

到这里,多轮对话最核心的模板层就真正建立起来了。

9.9 常见错误与排查顺序

最常见的错误一般是:

  1. 忘了在 prompt 末尾补 <|assistant|>
  2. role 对应标签映射错;
  3. 提取回复时找了第一个 assistant,而不是最后一个;
  4. 遇到下一个角色标签时没有截断;
  5. 没有做 trim,导致结果前后多出换行或空格。

建议排查顺序是:

  1. 先看 prompt 格式;
  2. 再看 assistant 起点;
  3. 最后看回复截断逻辑。

因为提取逻辑本质上依赖前面模板结构是否清晰。

9.10 思考题

  1. 为什么 prompt 末尾一定要落在 <|assistant|>,而不是 <|user|> 或纯空字符串?
  2. 为什么提取回复时要找最后一个 <|assistant|>
  3. 如果把多轮消息只按内容拼起来,不带任何标签,会丢掉哪类信息?

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 已经连上,客户端也不知道你在说什么。

所以本章故意把实践缩到两道协议题里,是为了让学员先掌握:

  1. 请求帧的最小结构;
  2. 响应帧的最小结构。

10.2 本章你要建立哪些判断

本章结束后,你应当能够清楚说出:

  1. 一个 HTTP 请求行为什么通常长成 METHOD PATH HTTP/1.1
  2. 为什么 headers 和 body 之间必须有一个空行。
  3. Content-Length 为什么是必须的,而不是“可选信息”。
  4. 为什么响应行的 \r\n 不是普通换行细节,而是协议要求。
  5. 为什么 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_line
  • student_http_build_response

这个切法非常合理,因为它刚好对应 HTTP 协议里最基础的两个动作:

  1. 从客户端来的第一行里拆出“请求意图”;
  2. 把服务端结果重新打包成客户端能理解的响应文本。

10.4 为什么请求行解析是“结构恢复”

请求行看起来只是一行字符串,例如:

GET /health HTTP/1.1

但对服务器来说,它不是普通文本,而是一段被压平的结构数据。
解析请求行做的事情,本质上是在把这段压平字符串重新恢复成三个字段:

  • method
  • path
  • version

这跟前一章“从模型原始输出里截出 assistant 回复”的思路其实非常像。
两者都是在做一件事:从一段线性文本里恢复协议结构。

10.5 为什么响应构建是“主动声明边界”

响应构建和请求解析正好反过来。

这里服务器不是在恢复结构,而是在主动声明结构。
你要明确告诉客户端:

  1. 这是 HTTP/1.1 响应;
  2. 状态码是多少;
  3. 内容类型是什么;
  4. body 有多长;
  5. headers 到哪里结束,body 从哪里开始。

这里最不能忽略的就是 Content-Length
因为 HTTP 是字节流协议,客户端如果不知道 body 的长度,就无法稳妥判断响应到底什么时候结束。

所以本章最应该建立的不是“能拼出一串文本”,而是:

一次合法响应,必须把自己的边界写清楚。

10.6 为什么课程现在只做协议骨架,不让你写完整服务

如果在这一章同时让学员:

  • 管 socket;
  • 管请求解析;
  • 管 JSON;
  • 管模型调用;
  • 管并发或阻塞行为;

那教学负担会一下子跳得非常高。

课程当前的选择更务实:
先把“协议骨架”单独拿出来练。

因为只要请求行解析和响应组装是清楚的,后面的完整服务化路径就已经有了坚实基础。
反过来,如果这两层还混乱,直接跑完整 server 只会让错误来源更加难找。

10.7 本章实践步骤

task 10.1:先读 student.cverify.c

进入:

cd course/practice/labs/lab10-step9

建议先读:

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

当前验证器主要检查:

  1. GET /health HTTP/1.1 能否被正确拆成三段;
  2. POST /v1/chat/completions HTTP/1.1 能否被正确拆成三段;
  3. 非法请求行是否会被拒绝;
  4. 200 OK 响应是否包含正确的 header 与 body;
  5. 404 Not Found 响应是否仍然是合法 HTTP 帧。

task 10.2:实现 student_http_parse_request_line

这一步建议你始终记住目标不是“做复杂解析器”,而是从最小协议格式里拿出三段字段。

实现时最值得关注的是:

  1. 找两个空格;
  2. 段长不要越界;
  3. 任何结构异常都应该返回失败,而不是悄悄截断。

这是一个很典型的协议解析习惯:
宁可明确报错,也不要默默吞掉坏输入。

task 10.3:实现 student_http_build_response

这一题最值得在脑子里保持一张“响应骨架图”:

status line
headers
空行
body

只要这张骨架没有乱,你再逐项补:

  • Content-Type
  • Content-Length
  • Connection: 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 常见错误与排查顺序

最常见的错误通常是:

  1. 请求行没有正确按两个空格切开;
  2. 缓冲区长度检查不严;
  3. 响应行用了 \n 而不是 \r\n
  4. Content-Length 算错;
  5. headers 和 body 之间忘了空行。

建议排查顺序是:

  1. 先看请求行三段是否拆对;
  2. 再看响应状态行;
  3. 最后看 header/body 边界是否正确。

10.9 思考题

  1. 为什么 Content-Length 对 HTTP/1.1 响应如此关键?
  2. 为什么协议里要求 \r\n,而不是普通的 \n
  3. 为什么说 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_alloc
  • student_kv_cache_append
  • student_kv_cache_len

也就是说,这章真正要学的第一步不是“我已经能写高性能推理”,而是:

我先得清楚 cache 在结构上到底是什么,它保存什么,它怎样被追加和读取。

11.1 为什么 KV cache 解决的是“重复计算”

在自回归生成里,第 t 次生成时,模型已经知道前面 0..t-1 个位置的历史。
如果没有缓存,模型通常会把整段历史从头到尾再跑一次 attention。

但注意力里有一类量有一个非常关键的性质:

  • 历史位置的 K 和 V 一旦算出来,在后续新 token 到来时并不会改变。

这意味着,真正每一步都必须重新算的,主要是新位置相关的那一小部分。
而历史 K/V 完全可以缓存下来复用。

所以 KV cache 的核心价值并不是“变了一个更聪明的公式”,而是:

把本来会重复做的历史计算结果留下来,下次别再重算。

11.2 本章你要建立哪些判断

这一章结束后,你应当能够清楚说出:

  1. 为什么 K 和 V 可以缓存,而 Q 不适合用同样方式处理。
  2. KV cache 的容量为什么与 num_layersmax_seq_lenhidden_dim 成正比。
  3. 为什么 append 一个新位置,本质上是在每层的 cache 里写入一行 K 和一行 V。
  4. 为什么 current_len 是 cache 状态的一部分,而不是临时变量。
  5. 为什么 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_alloc
  • student_kv_cache_append
  • student_kv_cache_len

课程这里故意没有要求你直接碰到底层 stride 或 attention 核心公式,而是先把 cache 生命周期最关键的三个接口做清楚:

  1. 先能建出来;
  2. 再能往里写;
  3. 最后能知道自己写到了哪里。

11.4 为什么 cache 首先是一个数据布局问题

很多人第一次接触 KV cache 时,会直接想到“推理加速”。
这当然没错,但如果只盯住速度,往往反而看不清它在代码里到底长什么样。

从数据结构角度看,KV cache 最核心的事情其实很朴素:

  1. 对每一层,都留一块能按序列位置存 K 的区域;
  2. 再留一块同样按序列位置存 V 的区域;
  3. 记录当前已经写到了第几个位置。

这说明 KV cache 不是一个抽象“开关”,而是一套明确的内存布局。

11.5 为什么 append 这一题有教学价值

student_kv_cache_append 看起来可能只是包一层调用,代码并不长。
但它的教学价值很高,因为它让学员亲手经历一个非常重要的推理流程:

  1. 新 token 来了;
  2. 当前层的新 K/V 算出来了;
  3. 要把它们放进 cache 的哪一层、哪一个位置。

只要这件事在脑子里清楚了,后面去理解 prefill、decode、cache 命中这些概念时,就会容易很多。

11.6 为什么 current_len 不是可有可无

cache 如果只是有一大块内存,却不知道已经写到了哪里,那它就没有办法安全参与下一轮生成。
所以 current_len 不是装饰字段,而是 cache 状态的一部分。

它回答的是:

现在这个 cache 里,前多少个位置已经是有效历史。

这也是为什么 student_kv_cache_append 不只是写数据,还要记得同步更新长度。

11.7 本章实践步骤

task 11.1:先读 student.cverify.c

进入:

cd course/practice/labs/lab11-step10

建议先读:

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

当前验证器会检查:

  1. cache 是否能被成功创建;
  2. cache 占用字节数是否符合公式;
  3. append 进去的 K/V 能否从对应位置正确读回;
  4. current_len 是否和追加次数一致。

这说明本章虽然没有直接测完整推理速度,但它已经抓住了 KV cache 最重要的结构行为。

task 11.2:实现 student_kv_cache_alloc

这一题最重要的不是代码量,而是理解:

  1. cache 不是普通临时数组;
  2. 它和模型配置直接相关;
  3. 一旦创建失败,就不应该继续假装后面的状态存在。

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 失败

这说明:

  1. 框架和链接已经可以跑起来;
  2. 但 cache 的最小生命周期接口还没有真正实现。

这是一种很好的教学起点,因为当前失败都集中在本章自己的实现范围里,没有混进过多外围噪声。

task 11.6:完成后重新验证

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

make clean && make test

如果实现正确,你应该看到:

  • cache 创建成功;
  • 容量公式匹配;
  • append 后读回数据正确;
  • current_len 正确推进。

到这里,KV cache 在课程里的第一层结构直觉就建立起来了。

11.8 常见错误与排查顺序

最常见的错误通常是:

  1. 创建 cache 时参数没传对;
  2. append 时没有做空指针保护;
  3. 追加成功后忘了更新长度;
  4. 写入了错误的层或错误的位置。

建议排查顺序是:

  1. 先看 alloc 是否成功;
  2. 再看 memory size 是否符合公式;
  3. 再看 append 后的具体读回值;
  4. 最后看 current_len

11.9 思考题

  1. 为什么 K 和 V 适合缓存,而 Q 不适合照搬同样模式?
  2. 为什么说 KV cache 是典型的“空间换时间”?
  3. 如果 num_layersmax_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_pairs
  • student_bpe_apply_merge

这意味着本章真正要你掌握的是 BPE 的核心循环直觉,而不是记住一整个工程实现的所有细节。

12.1 为什么字符级表示会变得不够用

字符级 tokenizer 的最大优点是稳:
只要字符集定义好了,任何文本都能被拆开。

但它的最大问题也很明显:
很多本来可以作为整体被理解的常见片段,会被拆得过碎。

比如:

hello

在字符级里会被拆成 5 个 token。
而对于一个已经在语料中反复出现过的常见词,这种拆法会让模型承担额外的序列长度和学习负担。

BPE 的出发点正是:

如果一对相邻 token 总是一起出现,那不如把它们合成一个新的、更大的 token。

12.2 本章你要建立哪些判断

这一章结束后,你应当能够清楚说出:

  1. BPE 的一个训练循环为什么可以概括成“统计相邻 pair -> 合并最常见 pair”。
  2. 为什么只有频次足够高的 pair 才值得合并。
  3. 为什么一次合并会让序列长度变短,而词表大小变大。
  4. 为什么 BPE 既能压缩常见词,又仍然保留退回字符级的能力。
  5. 为什么 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_pairs
  • student_bpe_apply_merge

这个切法非常好,因为它恰好把 BPE 的训练循环拆成了最核心的两半:

  1. 先看当前序列里哪一对最常一起出现;
  2. 再把这一对在序列里替换成新的 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 序列,因此更准确地说,是在做:

  1. 从左到右扫描;
  2. 一旦看到 (first, second) 这个相邻 pair;
  3. 就写入一个新的 new_id,并跳过下一个位置。

这里最值得建立的直觉是:

一次 merge 会把两个旧 token 折叠成一个新 token,所以序列长度只会变短或不变,不会变长。

这也是 BPE 能压缩输入长度的直接原因。

12.7 为什么本章故意不用完整系统来起步

如果一上来就让学员去读完整 bpe_tokenizer.c,大多数人会先被各种工程细节包住:

  • 词表存储;
  • merges 列表;
  • encode / decode 路径;
  • 文件保存加载。

这些当然都是真实系统的一部分,但它们不适合做第一观察点。

当前课程更务实的策略是:
先把“统计 pair”和“应用 merge”这两个核心动作亲手写出来。

这样你后面再看完整 BPE 系统时,就会知道哪些代码是在服务这两个动作,哪些只是外围管理。

12.8 本章实践步骤

task 12.1:先读 student.cverify.c

进入:

cd course/practice/labs/lab12-step11

建议先读:

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

当前验证器会检查:

  1. ababab 里能否找到最高频 pair;
  2. 没有重复 pair 时,是否正确返回 0;
  3. (a, b) 合并成新 id 后,序列内容和长度是否正确;
  4. 在一段更长文本上,重复做合并时,序列是否真的变短。

这些检查很适合作为 BPE 入门,因为它们全都对准“局部统计”和“局部替换”这两个核心动作。

task 12.2:实现 student_bpe_count_pairs

这一题建议你不要一开始就追求复杂数据结构。
当前 lab 的输入规模完全允许你先用简单暴力的方式,把逻辑写清楚。

最重要的是建立顺序:

  1. 先枚举所有相邻 pair;
  2. 再统计每个 pair 的次数;
  3. 选出最高频那个;
  4. 如果频次不到 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 常见错误与排查顺序

最常见的错误一般是:

  1. pair 次序写反;
  2. 统计次数时漏掉某些位置;
  3. 合并时没有正确跳过已经消费的第二个 token;
  4. out_len 忘写;
  5. 没处理 len < 2 这种边界情况。

建议排查顺序是:

  1. 先看简单样例 ababab
  2. 再看“无重复 pair”边界;
  3. 最后看多轮合并长度变化。

12.10 思考题

  1. 为什么频次只有 1 的 pair 往往不值得合并?
  2. 为什么一次 merge 会让序列缩短、词表变大?
  3. 为什么 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 会读输入、打输出;

但这些知识如果没有重新装成一条完整数据流,就很容易仍然是散的。

所以课程最后一章最应该做的,不是再引入一个更大的新主题,而是把这些后半段组件重新接起来,让学员明确看到:

  1. 文本怎样进来;
  2. token 怎样流经模型;
  3. 采样怎样把模型输出变回 token;
  4. token 又怎样被解码成可读文本;
  5. 这一切怎样放进一个最小 REPL 循环里。

13.2 本章你要建立哪些判断

这一章结束后,你应当能够清楚说出:

  1. BPE 对话闭环里最小数据流是什么。
  2. 为什么输入侧要先编码,而输出侧要再解码。
  3. 为什么单轮 one_turn 和外层 chat_loop 最好分开实现。
  4. 为什么 cache 让“后续 token 只喂增量输入”成为可能。
  5. 为什么 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_tokenize
  • student_bpe_chat_decode
  • student_bpe_chat_one_turn
  • student_chat_loop

这个切法非常好,因为它刚好把最终整合路径拆成四层:

  1. 输入编码;
  2. 输出解码;
  3. 单轮生成;
  4. 外层交互循环。

也就是说,这章不是让你面对一个巨大函数,而是把完整路径重新拆回可以一段段观察的形态。

13.4 为什么编码和解码必须单独成为接口

在教学上,把编码和解码拆成独立函数有一个非常大的好处:
它把“文本世界”和“token 世界”的边界明确暴露出来了。

如果你把它们直接写死在单轮对话函数里,学员很容易只看到一条很长的流程,而看不清哪些动作发生在:

  • 模型之外;
  • 模型之内;
  • 模型输出之后。

单独拆成:

  • student_bpe_chat_tokenize
  • student_bpe_chat_decode

就能非常明确地告诉学员:

模型从不直接理解字符串,它只处理 token 序列;字符串和 token 之间的翻译必须明确存在。

13.5 为什么 one_turn 才是整合章节的真正核心

外层 REPL 当然重要,但本章真正的核心还是 student_bpe_chat_one_turn
因为它正好承接了前面所有后半段主题:

  1. 用 BPE 把用户输入变成 token;
  2. 用模型前向得到 logits;
  3. 用采样策略选出新 token;
  4. 利用 cache 继续增量生成;
  5. 再把输出 token 解码回文本。

如果你把这一条链真正写通了,那前面几章学过的内容才第一次全部进入同一个函数视野。

13.6 为什么外层 chat_loop 仍然值得单独写

有些人会觉得外层循环只是“读一行、打一句”,没有什么技术含量。
但课程把它单独拿出来,恰恰是为了让学员看到:系统整合并不总是出在最复杂数学上,有时也出在很朴素的交互边界上。

一个能工作的最小 REPL,至少要明确:

  1. 输入从哪里读;
  2. 什么时候退出;
  3. 回复由谁生成;
  4. 生成后的内存由谁释放。

这正是很多小系统第一次真正“像个系统”的地方。

13.7 为什么当前 Lab13 不是“训练型端到端”

这里需要把定位讲清楚,不然学员和助教很容易被目录名误导。

当前 lab13-end-to-end 的真实内容,并不是从语料重新开始训练一整条大流水。
它更准确地说,是:

  • 在已有 framework 能力之上,
  • 把 BPE 输入路径和简单对话回路整合起来。

所以如果你在这一章里期待看到完整训练 epoch、模型文件落盘和复训逻辑,那就会和实际 lab 内容错位。
课程文稿必须把这一点写清楚,避免学员带着错误预期进入最后一章。

13.8 本章实践步骤

task 13.1:先读 student.cverify.c

进入:

cd course/practice/labs/lab13-end-to-end

建议先读:

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

当前验证器会检查:

  1. 编码接口是否能返回非空 token 序列;
  2. 解码接口是否能返回非空字符串;
  3. 单轮生成是否至少能走完一轮;
  4. 外层 chat loop 是否能读入一轮再退出;
  5. 空指针防护是否存在。

也就是说,这章并不要求“生成内容很好”,而是优先要求“整条接口链能走通”。

task 13.2:实现 student_bpe_chat_tokenize

这一题最适合先做,因为它最短,而且能把输入边界先稳住。

重点是:

  1. 明确空指针守护;
  2. 明确是否加 BOS;
  3. 明确是否加 EOS。

这会直接决定后面模型前向看到的序列起点长什么样。

task 13.3:实现 student_bpe_chat_decode

这一步和编码刚好相反。
它的意义不仅是“把 token 变回字符串”,更是在提醒你:

生成结束时,模型世界必须重新回到用户世界。

task 13.4:实现 student_bpe_chat_one_turn

这是本章最重要的函数。

建议你在实现时始终围绕一条清晰的数据流思考:

  1. 先把输入编码;
  2. 再跑前向;
  3. 再从最后一行 logits 采样;
  4. 再把新 token 继续喂回模型;
  5. 最后解码输出。

如果你把它写成“很多杂乱的小步骤”,很容易丢失主线。
但如果你始终守住这条数据流,整个函数其实并不难理解。

task 13.5:实现 student_chat_loop

这一题的重点不是花哨界面,而是交互闭环:

  1. 打印欢迎语;
  2. 读一行;
  3. 判断 /quit
  4. one_turn
  5. 打印回复;
  6. 释放资源。

到这里,后半段所有组件才第一次真正进入一个面向用户的最小系统。

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 常见错误与排查顺序

最常见的错误一般是:

  1. 编码接口没正确处理 BOS/EOS;
  2. 解码接口在空输入上行为不明;
  3. one_turn 没有把最后一行 logits 正确拿出来;
  4. cache 增量使用方式不清楚;
  5. chat_loop 忘了在 EOF 或 /quit 时退出;
  6. 临时内存释放不完整。

建议排查顺序是:

  1. 先看 tokenize/decode;
  2. 再看 one_turn
  3. 最后看外层 loop。

因为外层 loop 的正确性依赖前面三个接口都已经稳住。

13.10 思考题

  1. 为什么当前一章最适合先追求“链路通”,而不是先追求“回复质量好”?
  2. 为什么 BPE 词表和模型权重必须配套使用?
  3. 为什么把 one_turnchat_loop 分开,会让整合更清晰?

13.11 本章小结

Chapter 13 的意义,不在于再引入一个更新的主题,而在于把后半段已经学过的部件真正重新装起来。

从这一章结束时开始,学员应该能明确看到一条完整路径:

  • 文本输入;
  • BPE 编码;
  • 模型前向;
  • 采样生成;
  • BPE 解码;
  • 外层对话循环。

这条路径一旦真正走通,课程才算把“讲明白”和“做出来”完整闭合。