Agent 循环里每一轮发生了什么:Claude Agent SDK 的 agent loop 拆解

2026-08-18

用 Claude Agent SDK 写自动化脚本,第一天遇到的问题通常不是「怎么让它干活」,而是「它凭什么停下来」。同一段 prompt,有时候一个工具都没调就返回文本,有时候连着读文件、改代码、跑测试折腾半天;还有的时候返回了,但你去取结果字段,发现是空的。

官方文档 code.claude.com/docs/en/agent-sdk/agent-loop 那一页把这些写得挺清楚。这篇沿着那一页,把一次会话按「轮」拆开,落在两件事上:一轮由哪几个阶段构成,以及循环在哪几种情况下终止、终止时该读哪个字段

先把「一轮」的定义钉死

文档给的循环是五步,我按原文顺序复述:

  1. 接收 prompt。Claude 收到你的 prompt,连同 system prompt、工具定义和会话历史一起。SDK 这时吐出一条 SystemMessagesubtype"init",里面装着这次会话的元数据。
  2. 评估并回应。Claude 判断当前状态怎么往下走。它可能回文本、可能请求一次或多次工具调用、也可能两者都有。SDK 吐出一条 AssistantMessage,含这一轮的文本内容块与工具调用块。
  3. 执行工具。SDK 跑掉每一个被请求的工具并收集结果,这批结果再喂回给 Claude 做下一次决策。文档写明可以用 hooks 在工具真正执行前拦截、修改或阻断它。
  4. 重复。第 2、3 步作为一个循环反复进行,每一个完整的循环就是一轮(one turn)。Claude 会一直调工具、处理结果,直到它产出一次不带任何工具调用的回应
  5. 返回结果。SDK 先吐出最后一条 AssistantMessage(纯文本、无工具调用),紧接着是一条 ResultMessage

这里有一处特别容易被误解,文档专门说了:一轮是在循环内部完成的一次往返,中途不会把控制权交回你的代码。 Claude 产出带工具调用的输出、SDK 执行这些工具、结果自动回灌,这三件事是连着做完的。所以「轮」不是你这边的一次调用,是循环内部的一个计数单位。

文档举的例子是修 auth.ts 的失败测试:第一轮 Claude 调 Bashnpm test,第二轮调 Read 读两个文件,第三轮调 Edit 改完再调一次 Bash 重跑测试,最后一轮只回一段纯文本。文档给这段做的结论是:这一共是四轮,三轮带工具调用,一轮是最终的纯文本回应。 注意第三轮里有两次工具调用,但它仍然只算一轮——轮的边界是模型的一次回应,不是工具调用的次数。

一轮里流出来的消息,怎么和阶段对上

SDK 吐的是消息流,文档列了五种核心类型,每种对应循环的某个阶段:

消息类型什么时候出现
SystemMessage会话生命周期事件,靠 subtype 区分
AssistantMessage每次 Claude 回应之后,包括最后那次纯文本回应
UserMessage每次工具执行之后,装着回灌给 Claude 的工具结果;你在循环中途流式送入的用户输入也走这个类型
StreamEvent只有开启 partial messages 时才有,装原始 API 流式事件
ResultMessage标记 agent 循环的结束

SystemMessagesubtype 文档列了四个:"init"(这次运行的会话元数据)、"compact_boundary"(压缩之后触发)、"informational"(循环发出的纯文本状态横幅)、"worker_shutting_down"(因为宿主正在退出或 Remote Control 断开,循环会在当前这一轮结束后停止)。

有一条跨语言的坑,文档明写了:在 TypeScript 里,除 "init" 之外的每个 subtype 在 SDKMessage 联合类型里都是独立的类型,而不是 SDKSystemMessage 的一个 subtype。判类型的方式也两边不同——Python 用 isinstance()claude_agent_sdk 里导入的类做判断,TypeScript 判 type 字符串字段;而且 TypeScript 侧 AssistantMessageUserMessage 把原始 API 消息包在 .message 字段里,内容块在 message.message.content 而不是 message.content

工具执行这一段,谁并发谁排队

第 3 步不是「一股脑全跑」。文档写明:当 Claude 在同一轮里请求多个工具调用时,两个 SDK 都可以并发或顺序执行,取决于工具本身。只读工具(如 ReadGlobGrep,以及被标记为只读的 MCP 工具)可以并发;会改状态的工具(如 EditWriteBash)顺序执行以避免冲突。

自定义工具默认走顺序执行。想让它并发,要在 annotations 里设 readOnlyHint——TypeScript 和 Python 两个 SDK 用的都是来自 MCP SDK 的这个字段名。

工具能不能跑,由三个选项共同决定:allowed_tools / allowedTools 自动批准列出的工具(没列出的仍然可用,但要走审批);disallowed_tools / disallowedTools 阻断列出的工具,且不受其它设置影响;permission_mode / permissionMode 决定整体的人工监督程度。规则还能收窄到具体命令,文档给的写法是 "Bash(npm *)"

一个工具被拒之后会怎样?文档的说法是:Claude 会把拒绝消息当作工具结果收到,然后通常改换一种做法,或者报告自己无法继续。hooks 走的是同一个机制——PreToolUse 钩子拒掉一次调用,这次调用就不会执行,Claude 收到的是那条拒绝消息。文档还提了一句对上下文有实际影响的事实:钩子跑在你的应用进程里,不在 agent 的上下文窗口里,所以不消耗上下文。

落点:循环到底怎么终止

这是我认为这一页最值钱的部分。终止不是一回事,是几条互不相同的路径,返回的字段也不一样。

第一条是自然终止:Claude 产出一次不带工具调用的输出,循环结束,交付最终结果。

第二条是撞上限。文档给了两个选项,都在 ClaudeAgentOptions(Python)/ Options(TypeScript)上:

选项控制什么默认值
max_turns / maxTurns工具使用往返的最大次数无限制
max_budget_usd / maxBudgetUsd停止前的最大花费无限制

这两个默认值是文档写明的,随版本可能变动。max_turns 有个语义细节值得记:它只数带工具调用的轮。拿前面那个修测试的例子说,文档明说如果设 max_turns=2,循环会停在改文件那一步之前。也就是说,你以为留了两轮余量,实际只够跑完读文件。

预算上限还有几条边界条件,文档单独列了:预算封顶覆盖 subagent,它们的花费计入总数;一旦花费触顶,再想派生 subagent 会以 Budget limit reached 失败,同时 Claude Code 会停掉还在跑的后台 subagent。文档明确标注:这些封顶执行行为需要 Claude Code v2.1.217 或更高版本。 另外在流式输入下,你在某一轮还没跑完时送进去的消息,如果那一轮正好撞上 max-turns 上限,这条消息会留在队列里,并以它自己的 max-turns 上限另起一轮

第三条是出错终止。判断走的是 ResultMessage.subtype,两个 SDK 都有这个字段,文档列了五种:

subtype含义result 字段吗
success正常完成
error_max_turns完成前撞上 maxTurns
error_max_budget_usd完成前撞上 maxBudgetUsd
error_during_execution有错误打断了循环(例如 API 失败或请求被取消)
error_max_structured_output_retries在配置的重试次数内没能产出有效的结构化输出

result 字段只在 success 这一种上存在,所以文档反复强调:读它之前先查 subtype。开头说的「返回了但结果是空的」,多半就是这里。

好在所有 subtype 都带 total_cost_usdusagenum_turnssession_id,出错之后一样能记账和续跑。两个需要防的点文档也点了:会话崩溃之后那条 error_during_execution它的成本字段可能被清零、stop_reasonnull,进程发完就退出;另外 Python 侧 total_cost_usdusagemodel_usage 的类型是可选,读之前要判 None

还有一个我觉得挺反直觉的收尾细节:文档写明在 ResultMessage 之后,少量尾随的系统事件(例如 prompt_suggestion)仍可能到达,所以应该把流迭代到底,而不是拿到 result 就 break

subtypestop_reason 回答的不是同一个问题

这两个字段名字都像「为什么停了」,但管的层级不同。subtype 说的是循环为什么结束;stop_reason(TypeScript 里是 string | null,Python 里是 str | None)说的是模型在最后一轮为什么停止生成。文档给的常见值是 end_turn(正常收尾)、max_tokens(撞到输出上限)、refusal(模型拒绝了请求)。想检测拒绝就判 stop_reason === "refusal"(TypeScript)或 stop_reason == "refusal"(Python)。

文档还补了一条对照关系:在循环自己产出的错误结果上,stop_reason 带的是循环结束前最后一次 assistant 回应的值;而 Claude Code 在会话崩溃后合成的那条结果,stop_reasonnull。这条正好和上面那个「崩溃后成本字段可能为零」对上,是同一处崩溃场景的两个表现。

异常行为两种模式也不一样:单次 query() 吐出最终的错误结果消息之后会抛错,错误里带失败文本(文档举的例子是 Reached maximum number of turns),底层进程以非零码退出——文档说这个抛出是有意为之,要继续往下走就把循环包在 try 里。流式输入的会话则保持存活,可以接着发消息,会话崩溃除外

轮与轮之间,上下文不清零

终止条件之外还有一件事影响「它跑了几轮」:上下文窗口在同一会话内不会在轮之间重置,system prompt、工具定义、会话历史、工具输入与输出全部累积。

当上下文接近上限时,SDK 会自动压缩会话:概括较早的历史腾出空间,保留最近的往来与关键决策。这时流里会出现 type: "system" / subtype: "compact_boundary"(Python 里是 SystemMessage,TypeScript 里是独立的 SDKCompactBoundaryMessage 类型)。代价文档没有回避:较早的消息被摘要替换,早期对话里的具体指令可能保不住。因此文档给的做法是把长期规则放进 CLAUDE.md(通过 settingSources 加载)而不是放在初始 prompt 里,理由是 CLAUDE.md 每次请求都会重新注入。想在压缩前插手,用 PreCompact 钩子,它收到一个 trigger 字段,取值是 manualauto

平台差异:文档只对 Unix 写了一条

跟操作系统直接相关的限制,这一页里只有一处:permission_mode"bypassPermissions" 档,文档写明在 Unix 上以 root 运行时不能使用,并且在 TypeScript SDK 里还额外要求 options 里带 allowDangerouslySkipPermissions: true。文档同时限定这一档只应在隔离环境(CI、容器等)里用。

Windows 侧,这一页没有给出对应的限制说明,也没有写路径分隔符、shell 差异之类的注意事项。所以我不替它补——Windows 上跑之前,请以官方文档最新内容和你实际拿到的行为为准。顺带说一句安全边界:这套循环会在你的机器上真的执行 Bash、真的改文件,权限选项能收窄它的动作范围,但这不等于「授权了也没风险」,隔离与审计要按你自己的环境评估。

一段可以直接对照的配置

文档《Put it all together》一节给的 Python 示例,我原样抄它的选项部分:

options=ClaudeAgentOptions(
    allowed_tools=[
        "Read",
        "Edit",
        "Bash",
        "Glob",
        "Grep",
    ],  # Listing tools here auto-approves them (no prompting)
    setting_sources=[
        "project"
    ],  # Load CLAUDE.md, skills, hooks from current directory
    max_turns=30,  # Prevent runaway sessions
    effort="high",  # Thorough reasoning for complex debugging
),

上面这段是官方文档中的示例代码,其中的具体取值只是该示例当时的写法,不是推荐配置;以官方文档与实际的 --help 输出为准。顺带说一下里面的 effort:文档说它控制 Claude 投入多少推理,档位从低到高是 "low""medium""high""xhigh""max" 五档,并明确写了不是所有模型都支持这个参数;不设的话两个 SDK 都不传它,交给模型的默认行为。

回到开头那个问题。「它什么时候停」在这一页里其实有三个不同的答案,各自读不同的字段:模型不再要工具了(自然停)、你设的轮数或预算封顶了(error_max_turns / error_max_budget_usd)、执行中出了岔子(error_during_execution)。把这三条分清楚,再配上 stop_reason 看最后一轮模型自己为什么收尾,日志里那些「莫名其妙就返回了」的会话,基本都能定位到具体是哪一条路径。


本文依据 Claude Code 官方文档(code.claude.com/docs)于 2026-08-17 的公开内容整理。 该产品闭源,本文只复述官方文档写明的机制,不推断其内部实现我们没有对文中涉及的功能做过实测,因此不涉及界面外观、操作手感与运行速度的任何描述。 该产品迭代频繁,文中涉及的命令、配置项与默认值随版本变动,请以官方文档最新内容为准。 本文不涉及价格、额度与限流的具体数值,相关信息请以官方定价与用量说明页为准。

安全与合规相关做法请结合自身环境评估,本文不构成安全方案建议。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。