Claude Code 源码笔记 -- queryLoop
-
- queryLoop
-
- 压缩上下文
- [发送 API 请求前检查](#发送 API 请求前检查)
- [流式调用 AI](#流式调用 AI)
- [Backfill Observable Input](#Backfill Observable Input)
- [withheld 机制](#withheld 机制)
- [收集 assistant 消息和工具调用](#收集 assistant 消息和工具调用)
-
- [`streamingToolExecutor.addTool(toolBlock, assistantMessage)`](#
streamingToolExecutor.addTool(toolBlock, assistantMessage)) - `streamingToolExecutor.getCompletedResults()`
- [`streamingToolExecutor.addTool(toolBlock, assistantMessage)`](#
- [fallback 模型重试](#fallback 模型重试)
- 其他操作
-
- executePostSamplingHooks
- 中断检查
- [yield 上一轮的工具摘要](#yield 上一轮的工具摘要)
- `needsFollowUp`
- 工具执行后的各类附加处理
- 进入下一轮
queryLoop
核心方法:
query.ts/queryLoop()
整体流程
typescript
while (true) { // 外层状态机循环
// ---- 上下文管理 ----
applyToolResultBudget → snip → microcompact → collapse → autocompact
// ---- API 调用 ----
for await (message of callModel(...)) {
yield message // 流式输出给上层
收集 toolUseBlocks
[StreamingToolExecutor: 边流边执行工具]
}
// ---- 流结束后 ----
if (aborted) → 清理,return
if (!needsFollowUp) → 检查错误恢复,或正常 return
if (needsFollowUp) → 执行工具,把结果追加进消息,continue 下一轮
}
压缩上下文
latex
applyToolResultBudget ← 卸载超大 tool result
↓
snipCompactIfNeeded ← 剪切旧历史
↓
microcompact ← 清理旧工具结果
↓
applyCollapsesIfNeeded ← 【contextCollapse】提交暂存的折叠 + 投影视图
↓
autocompact ← 如果 collapse 让 token 降到阈值以下,autocompact 就跳过
↓
发送 API 请求
applyToolResultBudget()
压缩工具的内容,如果工具返回的结果超过预算阈值,那么工具的返回结果将被替换,原结果持久化到磁盘中,不在下次对话是原封不动返回给AI。当下次AI还是需要这个工具的具体内同时,AI再调用工具读取对应的持久化文件
plain
applyToolResultBudget(messages, state, writeToTranscript, skipToolNames)
│
├── state 为 undefined → 功能关闭,直接返回原消息
│
└── enforceToolResultBudget(messages, state, skipToolNames)
│
├─ 1. collectCandidatesByMessage()
│ 按"API wire 消息边界"分组,收集所有 tool_result 候选
│ (按 assistant 消息 ID 分边界,合并相同 ID 的碎片)
│
├─ 2. getPerMessageBudgetLimit()
│ 读取每条消息的 token 预算上限
│ (来自 GrowthBook flag 或硬编码常量)
│
├─ 3. 遍历每组候选(每条 API 消息):
│ │
│ ├─ partitionByPriorDecision() 三分候选:
│ │ ├── mustReapply: 已替换 → 直接从 Map 重应用(不做 I/O)
│ │ ├── frozen: 已见过但未替换 → 不可再动(缓存前缀已固化)
│ │ └── fresh: 首次出现 → 可以做预算决策
│ │
│ ├─ 跳过 skipToolNames 中的工具(如 Read,有自己的大小限制)
│ │
│ └─ frozenSize + freshSize > limit ?
│ Yes → selectFreshToReplace()
│ 按大小降序排序,贪心选出足够多的 fresh 结果
│ 直到 remaining ≤ limit
│ No → 本组不需替换
│
├─ 4. 并发 buildReplacement() (Promise.all)
│ 把选中的超大 tool_result 持久化到磁盘
│ 生成摘要预览字符串(含文件路径引用)
│
├─ 5. 更新 state(原子性):
│ seenIds.add(id) + replacements.set(id, previewStr)
│ 并记录 newlyReplaced 列表
│
└─ 6. replaceToolResultContents()
用 replacementMap 替换消息里对应的 content 字段
→ 返回修改后的 messages + newlyReplaced
│
└─ newlyReplaced.length > 0 → writeToTranscript(newlyReplaced)
把替换记录写入会话文件(仅 repl_main_thread / agent: 类来源)
用于 resume 时重建相同决策,保持缓存稳定
snipCompactIfNeeded()
剪切旧历史片段
microcompact()
通过依赖注入后的deps.microcompact调用
- Time-based:距离上一条 assistant 消息超过配置的分钟数时触发(说明 prompt cache 已经冷了)。把所有旧的 compactable tool result 内容替换为字符串 [Old tool result content cleared],只保留最近 N 条不动
- Cached Microcompact:需要模型支持。不修改本地消息,而是向 API 发送 cache_edits 指令,让服务端在不破坏已缓存前缀的前提下,在服务端直接删除旧的 tool result。本地消息不变,但服务端"看到的"历史里那些旧结果已经被删掉了,且 prompt cache 的命中不受影响。
- Fallback:如果以上两条都不触发,直接返回原消息不做任何处理(依赖后续 autocompact)。
contextCollapse.applyCollapsesIfNeeded()
不是把整个对话历史压缩成一段 AI 摘要,而是按"跨度(span)"粒度,把某些历史片段折叠成摘要,同时保留其他部分的原始细节。
关键概念
- span(跨度):一组连续的历史消息,代表某个任务片段(如"读了一堆文件"、"执行了一串命令")
- staged(暂存):已经决定要折叠,但还没提交。等待时机
- committed(已提交):真正折叠,从发给 AI 的消息里移除,换成摘要
- projectView:每轮发请求前,把已提交的折叠"投影"到消息数组里(原文替换为摘要),但 REPL 本地的完整历史不变
autoCompactIfNeeded()
通过依赖注入后的deps.autocompact调用
当对话 token 数接近模型上下文窗口上限时,用 AI 生成一段摘要替换掉大部分历史消息。
latex
autoCompactIfNeeded()
│
├── 1. 先尝试 sessionMemoryCompaction(轻量版)
│ 如果成功 → 直接返回,不走下面的全量压缩
│
└── 2. compactConversation()(全量压缩)
│
├── 执行 pre_compact hooks(用户/工具可注入自定义指令)
│
├── 用"fork agent"把完整历史发给 AI
│ 让 AI 生成一段对话摘要(summary)
│
├── 如果摘要请求本身也 prompt_too_long
│ → 截掉最老的消息,重试(最多 MAX_PTL_RETRIES 次)
│
├── 用摘要替换原来的全部历史消息
│ 保留:compact boundary 标记 + 最近几条消息
│
└── 执行 post_compact hooks + 清理状态
| 方式 | 信息损失 | 速度 | 触发时机 |
|---|---|---|---|
| applyToolResultBudget | 最小(有预览+磁盘文件) | 极快 | 单条消息过大 |
| microcompact | 中(旧工具结果清空) | 快(无 AI) | 定期/时间间隔 |
| snipCompact | 较大(直接删除历史段) | 快(无 AI) | 接近阈值 |
| contextCollapse.applyCollapsesIfNeeded | 中等(按 span 折叠,近期保留原文) | 中等(AI 生成分段摘要) | token 达 90% 开始暂存,95% 强制提交;替代 autocompact |
| autoCompactIfNeeded | 最大(全量替换为摘要) | 慢(需 AI 生成) | 触底时的最后手段 |
发送 API 请求前检查
calculateTokenWarningState()
在自动压缩关闭的情况下,防止 token 超限导致 API 直接报错,提前给用户一个友好提示,同时留出空间让用户手动执行/compact
给"什么压缩都不开"的用户的最后一道保护,其他压缩机制开启后这道门就让开,交给它们处理
执行条件
满足以下所有条件才执行检查:
- 本轮没有刚刚执行过 compaction
- querySource 不是 compact / session_memory(防止递归死锁)
- reactiveCompact 没开启自动压缩
- contextCollapse 没有接管(collapseOwnsIt = false)
流式调用 AI
queryModelWithStreaming()
通过依赖注入后调用deps.callModel()
如果流中途发生了 streaming fallback(streamingFallbackOccured=true主模型流式传输失败,切换备用模型),需要清理已收到的"孤儿消息":
- 对已 yield 的 assistantMessages 发出 tombstone(墓碑)消息,通知 UI 和 transcript 删除它们
- 重置 assistantMessages、toolResults、toolUseBlocks
- 重建 StreamingToolExecutor,防止旧 tool_use_id 泄漏到新请求流式 Fallback 处理
Backfill Observable Input
在工具参数被外部观察到之前,事后补充那些派生/规范化字段。
AI 生成的 input 是原始的、最简的(比如只有 file_path: "~/foo.ts"),backfill 就是在它被外部看到之前,把那些"应该有但 AI 没给"的派生字段事后补进去。类比数据库里的 backfill migration------原始数据不动,补充计算出来的衍生列。
AI 返回的 tool_use block 里包含一个 input对象,比如:
plain
json
插入复制
{ "file_path": "~/project/src/foo.ts" }
这个 input 有两个去向:
- 发回给 API(下一轮请求的历史消息里)→ 必须字节完全不变,否则破坏 prompt cache
- 给外部观察者看(SDK stream 输出、transcript 记录、hooks)→ 需要"规范化"的版本
两个需求互相矛盾:不能既不改又要改。解决方案是在 yield 前 clone 一份,只改副本。
latex
原始 message
│
├── 原始 message → assistantMessages.push() ← 保持字节不变,发回 API
│
└── clone message(yieldMessage)
│
对每个 tool_use block:
├── tool.backfillObservableInput(inputCopy) ← 就地修改副本
└── 只有当 backfill 新增了字段(不是覆盖)才替换
│
yield yieldMessage ← 外部看到的是规范化版本
backfillObservableInput()
当工具实现了该方法是才会backfill,FileReadTool / FileEditTool / FileWriteTool三个工具实现了该方法,其逻辑相同:
typescript
backfillObservableInput(input) {
// 把 ~ 或相对路径展开为绝对路径
if (typeof input.file_path === 'string') {
input.file_path = expandPath(input.file_path)
}
}
SendMessageTool也实现了该方法
withheld 机制
流式接收 AI 响应时,某些错误消息不能立刻 yield 给上层,要先扣押(withheld),等流结束后判断能否恢复,恢复成功就"消化掉"这个错误,恢复失败才释放出去。某些 SDK 调用者(如 cowork/desktop)一旦看到任何 error 消息就会立即终止会话。如果过早 yield,恢复循环虽然还在运行,但已经没有人在监听了------恢复白费。
plain
每条流式消息到来时:
① contextCollapse.isWithheldPromptTooLong() → prompt_too_long,且 collapse 能处理
② reactiveCompact.isWithheldPromptTooLong() → prompt_too_long,且 reactive compact 能处理
③ reactiveCompact.isWithheldMediaSizeError() → 媒体文件过大(图片/PDF)
④ isWithheldMaxOutputTokens() → max_output_tokens(输出 token 耗尽)
任意一个为 true → withheld = true → 不 yield,但仍 push 进 assistantMessages
流结束后的恢复链
plain
流结束,!needsFollowUp(没有工具调用)
│
├── isWithheld413(prompt_too_long)?
│ ├── drained = contextCollapse.recoverFromOverflow()
│ │ drained.committed > 0 → continue(重试)✓
│ │ drained.committed = 0 → 继续往下
│ └── reactiveCompact.tryReactiveCompact()
│ 成功 → continue(重试)✓
│ 失败 → yield 被扣押的错误,return 'prompt_too_long' ✗
│
├── isWithheldMedia(媒体过大)?
│ reactiveCompact.tryReactiveCompact() 处理
│ 失败 → yield 被扣押的错误,return 'image_error' ✗
│
└── isWithheldMaxOutputTokens(输出超限)?
① 先尝试"升级输出上限":8k → 64k,重试同一请求(continue)✓
② 再尝试"多轮恢复"(最多 3 次):
向 AI 注入提示消息 "Output token limit hit. Resume directly..."
追加到对话,continue 下一轮 ✓
③ 恢复次数耗尽(>3)→ yield 被扣押的错误 ✗
收集 assistant 消息和工具调用
每收到一条 assistant 类型消息:
assistantMessages.push(message)--- 存入本轮收到的 AI 回复- 提取其中的
tool_use, block → 存入 toolUseBlocks - 有
tool_use→needsFollowUp = true(标记本轮需要执行工具) - 如果是流式工具执行模式,同时
streamingToolExecutor.addTool(),立刻开始并发执行
streamingToolExecutor.addTool(toolBlock, assistantMessage)
- 判断工具是否存在,若不存在则返回值中标记
is_error: true,且默认标记状态为completed - 通过
tool.isConcurrencySafe(input)判断该工具是否可以和别的工具并发执行 - 工具加入队列
processQueue - 队列中若没有工具执行,则立即执行,若有且都是支持并发执行的,则全部立即执行,否则工具排队等待
streamingToolExecutor.getCompletedResults()
按顺序获取工具结果,每个工具都必须等待他前一个工具完成才能yield
fallback 模型重试
将前面的代码try-catch住,处理抛出的fallback类型异常
typescript
try { // ← 外层 try:捕获非 fallback 的真实错误
while (attemptWithFallback) { // ← 内层循环:专门用于 fallback 重试
attemptWithFallback = false
try { // ← 内层 try:捕获 FallbackTriggeredError
for await (message of callModel(...)) {
// 流式接收 + streaming fallback 处理
}
} catch (innerError) { // ← 内层 catch
if (innerError instanceof FallbackTriggeredError && fallbackModel) {
// 切换模型,清理状态
attemptWithFallback = true
continue // ← 重进 while,用新模型重试
}
throw innerError // ← 不是 FallbackTriggeredError,往外抛
}
}
} catch (error) { // ← 外层 catch:处理真实错误
// ImageSizeError、普通 API 错误等
return { reason: 'model_error' }
}
存在两种fallback机制:
streaming fallback(streamingFallbackOccured) |
FallbackTriggeredError | |
|---|---|---|
| 触发位置 | 流式接收过程中(onStreamingFallback回调) |
callModel 抛出异常 |
| 处理位置 | 流循环内部 | catch 块 |
| 重试范围 | 当前 for await循环继续接收新模型的消息 |
while(attemptWithFallback)重新发请求 |
| 触发原因 | 流式传输中途切换(API 层决定) | 连续 529(主模型过载,MAX_529_RETRIES 次后触发) |
fallback机制和withheld的区别:
- withheld 机制:针对 AI 成功响应但内容有错误(prompt_too_long、max_output_tokens)→ 扣押错误消息,等待恢复
- Fallback 重试:针对 API 请求本身失败(529 过载)→ 直接抛异常,不涉及 withheld
其他操作
executePostSamplingHooks
流式响应完成后,异步触发 PostSampling hooks(fire-and-forget,不等结果)。这类 hook 可以做日志、监控等后处理,不阻塞主流程。
中断检查
toolUseContext.abortController.signal.aborted
- 有 StreamingToolExecutor → 调 getRemainingResults() 让 executor 为未完成的工具生成合成 tool_result(保证 tool_use/tool_result 配对完整)
- 无 executor → yieldMissingToolResultBlocks() 补发
- 如果是 CHICAGO_MCP(computer use)→ 清理锁
- yield 中断消息,return { reason: 'aborted_streaming' }
yield 上一轮的工具摘要
pendingToolUseSummary是上一轮工具执行后异步发起的 Haiku 摘要请求,在 AI 流式输出期间已经跑完,这里 await 并 yield 结果
needsFollowUp
needsFollowUp表示本轮 AI 响应是否包含工具调用,需要执行工具后再发一次请求
latex
!needsFollowUp(AI 只是说话,没有调工具)
→ 走 "本轮结束" 路径
→ 处理 withheld 错误恢复、stop hooks、token budget
→ return { reason: 'completed' } 或其他退出原因
needsFollowUp = true(AI 调了工具)
→ 执行工具(runTools / streamingToolExecutor.getRemainingResults)
→ 把工具结果追加进消息
→ continue 进入下一轮循环(继续和 AI 对话)
工具执行后的各类附加处理
- abort 检查:工具执行中途被中断
- hook_stopped:hook 阻断了继续
- autocompact 轮次计数
- 队列命令注入:把排队的用户输入、memory 预取、skill 发现结果作为 attachment 追加进 toolResults
进入下一轮
- 刷新工具列表:新连接的 MCP server 立刻生效
- BG_SESSIONS 任务摘要:供 claude ps 查看
- maxTurns 检查:超轮次上限则退出
- 构建下一轮 State,continue:合并所有消息,开始下一轮