Agent 循环里每一轮发生了什么:Claude Agent SDK 的 agent loop 拆解
用 Claude Agent SDK 写自动化脚本,第一天遇到的问题通常不是「怎么让它干活」,而是「它凭什么停下来」。同一段 prompt,有时候一个工具都没调就返回文本,有时候连着读文件、改代码、跑测试折腾半天;还有的时候返回了,但你去取结果字段,发现是空的。
官方文档 code.claude.com/docs/en/agent-sdk/agent-loop 那一页把这些写得挺清楚。这篇沿着那一页,把一次会话按「轮」拆开,落在两件事上:一轮由哪几个阶段构成,以及循环在哪几种情况下终止、终止时该读哪个字段。
先把「一轮」的定义钉死
文档给的循环是五步,我按原文顺序复述:
- 接收 prompt。Claude 收到你的 prompt,连同 system prompt、工具定义和会话历史一起。SDK 这时吐出一条
SystemMessage,subtype是"init",里面装着这次会话的元数据。 - 评估并回应。Claude 判断当前状态怎么往下走。它可能回文本、可能请求一次或多次工具调用、也可能两者都有。SDK 吐出一条
AssistantMessage,含这一轮的文本内容块与工具调用块。 - 执行工具。SDK 跑掉每一个被请求的工具并收集结果,这批结果再喂回给 Claude 做下一次决策。文档写明可以用
hooks在工具真正执行前拦截、修改或阻断它。 - 重复。第 2、3 步作为一个循环反复进行,每一个完整的循环就是一轮(one turn)。Claude 会一直调工具、处理结果,直到它产出一次不带任何工具调用的回应。
- 返回结果。SDK 先吐出最后一条
AssistantMessage(纯文本、无工具调用),紧接着是一条ResultMessage。
这里有一处特别容易被误解,文档专门说了:一轮是在循环内部完成的一次往返,中途不会把控制权交回你的代码。 Claude 产出带工具调用的输出、SDK 执行这些工具、结果自动回灌,这三件事是连着做完的。所以「轮」不是你这边的一次调用,是循环内部的一个计数单位。
文档举的例子是修 auth.ts 的失败测试:第一轮 Claude 调 Bash 跑 npm test,第二轮调 Read 读两个文件,第三轮调 Edit 改完再调一次 Bash 重跑测试,最后一轮只回一段纯文本。文档给这段做的结论是:这一共是四轮,三轮带工具调用,一轮是最终的纯文本回应。 注意第三轮里有两次工具调用,但它仍然只算一轮——轮的边界是模型的一次回应,不是工具调用的次数。
一轮里流出来的消息,怎么和阶段对上
SDK 吐的是消息流,文档列了五种核心类型,每种对应循环的某个阶段:
| 消息类型 | 什么时候出现 |
|---|---|
SystemMessage | 会话生命周期事件,靠 subtype 区分 |
AssistantMessage | 每次 Claude 回应之后,包括最后那次纯文本回应 |
UserMessage | 每次工具执行之后,装着回灌给 Claude 的工具结果;你在循环中途流式送入的用户输入也走这个类型 |
StreamEvent | 只有开启 partial messages 时才有,装原始 API 流式事件 |
ResultMessage | 标记 agent 循环的结束 |
SystemMessage 的 subtype 文档列了四个:"init"(这次运行的会话元数据)、"compact_boundary"(压缩之后触发)、"informational"(循环发出的纯文本状态横幅)、"worker_shutting_down"(因为宿主正在退出或 Remote Control 断开,循环会在当前这一轮结束后停止)。
有一条跨语言的坑,文档明写了:在 TypeScript 里,除 "init" 之外的每个 subtype 在 SDKMessage 联合类型里都是独立的类型,而不是 SDKSystemMessage 的一个 subtype。判类型的方式也两边不同——Python 用 isinstance() 对 claude_agent_sdk 里导入的类做判断,TypeScript 判 type 字符串字段;而且 TypeScript 侧 AssistantMessage 和 UserMessage 把原始 API 消息包在 .message 字段里,内容块在 message.message.content 而不是 message.content。
工具执行这一段,谁并发谁排队
第 3 步不是「一股脑全跑」。文档写明:当 Claude 在同一轮里请求多个工具调用时,两个 SDK 都可以并发或顺序执行,取决于工具本身。只读工具(如 Read、Glob、Grep,以及被标记为只读的 MCP 工具)可以并发;会改状态的工具(如 Edit、Write、Bash)顺序执行以避免冲突。
自定义工具默认走顺序执行。想让它并发,要在 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_usd、usage、num_turns 和 session_id,出错之后一样能记账和续跑。两个需要防的点文档也点了:会话崩溃之后那条 error_during_execution,它的成本字段可能被清零、stop_reason 是 null,进程发完就退出;另外 Python 侧 total_cost_usd、usage、model_usage 的类型是可选,读之前要判 None。
还有一个我觉得挺反直觉的收尾细节:文档写明在 ResultMessage 之后,少量尾随的系统事件(例如 prompt_suggestion)仍可能到达,所以应该把流迭代到底,而不是拿到 result 就 break。
subtype 和 stop_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_reason 是 null。这条正好和上面那个「崩溃后成本字段可能为零」对上,是同一处崩溃场景的两个表现。
异常行为两种模式也不一样:单次 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 字段,取值是 manual 或 auto。
平台差异:文档只对 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 的公开内容整理。
该产品闭源,本文只复述官方文档写明的机制,不推断其内部实现;
我们没有对文中涉及的功能做过实测,因此不涉及界面外观、操作手感与运行速度的任何描述。
该产品迭代频繁,文中涉及的命令、配置项与默认值随版本变动,请以官方文档最新内容为准。
本文不涉及价格、额度与限流的具体数值,相关信息请以官方定价与用量说明页为准。
安全与合规相关做法请结合自身环境评估,本文不构成安全方案建议。