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 所有
夜雨聆风