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 决定 continuereturn { reason }------5 种终止原因中只有 1 种是正常出口,另外 4 种都是异常兜底 。对应代码:stopHookResult + state = { ...next }

接下来三章按 ② → ③ → ④ 顺序拆解。


API 调用全景:18+ 个参数的一次请求

deps.callModel() 的参数清单

query.ts L659 的 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 时跨边界传递------服务端看不到压缩前的完整历史,需要客户端主动汇报。
  • signaltoolUseContext.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_starttype: 'tool_use')时,查询循环立即调用 streamingToolExecutor.addTool(block, message)

typescript 复制代码
// query.ts L760-770 --- 流式循环中的工具触发
if (streamingToolExecutor) {
  for (const block of toolBlocks) {
    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 会取消所有正在执行的兄弟工具:

typescript 复制代码
// StreamingToolExecutor.ts --- 错误传播逻辑
if (toolName === BASH_TOOL_NAME && error) {
  this.siblingAbortController.abort('sibling_error')
}

这是因为 Bash 命令通常有前后依赖------如果 npm install 失败了,后续依赖它的工具也没有执行意义。而 FileRead 或 Glob 的失败是独立的------它们不会触发级联取消。

结果顺序:先完成不一定先产出

一个精妙的设计:StreamingToolExecutor 缓冲已完成的结果,按接收顺序产出------不是完成顺序。这保证了模型在下一轮看到 tool_result 时有确定性的顺序。

discard():Fallback 时清空

当流式 Fallback 触发时:

typescript 复制代码
// query.ts L733-739 --- Fallback 时清理
if (streamingToolExecutor) {
  streamingToolExecutor.discard()              // 清空所有 pending 工具
  streamingToolExecutor = new StreamingToolExecutor(  // 重建一个新的
    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 重试成功:

typescript 复制代码
// query.ts L1093-1116 --- Collapse Drain
if (feature('CONTEXT_COLLAPSE') && contextCollapse &&
    state.transition?.reason !== 'collapse_drain_retry') {
  const drained = 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()

typescript 复制代码
// query.ts L1119-1165 --- Reactive Compact
const compacted = await reactiveCompact.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 时:

  1. 设置 streamingFallbackOccured = true
  2. 外层检测到标志 → yield tombstone 清理废弃消息
  3. streamingToolExecutor.discard() 清空旧工具
  4. attemptWithFallback = true → 用 fallbackModel 重新 API 调用
  5. yield system message "Switched to fallback model"
  6. continue 回到循环顶部(retry 已经由外层 while(attemptWithFallback) 完成)

Continue 5:OTK Escalate(输出 token 超限升级)

当模型输出达到 output token 上限(isWithheldMaxOutputTokens)时,第一策略是升级上限

typescript 复制代码
// query.ts L1199-1220 --- OTK Escalate
if (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 消息让模型继续:

typescript 复制代码
// query.ts L1223-1251 --- OTK Recovery
if (maxOutputTokensRecoveryCount < MAX_OUTPUT_TOKENS_RECOVERY_LIMIT) {
  const recoveryMessage = 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)时:

typescript 复制代码
// query.ts L1282-1305 --- Stop Hook Blocking
if (stopHookResult.blockingErrors.length > 0) {
  state = { ...nextState, stopHookActive: true,
            transition: { reason: 'stop_hook_blocking' } }
  continue  // ← 注入错误消息,让模型修正后重试
}

stopHookActive 标志防止死循环------如果 hook 两次阻断同一个响应,说明模型无法满足 hook 的要求。

Bonus:Token Budget Continue

TOKEN_BUDGET feature 开启且预算未耗尽时:

typescript 复制代码
// query.ts L1316-1340 --- Token Budget nudge
if (decision.action === 'continue') {
  state = { ...nextState,
            transition: { reason: 'token_budget_continuation' } }
  continue  // ← 注入 nudge 消息提醒模型注意预算
}

这不是"错误恢复",而是"主动续命"------在预算快耗尽但还没耗尽时,注入提醒消息让模型加快节奏。


模型 Fallback:优雅降级

Fallback 的完整链路值得单独展开。触发条件是:主模型流式响应中抛出 FallbackTriggeredError(不是 4xx/5xx 网络错误,而是模型侧异常)。

完整处理流程(query.ts L712-750):

  1. 检测 streamingFallbackOccured 标志
  2. yield tombstone 给所有 assistantMessages------这些 partial 消息的 thinking blocks 有无效签名,不清理会导致后续 API 调用报"thinking blocks cannot be modified"错误
  3. 清空 assistantMessagestoolResultstoolUseBlocksneedsFollowUp
  4. streamingToolExecutor.discard() + 新建一个
  5. attemptWithFallback = true → 外层 while 循环重新走 API 调用

关键设计:tombstone 事件通知 REPL 移除 UI 中的旧消息,保证用户界面的清洁。


用户中断:Ctrl+C 的优雅处理

toolUseContext.abortController.signal.aborted 在两个关键位置被检查:

  1. 流式过程中deps.callModel() 接收 signal,中断后 isAbortedStreamingReason 为 true → 补全缺失的 tool_result → return { reason: 'aborted_streaming' }
  2. 工具执行中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 之前,代码都保证了两件事:

  1. 缺失的 tool_result 被补全(yieldMissingToolResultBlocks
  2. 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 源码感兴趣,欢迎关注本系列 后续更新。有任何想法或疑问,欢迎评论区留言讨论 👋

相关推荐
一直会游泳的小猫1 小时前
Claude Code 连 MySQL:保姆级教程
mysql·mcp·claude code
marsh02061 小时前
39 openclaw持续集成实践:自动化构建与部署流程
运维·ci/cd·ai·自动化·编程·技术
活跃的煤矿打工人1 小时前
【星海出品】防止大模型强依赖(二)
ai·gpu算力
一条水里的鱼1 小时前
把本地 skill 注册到 Claude Code Plugin 的完整流程
claude code
LucaJu1 小时前
DeepAgents 人工介入实战|LangGraph 实现 Agent 高危工具人工审批
python·langchain·agent·langgraph·deepagents
AI刀刀2 小时前
手机AI怎么导出pdf
人工智能·ai·智能手机·pdf·deepseek·ds随心转
皆过客,揽星河2 小时前
如何在 Edge 浏览器中使用 Deepsider 插件调用 GPT-Image-2.0
gpt·ai·ai作画·硬件工程·ai提示词·gpt-image-2.0·最新gpt版本体验
GoAI2 小时前
《深入浅出Agent》:项目深度解析Autoresearch
人工智能·深度学习·大模型·llm·agent
风无雨2 小时前
一、环境搭建与准备阶段(第 3–4 周)
ai