Agent Loop 的 while 循环什么时候开始不够用

前段时间刷了 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 才变为 allowdeny

若结果是 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 行为拆成基于持久化状态的独立转换。

回到最开始那项迟到的审批。

如果用户马上点击「允许」,两种设计的体验差别不大。

只有当等待跨越当前的进程、连接或执行现场时,状态机的价值才真正出现。

相关推荐
禾高网络34 分钟前
养老系统开发:背景、架构与养老行业展望
java·大数据·人工智能·小程序·架构
小雨笙笙35 分钟前
机器学习:评估模型与选择
人工智能·算法·机器学习
lifallen39 分钟前
长任务怎样选择遗忘:clearing、compaction 与 memory
人工智能·学习·ai·ai编程
Luhui Dev41 分钟前
大角几何如何保证图片导出尺寸一致性?
人工智能·数学·ai·luhuidev
yyk3332443 分钟前
卷积神经网络
人工智能·神经网络·cnn
翼龙云_cloud1 小时前
腾讯云国际代理商:如何用云服务器快速部署WorkBuddy AI助手?
服务器·人工智能·云计算·腾讯云
智码看视界1 小时前
Day66-开源模型vs闭源模型:技术决策框架
开源·私有化部署·开源模型·模型选型·gpu推理·闭源模型·决策框架
我爱写代码i1 小时前
边缘计算与小模型在工业预测性维护、机器视觉质检中有哪些具体的落地实施案例与架构设计?
人工智能·边缘计算