# 12. 番外篇：拆开 default_chatter 的 enhanced 状态机，理解“标准上下文”怎么流动

> **导读** 本章是一篇番外，专门拆解 `default_chatter` 的 `enhanced` 状态机。系统维护这套四相位控制流——`WAIT_USER`、`MODEL_TURN`、`TOOL_EXEC`、`FOLLOW_UP`——并不是为了增加复杂度，而是为了保证每一轮多步对话在上下文结构上始终合法。理解这套状态机，是理解系统如何在真实运行时推进对话的关键。

前面几章我们一直在教你怎么写插件、怎么接 Prompt、怎么接 LLM、怎么把 Tool 放进调用链。

但如果你开始往真实运行时多看两眼，很快就会遇到一个问题：

> **为什么同样是“发请求给模型”，default_chatter 这一层看起来像在维护一套很严格的状态机？**

答案很简单。

因为到了真实对话循环里，系统已经不能只关心“这次要不要发请求”，还得关心：

- 当前上下文是不是合法。
- 现在能不能插入新的 USER 消息。
- 模型刚刚返回的是普通文本，还是 tool calls。
- tool result 追加完之后，能不能直接接新用户输入。

也就是说，`default_chatter` 维护这套状态机，不是为了把代码写得更像“框架”，而是为了保证一轮多步对话在结构上始终成立。

这一章是个番外篇，只做一件事：

> **拆开 `default_chatter` 的 `enhanced` 流程，让你真正看懂“标准上下文”在运行时是怎么被一段一段推出来的。**

这里先提前说明边界：

- 只讲 `enhanced`
- 当前仅保留这一套状态机执行路径
- 不把默认聊天插件的所有业务细节都拖进来
- 重点只放在状态流转和上下文结构

## 12.1 先记住一句话：它不是“循环发请求”，而是在维护一条合法上下文链

如果你只从表面看 `default_chatter`，很容易把它理解成：

1. 取消息
2. 发 LLM
3. 执行工具
4. 再发 LLM
5. 等下一轮

这个理解不算错，但还不够。

更准确地说，它真正维护的是这样一条链：

```text
SYSTEM / TOOL（固定能力层）
USER
ASSISTANT(tool_calls 或普通文本)
TOOL_RESULT
ASSISTANT(follow-up)
USER
...
```

这里最关键的一点是：

> **它维护的不是“函数调用顺序”，而是“上下文角色顺序”。**

一旦角色顺序乱了，后面的模型请求就不是“不太优雅”那么简单，而是会直接变成不合法上下文。

## 12.2 什么叫“标准上下文”

在这一套实现里，所谓“标准上下文”，可以先理解成一套被严格约束的对话消息结构。

它的核心约束不是“有哪些内容”，而是“这些内容按什么 role 顺序出现”。

当前底层上下文管理器关心的主链大致是：

```text
USER -> ASSISTANT(tool_calls) -> TOOL_RESULT -> ASSISTANT -> USER
```

这里有两个很容易忽略的点。

第一，`SYSTEM` 和 `TOOL` 虽然也会出现在 payload 里，但它们更像固定层：

- `SYSTEM` 负责提供系统提示词
- `TOOL` 负责把可用工具 schema 暴露给模型

它们不会被拿来判断“当前对话链闭没闭合”。

第二，真正需要严格闭合的是对话链本身：

- 如果 `ASSISTANT` 里带了 `tool_calls`
- 那后面就必须补齐对应的 `TOOL_RESULT`
- 补完 `TOOL_RESULT` 之后，不能直接接下一条 `USER`
- 必须先有一条新的 `ASSISTANT` 来承接

这就是为什么 `default_chatter` 不能随便“想加一条 USER 就加一条 USER”。

## 12.3 enhanced 模式其实是一个很克制的四相位 FSM

如果把业务细节剥掉，`enhanced` 模式其实只有四个相位：

```text
WAIT_USER
-> MODEL_TURN
-> TOOL_EXEC
-> FOLLOW_UP
-> WAIT_USER
```

看起来像很多层，实际上非常克制。

你可以把它先粗暴理解成：

- `WAIT_USER`：还没准备好发请求，只能等新消息
- `MODEL_TURN`：准备好把当前 USER 输入交给模型
- `TOOL_EXEC`：模型已经回了 tool calls，现在轮到系统执行
- `FOLLOW_UP`：tool result 已经写回去，轮到模型补完真正回复

真正重要的是：

> **这四个相位把“什么时候允许写 USER，什么时候允许 send，什么时候只能写 TOOL_RESULT”分开了。**

这正是状态机存在的意义。

## 12.4 第一相位：WAIT_USER，不只是“等消息”，而是在守住 USER 的入口

很多人看到 `WAIT_USER`，第一反应是“这不就是空转等待吗”。

其实不是。

它更像一个闸门。

这个相位里，系统会先拉取当前未读消息，然后做两件事：

1. 判断现在是不是可以接新的用户输入
2. 决定这批未读消息要不要真的进入本轮上下文

这里有个很关键的保护：

如果当前上下文尾部还是 `TOOL_RESULT`，那就说明上一轮工具链还没被 assistant follow-up 收口。这个时候即使有新未读消息，也不能直接塞新的 `USER` 进去，而是必须先回到 `FOLLOW_UP`。

这件事非常重要，因为它防止了这种非法结构：

```text
ASSISTANT(tool_calls)
TOOL_RESULT
USER
```

这条链缺了一段 assistant 承接，所以不能成立。

也就是说，`WAIT_USER` 的价值不只是“等”，而是在守住：

> **只有当当前对话链已经收口，系统才允许新的 USER 进入上下文。**

## 12.5 未读消息不是立刻变成 USER，而是先过一层“要不要响应”

`enhanced` 模式还有一个很容易被忽略，但其实很贴近真实产品的动作：

> **拿到未读消息后，不会立刻把它们都塞进上下文，而是先做一次 sub-agent 判定。**

这个判定的目标不是生成回复，而是做一个更轻量的选择：

- 这批消息值不值得当前 bot 响应
- 如果不值得，直接继续等待

所以流程不是：

```text
unreads -> 一定发给主模型
```

而是：

```text
unreads -> sub-agent 判定 -> 再决定是否进入主对话链
```

这一步的好处很直接：

- 降低无意义主模型调用
- 避免机器人对所有消息都过度反应
- 把“是否回应”与“如何回应”拆成两层

## 12.6 真正进入上下文之前，会先把历史和未读整理成一段标准 USER 文本

如果 sub-agent 判断应该响应，系统接下来不会把“未读消息对象列表”直接扔进 LLM。

它会先构建一段格式化后的 USER 文本，大致由三块组成：

- 历史消息
- 新收到的未读消息
- 额外约束信息

这一层非常像你前面几章自己在插件里做的 prompt build，只不过 `default_chatter` 把它做成了标准形态。

也就是说，在 `enhanced` 里，模型真正看到的 USER 内容，并不是零散对象，而是一段已经整理好的、适合推理的文本上下文。

你可以把它理解成：

> **业务消息先被翻译成 prompt 友好的 USER 片段，再进入对话链。**

## 12.7 为什么这里叫“upsert pending unread payload”

这个命名其实挺传神。

当系统确认这批未读消息应该进入上下文时，它不是无脑 append 一个新 USER payload，而是会先看当前尾部是不是已经有 USER：

- 如果最后一个 payload 本来就是 USER，就把文本合进去
- 如果没有，就新建一个 USER payload

这背后的思路很实用：

> **在真正发请求前，USER 输入可以继续合并整理；但一旦进入模型轮次，结构就不能乱动了。**

所以这一步本质上是在做“发送前整理”，不是在做“自由追加对话历史”。

## 12.8 第二相位：MODEL_TURN，真正把这轮 USER 输入交给模型

到了 `MODEL_TURN`，事情反而简单了。

这时候系统已经准备好了：

- system prompt
- 历史与未读拼出来的 USER 内容
- 注入进去的可用工具

然后才会执行真正的：

```text
send()
```

这里有个很值得注意的点：

当前响应在被 `await` 完成后，会自动把模型输出追加回上下文。

所以如果模型这次返回的是：

- 一段普通文本
- 或一组 `tool_calls`

它都会先落成一条新的 `ASSISTANT` payload。

这一步特别重要，因为后面的 `TOOL_EXEC` 根本不是凭空开始的，它正是建立在：

> **上下文里已经有了一条 assistant 响应，而且这条响应里可能包含 tool calls。**

## 12.9 第三相位：TOOL_EXEC，这一相位不负责发新 USER，它只负责把工具链补齐

这是最容易误解的一段。

很多人第一次读代码，会以为“既然都到工具阶段了，那系统现在应该继续组织下一轮 prompt”。

不是。

`TOOL_EXEC` 相位非常克制，它只做三类事：

1. 检查这一轮 assistant 到底有没有返回 `call_list`
2. 按顺序处理每个 tool call
3. 把 `ToolResult` 写回当前响应上下文

也就是说，在这个阶段，系统不是在开启新一轮用户对话，而是在补齐上一条 assistant(tool_calls) 后面的那半截结构。

如果把这一段压成最短主线，就是：

```text
ASSISTANT(tool_calls)
-> 执行工具
-> TOOL_RESULT
```

所以这里的关键词不是“再次请求模型”，而是“先把工具结果落回去”。

## 12.10 去重为什么会出现在 TOOL_EXEC，而不是更早

这一点如果不专门讲，读者很容易觉得去重像个边角补丁。

其实不是。

去重恰恰应该出现在 `TOOL_EXEC`，因为只有到这里，系统才真正拿到了：

- tool call 的名字
- 具体参数
- 它属于当前轮还是跨轮 follow-up

也只有这时候，系统才能判断：

- 这是同一轮里重复的调用
- 还是跨轮又发了一遍同样的调用

如果更早做，你手里还没有完整 call 信息；如果更晚做，工具可能已经执行过了。

所以它放在这里，不是偶然，而是最合适。

## 12.11 为什么 action-only 的一轮，还要补一个 suspend assistant

这是整个状态机里最像“小机关”的地方，但它其实是在修一个结构问题。

设想一种情况：

- 这轮 assistant 返回的全是 action 调用
- 系统把它们都执行了
- 这些 action 又不需要继续 follow-up 推理

如果此时什么都不补，当前上下文尾部就可能停在一个对后续不友好的位置。

所以实现里会在“本轮全是 action 调用”时，补一个特殊的 assistant 占位文本，也就是所谓的 suspend payload。

你可以把它理解成：

> **这不是为了给用户看，而是为了让上下文结构保持一个可继续运行的收尾形状。**

这类设计很能体现状态机思维：

- 它不一定改变业务结果
- 但它会显著影响上下文是否合法、下一轮能不能稳定继续

## 12.12 第四相位：FOLLOW_UP，不是“再问一次”，而是让 assistant 把链闭合

只要这一轮工具执行后产生了真正需要推理吸收的 `ToolResult`，系统就会进入 `FOLLOW_UP`。

这个相位最重要的意义不是“多调用一次模型”，而是：

> **让 assistant 基于刚刚写回的 ToolResult，把这一轮对话真正收口。**

也就是说，`FOLLOW_UP` 的位置其实是：

```text
ASSISTANT(tool_calls)
-> TOOL_RESULT
-> ASSISTANT(follow-up)
```

这一步完成之后，整条工具调用链才算真正闭合。闭合之后，状态机才会放心地回到 `WAIT_USER`，允许下一批用户消息进入。

如果你把这一层省掉，就会很容易出现这样的问题：

- 工具结果已经写回去了
- 但 assistant 还没真正用这个结果形成完整答复
- 下一条 USER 却已经想插进来

这就是状态机要避免的事情。

## 12.13 所以 enhanced 的核心，不是“多轮”，而是“闭环”

讲到这里，你再回头看 `enhanced`，会发现它最核心的价值其实不是“支持多轮推理”，而是：

> **它把一轮对话拆成了几个必须按顺序闭合的小阶段。**

这几个阶段一旦闭合，标准上下文就稳定；只要有一个阶段没闭合，新的 USER 就不该进来。

所以它真正维护的是：

- USER 什么时候进入
- ASSISTANT 什么时候产出 tool calls
- TOOL_RESULT 什么时候写回
- ASSISTANT 什么时候补完 follow-up

而不是“框架作者心情好所以搞了个状态机”。

## 12.14 把“标准上下文”压缩成一张脑内图

如果你希望以后再看 `default_chatter` 时不容易迷路，可以把这套东西压缩成下面这张脑内图：

```text
WAIT_USER
  读取 unreads
  -> sub-agent 判断要不要响应
  -> 组装 USER 文本
  -> 写入待发送上下文

MODEL_TURN
  send()
  -> 生成 ASSISTANT

TOOL_EXEC
  读取 call_list
  -> 执行工具
  -> 写回 TOOL_RESULT
  -> action-only 时补 SUSPEND

FOLLOW_UP
  再次 send()
  -> 生成 ASSISTANT(follow-up)
  -> 回到 WAIT_USER
```

只要这张图在你脑子里是通的，你以后再去看更细的实现，就不会只看到一堆 if/while，而会知道每一段其实都在维护哪一截上下文结构。

## 12.15 对插件作者来说，这个番外最值得带走什么

如果你不是要改 `default_chatter` 本身，而只是想更稳地写插件，那这一章最值得带走的其实只有三件事：

1. `ToolResult` 不是随便追加的，它是在补齐 `assistant(tool_calls)` 后面的结构。
2. follow-up 不是“可有可无的再问一次”，而是很多工具链闭合所必须的 assistant 承接。
3. 新的 USER 不能随便插进一个还没闭合的上下文尾部。

你只要把这三件事记住，以后无论是自己写 Tool、接 Agent，还是调试上下文异常，都会更容易判断问题到底出在哪一段。

## 12.16 把这一章压缩成一句话

如果要把这篇番外压缩成一句最值得带走的话，那就是：

> **`default_chatter` 的 `enhanced` 模式本质上不是在“反复调用模型”，而是在用一个四相位状态机，严格维护一条合法闭合的标准上下文链。**

理解了这一点，后面你再看工具调用、follow-up、去重和上下文校验，就不会觉得它们是分散的小机制，而会知道它们其实都在服务同一件事：

> **让对话结构始终成立。**