对应实践:
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 服务,对外暴露给其它程序调用。