Skip to content

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

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

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

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

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

答案很简单。

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

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

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

这一章是个番外篇,只做一件事:

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

这里先提前说明边界:

  • 只讲 enhanced
  • 不展开 classical
  • 不把默认聊天插件的所有业务细节都拖进来
  • 重点只放在状态流转和上下文结构

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

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

第一,SYSTEMTOOL 虽然也会出现在 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_chatterenhanced 模式本质上不是在“反复调用模型”,而是在用一个四相位状态机,严格维护一条合法闭合的标准上下文链。

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

让对话结构始终成立。

贡献者

The avatar of contributor named as minecraft1024a minecraft1024a
The avatar of contributor named as Windpicker-owo Windpicker-owo

页面历史

Released under the GPL-3.0 License.

新对话
MoFox 助手

下午好。

今天想做点什么?

聊天内容可能会被记录以用于改进服务及其质量,并会遵循我们的隐私政策进行处理。