Agentic 状态机

Douwe Osinga's Blog 2026-11-01T03:59:14.249646

本文同步自 goose 博客。

goose 从一团乱麻般的 agent 循环,走向了一组井然有序的状态机——而这些状态机,goose 很久以前就已经启动。Agent 领域的“狗年”概念完全比不了。goose 比通用 agentic AI 的出现还要早一些。

这一切最初的设想是:如果把 shell 会话的控制权交给 LLM,会发生什么?结果很有意思。模型写一条命令,命令执行,结果回到对话中,模型再决定下一步做什么。我们的工具调用能力正是从那次实验里自然生长出来的,如今大多数编码 agent 核心处的 agent 循环也是如此。

后来工具调用被正式规范化,MCP 也随之出现。审批、上下文管理、待办列表、置顶提醒、子 agent、重试、钩子、技能、配方和引导也陆续加入。但最初的 agent 循环没有变。每加一个功能,就多一个分支、多一个局部变量、多一个特殊处理。一个对话轮次里可能同时活跃着十几个不同的关注点,彼此互相影响。取消操作需要知道当前是在推理还是在跑工具。上下文压缩需要理解不同 provider 的错误和使用量。工具可以改变提示词。一个停止钩子可以把已经完成的回答重新变回更多工作。循环开始变成一团意大利面。

插件系统看起来是自然的答案。它确实有用,但也有上限:添加一个工具或监听一个事件很合适;可一旦插件需要影响整个轮次,中心循环仍然得决定它什么时候运行、它对提示词贡献什么、它是否可以继续这个轮次,以及它的状态如何在重启后存活。插件 API 逐渐为 agent 循环的每一个部分都长出了一个钩子,而循环仍然负责协调所有这些钩子。

goose 走的是另一条路。我们用可重入状态机(re-entrant state machine)取代了经典的智能体循环(agent loop)。持久化的对话就是主状态,智能体的每一段行为都被编码成一个 Operation。状态机按顺序检查这些 Operation,每个 Operation 要么放行,要么返回一组效果(effects),第一个生效的 Operation 赢下这一步。它的效果被持久化保存,状态机随后带着新状态从头再来一遍。

以一个普通的工具调用为例。推理环节发现最后一条消息来自用户,于是生成一条请求工具的助手消息。到了下一步,审批 Operation 获得第一个检查它的机会。如果需要审批,它就往对话里加一条请求,然后把控制权交还给客户端。用户做决定期间,没有任何东西在后台空转。客户端稍后把答复存下来,一个新建的状态机从那里接着往下走。如果不需要审批,这个 Operation 就放行。接着工具执行 Operation 认出这个工具并运行它,把结果加进对话。推理环节看到工具返回的内容,再问模型下一步做什么。当模型最终给出回答、不再请求新工具时,由停止钩子(stop-hook)Operation 决定这一轮是否可以结束。

这种可重入性(re-entrancy)的意义不止于代码组织。服务器不必为了等待输入而让一个协程及其所有局部变量一直活着。进程可以重启,然后从上一个持久化的步骤继续。另一个前端也能加载同一个会话接着跑。便利运行器(convenience runner)仍会反复执行步骤,直到状态机让出控制权,但它只是套在状态机外面的一个小驱动器,而状态机本身可以一次只调用一步。

各个 Operation 彼此独立,但协同工作。一个工具处理器只认领自己会执行的工具;遇到不认识的,它就放行,把机会留给别的 Operation。最后的未知工具 Operation 则把剩下没人处理的东西统统变成一条返回给模型的错误。这种归属关系不只体现在执行环节。技能(skills)Operation 统一管理列出技能的命令、加载技能的工具,以及告诉模型有哪些技能存在的提示词文本。一旦删掉这个 Operation,这三样东西就一起消失了。

推理虽然特殊,但本质上仍是一个 Operation(操作)。调用模型之前,它会从流水线中的每个 Operation 收集工具、提示词片段和当前关注点。max-turns Operation 可以告诉模型预算快用完了;recipe Operation 可以添加一个最终输出工具;skills Operation 可以公布可用的技能。推理本身不需要知道这些贡献来自哪里。

另一个有用的区分是效果(effects)与事件(events)。事件是实时流:一段文本、一条工具通知或一次用量更新,客户端应当立即看到。效果是持久结果:追加这条消息、替换这个会话、更新这个工具请求或记录这次用量。调用方可以向状态机请求执行一步,然后自行决定如何应用这些效果;也可以使用便捷运行器,持续执行步骤直到状态机让出控制权给客户端。

测试变得容易多了。我们用一个模拟模型提供方的小型 HTTP 服务器和一个真实的有状态计算器扩展,来测试完整的 Operation 集合。我们还有一个测试运行器:每执行完一步,就丢弃整个状态机,再从持久化的会话中重建一个新的。如果一次工具调用仍然能从请求走到响应再走到最终答案,就说明进度确实被状态捕获了,而不是藏在某个局部变量里。

并非所有状态都已消失。提供方、扩展管理器和钩子仍然是运行时依赖。待处理的引导指令仍然从内存队列开始,外部副作用也需要小心:如果工具执行成功,但进程在响应持久化之前崩溃了,重建状态机并不能神奇地让这个工具安全地重复执行。真正有用的变化是,这些边界现在变得可见了,而不是混在一个长期运行的协程里。

结果是代码库更容易测试、重构和实验。改变智能体的新想法可以表示为一个 Operation,加入流水线即可,无需改动状态机核心。最初的智能体循环让 goose 出乎意料地走了很远。现在,我们可以在不增加新分支的情况下改变智能体的行为。

查看原文