前段时间刷了 Claude Code 源码,感觉 Agent Loop 也就这样了,玩不出什么花了。
用户输入一句话,模型决定要不要调用 Tool;如果调用 Tool,就把结果放回 messages,再请求一次模型;模型不再调用 Tool 时,整个任务结束。
1 | while (true) { |
这套循环很朴素,但 Claude Code 已经在它上面补了非常多生产环境能力:权限确认、上下文压缩、输出截断恢复、模型 fallback、Hook、取消、远程会话、JSONL 持久化等等。
最近帮朋友做点 Agent 的活儿,引入了 Goose 作为基座,发现 Goose 的 Agent Loop 竟然是一个状态机。
Claude Code 已经证明,一个 while (true) 可以承载复杂的 Agent 行为。Goose 为什么还要换成状态机?
从一个场景看起:
用户没有立刻批准
假设用户让 Agent 做一件事:
1 | 删除测试环境中过期的用户数据。 |
模型理解任务后,返回:
1 | ToolRequest(delete_records) |
这个操作显然不能直接执行。系统需要向用户确认:
1 | 该操作将删除 1,284 条测试环境记录,是否继续? |
如果用户一分钟内点击「允许」,事情很简单。
1 | 模型请求工具 |
Claude Code 当前的设计很适合这种活跃交互。
Claude Code 中主流程由 queryLoop() 持续运行。模型返回 Tool Use 后,执行层会进入权限判断;最终,checkPermissionsAndCallTool() 会等待 canUseTool() 的结果。
canUseTool() 在交互模式下会创建一个 Promise:一边将待确认的 Tool 放进终端界面的确认队列,一边保留 Promise 的 resolve。此时执行链停在 await canUseTool(...);用户点击「允许」或「拒绝」后,界面调用对应的 resolve,Promise 才变为 allow 或 deny。
若结果是 allow,原来的调用链继续执行 tool.call();若结果是 deny,则生成一条拒绝的 Tool Result 并交回模型。整个过程中,queryLoop() 不会进入下一轮模型调用,它在等待本轮 Tool 调度完成。

也就是说,Claude Code 的控制流大致是:
1 | queryLoop 正在运行 |
这套机制在本地终端中很自然。只要 Claude Code 进程仍在运行、等待权限结果的调用链没有被取消,用户两小时后点击「允许」,原来的 Tool 调用依然可以继续。
这里有个细节:等待两个小时本身并不是问题。只要原进程、连接和调用链都还在,await canUseTool(...) 就可以一直等待;用户回来后调用 Promise 的 resolve,原来的 Tool 调用自然会继续。
真正的分界点在于,等待是否跨越当前执行现场。
最直观的情况是:用户关掉了终端,Claude Code 进程崩溃,或机器休眠导致当前连接和进程不再可靠。更复杂的产品中,审批可能由企业系统完成;任务也可能交给后台执行服务处理。
这里的「后台执行服务」是指从队列中取出任务、在后台运行的进程,它为了重启、迁移或扩缩容,未必会一直是最初处理任务的那个进程。Tool 也可能是异步任务,要等外部系统晚些时候回调。
问题在于,Promise 只是当前进程内存中的一个续执行点。进程结束后,Promise 与它的 resolve 都会消失;外部系统回传「审批单 123 已通过」时,也无法直接找到原来内存中的 Promise。让一个后台执行服务长时间挂着等待异步 Tool 的回调,通常也不经济。
这时,问题不再是「如何等待用户点击」,而是:
原来的 Agent 调用栈已经不存在时,系统如何知道这项 Tool 调用仍在等待审批?审批通过后,又该从哪里继续?
一旦不能依赖内存里的 Promise、局部变量和原来的调用栈,系统就需要把「正在等什么、等到后做什么」保存成可恢复的状态。
Goose 状态机真正想解决的,正是这种跨越执行生命周期的等待。
Claude Code 的 while 循环已经很成熟
在讨论 Goose 前,还是先把 Claude Code 说清楚。
Claude Code 并不是一个只有「模型 → Tool → 模型」的简单循环。
queryLoop() 内部维护了一个很完整的 state:
1 | { |
不需要逐个背字段。它们大致在做三件事:保留当前对话与 Tool 上下文;记录本轮已经走到哪里;以及在压缩、截断、重试时防止反复兜圈子。
这也正是它和最初那种几行 while 循环的差别:主循环除了「下一步调模型还是调工具」,还得同时带着这些临时状态往前走。
比如模型因为上下文过长而失败时,Claude Code 不会马上把错误展示给用户。它会尝试压缩消息,然后构造新的 state:
1 | state = { |
下一轮 while (true) 再带着压缩后的上下文请求模型。
输出截断时也是类似的处理:
1 | 模型输出被截断 |
所以 Claude Code 的 while 循环不是简单实现,而是一个围绕「本次活跃查询」不断推进的调度中心。
它很适合解决:
1 | 当前终端中的任务如何持续高质量地跑完? |
Goose 想解决的,则是另一个问题:
1 | 当前任务即使暂停、离开进程、等待外部事件, |
Goose 官方怎么解释这次改造
Goose 官方在 Unrolling the agent loop 中,对旧式 Agent Loop 的描述很直接:
1 | 一个持有大量本地状态的、单体的流式协程。 |
它提出将 Agent Loop 「展开」为一个可重复进入的状态机:
1 | 一次只推进一个 turn; |
官方给出的动机主要有三个:
1 | 1. 每个 turn 是普通函数调用,容易测试和推理; |
Goose 后续在 2026 Q3 Roadmap 中,也把它放进了「Goose 作为可嵌入 Agent 开发套件」的方向中:希望开发者能替换运行时组件、加入应用策略,并接入长任务或横向扩展的编排系统。
这里的重点不是「状态机比 while 高级」。
而是:当任务不再是一次短暂的终端交互时,不能把正确性建立在「那条异步调用链还活着」上。
最小 Demo:不记住状态,只读取历史
先写一个最小 Demo。
假设模型总是想查询销售数据,查询前必须得到用户确认:
1 | function nextStep(history) { |
这里的 return 不是直接执行工具,也不是直接结束整个任务。它只是返回「下一步要做什么」:普通消息要写入历史,execute_tool 则应交给工具执行器。实际运行器负责执行这些动作,并在下一次根据更新后的历史调用 nextStep():
1 | async function executeTool(effect) { |
将上面的 nextStep()、executeTool() 和 advance() 放进同一个文件后,下面的 runDemo() 就可以直接运行完整流程:
1 | async function runDemo() { |
最终 history 的状态变化是:
1 | user |
这个 Demo 当然也有 history 变量;为了便于运行,它暂时只是一个数组。关键不在于「完全不用变量」,而在于状态不依赖这些只能存在于当前调用栈中的临时标志位:
1 | let pendingTool = null |
真实系统会将 history 写入 Session、数据库或日志;进程重启后,可以重新加载它。这样就没有一个必须常驻内存的标志位变量,也没有一个必须从原位置恢复的调用栈。
换句话说,运行时的 history 变量只是持久化历史在内存中的当前表示。任务状态可以从这份历史重新推导:
1 | 是否已经请求工具? |
然后根据这些事实,推导下一步。
这就是 Goose 设计里最重要的一句话:
Conversation 不只是模型的上下文,也是 Agent 的运行状态。
Goose 的源码如何实现
Goose 当前状态机的入口在:
1 | crates/goose/src/agents/agent.rs |
create_state_machine() 会组装一组有序的 Operation:
1 | 开始 |
create_state_machine() 的源码核心并不复杂:它把这些 Operation 按顺序装进 steps,再把模型推理放到最后。重点不在记住每一个类名,而在这条顺序是程序固定下来的:审批在工具执行前,模型推理在最后。

这里的每一个步骤都不直接说「继续跑主循环」。
它们只做两件事:
1 | 1. 根据当前 Session 与 Conversation,判断自己是否适用; |
这些 Effects 由 SessionManager 统一应用:
1 | AppendMessage → 追加并持久化消息 |
状态机运行器自己仍然有一个很小的循环:
1 | loop { |
所以 Goose 并没有消灭 while。
它只是让 while 不再承载业务状态。
1 | Claude Code 的 while: |
复杂逻辑被拆到了各自的 Operation 里。
再回到审批场景
现在把前面的删除操作放回两种架构里看。
Claude Code
1 | queryLoop 正在运行 |
它的优势是直接、顺滑,尤其适合本地终端中的即时交互。
Goose
1 | Conversation 出现 ToolRequest(delete_records) |

它的优势是,任务可以不依赖原来的进程。
这里的「新的状态机运行」不是指原来的 while 循环挂着一小时后再继续。写入 ActionRequired 后,当前运行会在没有可继续处理的步骤时结束,或将控制权交回客户端;没有新事件时,不存在一个空转的循环。
一小时后,审批来自网页、Slack 或内部系统时,系统先将 ToolConfirmationResponse 写入持久化 Conversation,再用同一个 Session 发起新的运行。新的运行加载这份更新后的历史,看到「该 ToolRequest 已批准但尚未执行」,才继续审批标记、工具执行与模型推理。
因此,状态机的基本模型不是「一直 while 等待外部事件」,而是「外部事件到达后,基于新的 Conversation 再推进若干步」。
为什么这对复杂 Agent 有价值
如果只是在本地写代码,很多时候 Claude Code 的方式已经足够好。
但 Agent 一旦开始承担更长、更异步、更受约束的任务,就会遇到一些新需求:
1 | - 审批来自外部系统 |
状态机的好处会慢慢显现出来。
1. 企业策略有了明确插入点
例如某个组织规定:
1 | 生产环境操作必须关联变更单。 |
在单体循环里,通常会变成 Tool 执行前的一段额外判断。
在 Goose 的模型里,可以更明确地表达为:
1 | ToolRequest |
策略的位置、输入与输出都清楚。
2. 某个状态转换可以单独测试
例如要测试:
1 | 用户拒绝 delete_records 后,工具绝不能执行。 |
在完整 queryLoop 中,通常需要模拟模型输出、权限界面、Tool 调度和消息流。
在 Goose 的模型中,可以直接准备:
1 | ToolRequest(delete_records) |
然后只验证审批 Operation 是否将请求标记为不可执行。
3. 可以接入外部编排系统
当一个 turn 是短暂、可持久化的状态转换时,任务可以更自然地接到:
1 | 消息队列 |
Goose 官方也明确将事件驱动、长任务和横向扩展视为这种设计的目标之一。
总
Claude Code 的 queryLoop() 很值得学习。
它展示了一条成熟 Agent 主循环如何处理流式模型调用、工具执行、权限、压缩、错误恢复和会话续写。
Goose 的状态机也很值得学习。
它关注的是另一层问题:当 Agent 不再是一条持续运行的调用链,而是一个会暂停、恢复、等待外部事件、迁移和扩展的任务时,系统应该如何继续运行。
所以两者的差异不是:
1 | Claude Code 用 while; |
更准确的说法是:
1 | Claude Code: |
回到最开始那项迟到的审批。
如果用户马上点击「允许」,两种设计的体验差别不大。
只有当等待跨越当前的进程、连接或执行现场时,状态机的价值才真正出现。