第 07 课:query.ts — Agent 循环的心脏

0 阅读10分钟

模块三:Agent 核心循环 | 前置依赖:第 06 课 | 预计学习时间:75 分钟


学习目标

完成本课后,你将能够:

  1. 解释 query() 异步生成器函数的签名与返回类型
  2. 完整追踪 while(true) 循环中一次迭代的所有阶段
  3. 说明 callModel() 的调用方式与流式响应处理
  4. 理解 tool_use 检测、findToolByName() 分发、以及工具结果收集的完整流程
  5. 描述错误恢复机制(FallbackTriggeredError、max_output_tokens recovery、reactive compact)

7.1 query.ts 的地位

在第 01 课中,我们说 query.ts 是整个系统的"心跳"。现在我们要深入这颗心脏的每一条血管。

query.ts 大约 1730 行,是一个单文件状态机。它的核心职责:

query.ts 职责清单:
├── 接收消息列表,构建 API 请求
├── 调用 Claude API(通过 callModel)
├── 流式接收响应(text 块 + tool_use 块)
├── 检测 tool_use → 分发工具执行
├── 收集工具结果 → 追加到消息列表
├── 判断是否继续循环
├── 上下文压缩(auto-compact、snip、microcompact)
├── 错误恢复(模型降级、prompt-too-long、max_output_tokens)
└── 附件注入(memory、skill、queued commands)

为什么是生成器函数?

query() 不是普通的 async function,而是 async function* —— 异步生成器。这个设计选择至关重要:

// query.ts 核心签名
export async function* query(
  params: QueryParams,
): AsyncGenerator<
  | StreamEvent
  | RequestStartEvent
  | Message
  | TombstoneMessage
  | ToolUseSummaryMessage,
  Terminal
> {
  const consumedCommandUuids: string[] = []
  const terminal = yield* queryLoop(params, consumedCommandUuids)
  for (const uuid of consumedCommandUuids) {
    notifyCommandLifecycle(uuid, 'completed')
  }
  return terminal
}

为什么用生成器而不是回调?

方式问题
回调/EventEmitter调用方无法控制消费节奏;容易内存泄漏
Promise只能返回一次值;无法表达"流"
Observable (RxJS)增加大量依赖;学习成本高
AsyncGenerator原生 JS;调用方用 for await...of 按需消费;支持背压

调用方(REPL.tsx)这样消费:

for await (const event of query(params)) {
  // 每个 event 可能是:
  // - StreamEvent(text 块 / tool_use 块的流式片段)
  // - Message(完整的 assistant/user/system 消息)
  // - TombstoneMessage(需要从 UI 移除的消息)
  // - ToolUseSummaryMessage(工具使用摘要)
  handleEvent(event)
}

7.2 QueryParams — 输入参数

进入循环前,先看输入:

export type QueryParams = {
  messages: Message[]           // 完整对话历史
  systemPrompt: SystemPrompt    // 系统提示词
  userContext: { [k: string]: string }   // 用户上下文(CLAUDE.md 等)
  systemContext: { [k: string]: string } // 系统上下文(git status 等)
  canUseTool: CanUseToolFn      // 权限检查函数
  toolUseContext: ToolUseContext // 工具执行上下文(巨大的上下文对象)
  fallbackModel?: string        // 降级模型名称
  querySource: QuerySource      // 查询来源('repl_main_thread', 'agent:', 'sdk')
  maxOutputTokensOverride?: number
  maxTurns?: number             // 最大工具使用轮数
  skipCacheWrite?: boolean
  taskBudget?: { total: number }  // API task_budget
  deps?: QueryDeps              // 依赖注入(测试用)
}

注意 deps 参数 —— 这是依赖注入的入口,允许测试替换 callModelautocompactmicrocompact 等核心依赖。


7.3 循环状态机 — State 类型

queryLoop 内部维护一个可变状态对象,在每次循环迭代之间传递:

type State = {
  messages: Message[]                    // 当前消息列表(每轮可能变化)
  toolUseContext: ToolUseContext          // 工具执行上下文
  autoCompactTracking: AutoCompactTrackingState | undefined
  maxOutputTokensRecoveryCount: number   // max_output_tokens 恢复次数
  hasAttemptedReactiveCompact: boolean   // 是否已尝试 reactive compact
  maxOutputTokensOverride: number | undefined
  pendingToolUseSummary: Promise<ToolUseSummaryMessage | null> | undefined
  stopHookActive: boolean | undefined
  turnCount: number                      // 当前轮次计数
  transition: Continue | undefined       // 上一次迭代为什么 continue
}

transition 字段记录上一轮为什么继续循环,这对调试和测试非常有价值:

// 所有可能的 continue 原因
type Continue =
  | { reason: 'next_turn' }                    // 正常:有 tool_use,继续
  | { reason: 'reactive_compact_retry' }       // prompt-too-long 后压缩重试
  | { reason: 'collapse_drain_retry'; committed: number }
  | { reason: 'max_output_tokens_recovery'; attempt: number }
  | { reason: 'max_output_tokens_escalate' }
  | { reason: 'stop_hook_blocking' }
  | { reason: 'token_budget_continuation' }

7.4 while(true) 循环的完整流程

这是整个文件的核心。每次迭代包含以下阶段:

┌─────────────────────────────────────────────────────────────┐
│                 queryLoop: while(true) 一次迭代              │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  ① 解构 state → 获取本轮参数                                 │
│    │                                                        │
│  ② 上下文预处理                                              │
│    ├── applyToolResultBudget() → 裁剪过大的工具结果           │
│    ├── snipCompactIfNeeded() → 历史修剪                      │
│    ├── microcompact() → 微压缩                               │
│    ├── applyCollapsesIfNeeded() → 上下文折叠                  │
│    └── autocompact() → 自动压缩(可能触发完整压缩)            │
│    │                                                        │
│  ③ 阻塞检查 — token 是否超限                                 │
│    │                                                        │
│  ④ callModel() — 调用 Claude API                            │
│    ├── for await (message of callModel(...))                │
│    ├── 收集 assistantMessages[]                              │
│    ├── 检测 tool_use 块 → toolUseBlocks[]                   │
│    ├── 流式工具执行(StreamingToolExecutor)                  │
│    └── 处理 FallbackTriggeredError(模型降级)                │
│    │                                                        │
│  ⑤ 后处理                                                   │
│    ├── executePostSamplingHooks()                            │
│    ├── 处理 abort(用户中断)                                 │
│    └── yield pendingToolUseSummary                           │
│    │                                                        │
│  ⑥ 分支判断                                                 │
│    ├── needsFollowUp === false → 尝试退出                    │
│    │   ├── prompt-too-long 恢复                              │
│    │   ├── max_output_tokens 恢复                            │
│    │   ├── stop hooks 检查                                   │
│    │   ├── token budget 检查                                 │
│    │   └── return { reason: 'completed' }                   │
│    │                                                        │
│    └── needsFollowUp === true → 执行工具                     │
│        ├── runTools() / streamingToolExecutor                │
│        ├── 收集 toolResults[]                                │
│        ├── 注入附件(memory、skill、queued commands)         │
│        ├── 检查 maxTurns 限制                                │
│        └── state = next → continue                          │
│                                                             │
└─────────────────────────────────────────────────────────────┘

阶段详解

① 解构状态

每次迭代开始时,从 state 中解构出本轮需要的所有变量:

let { toolUseContext } = state
const {
  messages,
  autoCompactTracking,
  maxOutputTokensRecoveryCount,
  hasAttemptedReactiveCompact,
  maxOutputTokensOverride,
  pendingToolUseSummary,
  stopHookActive,
  turnCount,
} = state

注意 toolUseContextlet 声明 —— 它在迭代内会被重新赋值(queryTracking 更新)。

② 上下文预处理流水线

在调用 API 之前,消息列表要经过 5 层处理:

原始消息
  │
  ▼ applyToolResultBudget()    — 大结果持久化到磁盘
  │
  ▼ snipCompactIfNeeded()      — 历史修剪(HISTORY_SNIP feature)
  │
  ▼ microcompact()             — 精细压缩(移除冗余的 tool_result)
  │
  ▼ applyCollapsesIfNeeded()   — 上下文折叠(CONTEXT_COLLAPSE feature)
  │
  ▼ autocompact()              — 完整自动压缩(生成摘要替代历史)
  │
  ▼ 就绪的 messagesForQuery

如果 autocompact 触发了压缩,会 yield 新的压缩边界消息:

if (compactionResult) {
  const postCompactMessages = buildPostCompactMessages(compactionResult)
  for (const message of postCompactMessages) {
    yield message  // 通知调用方:消息已被压缩
  }
  messagesForQuery = postCompactMessages
}

③ 阻塞限制检查

如果 token 计数超过硬限制且 auto-compact 未开启,直接返回错误:

const { isAtBlockingLimit } = calculateTokenWarningState(
  tokenCountWithEstimation(messagesForQuery) - snipTokensFreed,
  toolUseContext.options.mainLoopModel,
)
if (isAtBlockingLimit) {
  yield createAssistantAPIErrorMessage({
    content: PROMPT_TOO_LONG_ERROR_MESSAGE,
    error: 'invalid_request',
  })
  return { reason: 'blocking_limit' }
}

④ callModel — API 调用与流式处理

这是核心中的核心:

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: currentModel,
    fallbackModel,
    querySource,
    // ... 更多选项
  },
})) {
  // 处理每个流式消息
  if (message.type === 'assistant') {
    assistantMessages.push(message)
    
    // 检测 tool_use 块
    const msgToolUseBlocks = message.message.content.filter(
      content => content.type === 'tool_use',
    ) as ToolUseBlock[]
    if (msgToolUseBlocks.length > 0) {
      toolUseBlocks.push(...msgToolUseBlocks)
      needsFollowUp = true  // 标记:需要继续循环
    }
    
    // 流式工具执行(边收边做)
    if (streamingToolExecutor) {
      for (const toolBlock of msgToolUseBlocks) {
        streamingToolExecutor.addTool(toolBlock, message)
      }
    }
  }
  
  // yield 给调用方(除非被扣留)
  if (!withheld) {
    yield yieldMessage
  }
}

关键细节:needsFollowUp 标志

这是决定循环是否继续的唯一信号。只有当 tool_use 块存在时才设为 truestop_reason 不可靠(源码注释明确说明),所以不依赖它。


7.5 FallbackTriggeredError — 模型降级

当主模型不可用(过载)时,API 层抛出 FallbackTriggeredError

} catch (innerError) {
  if (innerError instanceof FallbackTriggeredError && fallbackModel) {
    // 切换到降级模型
    currentModel = fallbackModel
    attemptWithFallback = true

    // 清除已收集的消息(它们属于失败的请求)
    yield* yieldMissingToolResultBlocks(
      assistantMessages,
      'Model fallback triggered',
    )
    assistantMessages.length = 0
    toolUseBlocks.length = 0
    needsFollowUp = false

    // 通知用户
    yield createSystemMessage(
      `Switched to ${renderModelName(innerError.fallbackModel)} ...`,
      'warning',
    )
    continue  // 重试内层循环
  }
  throw innerError
}

降级流程图:

请求主模型 → 失败(FallbackTriggeredError)
    │
    ▼
清除已收集的 assistantMessages
    │
    ▼
为孤立的 tool_use 生成错误 tool_result(yieldMissingToolResultBlocks)
    │
    ▼
yield 系统消息告知用户
    │
    ▼
切换 currentModel = fallbackModel
    │
    ▼
attemptWithFallback = true → continue(重试内层 while)

注意 yieldMissingToolResultBlocks 的作用:如果流式传输过程中已经 yield 了部分 tool_use 块,降级时必须为每个 tool_use 生成一个对应的 tool_result(标记为错误),否则 API 协议会不一致。


7.6 工具执行与结果收集

needsFollowUp === true,进入工具执行阶段:

const toolUpdates = streamingToolExecutor
  ? streamingToolExecutor.getRemainingResults()  // 流式执行器:收集剩余结果
  : runTools(toolUseBlocks, assistantMessages, canUseTool, toolUseContext)

for await (const update of toolUpdates) {
  if (update.message) {
    yield update.message  // 工具结果消息

    // 收集归一化后的 tool_result
    toolResults.push(
      ...normalizeMessagesForAPI(
        [update.message],
        toolUseContext.options.tools,
      ).filter(_ => _.type === 'user'),
    )
  }
  if (update.newContext) {
    updatedToolUseContext = { ...update.newContext, queryTracking }
  }
}

两种执行模式:

模式触发条件特点
StreamingToolExecutorfeature gate 开启边接收边执行;并发安全的工具先跑
runTools默认等 API 响应完整后再批量执行

findToolByName — 工具查找

工具分发通过 findToolByName 完成(定义在 Tool.ts 中):

export function findToolByName(tools: Tools, name: string): Tool | undefined {
  return tools.find(t => toolMatchesName(t, name))
}

export function toolMatchesName(
  tool: { name: string; aliases?: string[] },
  name: string,
): boolean {
  return tool.name === name || (tool.aliases?.includes(name) ?? false)
}

支持别名查找 —— 工具重命名后,旧名仍可工作。


7.7 附件注入

工具执行完成后、下一轮循环前,系统注入各种附件:

// 1. 排队的命令(用户中途输入)
for await (const attachment of getAttachmentMessages(
  null, updatedToolUseContext, null,
  queuedCommandsSnapshot,
  [...messagesForQuery, ...assistantMessages, ...toolResults],
  querySource,
)) {
  yield attachment
  toolResults.push(attachment)
}

// 2. 记忆预取结果
if (pendingMemoryPrefetch && pendingMemoryPrefetch.settledAt !== null) {
  const memoryAttachments = filterDuplicateMemoryAttachments(...)
  for (const memAttachment of memoryAttachments) {
    const msg = createAttachmentMessage(memAttachment)
    yield msg
    toolResults.push(msg)
  }
}

// 3. 技能发现结果
if (skillPrefetch && pendingSkillPrefetch) {
  const skillAttachments = await skillPrefetch.collectSkillDiscoveryPrefetch(...)
  for (const att of skillAttachments) {
    const msg = createAttachmentMessage(att)
    yield msg
    toolResults.push(msg)
  }
}

注入时机是关键:必须在工具执行之后、下一次 API 调用之前。如果在工具执行之前注入,可能会打断 tool_use / tool_result 的配对关系。


7.8 循环退出条件

循环有多种退出路径:

// 正常退出 — 没有 tool_use,Claude 完成了回复
if (!needsFollowUp) {
  // ... 各种恢复检查 ...
  return { reason: 'completed' }
}

// 用户中断
if (toolUseContext.abortController.signal.aborted) {
  return { reason: 'aborted_streaming' }  // 或 'aborted_tools'
}

// 达到最大轮次
if (maxTurns && nextTurnCount > maxTurns) {
  yield createAttachmentMessage({ type: 'max_turns_reached', ... })
  return { reason: 'max_turns', turnCount: nextTurnCount }
}

// Hook 阻止继续
if (shouldPreventContinuation) {
  return { reason: 'hook_stopped' }
}

// 阻塞限制
return { reason: 'blocking_limit' }

// 模型错误
return { reason: 'model_error', error }

所有退出都通过 Terminal 类型返回原因,这让调用方知道循环为什么结束。


7.9 max_output_tokens 恢复机制

当 Claude 的输出被截断(达到 max_output_tokens 限制)时,系统有两级恢复:

第一级:Escalate(升级)

如果使用的是默认的 8k token 上限,先尝试升级到 64k:

if (capEnabled && maxOutputTokensOverride === undefined) {
  const next: State = {
    ...state,
    maxOutputTokensOverride: ESCALATED_MAX_TOKENS,  // 64k
    transition: { reason: 'max_output_tokens_escalate' },
  }
  state = next
  continue  // 用更高限制重试
}

第二级:Recovery(接续)

如果 64k 也不够,注入恢复消息要求 Claude 继续:

if (maxOutputTokensRecoveryCount < MAX_OUTPUT_TOKENS_RECOVERY_LIMIT) {  // 最多 3 次
  const recoveryMessage = createUserMessage({
    content: `Output token limit hit. Resume directly — no apology, no recap...`,
    isMeta: true,
  })
  state = {
    ...state,
    messages: [...messagesForQuery, ...assistantMessages, recoveryMessage],
    maxOutputTokensRecoveryCount: maxOutputTokensRecoveryCount + 1,
    transition: { reason: 'max_output_tokens_recovery', attempt: ... },
  }
  continue
}

恢复消息的措辞经过精心设计:"不要道歉、不要回顾你在做什么、从中断点直接继续、把剩余工作拆成小块"。


7.10 状态转移图

                    ┌──────────────────┐
                    │   while(true)    │
                    │   循环入口        │
                    └───────┬──────────┘
                            │
                   ┌────────▼────────┐
                   │  上下文预处理    │
                   │  (compact 等)   │
                   └────────┬────────┘
                            │
                   ┌────────▼────────┐
                   │  callModel()    │
                   │  流式接收响应    │
                   └────────┬────────┘
                            │
              ┌─────────────┼─────────────┐
              │                           │
     ┌────────▼────────┐       ┌──────────▼──────────┐
     │ needsFollowUp   │       │ needsFollowUp       │
     │ === false        │       │ === true             │
     │ (无 tool_use)    │       │ (有 tool_use)        │
     └────────┬────────┘       └──────────┬──────────┘
              │                            │
     ┌────────▼────────┐       ┌──────────▼──────────┐
     │ 恢复检查        │       │ 执行工具             │
     │ PTL/MOT/hooks   │       │ runTools()          │
     └───┬────┬────────┘       └──────────┬──────────┘
         │    │                            │
    ┌────▼┐ ┌─▼──────┐        ┌──────────▼──────────┐
    │退出 │ │continue│        │ 注入附件             │
    │     │ │(恢复)  │        │ (memory/skill/cmd)  │
    └─────┘ └────────┘        └──────────┬──────────┘
                                         │
                              ┌──────────▼──────────┐
                              │ state = next        │
                              │ continue            │
                              └─────────────────────┘

课后练习

练习 1:追踪一次完整循环

假设用户发送 "请帮我看看 src/index.ts 的内容",Claude 返回一个包含 tool_use (Read 工具) 的响应。追踪这次交互在 queryLoop 中的完整路径:

  1. 第一次迭代中 callModel() 返回什么?
  2. needsFollowUp 在何处被设为 true
  3. 工具执行发生在循环的哪个位置?
  4. 第二次迭代的 messages 数组比第一次多了什么?
  5. 循环如何知道第二次迭代可以退出?

练习 2:降级机制分析

阅读 FallbackTriggeredError 的处理代码,回答:

  1. 为什么降级时需要清空 assistantMessages
  2. yieldMissingToolResultBlocks 解决什么问题?
  3. 如果 fallbackModelundefined,降级会怎样?
  4. 流式降级(streamingFallbackOccured)和异常降级有什么区别?

练习 3:设计恢复策略

max_output_tokens 恢复最多尝试 3 次(MAX_OUTPUT_TOKENS_RECOVERY_LIMIT)。思考:

  1. 为什么不能无限重试?
  2. 恢复消息为什么要求 "不要道歉"?
  3. 如果你来设计这个恢复机制,你会做什么不同的设计?

练习 4:依赖注入实验

queryLoop 通过 deps 参数实现依赖注入:

const deps = params.deps ?? productionDeps()

思考如果你要为 queryLoop 写测试:

  1. 需要 mock 哪些 deps 方法?
  2. 如何模拟一个 prompt_too_long 错误?
  3. 如何验证 autocompact 被正确触发?

本课小结

要点内容
函数签名async function* query() — 异步生成器,yield 流式事件
核心循环while(true) + State 状态对象 + transition 记录 continue 原因
API 调用deps.callModel() 返回 AsyncIterable<Message>
tool_use 检测遍历 message.contenttype === 'tool_use' 的块
工具分发findToolByName() 支持主名称和别名查找
上下文压缩5 层流水线:budget → snip → micro → collapse → auto
错误恢复模型降级 / prompt-too-long reactive compact / max_output_tokens escalate+recovery
循环退出Terminal 类型记录退出原因:completed / aborted / max_turns / hook_stopped / error

下一课预告

第 08 课:消息类型与对话流转 — 我们将深入 types/message.tsutils/messages.ts,理解 Claude Code 的 7 种消息类型(UserMessage、AssistantMessage、SystemMessage、AttachmentMessage、ProgressMessage、ToolUseSummaryMessage、TombstoneMessage)各自的结构、创建方式、以及它们如何在系统中流转。消息是 Agent 循环的血液,理解了它们,你就能读懂系统中任何一段数据流。