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 解码;
  • 外层对话循环。

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