附录 A2:常见陷阱与调试

附录:按需查阅,不必从头读

如果说 A1 解决的是“我缺什么基础工具”,那 A2 解决的就是另一类更常见的现场问题:你已经知道这章要做什么,也已经开始写代码了,但终端里出现的现象和讲义里承诺的不一样。

对项目课而言,这种“不一样”最容易让人慌。因为初学者往往分不清自己看到的是:

  • 代码逻辑还没写完导致的正常失败;
  • 练习框架自身的初始基线;
  • 编译、路径、缓存、模型产物等工程问题;
  • 真正的实现错误。

这份附录的任务,就是把这些情况拆开。它不试图代替每章的“常见错误”小节,而是给你一张更全的排错地图:当你面对一个症状时,先判断它属于课程推进的哪一阶段,再看这类阶段最常出现的坑。

A2.0 先建立排错顺序

在看具体条目之前,先记住一个简单但非常重要的顺序。很多人排错越来越乱,不是因为不会调试,而是因为顺序反了。

遇到问题时,优先按下面四步来:

  1. 先确认你在哪个目录。
    很多错误不是实现错了,而是你根本没在当前 lab 里跑命令。
  2. 再确认你跑的是不是课程要求的标准命令。
    当前主线 lab 的默认验证入口是 make clean && make test
  3. 再判断这是“实验初始失败”还是“你引入的新失败”。
    每个 lab 的 TASK.md 都写了初始基线,先对照那个看。
  4. 最后才进入代码级调试。
    也就是 printf、GDB、valgrind、ASAN 这些手段。

只要这四步顺序不乱,大多数问题都能很快缩小范围。

为了让你更快定位,本附录按课程推进阶段组织,而不是简单按“错误类型”平铺。因为同样一句 Segmentation fault,出现在 Chapter 1 和出现在 Chapter 12,语境完全不同。

A2.1 Chapter 0 到 Lab01 入口阶段

这一阶段的问题,通常不是模型逻辑问题,而是课程入口、目录和工具链还没对上。也就是说,你还没真正进入“做题”,先不要上来就怀疑 student.c

A2.1.1 course/practice/ 目录是空的 / bootstrap-practice.sh 不存在

  • 症状:你已经 clone 了课程仓库,但 course/practice/ 下面是空的,或者只有一个 gitlink;运行 bash course/practice/scripts/bootstrap-practice.sh 直接报 No such file or directory
  • 原因:这通常不是课程损坏,而是仓库内容不完整、目录没切对,或者你拿到的工作区本身就不是课程默认状态。
  • 解决

    cd /path/to/miniLLM
    ls course/practice
    bash course/practice/scripts/bootstrap-practice.sh
    

    如果 course/practice/ 本身就不存在或明显不完整,先重新确认你拿到的是完整仓库,再继续执行后面的步骤。

这里真正要记住的不是某一条 Git 命令,而是一个判断:目录缺失时,先确认工作区是否完整,不要先猜课程脚本写错了。

A2.1.2 gcc / make 找不到

  • 症状:一跑 bootstrap 脚本就看到 gcc: command not foundmake: command not found
  • 原因:当前机器还没有主线课程默认假设的 C 工具链。
  • 解决:回到 A1.1 环境与版本最低要求 补工具链,再重新跑 bootstrap。

这类错误本质上不是“lab 失败”,而是“还没进入 lab”。不要在 practice 目录里继续硬试其它命令。

A2.1.3 Lab01 一上来就是 3 FAIL + 1 PASS

  • 症状:你第一次跑 lab01-step0,发现并不是全通过,而是大概 3 FAIL + 1 PASS
  • 原因:这是当前课程里的正常起点。Lab01 的 smoke test 不是为了证明你已经完成 Chapter 1,而是为了证明当前 lab 能编译、能运行、能把“未完成的 TODO”以失败形式显式暴露出来。
  • 解决:不要把它当成环境错误。继续去读 Chapter 1lab01-step0/TASK.md,开始补 student.c 里的最初几个函数。

这条很重要,因为初学者很容易把“初始失败”误读成“课程坏了”。而这门课恰恰是故意把失败暴露出来,作为正式学习的起点。

A2.2 Chapter 1 到 Chapter 5:基础算子与模型骨架阶段

这一阶段学员最常遇到的不是大模型特有问题,而是更底层的工程错误:头文件没找到、维度对不上、softmax 结果爆掉、注意力配置不合法。好消息是,这些错误都比较局部,一旦学会定位,后面很多章节都能复用同样的方法。

A2.2.1 Makefile 找不到头文件

  • 症状fatal error: tensor.h: No such file or directory,或者其它类似的 xxx.h 找不到。
  • 原因:最常见情况是你没进具体的 stepN/labXX-stepY/ 目录,就直接在别处敲了 make。另一种情况是 Makefile 的 include 路径没带上。
  • 解决

    pwd
    make clean && make test
    

    如果路径不对,先 cd 到正确 lab。
    如果路径已经对了,还报错,再去看 Makefile 里是否包含正确的 -I 选项。

这类问题表面看像“编译器抱怨”,实质上通常只是工作目录错了。

A2.2.2 hidden_dim 不能被 num_heads 整除

  • 症状:看到类似 hidden_dim % num_heads == 0 的断言失败,或者 attention 阶段莫名其妙段错误。
  • 原因:多头注意力需要把隐藏维度平均切给每个 head。如果总维度不能整除 head 数,后续切分一定会失去边界。
  • 解决:同步检查配置里的两个量,只接受下面这种关系:

    head_dim = hidden_dim / num_heads
    hidden_dim % num_heads == 0
    

如果你是在本课程默认配置下遇到这个问题,优先怀疑配置被改动过;如果是你自己改实验参数,就先回纸上算一遍,不要让代码替你做整数除法猜测。

A2.2.3 矩阵维度不匹配

  • 症状A.n == B.m 一类断言失败,或 reshape 前后元素数对不上。
  • 原因:你对张量形状的心理模型和代码里的真实布局不一致。最常见的是把 (seq_len, hidden_dim)(hidden_dim, seq_len) 混了,或者忘了某一步实际上还需要转置。
  • 解决:不要立刻埋头改公式,先把维度打印出来:

    fprintf(stderr, "Q=[%d,%d] K=[%d,%d]\n",
            Q.shape[0], Q.shape[1], K.shape[0], K.shape[1]);
    

很多维度 bug 一旦打印出形状,就已经不是抽象数学问题,而是非常具体的“第 0 维和第 1 维被我想反了”。

A2.2.4 softmax 输出全是 nan

  • 症状:loss 一开始就是 nan,或者注意力权重打印出来全是 nan
  • 原因:通常是数值稳定性没守住。最常见的三种情况:
    1. 忘了按 sqrt(head_dim) 缩放;
    2. 忘了先减去最大值;
    3. LayerNorm 的分母保护不够,出现了除零。
  • 解决:回到 Chapter 1 和 Chapter 4 的稳定实现,逐项确认:

    float scale = 1.0f / sqrtf((float)head_dim);
    

    和:

    scores[i] = expf(scores[i] - maxv);
    

当你看到 nan 时,先别怀疑训练器。数值代码里,nan 往往早在更前面就已经产生了。

A2.3 Chapter 6 到 Chapter 8:完整模型、训练与生成阶段

从这一阶段开始,错误不再只停留在单个算子层面。你会第一次面对“单个函数都没明显错,但整个系统行为不对”的情况。最常见表现是:模型能跑,但 loss 不降;能生成,但只会吐乱码;能算,但缓存和梯度流没有真正连起来。

A2.3.1 model.bin / model_bpe.bin 找不到

  • 症状:运行生成或对话命令时,直接报 No such file or directory: model.binmodel_bpe.bin
  • 原因:通常只有三种可能:
    1. 你还没完成训练或导出;
    2. 产物文件在当前目录之外;
    3. 你从错误目录启动了程序。
  • 解决

    pwd
    ls model.bin
    ls model_bpe.bin
    

    先确认文件在不在当前目录,再确认课程这一章是否本来就要求你先训练再生成。

这里最容易犯的错,是把“运行命令成功启动”误认为“模型产物已经准备好了”。对生成阶段来说,权重文件本身就是输入的一部分。

A2.3.2 未训练模型生成乱码

  • 症状:模型一开始生成的是乱码、重复 token、空字符或明显无语义内容。
  • 原因:这往往不是 bug,而是未训练权重的正常行为。随机初始化模型的 softmax 输出接近均匀分布,采样出来的东西当然像噪声。
  • 解决:先对照本章讲义问自己一个问题:我现在看到的,是“课程要求我先观察未训练行为”,还是“我已经完成训练后仍然乱码”?

    如果还没训练,这个现象本来就合理。
    如果已经训练过,再去看 loss 是否真实下降,或者模型文件是否确实被更新了。

这一条经常能避免无效排错。因为不是所有“不像人话”的输出都意味着实现错了。

A2.3.3 Loss 不下降

  • 症状:训练跑了很多步,loss 几乎不动,停在一个高位附近。
  • 原因:最典型的几个根因是:
    1. 学习率太小;
    2. 标签没有做 GPT 需要的 shift;
    3. 某层梯度根本没回传;
    4. 数据本身过于单一。
  • 解决:不要同时改十个地方。按下面顺序排:

    1. 临时把 learning_rate 调大一点,看 loss 是否对梯度敏感;
    2. 打印一个 batch,确认标签相对输入做了对齐偏移;
    3. 在关键层反向传播后看梯度是否全 0;
    4. 检查训练文本本身是不是几乎没信息量。

训练阶段最忌讳的就是“loss 不降,于是到处乱改”。先确认优化链路有没有活着,再讨论模型质量。

A2.3.4 KV Cache 长度超界

  • 症状:多轮生成或对话时,跑着跑着崩掉,或者看到 seq_len > max_seq_len 一类错误。
  • 原因:KV cache 通常在初始化时按固定最大长度分配;如果上下文不断增长,又没有裁剪或扩容,就会越界。
  • 解决

    1. 临时减小对话历史长度,确认问题就是“长度累积过多”;
    2. 从配置里调大 max_seq_len,重新编译;
    3. 在写 cache 之前显式检查边界,让错误更早、更清楚地暴露。

这类问题的本质不是 attention 算错了,而是系统级资源边界没有提前说清楚。

A2.4 Chapter 9 到 Chapter 13:对话、HTTP、BPE 与整合阶段

后半程的错误更容易带有“系统表象”,也就是说,程序看起来已经不是一个单纯的数学实验,而是一个在处理字符串、协议、端口、文件、子词词表的完整程序。这时排错方法也要随之切换:你不再只看张量,还要看文本边界、网络入口和持久化产物。

A2.4.1 HTTP 端口被占用

  • 症状:服务启动时报 Address already in use,或 curl 请求始终连不上。
  • 原因:端口已被旧进程或其它服务占用。
  • 解决

    lsof -i :8080
    ss -tlnp | grep 8080
    kill <PID>
    

    或者临时换一个端口继续验证。

这里别急着看 HTTP 解析函数。端口冲突是操作系统资源问题,不是协议逻辑问题。

A2.4.2 BPE 训练数据太小

  • 症状:训练出来的词表几乎没长大,或者反复合并相同的微小模式,效果很差。
  • 原因:BPE 从语料统计中学习高频合并。如果输入文本太短、太单一,它根本没有足够的统计信号。
  • 解决:优先增加训练文本长度,而不是一上来调小目标词表或怀疑代码框架。

对 BPE 来说,数据规模本身就是算法输入的一部分。语料太小,不是“代码性能差”,而是任务设定根本不够支撑学习。

A2.4.3 ./*_bpe 输出混乱或和预期不符

  • 症状:BPE 相关命令能跑,但输出和 TASK.md 里的基线差很远,或者字符边界看起来被切坏。
  • 原因:可能是词表文件不匹配、训练产物没更新、编码假设混了,也可能只是还在看“未完成状态”的正常失败。
  • 解决

    1. 先确认词表和模型文件是不是本章最新产物;
    2. 再对照 TASK.md 里的当前实验基线和完成后目标输出;
    3. 如果差异仍然异常,再逐步检查 merge 逻辑和字符串拼接。

后半程排错的关键,是不要只看最终字符串,要把它拆回“词表是否对、模型是否对、当前测试阶段是否对”三件事。

A2.5 跨阶段通用问题

有些坑几乎会贯穿全课程。它们不属于某一个特定 chapter,但一旦遇到,排错方式比较稳定。

A2.5.1 valgrind: command not found

  • 症状make memcheck 一开始就找不到 valgrind
  • 原因:本机没装,或者当前平台不支持。
  • 解决:回到 A1.6;如果平台不适合 valgrind,直接用 ASAN 方案,不必强行折腾。

A2.5.2 definitely lost: N bytes

  • 症状:valgrind 结尾显示有确定泄漏。
  • 原因:申请了内存但没有在所有出口路径上释放。
  • 解决:从分配点追所有权,确认函数退出前是否配对释放。必要时缩小到最小复现函数,而不是在完整程序里盲看。

A2.5.3 Invalid read / use-after-free

  • 症状:valgrind 报非法读,或提示某地址属于已经释放的块。
  • 原因:你释放后还在用,或者某个结构体仍保留旧指针。
  • 解决:画生命周期图。malloc、借用、转移、free 各画一条线,任何交叉都值得怀疑。释放后立刻置 NULL,能减少二次伤害。

A2.5.4 double free or corruption

  • 症状:程序在释放阶段崩,提示二次释放或堆损坏。
  • 原因:同一块资源被两个位置都当成“自己负责释放”。
  • 解决:回到所有权约定,明确只有一个角色负责真正 free。别试图靠“碰巧只 free 一次”糊过去。

A2.5.5 Makefile 没把新 .c 编进去

  • 症状:函数已经写了,但链接时报 undefined reference
  • 原因:对应源文件没进 SRCS
  • 解决:先看 Makefile,不要先怀疑编译器:

    grep -n '^SRCS' Makefile
    

课程里很多 lab 的边界都靠 Makefile 明确控制。实现写好了但目标文件没参与构建,是很典型的新手问题。

A2.6 当你完全不知道问题属于哪一类

如果前面的症状都没命中,说明你现在最需要的不是更多经验条目,而是一套能快速收缩范围的方法。下面这组动作比继续翻长文更有效:

  1. 最小复现
    把问题缩到一个最短输入、一个最小函数调用。能在 20 行里复现的 bug,不要留在整套模型里找。
  2. 补观察点
    在关键边界打印形状、长度、状态码、返回值,不要只打印“到这里了”。
  3. 二分问题位置
    从流程中间插断言或打印,看前半段已经错了,还是后半段才错。
  4. 回看最近改动
    对 C 项目来说,最后改过的那几处代码,命中率通常极高。

你真正要避免的,不是“查附录”,而是下面这种状态:

既没有先确认目录和命令,也没有先对照当前 lab 的基线,就直接怀疑整个课程内容有问题。

项目课的调试,很多时候不是更聪明,而是更有秩序。

下一份文档:A3 参考资料与延伸阅读