Claude Code 深度拆解:Agent 执行内核 3 — 从 API 调用到安全退出
Hi,大家好,欢迎来到维元码簿。
本文属于《Claude Code 源码 Deep Dive》系列,专注于 Agent 执行内核中的API 调用、流式处理、工具执行、错误恢复与生命周期收尾板块。如果你想了解整个系列,可以先看系列开篇 | Claude Code 源码架构概览:51万行代码的模块地图。
API 调用不只是"发出请求、等待回复"——真正的工程挑战是出意外了怎么兜底。413 爆了、token 超了、模型挂了、用户按了 Ctrl+C……Claude Code 不是靠一把 try-catch 包办一切,而是在循环体不同位置设置了 7 个精准的 continue 点,每种异常都有专属的恢复路径。与此同时,代码还在流式响应中同步执行工具——模型还在输出,工具已经在跑了。
读完全文,你将能回答这几个问题:
- 模型输出到一半,工具已经在执行了——怎么做到的?答案:StreamingToolExecutor 的
addTool()在流中识别到完整的 tool_use block 后立即加入执行队列——不等待 message_stop。 - API 返回 413,Agent 会崩溃吗?答案:不会——Collapse Drain 机制立即将待折叠的对话内容提交压缩以释放上下文窗口;若空间仍不足,再启动 Reactive Compact 做更激进的摘要压缩。两次自救都不行才放弃。错误全程 withhold,用户根本看不到。
- 7 个 continue 点分别处理什么?为什么不是一把 try-catch?答案:PTL 恢复、OTK 升级/恢复、Fallback 切换、Stop Hook 阻断、Token Budget 续命——每种异常有自己的精准恢复路径。
本篇覆盖的源码范围
| 模块 | 核心文件 | 核心代码行 | 文件总行 | 职责 |
|---|---|---|---|---|
| API 调用 | src/query.ts | L558-997 | 1730 行 | callModel 流式请求、assistant 消息累积、事件分发 |
| 流式工具执行 | src/services/tools/StreamingToolExecutor.ts | L1-519 | 531 行 | 边收边执行、并发调度、siblingAbort |
| 工具编排 | src/services/tools/toolOrchestration.ts | L1-189 | 189 行 | 只读并行/写入串行分区 |
| 错误恢复 | src/query.ts | L1062-1358 | 1730 行 | PTL/OTK/Fallback 恢复路径 |
| Stop Hooks | src/query/stopHooks.ts | L1-474 | 474 行 | Stop/TeammateIdle/TaskCompleted 三元组 |
| Token 预算 | src/query/tokenBudget.ts | L1-94 | 94 行 | 预算追踪与 nudge 注入 |
前情提要:从消息压缩到"真正干活"
在子命题 2中,四层压缩已经把messagesForQuery从 85,000 token 瘦身到 ~12,000 token。「① 准备」阶段到此收尾——接下来进入 while(true) 循环的另外三个阶段:
- ② 调用:拼装
messages + systemPrompt + tools发起请求 →for await接收流式响应、边收边解析 tool_use 块、主模型异常时设标志位切换 fallbackModel 重试。对应代码:deps.callModel(...)+onStreamingFallback。 - ③ 执行:已完成的工具结果按接收顺序产出;Bash 出错触发级联取消兄弟工具;Hook 阻断消息、工具结果等附件一并收集 yield。对应代码:
StreamingToolExecutor/runTools+attachment。 - ④ 转移:Stop → TeammateIdle → TaskCompleted 三元组依次执行,阻断则注入反馈让模型修正;最后更新 state 决定
continue或return { reason }——5 种终止原因中只有 1 种是正常出口,另外 4 种都是异常兜底。对应代码:stopHookResult+state = { ...next }。
接下来三章按 ② → ③ → ④ 顺序拆解。
API 调用全景:18+ 个参数的一次请求
deps.callModel() 的参数清单
query.tsL659 的deps.callModel()调用一次性传入 18+ 个参数——远超一个普通 HTTP 请求的复杂度:
for await (const message of deps.callModel({ messages: prependUserContext(messagesForQuery, userContext), systemPrompt: fullSystemPrompt, thinkingConfig: toolUseContext.options.thinkingConfig, tools: toolUseContext.options.tools, signal: toolUseContext.abortController.signal, options: { model, fastMode, toolChoice, isNonInteractiveSession, fallbackModel, onStreamingFallback, querySource, agents, allowedAgentTypes, hasAppendSystemPrompt, maxOutputTokensOverride, fetchOverride, mcpTools, hasPendingMcpServers, queryTracking, effortValue, advisorModel, skipCacheWrite, agentId, addNotification, taskBudget: { total, remaining } } }))其中几个关键参数值得展开:
- fallbackModel + onStreamingFallback:当主模型流式出错(非 4xx/5xx),回调
onStreamingFallback设置标志位,外层 while 循环检测后切换模型重试。 - taskBudget:服务端预算追踪机制。
remaining由客户端在 compact 时跨边界传递——服务端看不到压缩前的完整历史,需要客户端主动汇报。 - signal:
toolUseContext.abortController.signal,用户 Ctrl+C 时触发 abort,所有正在执行的工具、正在进行的 API 调用同步取消。
流式事件的四种类型
for await (const message of deps.callModel(...))的每次迭代产生四种可能的消息类型:
| 消息类型 | 何时产生 | 处理方式 |
|---|---|---|
| assistant | 模型的每次内容输出 | 累积到assistantMessages[];提取 tool_use block 加入 StreamingToolExecutor;yield 给 REPL |
| system | 系统级通知(如 “Switched to fallback model”) | yield 给 REPL 展示 |
| attachment | Hook 阻断、工具结果等附件 | yield + 检查是否 preventContinuation |
| tombstone | 流式 Fallback 时标记废弃消息 | yield 给 REPL 移除 UI 中的废弃消息 |
StreamingToolExecutor:边收边做的并发引擎
这是 Claude Code Agent 循环中最巧妙的设计。传统 Agent 是"等模型说完 → 解析 tool_use → 执行工具"。Claude Code 更进一步——模型还在输出时,工具已经在跑了。
下图把「流式事件接收」与「工具执行」画在同一条时间轴上——模型 tool_use block 一识别完整,工具就立即开跑,不等 message_stop。
addTool():流中触发
当流式响应中出现content_block_start(type: 'tool_use')时,查询循环立即调用streamingToolExecutor.addTool(block, message):
// query.ts L760-770 — 流式循环中的工具触发if(streamingToolExecutor){for(constblockoftoolBlocks){streamingToolExecutor.addTool(block,message)}}addTool()的职责是:检查并发安全性 → 如果可以立即执行 → 启动 tool.call();否则排入队列等待。
并发控制:只读并行,写入串行
StreamingToolExecutor的核心规则只有一条——canExecuteTool(isConcurrencySafe):
可以执行新工具,当且仅当: - 没有正在执行的工具(空闲),或者 - 新工具安全 且 所有正在执行的工具也安全这个简单的规则产生了三种调度场景:
- 只读工具(Glob、Grep、FileRead):
isConcurrencySafe = true→ 可以并行 - 写入工具(Edit、Write):
isConcurrencySafe = false→ 必须独占 - 执行工具(Bash):
isConcurrencySafe = false→ 必须独占,且出错时取消兄弟
下面这张甘特图展示了 5 个工具在 StreamingToolExecutor 下的并发调度时序。
siblingAbortController:Bash 出错的级联取消
当 BashTool 执行出错时,StreamingToolExecutor会取消所有正在执行的兄弟工具:
// StreamingToolExecutor.ts — 错误传播逻辑if(toolName===BASH_TOOL_NAME&&error){this.siblingAbortController.abort('sibling_error')}这是因为 Bash 命令通常有前后依赖——如果npm install失败了,后续依赖它的工具也没有执行意义。而 FileRead 或 Glob 的失败是独立的——它们不会触发级联取消。
结果顺序:先完成不一定先产出
一个精妙的设计:StreamingToolExecutor缓冲已完成的结果,按接收顺序产出——不是完成顺序。这保证了模型在下一轮看到 tool_result 时有确定性的顺序。
discard():Fallback 时清空
当流式 Fallback 触发时:
// query.ts L733-739 — Fallback 时清理if(streamingToolExecutor){streamingToolExecutor.discard()// 清空所有 pending 工具streamingToolExecutor=newStreamingToolExecutor(// 重建一个新的toolUseContext.options.tools,canUseTool,toolUseContext,)}旧的 StreamingToolExecutor 被丢弃——它的工具调用使用的是旧模型的 tool_use_id,在新模型下毫无意义。
runTools:非流式 Fallback 模式
当config.gates.streamingToolExecution关闭时,走runTools()路径。它执行partitionToolCalls()分区:
- 并发安全组:
runToolsConcurrently()并行执行 - 非安全组:
runToolsSerially()逐个执行(因为前一个可能影响后一个的 context)
注意runTools()的调用时机——它是在整个流式响应结束后才执行的,不像 StreamingToolExecutor 那样边收边做。这就是为什么 StreamingToolExecutor 是默认路径:延迟更低。
错误恢复全景:7 个 continue 点的精准矩阵
现在进入最重要的话题:错误恢复。Claude Code 不是用一把 try-catch 包住整个循环——它在循环体的不同位置设置了7 个精准的 continue 点,每种异常有自己的恢复路径。
下面这张决策树展示了 7 个 continue 点从 while(true) 出发的完整恢复网络。
Continue 1:AutoCompact 继续
压缩完成后,通过state = { ... }+continue回到循环顶部——这是最"正常"的 continue,不是错误恢复。
Continue 2:Collapse Drain(413 恢复第一层)
当 API 返回 413(prompt_too_long)时,错误被 withhold 拦截,不 yield 给用户。第一层恢复是contextCollapse.recoverFromOverflow()。这里的 “drain” 含义是:Context Collapse 系统预先标记了可折叠的消息对但尚未提交——Collapse Drain 就是把所有待处理的折叠操作一次性执行,用压缩后的摘要替换原文,从而释放上下文窗口空间让 API 重试成功:
// query.ts L1093-1116 — Collapse Drainif(feature('CONTEXT_COLLAPSE')&&contextCollapse&&state.transition?.reason!=='collapse_drain_retry'){constdrained=contextCollapse.recoverFromOverflow(messagesForQuery,querySource)if(drained.committed>0){state={...nextState,transition:{reason:'collapse_drain_retry'}}continue// ← 排水成功,重试}}这是零额外 API 成本的恢复——只利用已有的折叠数据。
Continue 3:Reactive Compact(413 恢复第二层)
如果 Collapse Drain 失败(没有可折叠的内容了),进入reactiveCompact.tryReactiveCompact():
// query.ts L1119-1165 — Reactive Compactconstcompacted=awaitreactiveCompact.tryReactiveCompact({...})if(compacted){state={...nextState,hasAttemptedReactiveCompact:true,transition:{reason:'reactive_compact_retry'}}continue// ← 压缩成功,重试}// 失败 → return { reason: 'prompt_too_long' }这里的关键标志是hasAttemptedReactiveCompact——如果 RC 已经尝试过一次且仍然 413,不再重试,直接退出。
下面这张对比图并列展示了 PTL(prompt-too-long)和 OTK(output token 超限)两种恢复路径的完整流程。
Continue 4:流式 Fallback 切换
当主模型流式响应中出现FallbackTriggeredError时:
- 设置
streamingFallbackOccured = true - 外层检测到标志 → yield tombstone 清理废弃消息
streamingToolExecutor.discard()清空旧工具attemptWithFallback = true→ 用 fallbackModel 重新 API 调用- yield system message “Switched to fallback model”
continue回到循环顶部(retry 已经由外层 while(attemptWithFallback) 完成)
Continue 5:OTK Escalate(输出 token 超限升级)
当模型输出达到 output token 上限(isWithheldMaxOutputTokens)时,第一策略是升级上限:
// query.ts L1199-1220 — OTK Escalateif(capEnabled&&maxOutputTokensOverride===undefined&&!process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS){state={...nextState,maxOutputTokensOverride:ESCALATED_MAX_TOKENS,transition:{reason:'max_output_tokens_escalate'}}continue// ← 用更大的 max_tokens 重试同一请求}这是一个聪明的优化——不需要多轮对话,同一个请求用更大的输出预算重试。
Continue 6:OTK Recovery(注入恢复消息)
如果 64k 上限仍然不够,降级为多轮恢复——注入一条 meta 消息让模型继续:
// query.ts L1223-1251 — OTK Recoveryif(maxOutputTokensRecoveryCount<MAX_OUTPUT_TOKENS_RECOVERY_LIMIT){constrecoveryMessage=createUserMessage({content:`Output token limit hit. Resume directly — no apology, no recap...`,isMeta:true,})state={...nextState,maxOutputTokensRecoveryCount:maxOutputTokensRecoveryCount+1,transition:{reason:'max_output_tokens_recovery'}}continue}最多尝试MAX_OUTPUT_TOKENS_RECOVERY_LIMIT次,耗尽后 yield 错误并退出。
Continue 7:Stop Hook Blocking
当 Stop Hook 返回blockingErrors(exit code 2)时:
// query.ts L1282-1305 — Stop Hook Blockingif(stopHookResult.blockingErrors.length>0){state={...nextState,stopHookActive:true,transition:{reason:'stop_hook_blocking'}}continue// ← 注入错误消息,让模型修正后重试}stopHookActive标志防止死循环——如果 hook 两次阻断同一个响应,说明模型无法满足 hook 的要求。
Bonus:Token Budget Continue
当TOKEN_BUDGETfeature 开启且预算未耗尽时:
// query.ts L1316-1340 — Token Budget nudgeif(decision.action==='continue'){state={...nextState,transition:{reason:'token_budget_continuation'}}continue// ← 注入 nudge 消息提醒模型注意预算}这不是"错误恢复",而是"主动续命"——在预算快耗尽但还没耗尽时,注入提醒消息让模型加快节奏。
模型 Fallback:优雅降级
Fallback 的完整链路值得单独展开。触发条件是:主模型流式响应中抛出FallbackTriggeredError(不是 4xx/5xx 网络错误,而是模型侧异常)。
完整处理流程(query.ts L712-750):
- 检测
streamingFallbackOccured标志 - yield tombstone给所有
assistantMessages——这些 partial 消息的 thinking blocks 有无效签名,不清理会导致后续 API 调用报"thinking blocks cannot be modified"错误 - 清空
assistantMessages、toolResults、toolUseBlocks、needsFollowUp streamingToolExecutor.discard()+ 新建一个attemptWithFallback = true→ 外层 while 循环重新走 API 调用
关键设计:tombstone 事件通知 REPL 移除 UI 中的旧消息,保证用户界面的清洁。
用户中断:Ctrl+C 的优雅处理
toolUseContext.abortController.signal.aborted在两个关键位置被检查:
- 流式过程中:
deps.callModel()接收 signal,中断后isAbortedStreamingReason为 true → 补全缺失的 tool_result →return { reason: 'aborted_streaming' } - 工具执行中:
getRemainingResults()或runTools()内部检查 signal,已完成的工具结果正常产出,未开始的丢弃
yieldMissingToolResultBlocks()保证了中断时不会留下"悬空"的 tool_use——模型期望每个 tool_use 都有对应的 tool_result。
Stop Hooks 生命周期:三元组的协奏
当needsFollowUp === false时,进入handleStopHooks()。这是三种 Hook 的完整执行流程:
| Hook | 触发条件 | 阻断后果 |
|---|---|---|
| Stop | 每次 turn 结束(模型主动 end_turn) | blockingErrors → continue 注入反馈消息 |
| TeammateIdle | Teammate agent 即将闲置 | 阻断 → agent 继续工作 |
| TaskCompleted | Teammate 完成任务 | 阻断 → agent 重新处理 |
执行顺序在stopHooks.ts中编排:先 Stop → 再 TeammateIdle → 最后 TaskCompleted。每个 Hook 可以返回三种结果:
- Success(exit code 0):继续
- Blocking(exit code 2):注入阻断消息 →
continue回到循环 - Prevent(exit code 1):直接终止,不重试
stopHookActive是防止死循环的关键——如果上一轮已经是 Stop Hook 阻断导致的 continue,这一轮 Stop Hook 再次阻断,preventContinuation机制会触发,防止无限循环。
循环终止:5 条出路
下面这张图展示了从 while(true) 出发的 5 条终止路径——只有一条是"正常出口"。
| 终止原因 | 触发条件 | Terminal.reason | 颜色 |
|---|---|---|---|
| completed | 模型主动 end_turn + stop hooks 通过 + token budget 耗尽 | 'completed' | 🟢 绿色 |
| max_turns | turnCount > maxTurns | 'max_turns' | 🟡 黄色 |
| prompt_too_long | PTL 恢复(Collapse Drain + Reactive Compact)全部失败 | 'prompt_too_long' | 🔴 红色 |
| aborted_streaming / aborted_tools | 用户 Ctrl+C | 'aborted_streaming'/'aborted_tools' | 🟠 橙色 |
| model_error | API 返回非 PTL/非 fallback 的错误 | 'completed'(走 stop hooks 后) | 🔴 深红 |
只有completed是正常出口。其他 4 条都是各种异常场景的最终兜底。每一次 exit 之前,代码都保证了两件事:
- 缺失的 tool_result 被补全(
yieldMissingToolResultBlocks) - Stop Hooks(或 Stop Failure Hooks)被调用
本章小结
本文拆解了消息压缩完成后的完整处理链路:
- API 调用一次性传入 18+ 个参数——这不是一个简单的 HTTP 请求,而是一个携带了系统 Prompt、工具定义、预算信息、Fallback 配置的完整上下文包。
- StreamingToolExecutor实现了"边收边做"——模型还在输出时,工具已经在运行。只读并行、写入串行,Bash 出错级联取消兄弟。
- 7 个 continue 点不是补丁——是精心设计的恢复策略矩阵。PTL 有 Collapse Drain → Reactive Compact 两层自救;OTK 有 Escalate(8k→64k)→ Recovery 消息注入两层;Fallback 有模型切换……
- Stop Hooks 三元组在每次 turn 结束后运行——Stop → TeammateIdle → TaskCompleted,每个 Hook 可以阻断并让 Agent 继续。
- 5 条出路——只有
completed是正常出口。每一次退出都保证了 tool_result 完整 + Hooks 执行。
系列导航:
本文属于《Claude Code 源码 Deep Dive》系列中「Agent 执行内核」命题的子篇章,专注于API 调用、流式处理与安全退出。
姊妹篇(可独立阅读):
- Claude Code 深度拆解:Agent 执行内核 1 — 主循环与状态机
- Claude Code 深度拆解:Agent 执行内核 2 — Pipeline 与上下文压缩
如果这篇文章对你有帮助,欢迎点赞收藏支持一下。如果你对 Claude Code 源码感兴趣,欢迎关注本系列后续更新。有任何想法或疑问,欢迎评论区留言讨论👋
