乐于分享
好东西不私藏

Claude Code 源码拆解(三):一个 1730 行的死循环,烧掉了 25 万次 API 调用

Claude Code 源码拆解(三):一个 1730 行的死循环,烧掉了 25 万次 API 调用

「Claude Code 源码拆解」系列 · 第三篇

一个文件 67KB、1730 行,却是整个 Claude Code 的心脏。它管着 AI 怎么调 API、怎么执行工具、怎么从错误中爬起来、以及怎么在上下文快爆的时候把记忆压缩掉。这篇文章我把 query.ts 从头到尾拆一遍,贴关键代码,讲清楚每层在干什么。

直接进。

一、两层生成器:外壳和内核

query.ts 的架构不是你想的”一个大函数跑一个 while 循环”。它是两层 AsyncGenerator 嵌套:

// 外层:公开入口export async function*query(params:QueryParams) {constconsumedCommandUuids:string[] = []constterminal =yield*queryLoop(params, consumedCommandUuids)// 只有正常返回才到这里——抛异常会直接穿透for(constuuidofconsumedCommandUuids) {notifyCommandLifecycle(uuid,‘completed’)  }returnterminal}// 内层:真正的循环async function*queryLoop(params, consumedCommandUuids) {letstate:State= { messages, toolUseContext, turnCount:1, … }while(true) {// 每次迭代处理一个 turn,1400+ 行逻辑    state = next  }}

外层负责命令生命周期通知(告诉 UI “这批命令执行完了”),内层才是真正的 Agent 循环。

为什么要分两层?因为 yield* 能把内层 generator 的所有 yield 透传出去,同时保证无论内层怎么退出(return / throw / .return()),外层都能做统一的清理。这是个很经典的 generator 委托模式。

二、State 对象:7 个 continue 站点的秘密

循环里有个设计让我印象很深。通常写循环你会用一堆变量,但这里把所有跨迭代的可变状态塞进了一个 State 对象:

typeState= {  messages:Message[]  toolUseContext:ToolUseContext  autoCompactTracking:AutoCompactTrackingState|undefined  maxOutputTokensRecoveryCount:number  hasAttemptedReactiveCompact:boolean  maxOutputTokensOverride:number|undefined  stopHookActive:boolean|undefined  turnCount:number  transition:Continue|undefined// 记录”为什么上次 continue 了”}

transition 字段特别有意思——它记录了上一次迭代为什么 continue 了,值有这些:

next_turn — 正常进入下一轮

reactive_compact_retry — 上下文太长,压缩后重试

max_output_tokens_recovery — 输出超限,注入恢复消息重试

max_output_tokens_escalate — 从 8k 升到 64k 重试

collapse_drain_retry — 上下文折叠后重试

stop_hook_blocking — stop hook 阻止了退出

token_budget_continuation — 预算续跑

这不是过度设计——在一个 1400 行的 while(true) 里有 7 个 continue 站点,如果用 9 个独立变量,出 bug 的时候你根本不知道状态是在哪个 continue 站点被改错的。

三、巫师注释:Thinking Block 的三条铁律

上一篇预告的”巫师注释”,它是这样的:

/** * The rules of thinking are lengthy and fortuitous. They require * plenty of thinking of most long duration and deep meditation * for a wizard to wrap one’s noggin around. * * The rules follow: * 1. A message that contains a thinking or redacted_thinking block *    must be part of a query whose max_thinking_length > 0 * 2. A thinking block may not be the last message in a block * 3. Thinking blocks must be preserved for the duration of an *    assistant trajectory * * Heed these rules well, young wizard. For they are the rules of * thinking, and the rules of thinking are the rules of the universe. * If ye does not heed these rules, ye will be punished with an * entire day of debugging and hair pulling. */

翻译一下这三条规则:

1. 如果消息里有 thinking block,那这次 API 调用的 max_thinking_length 必须 > 0

2. thinking block 不能是消息的最后一个块

3. thinking block 必须在整个 assistant 轨迹(一次对话 + 工具调用 + 后续回复)期间保留,不能删

这三条看起来简单,做起来要命。特别是第三条——当你需要压缩上下文的时候,你不能随便删掉 thinking block,否则 API 会 400。但 thinking block 又很大(模型的”思考过程”经常好几千 token),不删上下文就爆了。

写这段注释的人用了中世纪巫师的口吻,说不遵守就会”花一整天调试和拔头发”。这绝对是真实经历。

四、流式响应处理:三个你没想到的边缘 case

Tombstone 机制

当流式传输中途出错需要降级(比如 529 过载,切换备用模型)时,已经 yield 出去的消息怎么办?它们可能包含不完整的 thinking block,签名是旧模型的——发给新模型会直接 400。

解法是发”墓碑消息”:

if (streamingFallbackOccured) {// 给 UI 发 tombstone,移除孤儿消息for(constmsgofassistantMessages) {yield{ type:‘tombstone’, message: msg }  }  assistantMessages.length =0// 重建工具执行器  streamingToolExecutor?.discard()  streamingToolExecutor =newStreamingToolExecutor(…)}

UI 收到 tombstone 后会把对应消息从界面上删掉。这个机制的存在说明了一件事——流式 AI 的错误处理比你想象的复杂,因为你已经把半成品展示给用户了。

错误扣留(Withholding)

有些错误是可恢复的(上下文太长、输出超限),如果直接 yield 出去,SDK 调用方(比如桌面端)会认为会话挂了,直接终止。但其实 query.ts 内部还在尝试恢复。

所以它”扣留”了这些错误,不 yield,但仍然推入 assistantMessages 数组以便后续恢复逻辑检测:

let withheld = falseif (contextCollapse?.isWithheldPromptTooLong(message)) withheld = trueif (reactiveCompact?.isWithheldPromptTooLong(message)) withheld = trueif (isWithheldMaxOutputTokens(message)) withheld = trueif (!withheld) yield yieldMessage

注释里写了原因:提前 yield 错误会让 SDK 调用方(桌面端)终止会话,但恢复循环还在跑——没有人在监听了。
Backfill 克隆

工具执行后需要把额外信息回填到 tool_use 消息里。但原始消息不能改——因为它会被送回 API,任何字节变动都会破坏 prompt caching。

所以它克隆了消息,只在克隆版上做修改再 yield:

// 原始 message 不动(prompt caching 需要字节匹配)// 克隆一份做 backfill 再 yield 给 SDKlet yieldMessage = messageif (addedFields) {  clonedContent[i] = { …block, input: inputCopy }  yieldMessage = { …message,    message: { …message.message, content: clonedContent } }}

五、错误恢复:永不放弃的三段式

Prompt Too Long(413)恢复

上下文超了怎么办?两段式:

1. 先试 Context Collapse Drain——把已折叠的上下文提交掉,便宜、保留细粒度信息

2. 再试 Reactive Compact——完整摘要,丢失细节但能活下来

3. 都失败了才真正报错

每段只试一次。怎么防止重复?用 state.transition 判断:如果上次已经走过 collapse_drain_retry,就跳过直接进 reactive compact。

Max Output Tokens 恢复

输出超限更有意思,三段式:

第一段:升级——如果你用的是默认的 8k 上限,直接无声升到 64k,不注入任何消息,不打断模型思路:

if (capEnabled && maxOutputTokensOverride === undefined) {  state = { …state,    maxOutputTokensOverride: ESCALATED_MAX_TOKENS,    transition: { reason: ‘max_output_tokens_escalate’ } }continue}

第二段:多轮恢复——64k 也不够的话,注入一条精心设计的恢复消息,最多试 3 次:

const recoveryMessage = createUserMessage({  content: `Output token limit hit. Resume directly —    no apology, no recap of what you were doing.    Pick up mid-thought if that is where the cut happened.    Break remaining work into smaller pieces.`,  isMeta: true,})

注意这条消息的措辞:no apology, no recap——因为模型被截断后的本能反应是道歉然后重新概括上文,这会浪费 token。Pick up mid-thought 让它直接从断点继续。

第三段:放弃——3 次都不行,才把之前扣留的错误 yield 出去。

死循环防护:一个真实的血泪教训

// Preserve the reactive compact guard — if compact already ran and// couldn’t recover from prompt-too-long, retrying after a stop-hook// blocking error will produce the same result. Resetting to false// here caused an infinite loop:// compact → still too long → error → stop hook blocking// → compact → … burning thousands of API calls.hasAttemptedReactiveCompact,  // 故意保留,不重置

有人把 hasAttemptedReactiveCompact 在 stop hook 之后重置为 false,导致了死循环,烧掉了上千次 API 调用。BQ 数据显示有 1,279 个会话出现了 50+ 次连续失败(最多 3,272 次),每天浪费约 25 万次 API 调用。

这就是为什么代码里有个熔断器:连续失败 3 次后,直接停止尝试。

六、上下文压缩:五层流水线

每次循环迭代,消息在送出 API 之前要经过五层压缩,按顺序执行:

1.applyToolResultBudget— 工具结果大小预算(裁剪过大的输出)2.snipCompact— 历史裁剪(删除太旧的消息)3.microcompact— 微压缩(移除旧工具的详细结果)4.contextCollapse— 上下文折叠(保留结构,压缩内容)5.autocompact— 自动摘要(完整重写为摘要)

它们不是互斥的——snip 和 microcompact 可以同时跑,collapse 和 autocompact 则互斥(collapse 开启时 autocompact 被抑制,因为 collapse 自己管上下文)。

为什么要五层?因为每层的代价不同。前面的层便宜、损失小,后面的层贵但更有效。能用便宜方案解决的就不上贵的。

写在最后

读完 query.ts 的 1730 行代码,我最大的感受是:AI Agent 的核心循环本身很简单(调 API → 执行工具 → 再调 API),但真正的工程量全在”出了问题怎么办”。

错误恢复有三段式,上下文管理有五层流水线,流式降级有 tombstone 机制,死循环有熔断器,thinking block 有巫师三定律。这些东西加起来,才是这个文件 67KB 的原因。

最让我佩服的不是代码本身,而是注释——几乎每个 continue 站点、每个恢复路径都有详细的注释解释”为什么这么做”和”之前踩了什么坑”。这说明这个文件经历了大量的线上事故打磨,每个边缘 case 都是真金白银的 API 调用烧出来的。

📄 完整代码分析报告(1.5万字)

我让 Claude Code 用 4 个 Agent 并行,对它自己的源码做了一份完整分析报告,覆盖 22,068 个文件,包含:核心架构分层、40+ 工具清单、状态管理详解、Hook 生命周期、权限系统、设计模式识别…

后台私信「claude code」即可获取 👇另有一份更详细的架构流程图正在整理中,敬请期待

一个问题留给大家:

query.ts

 单文件 1730 行、7 个 continue 站点、三段式错误恢复——你觉得这种”一个文件搞定所有边缘 case”,是工程上的务实选择,还是迟早要拆的定时炸弹?评论区聊聊 👇

下一篇,拆 API 层——90 秒流式超时看门狗、10 次重试引擎,以及那个为了避免 O(n²) 而绕开 SDK 官方封装的骚操作。

本文基于 npm 包 @anthropic-ai/claude-code v2.1.88 的反编译源码进行分析仅用于技术学习 · 源码版权归 Anthropic 所有