Skip to content

第4章 Query 引擎:Agent 的心脏

"The heart of software is its loop." -- Gerald Weinberg, The Psychology of Computer Programming

本章要点

  • query.tsAsyncGenerator 编排模式:为什么选择生成器而非 Promise
  • QueryEngine.ts 的会话生命周期管理与系统提示词动态组装
  • 查询循环状态机的完整状态转换图谱
  • Auto-compact、Token Budget、Stop Hooks 三大停止与续行机制的协同运作
  • queryLoopState 结构体的精妙设计与九种 continue 语义
  • 错误恢复的分级策略:从扣留到降级,从压缩到放弃

如果说 CLI 启动流程是 Claude Code 的骨骼,那么 Query 引擎就是它的心脏。每一次用户提问,每一次工具调用,每一次模型响应,都由这颗心脏驱动完成。在本章中,我们将深入 query.tsQueryEngine.ts 这两个核心文件,从类型定义到状态机实现,从消息预处理到错误恢复,逐层揭示 Claude Code 最核心的运行机制。

4.1 总览:两层架构的分工

以下架构图展示了 QueryEngine 和 query 两层的分工及数据流向:

Claude Code 的查询引擎采用了经典的分层设计,由两个核心文件构成。上层的 QueryEngine.ts 是一个有状态的类,负责管理整个对话的生命周期,包括会话级别的状态维护、系统提示词的动态组装、用户输入的预处理以及消息的持久化存储。下层的 query.ts 则是一个无状态的纯函数(准确地说是异步生成器函数),负责单次查询的核心循环调度,包括消息标准化、API 调用、工具执行编排和停止条件判断。

这种分层的好处是显而易见的。query.ts 的循环逻辑不依赖任何外部状态,所有需要的信息都通过参数传入,这使得它可以被不同的上层调用者复用——无论是交互式 REPL 中的 ask() 函数,还是 SDK 无头模式中的 QueryEngine.submitMessage(),最终都会调用同一个 query() 函数。而 QueryEngine.ts 则封装了 SDK 特有的关注点:权限拒绝追踪、结构化输出强制执行、文件历史快照、以及 SDK 消息格式转换。

理解这两个文件的协作关系,是读懂整个 Claude Code 架构的关键。以下是它们之间的调用层次:

QueryEngine.ts (会话层)
  |
  |-- submitMessage()  会话入口,AsyncGenerator
  |     |
  |     |-- processUserInput()   用户输入预处理(斜杠命令解析等)
  |     |-- fetchSystemPromptParts()  系统提示词获取
  |     |-- query()  调用下层循环
  |     |-- 消息持久化 / 转录记录 / SDK 格式转换
  |
query.ts (循环层)
  |
  |-- query()  入口 AsyncGenerator,薄包装
  |     |
  |     |-- queryLoop()  核心 while(true) 循环
  |           |
  |           |-- 消息预处理流水线(snip / microcompact / collapse / autocompact)
  |           |-- 流式 API 调用(callModel)
  |           |-- 工具执行(runTools / StreamingToolExecutor)
  |           |-- 停止条件判断与错误恢复
  |           |-- state 转移 -> continue 或 return

在这个架构中,数据的流向是单向的:用户输入从 QueryEngine 流入 query,中间产物(流式事件、工具结果、系统消息)通过 yieldquery 流出到 QueryEngine,再经过格式转换后流向最终的 SDK 消费者。这种单向数据流极大地简化了心智模型,避免了双向通信带来的复杂性。

4.2 query.ts:高层编排

query.ts 是整个 Claude Code 中最长的单文件,约 1730 行。它的核心是一个 while(true) 循环,通过九种不同的 continue 路径和多种 return 路径实现完整的查询生命周期管理。我们从类型定义开始,逐步深入每一个关键环节。

4.2.1 QueryParams 类型定义的关键字段

query 函数的入参通过 QueryParams 类型定义,它携带了一次查询所需的全部上下文。这个类型是理解 query 系统的入口点:

typescript
// 文件:src/query.ts

export type QueryParams = {
  messages: Message[]
  systemPrompt: SystemPrompt
  userContext: { [k: string]: string }
  systemContext: { [k: string]: string }
  canUseTool: CanUseToolFn
  toolUseContext: ToolUseContext
  fallbackModel?: string
  querySource: QuerySource
  maxOutputTokensOverride?: number
  maxTurns?: number
  skipCacheWrite?: boolean
  taskBudget?: { total: number }
  deps?: QueryDeps
}

这些字段可以按照职责划分为四个类别,每个类别的设计都有其深层考量:

对话上下文类messages 是当前对话的完整消息历史数组,包含用户消息、助手消息、系统消息和工具结果。systemPrompt 是预编译好的系统提示词数组,userContext 包含 CLAUDE.md 配置文件等用户级上下文,systemContext 包含 git 状态和日期等系统级上下文。这三者将通过不同的方式注入到 API 请求中——这个设计决策与 Anthropic API 的缓存机制密切相关,我们将在 4.2.3 节详细讨论。

权限与工具类canUseTool 是一个异步函数,用于判断特定工具在当前上下文中是否可以使用。toolUseContext 是一个重要的上下文对象,它不仅携带了可用工具列表和模型配置,还包含了 MCP 客户端列表、abort controller、应用状态访问器等运行时基础设施。可以说,toolUseContext 是连接 query 循环与外部世界的纽带。

控制参数类maxTurns 限制模型与工具之间的最大交互轮数,taskBudget 设置 token 消耗预算,fallbackModel 指定主模型不可用时的降级目标。querySource 标识查询的来源(如 sdkrepl_main_threadcompactsession_memory 等),这个字段在后续的逻辑分支中频繁出现,用于区分主线程查询和各种派生查询(如压缩子 agent)。

依赖注入类deps 字段是整个设计中最值得关注的架构决策之一。通过 QueryDeps 接口,测试可以直接注入 mock 实现,而生产环境则使用 productionDeps() 返回真实依赖:

typescript
// 文件:src/query/deps.ts

export type QueryDeps = {
  callModel: typeof queryModelWithStreaming
  microcompact: typeof microcompactMessages
  autocompact: typeof autoCompactIfNeeded
  uuid: () => string
}

export function productionDeps(): QueryDeps {
  return {
    callModel: queryModelWithStreaming,
    microcompact: microcompactMessages,
    autocompact: autoCompactIfNeeded,
    uuid: randomUUID,
  }
}

这里使用了 typeof fn 来定义类型,这确保了接口签名与真实实现始终保持同步——如果真实函数的参数发生变化,TypeScript 编译器会立即在所有 mock 实现上报错。源码注释中特别说明,这种模式取代了之前分散在 6 到 8 个测试文件中的 spyOn 模式,显著降低了测试的模板代码量。当前刻意将范围限制在 4 个依赖上以验证模式的可行性,未来会逐步扩展到 runToolshandleStopHooks 等更多依赖。

4.2.2 消息标准化流程

以下流程图展示了消息在进入 API 调用之前经历的五级预处理流水线:

在每一轮循环迭代开始时,消息数组需要经过一条多级预处理流水线。每一级都可能缩减消息数组的大小或修改其内容,最终目标是将消息控制在模型上下文窗口的安全范围内。这是 Claude Code 处理长对话的核心策略。

第一级:Compact 边界截取。调用 getMessagesAfterCompactBoundary(messages) 截取最近一次压缩边界之后的消息。压缩边界是一个特殊的系统消息,标记着"从这里开始才是有效对话"。这一步确保了压缩前的原始历史不会被重复发送给 API。

第二级:Tool Result 预算裁剪applyToolResultBudget 函数对每条消息中的工具结果施加大小限制。某些工具(如读取大文件)可能产生巨大的输出,如果不加限制,单条工具结果就可能占据大量上下文空间。这个函数会将超出预算的内容替换为摘要占位符,并可选地将替换记录持久化以支持会话恢复。值得注意的是,通过工具定义中的 maxResultSizeChars 属性,某些工具可以显式声明不受此限制约束。

第三级:Snip 压缩snipCompactIfNeeded 是一种轻量级的消息裁剪策略。它识别对话中间的低价值消息(如已过时的中间工具调用),将其移除以释放 token 空间。与完整的 autocompact 不同,snip 不需要调用 API 来生成摘要,因此成本为零。它释放的 token 数通过 snipTokensFreed 变量传递给后续的 autocompact 检查,以修正 token 估算的偏差。

第四级:MicrocompactmicrocompactMessages 对工具结果进行更细粒度的压缩处理,例如折叠重复的文件读取结果、删除已缓存的内容等。这是一种"缓存编辑"操作,可以利用 API 的 cache_deleted_input_tokens 特性来精确衡量节省的 token 数。

第五级:Context CollapseapplyCollapsesIfNeeded 是最精巧的上下文管理机制。它是一种投影式(projection-based)方法:REPL 端保留完整的对话历史以支持 UI 回滚,但发送给 API 的消息是经过折叠的"视图"。折叠操作通过 commit log 实现持久化,每次调用 projectView() 时重放 log 来重建视图。这种设计使得折叠可以跨轮次持续生效,而不仅仅是一次性的操作。

这五个步骤的执行顺序经过精心安排。snip 在 microcompact 之前运行,因为 snip 移除的是整条消息,而 microcompact 处理的是消息内部的内容——先做粗粒度裁剪可以减少细粒度处理的工作量。context collapse 在 autocompact 之前运行,因为如果折叠已经将 token 数降到阈值以下,就不需要触发代价高昂的 API 压缩调用。这种分层递进的策略确保了系统总是优先使用最廉价的手段。

4.2.3 上下文构建与缓存友好的注入策略

理解系统上下文的注入方式,需要先理解 Anthropic API 的 prompt caching 机制。API 的缓存以系统提示词和消息前缀为键:如果两次请求的系统提示词和前几条消息完全字节匹配,后续的内容就可以复用缓存。这意味着,频繁变化的内容应该放在消息序列的末端或中间,而稳定的内容应该放在开头。

Claude Code 正是基于这个原则来设计上下文的注入方式。系统提示词(相对稳定)通过 systemPrompt 参数传递,系统上下文(git 状态等,会话内不变)被追加到系统提示词末尾:

typescript
// 文件:src/query.ts(循环体内)

const fullSystemPrompt = asSystemPrompt(
  appendSystemContext(systemPrompt, systemContext),
)

而用户上下文(CLAUDE.md 内容,可能因 memory 更新而变化)被作为消息数组的第一条用户消息前置:

typescript
// 文件:src/utils/api.ts

export function prependUserContext(
  messages: Message[],
  context: { [k: string]: string },
): Message[] {
  return [
    createUserMessage({
      content: `<system-reminder>\nAs you answer the user's questions, ` +
        `you can use the following context:\n` +
        `${Object.entries(context)
          .map(([key, value]) => `# ${key}\n${value}`)
          .join('\n')}
        IMPORTANT: this context may or may not be relevant to your tasks.` +
        `\n</system-reminder>\n`,
    }),
    ...messages,
  ]
}

appendSystemContext 的实现同样值得注意:它将上下文条目转换为 key: value 格式的纯文本,拼接到系统提示词数组的末尾。这里使用 .filter(Boolean) 过滤空字符串,确保不会产生多余的空行。

typescript
// 文件:src/utils/api.ts

export function appendSystemContext(
  systemPrompt: SystemPrompt,
  context: { [k: string]: string },
): string[] {
  return [
    ...systemPrompt,
    Object.entries(context)
      .map(([key, value]) => `${key}: ${value}`)
      .join('\n'),
  ].filter(Boolean)
}

这种双路径注入的设计确保了缓存效率最大化。在一次典型的多轮对话中,系统提示词和系统上下文组成的前缀在整个会话期间保持不变,可以持续命中缓存。用户上下文虽然理论上可能变化(例如 memory 文件被更新),但在大多数对话中也是稳定的。真正频繁变化的只有对话消息本身。

4.2.4 Token 预算追踪与自动续行

Token 预算(Token Budget)机制允许用户为一次交互设定 token 消耗上限。其核心思想是:即使模型主动选择了停止(stop_reason === 'end_turn'),如果 token 消耗还远未达到预算上限,系统会自动注入一条 nudge 消息促使模型继续工作。这在需要大量工具调用的复杂任务中特别有用——模型可能在完成部分工作后就认为"够了",但用户实际期望它继续。

Token 预算的追踪状态封装在 BudgetTracker 结构体中:

typescript
// 文件:src/query/tokenBudget.ts

export type BudgetTracker = {
  continuationCount: number      // 已续行次数
  lastDeltaTokens: number        // 上一次续行期间的增量 token 数
  lastGlobalTurnTokens: number   // 上一次检查时的全局 token 数
  startedAt: number              // 开始时间戳
}

核心判断逻辑在 checkTokenBudget 函数中。这个函数的返回类型是一个判别联合——要么是 ContinueDecision(继续),要么是 StopDecision(停止):

typescript
// 文件:src/query/tokenBudget.ts

const COMPLETION_THRESHOLD = 0.9
const DIMINISHING_THRESHOLD = 500

export function checkTokenBudget(
  tracker: BudgetTracker,
  agentId: string | undefined,
  budget: number | null,
  globalTurnTokens: number,
): TokenBudgetDecision {
  if (agentId || budget === null || budget <= 0) {
    return { action: 'stop', completionEvent: null }
  }

  const turnTokens = globalTurnTokens
  const pct = Math.round((turnTokens / budget) * 100)
  const deltaSinceLastCheck = globalTurnTokens - tracker.lastGlobalTurnTokens

  const isDiminishing =
    tracker.continuationCount >= 3 &&
    deltaSinceLastCheck < DIMINISHING_THRESHOLD &&
    tracker.lastDeltaTokens < DIMINISHING_THRESHOLD

  if (!isDiminishing && turnTokens < budget * COMPLETION_THRESHOLD) {
    tracker.continuationCount++
    tracker.lastDeltaTokens = deltaSinceLastCheck
    tracker.lastGlobalTurnTokens = globalTurnTokens
    return {
      action: 'continue',
      nudgeMessage: getBudgetContinuationMessage(pct, turnTokens, budget),
      continuationCount: tracker.continuationCount,
      pct,
      turnTokens,
      budget,
    }
  }
  // 达到阈值或递减,返回 stop
  // ...
}

这里有两个精妙的防护机制值得深入分析:

完成阈值COMPLETION_THRESHOLD = 0.9)。当已消耗 90% 预算时停止续行,而非等到 100%。这个看似保守的阈值背后有实际考量:模型在收到续行指令后,可能会生成一段"收尾"文本来总结之前的工作,这段文本可能消耗相当多的 token 但没有实际进展。留出 10% 的缓冲区可以避免在"总结回顾"上浪费预算。

递减检测DIMINISHING_THRESHOLD = 500)。如果已经续行了 3 次以上,且最近两次续行每次都只新增了不到 500 token,系统判定模型已经进入"空转"状态并强制停止。这个检测可以有效防止模型在无实质进展时消耗大量预算——例如模型可能反复生成"让我继续检查..."之类的话术而不执行任何工具。

值得注意的是,子 agent(agentId 非空时)被排除在预算控制之外。这是因为子 agent 的 token 消耗已经包含在父 agent 的预算中,重复计算会导致过早终止。

以下状态机图展示了 Token 预算追踪系统的决策逻辑:

4.2.5 Auto-Compact 逻辑

Auto-compact 是 Claude Code 保持长对话可用性的核心机制。当对话长度接近模型上下文窗口的限制时,系统会自动将历史对话压缩为一个简洁的摘要,释放上下文空间以继续对话。整个机制的设计体现了大量的工程经验。

基于 VitePress 构建