Claude Code 源码笔记 -- queryLoop

Claude Code 源码笔记 -- queryLoop

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调用

  1. Time-based:距离上一条 assistant 消息超过配置的分钟数时触发(说明 prompt cache 已经冷了)。把所有旧的 compactable tool result 内容替换为字符串 [Old tool result content cleared],只保留最近 N 条不动
  2. Cached Microcompact:需要模型支持。不修改本地消息,而是向 API 发送 cache_edits 指令,让服务端在不破坏已缓存前缀的前提下,在服务端直接删除旧的 tool result。本地消息不变,但服务端"看到的"历史里那些旧结果已经被删掉了,且 prompt cache 的命中不受影响。
  3. 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 有两个去向:

  1. 发回给 API(下一轮请求的历史消息里)→ 必须字节完全不变,否则破坏 prompt cache
  2. 给外部观察者看(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 类型消息:

  1. assistantMessages.push(message) --- 存入本轮收到的 AI 回复
  2. 提取其中的 tool_use, block → 存入 toolUseBlocks
  3. tool_useneedsFollowUp = true(标记本轮需要执行工具)
  4. 如果是流式工具执行模式,同时 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:合并所有消息,开始下一轮
相关推荐
凯尔萨厮2 小时前
Spring学习笔记(基于配置文件)
spring
计算机学姐2 小时前
基于SpringBoot的高校竞赛管理系统
java·spring boot·后端·spring·信息可视化·tomcat·mybatis
AnalogElectronic2 小时前
普通数据源和druid数据源区别以及druid参数详解
java
東雪木2 小时前
Java学习——泛型基础:泛型的核心作用、泛型类 / 方法 / 接口的定义
java·学习·java面试
一叶飘零_sweeeet2 小时前
ConcurrentHashMap 深度解析:从 JDK7 到 JDK8 的演进与并发安全保障
java·并发编程
三原2 小时前
超级好用的三原后台管理v1.0.0发布🎉(Vue3 + Ant Design Vue + Java Spring Boot )附源码
java·vue.js·开源
文慧的科技江湖2 小时前
光储充协同的终极闭环:用SpringCloud微服务打造“发-储-充-用“智能能源网络 - 慧知开源充电桩管理平台
java·开发语言·spring cloud·微服务·能源·充电桩开源平台·慧知重卡开源充电桩平台
水云桐程序员2 小时前
Quartus II集成开发环境 |FPGA
笔记·fpga开发·硬件工程·创业创新
東雪木2 小时前
Java学习——内部类(成员内部类、静态内部类、局部内部类、匿名内部类)的用法与底层实现
java·开发语言·学习·java面试