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.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 时跨边界传递------服务端看不到压缩前的完整历史,需要客户端主动汇报。 - 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):
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 时:
- 设置
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)时,第一策略是升级上限:
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):
- 检测
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 源码感兴趣,欢迎关注本系列 后续更新。有任何想法或疑问,欢迎评论区留言讨论 👋