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