前段时间刷了 Claude Code 源码,感觉 Agent Loop 也就这样了,玩不出什么花了。
用户输入一句话,模型决定要不要调用 Tool;如果调用 Tool,就把结果放回 messages,再请求一次模型;模型不再调用 Tool 时,整个任务结束。
javascript
while (true) {
const assistantMessages = await callModel(messages)
const toolUses = findToolUses(assistantMessages)
if (toolUses.length === 0) {
return
}
const toolResults = await runTools(toolUses)
messages = [
...messages,
...assistantMessages,
...toolResults,
]
}
这套循环很朴素,但 Claude Code 已经在它上面补了非常多生产环境能力:权限确认、上下文压缩、输出截断恢复、模型 fallback、Hook、取消、远程会话、JSONL 持久化等等。
最近帮朋友做点 Agent 的活儿,引入了 Goose 作为基座,发现 Goose 的 Agent Loop 竟然是一个状态机。
Claude Code 已经证明,一个 while (true) 可以承载复杂的 Agent 行为。Goose 为什么还要换成状态机?
从一个场景看起:
用户没有立刻批准
假设用户让 Agent 做一件事:
text
删除测试环境中过期的用户数据。
模型理解任务后,返回:
text
ToolRequest(delete_records)
这个操作显然不能直接执行。系统需要向用户确认:
text
该操作将删除 1,284 条测试环境记录,是否继续?
如果用户一分钟内点击「允许」,事情很简单。
text
模型请求工具
→ 等待用户确认
→ 执行工具
→ 返回 Tool Result
→ 模型继续
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 的控制流大致是:
text
queryLoop 正在运行
→ 模型返回 ToolUse
→ Tool 执行层调用 canUseTool
→ UI 等待用户确认
→ 用户同意
→ 原来的 Tool 调用继续执行
→ Tool Result 回到 messages
→ 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:
javascript
{
messages, // 当前对话
toolUseContext, // 正在处理的 Tool 上下文
turnCount, // 已经跑了多少轮
// 压缩、输出截断等恢复过程的临时记录
autoCompactTracking,
maxOutputTokensOverride,
maxOutputTokensRecoveryCount,
hasAttemptedReactiveCompact,
transition, // 上一轮为何重试或继续
// 还有 Tool 摘要、Stop Hook 等控制状态
}
不需要逐个背字段。它们大致在做三件事:保留当前对话与 Tool 上下文;记录本轮已经走到哪里;以及在压缩、截断、重试时防止反复兜圈子。
这也正是它和最初那种几行 while 循环的差别:主循环除了「下一步调模型还是调工具」,还得同时带着这些临时状态往前走。
比如模型因为上下文过长而失败时,Claude Code 不会马上把错误展示给用户。它会尝试压缩消息,然后构造新的 state:
javascript
state = {
messages: postCompactMessages,
toolUseContext,
hasAttemptedReactiveCompact: true,
turnCount,
transition: {
reason: 'reactive_compact_retry',
},
}
continue
下一轮 while (true) 再带着压缩后的上下文请求模型。
输出截断时也是类似的处理:
text
模型输出被截断
→ 临时提高 token 上限重新生成
或
→ 保留半截输出,追加一条「从断点继续」的消息
→ 继续下一轮
所以 Claude Code 的 while 循环不是简单实现,而是一个围绕「本次活跃查询」不断推进的调度中心。
它很适合解决:
text
当前终端中的任务如何持续高质量地跑完?
Goose 想解决的,则是另一个问题:
text
当前任务即使暂停、离开进程、等待外部事件,
系统是否仍然能可靠地知道下一步是什么
Goose 官方怎么解释这次改造
Goose 官方在 Unrolling the agent loop 中,对旧式 Agent Loop 的描述很直接:
text
一个持有大量本地状态的、单体的流式协程。
它提出将 Agent Loop 「展开」为一个可重复进入的状态机:
text
一次只推进一个 turn;
每个 turn 到达明确停止点后返回;
Conversation 本身就是状态。
官方给出的动机主要有三个:
text
1. 每个 turn 是普通函数调用,容易测试和推理;
2. 交互之间不保存挂起状态,适合事件驱动架构;
3. 每种行为都是可组合的 Operation,可以替换或扩展。
Goose 后续在 2026 Q3 Roadmap 中,也把它放进了「Goose 作为可嵌入 Agent 开发套件」的方向中:希望开发者能替换运行时组件、加入应用策略,并接入长任务或横向扩展的编排系统。
这里的重点不是「状态机比 while 高级」。
而是:当任务不再是一次短暂的终端交互时,不能把正确性建立在「那条异步调用链还活着」上。
最小 Demo:不记住状态,只读取历史
先写一个最小 Demo。
假设模型总是想查询销售数据,查询前必须得到用户确认:
javascript
function nextStep(history) {
// 从已发生的事件推导当前状态,不依赖函数外的临时变量。
const kinds = new Set(history.map(item => item.kind))
// 新任务刚开始:模型决定请求查询工具。
if (history.length === 1 && history[0].kind === 'user') {
return {
kind: 'tool_request',
tool: 'query_sales',
args: { month: '2026-08' },
}
}
// 已有工具请求、尚未审批:要求用户确认。
if (
kinds.has('tool_request') &&
!kinds.has('approval') &&
!kinds.has('action_required')
) {
return {
kind: 'action_required',
message: '查询经营数据需要用户确认',
}
}
// 用户已批准、工具尚未执行:返回一个待执行的 Tool Effect。
if (kinds.has('approval') && !kinds.has('tool_response')) {
return {
kind: 'execute_tool',
tool: 'query_sales',
args: { month: '2026-08' },
}
}
// 工具已有结果:模型基于结果生成最终回答。
if (kinds.has('tool_response') && !kinds.has('assistant')) {
return {
kind: 'assistant',
text: '8 月营收为 128 万元,环比增长 12%。',
}
}
// 没有可推进的状态,任务暂时结束。
return null
}
这里的 return 不是直接执行工具,也不是直接结束整个任务。它只是返回「下一步要做什么」:普通消息要写入历史,execute_tool 则应交给工具执行器。实际运行器负责执行这些动作,并在下一次根据更新后的历史调用 nextStep():
javascript
async function executeTool(effect) {
if (effect.tool !== 'query_sales') {
throw new Error(`未知工具:${effect.tool}`)
}
// 真实系统会在这里调用 MCP、数据库或 HTTP API。
// Demo 用固定结果模拟一次异步工具调用。
return {
kind: 'tool_response',
tool: effect.tool,
result: { revenue: 1280000 },
}
}
async function advance(history) {
const event = nextStep(history)
// null 表示当前没有可推进的步骤,例如正在等审批。
if (event === null) {
return null
}
if (event.kind === 'execute_tool') {
// Effect 本身不进入模型上下文;执行后的结果才写回历史。
const toolResponse = await executeTool(event)
history.push(toolResponse)
return toolResponse
}
// 普通事件写入 Session 或数据库;Demo 先追加到数组。
history.push(event)
return event
}
将上面的 nextStep()、executeTool() 和 advance() 放进同一个文件后,下面的 runDemo() 就可以直接运行完整流程:
javascript
async function runDemo() {
const history = [
{ kind: 'user', text: '生成 8 月销售摘要' },
]
console.log(await advance(history))
// => { kind: 'tool_request', tool: 'query_sales', ... }
console.log(await advance(history))
// => { kind: 'action_required', message: '查询经营数据需要用户确认' }
console.log(await advance(history))
// => null:已有待确认请求,状态机在此停止并等待外部事件。
// 两小时后,审批系统或用户界面将确认结果写入持久化历史。
history.push({ kind: 'approval', allowed: true })
console.log(await advance(history))
// => { kind: 'tool_response', tool: 'query_sales', ... }
// 这里的 advance() 已实际调用 executeTool()。
console.log(await advance(history))
// => { kind: 'assistant', text: '8 月营收为 128 万元,环比增长 12%。' }
console.log(await advance(history))
// => null:已经得到最终回答,本轮任务结束。
return history
}
runDemo().catch(console.error)
最终 history 的状态变化是:
text
user
→ tool_request(query_sales)
→ action_required(等待用户确认)
→ approval
→ tool_response(query_sales)
→ assistant(生成最终摘要)
这个 Demo 当然也有 history 变量;为了便于运行,它暂时只是一个数组。关键不在于「完全不用变量」,而在于状态不依赖这些只能存在于当前调用栈中的临时标志位:
javascript
let pendingTool = null
let waitingForApproval = true
真实系统会将 history 写入 Session、数据库或日志;进程重启后,可以重新加载它。这样就没有一个必须常驻内存的标志位变量,也没有一个必须从原位置恢复的调用栈。
换句话说,运行时的 history 变量只是持久化历史在内存中的当前表示。任务状态可以从这份历史重新推导:
text
是否已经请求工具?
是否已经要求审批?
用户是否已经同意?
工具是否已经产生结果?
然后根据这些事实,推导下一步。
这就是 Goose 设计里最重要的一句话:
Conversation 不只是模型的上下文,也是 Agent 的运行状态。
Goose 的源码如何实现
Goose 当前状态机的入口在:
text
crates/goose/src/agents/agent.rs
create_state_machine() 会组装一组有序的 Operation:
text
开始
↓
用户中途纠偏(Steer)
↓
最大轮数限制
↓
上下文压缩
↓
工具调用压缩
↓
工具审批
↓
Skill / Recipe
↓
工具执行
↓
未知工具处理
↓
重试
↓
Stop Hook
↓
错误退出
↓
模型推理
create_state_machine() 的源码核心并不复杂:它把这些 Operation 按顺序装进 steps,再把模型推理放到最后。重点不在记住每一个类名,而在这条顺序是程序固定下来的:审批在工具执行前,模型推理在最后。

这里的每一个步骤都不直接说「继续跑主循环」。
它们只做两件事:
text
1. 根据当前 Session 与 Conversation,判断自己是否适用;
2. 如果适用,产生一组 Effects。
这些 Effects 由 SessionManager 统一应用:
text
AppendMessage → 追加并持久化消息
ReplaceConversation → 替换压缩后的对话
SetRecipe → 保存当前 Recipe
SetExtensionData → 保存扩展状态
RecordUsage → 保存模型用量
状态机运行器自己仍然有一个很小的循环:
rust
loop {
let session = load_session(session_id).await?;
let Some(result) = machine.step(&session).await? else {
break;
};
apply_effects(&session, result.effects).await?;
if result.yield_to_client {
break;
}
}
所以 Goose 并没有消灭 while。
它只是让 while 不再承载业务状态。
text
Claude Code 的 while:
负责推进模型、Tool、恢复、压缩、权限等大量任务逻辑。
Goose 的 while:
负责加载状态、执行一个步骤、应用 Effects、再读取状态。
复杂逻辑被拆到了各自的 Operation 里。
再回到审批场景
现在把前面的删除操作放回两种架构里看。
Claude Code
text
queryLoop 正在运行
→ ToolUse(delete_records)
→ 权限系统返回 ask
→ UI 展示确认队列
→ 当前执行链等待用户
→ 用户批准
→ tool.call()
→ Tool Result 回写 messages
→ queryLoop 继续
它的优势是直接、顺滑,尤其适合本地终端中的即时交互。
Goose
text
Conversation 出现 ToolRequest(delete_records)
↓
ToolApprovalOperation 发现未审批请求
↓
写入 ActionRequired(delete_records)
↓
本次状态机运行结束,不会在这里挂着等待
用户稍后批准
↓
Conversation 出现 ToolConfirmationResponse
↓
以同一个 Session 发起一次新的状态机运行
↓
ToolApprovalOperation 将请求标记为可执行
↓
ToolExecutionOperation 执行
↓
写入 ToolResponse
↓
InferenceRunner 再次调用模型

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