Claude Code设计与实现-第4章 Query 引擎:Agent 的心脏

9 阅读29分钟

《Claude Code 设计与实现》完整目录

第4章 Query 引擎:Agent 的心脏

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

:::tip 本章要点

  • 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 两层的分工及数据流向:

graph TB
    subgraph Upper["上层: QueryEngine.ts (会话层)"]
        SM["submitMessage()"]
        PUI["processUserInput()<br/>斜杠命令解析"]
        FSP["fetchSystemPromptParts()<br/>系统提示词获取"]
        Persist["消息持久化 / 转录记录"]
        SDK["SDK 格式转换"]
    end

    subgraph Lower["下层: query.ts (循环层)"]
        Q["query() 入口"]
        QL["queryLoop() while(true)"]
        subgraph Pipeline["消息预处理流水线"]
            Snip["snipCompact"]
            Micro["microcompact"]
            Collapse["contextCollapse"]
            Auto["autocompact"]
        end
        Call["callModel 流式API调用"]
        RT["runTools 工具执行"]
        Stop["停止条件判断"]
    end

    SM --> PUI --> FSP --> Q
    Q --> QL
    QL --> Pipeline --> Call
    Call --> RT --> QL
    Call --> Stop
    Stop -- "end_turn" --> Persist --> SDK
    Stop -- "需要续行" --> QL

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 系统的入口点:

// 文件: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() 返回真实依赖:

// 文件: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 调用之前经历的五级预处理流水线:

flowchart LR
    Input["原始消息数组"] --> L1["1. Compact 边界截取<br/>getMessagesAfterCompactBoundary"]
    L1 --> L2["2. Tool Result 预算裁剪<br/>applyToolResultBudget"]
    L2 --> L3["3. Snip 压缩<br/>snipCompactIfNeeded<br/>(零成本裁剪)"]
    L3 --> L4["4. Microcompact<br/>microcompactMessages<br/>(细粒度压缩)"]
    L4 --> L5["5. Context Collapse<br/>applyCollapsesIfNeeded<br/>(投影式折叠)"]
    L5 --> Output["处理后消息 -> API"]

    style L1 fill:#e1f5fe
    style L2 fill:#e8f5e9
    style L3 fill:#fff3e0
    style L4 fill:#fce4ec
    style L5 fill:#f3e5f5

在每一轮循环迭代开始时,消息数组需要经过一条多级预处理流水线。每一级都可能缩减消息数组的大小或修改其内容,最终目标是将消息控制在模型上下文窗口的安全范围内。这是 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 状态等,会话内不变)被追加到系统提示词末尾:

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

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

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

// 文件: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) 过滤空字符串,确保不会产生多余的空行。

// 文件: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 结构体中:

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

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

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

// 文件: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 预算追踪系统的决策逻辑:

stateDiagram-v2
    [*] --> CheckPreconditions: end_turn 触发预算检查
    CheckPreconditions --> Stop: agentId 非空 / 无预算
    CheckPreconditions --> CalcUsage: 主线程 + 有预算

    CalcUsage --> CheckDiminishing: 计算消耗百分比和增量
    CheckDiminishing --> Stop: 续行>=3次 且 增量<500 token
    CheckDiminishing --> CheckThreshold: 未递减

    CheckThreshold --> Continue: 消耗 < 90% 预算
    CheckThreshold --> Stop: 消耗 >= 90% 预算

    Continue --> InjectNudge: 注入续行消息
    InjectNudge --> [*]: 回到 queryLoop 下一轮

    Stop --> [*]: 返回 Terminal

4.2.5 Auto-Compact 逻辑

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

阈值的计算逻辑位于 autoCompact.ts 中:

// 文件:src/services/compact/autoCompact.ts

const MAX_OUTPUT_TOKENS_FOR_SUMMARY = 20_000
export const AUTOCOMPACT_BUFFER_TOKENS = 13_000

export function getEffectiveContextWindowSize(model: string): number {
  const reservedTokensForSummary = Math.min(
    getMaxOutputTokensForModel(model),
    MAX_OUTPUT_TOKENS_FOR_SUMMARY,
  )
  let contextWindow = getContextWindowForModel(model, getSdkBetas())
  return contextWindow - reservedTokensForSummary
}

export function getAutoCompactThreshold(model: string): number {
  const effectiveContextWindow = getEffectiveContextWindowSize(model)
  return effectiveContextWindow - AUTOCOMPACT_BUFFER_TOKENS
}

这个计算可以用一个公式概括:threshold = contextWindow - outputReserve - buffer。其中 outputReserve(20,000 token)为压缩摘要的生成预留空间(基于 p99.99 的摘要输出长度为 17,387 token 的实际数据),buffer(13,000 token)为新的对话内容预留空间。

整个压缩流程由 autoCompactIfNeeded 函数协调。这个函数包含了几个关键的工程决策:

// 文件:src/services/compact/autoCompact.ts

const MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES = 3

export async function autoCompactIfNeeded(
  messages: Message[],
  toolUseContext: ToolUseContext,
  cacheSafeParams: CacheSafeParams,
  querySource?: QuerySource,
  tracking?: AutoCompactTrackingState,
  snipTokensFreed?: number,
): Promise<{
  wasCompacted: boolean
  compactionResult?: CompactionResult
  consecutiveFailures?: number
}> {
  // 断路器:连续失败超过 3 次则放弃
  if (
    tracking?.consecutiveFailures !== undefined &&
    tracking.consecutiveFailures >= MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES
  ) {
    return { wasCompacted: false }
  }
  // ...先尝试 session memory compaction
  // ...然后才是完整的 compactConversation
}

断路器模式是一个由真实数据驱动的工程决策。源码注释中引用了具体的遥测数据:2026 年 3 月 10 日的数据显示,有 1,279 个会话出现了 50 次以上的连续压缩失败(最高达 3,272 次),每天全局浪费约 250,000 次 API 调用。设置 3 次上限后,这种浪费被彻底消除。这是一个典型的"在源码注释中记录决策依据"的范例。

递归保护也很关键。当 querySourcecompactsession_memory 时,autocompact 会被跳过。这是因为这些查询本身就是压缩子 agent 的派生查询,如果它们也触发压缩就会形成死锁——压缩 agent 需要运行才能减少 token 数量,但它本身运行又需要压缩。

Context Collapse 互斥。当 context collapse 功能启用时,proactive autocompact 会被禁用。这是因为 collapse 机制自身有一套从 90% 提交到 95% 阻塞的渐进策略,autocompact 的阈值(约 93%)恰好落在这个范围内,两者同时运行会竞争并导致不可预测的行为。但 reactive compact(作为 413 错误的应急响应)仍然可以在 collapse 失败后作为兜底。

4.2.6 API 调用与多层重试

以下流程图展示了 API 调用的多层重试和降级策略:

flowchart TD
    Start["API 调用开始"] --> TryCall["deps.callModel 流式请求"]
    TryCall --> StreamOK{"流式传输成功?"}
    StreamOK -- "是" --> Process["处理流式消息"]
    StreamOK -- "请求前失败" --> FBCheck{"有 fallbackModel?"}
    FBCheck -- "是" --> SwitchModel["切换到 fallbackModel<br/>清空中间状态"]
    SwitchModel --> TryCall
    FBCheck -- "否" --> ThrowErr["抛出错误"]

    Process --> StreamFB{"流中降级?<br/>onStreamingFallback"}
    StreamFB -- "否" --> Continue["正常继续"]
    StreamFB -- "是" --> Tombstone["yield tombstone 消息<br/>通知 UI 移除已渲染内容"]
    Tombstone --> StripSig["stripSignatureBlocks<br/>清除 thinking 签名"]
    StripSig --> SwitchModel

    Process --> Err413{"413 过长?"}
    Err413 -- "是" --> ReactiveCompact["reactive compact<br/>紧急压缩"]
    ReactiveCompact --> TryCall
    Err413 -- "否" --> Continue

API 调用发生在 queryLoop 的核心流式循环中。这个循环的设计包含了多层重试和降级机制,确保在各种故障场景下都能给出最优的用户体验。

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

let attemptWithFallback = true
while (attemptWithFallback) {
  attemptWithFallback = false
  try {
    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,
        onStreamingFallback: () => {
          streamingFallbackOccured = true
        },
        querySource,
        maxOutputTokensOverride,
        // ...更多选项
      },
    })) {
      // 流式处理每个消息事件
    }
  } catch (innerError) {
    if (innerError instanceof FallbackTriggeredError && fallbackModel) {
      currentModel = fallbackModel
      attemptWithFallback = true
      // 清空中间状态...
      continue
    }
    throw innerError
  }
}

重试机制包含两层,分别处理不同时机的故障:

请求级降级重试。当 API 调用在流式传输开始前就失败(例如模型过载),withRetry 中间层会抛出 FallbackTriggeredError。此时系统切换到 fallbackModel,清空所有中间状态(assistantMessagestoolResultstoolUseBlocks),然后重试整个请求。这个过程对用户来说几乎是无感的——他们只会看到一条系统通知说模型已切换。

流式降级处理。更复杂的情况是流式传输已经开始后发生降级(通过 onStreamingFallback 回调检测)。此时系统已经向下游 yield 了部分消息,这些消息需要被"撤回"。Claude Code 通过 tombstone(墓碑)机制实现这一点:

// 文件:src/query.ts

if (streamingFallbackOccured) {
  for (const msg of assistantMessages) {
    yield { type: 'tombstone' as const, message: msg }
  }
  assistantMessages.length = 0
  toolResults.length = 0
  toolUseBlocks.length = 0
  needsFollowUp = false
}

Tombstone 消息通知 UI 层移除之前已渲染的不完整消息。同时,如果正在使用 StreamingToolExecutor,也需要丢弃其内部已积累的工具执行结果并创建全新的执行器实例,防止旧的 tool_use_id 泄漏到新的请求中。

在流式降级过程中,还需要特别处理 thinking block 的签名问题。Thinking block 的签名是与特定模型绑定的,如果将一个模型的 thinking block 发送给另一个模型,会导致 400 错误。因此,在模型降级前需要调用 stripSignatureBlocks 清除所有签名。

4.3 QueryEngine.ts:底层引擎

4.3.1 类设计与状态管理

以下类图展示了 QueryEngine 的核心状态和方法:

classDiagram
    class QueryEngine {
        -config: QueryEngineConfig
        -mutableMessages: Message[]
        -abortController: AbortController
        -permissionDenials: SDKPermissionDenial[]
        -totalUsage: NonNullableUsage
        -readFileState: FileStateCache
        -discoveredSkillNames: Set~string~
        +submitMessage(prompt, options) AsyncGenerator~SDKMessage~
        -processUserInput(content) ContentBlockParam[]
        -buildToolUseContext() ToolUseContext
        -persistMessages() void
    }

    class QueryParams {
        +messages: Message[]
        +systemPrompt: SystemPrompt
        +userContext: Map
        +systemContext: Map
        +canUseTool: CanUseToolFn
        +toolUseContext: ToolUseContext
        +maxTurns: number
        +taskBudget: BudgetConfig
    }

    class QueryDeps {
        +callModel() AsyncGenerator
        +microcompact() Promise
        +autocompact() Promise
        +uuid() string
    }

    QueryEngine --> QueryParams: 构建并传入
    QueryParams --> QueryDeps: 注入依赖
    QueryEngine --> FileStateCache: 管理文件状态

QueryEngine 是一个有状态的类,��个对话实例化一个。它的设计哲学是"一个实例,一个对话"——通过多次调用 submitMessage() 来在同一个对话中进行多轮交互。

// 文件:src/QueryEngine.ts

export class QueryEngine {
  private config: QueryEngineConfig
  private mutableMessages: Message[]
  private abortController: AbortController
  private permissionDenials: SDKPermissionDenial[]
  private totalUsage: NonNullableUsage
  private hasHandledOrphanedPermission = false
  private readFileState: FileStateCache
  private discoveredSkillNames = new Set<string>()
  private loadedNestedMemoryPaths = new Set<string>()
  // ...
}

几个关键的状态字段值得解释:

mutableMessages 是整个对话的消息数组,跨多次 submitMessage 调用持久存在。每次 submitMessage 都会向其中追加新消息。注意名称中的 "mutable" 前缀——这是有意为之的命名约定,提醒开发者这个数组是可变的。

readFileState 是文件状态缓存,记录了模型已经读取或修改过哪些文件。它在 memory prefetch 的去重逻辑中发挥作用:如果模型已经通过 Read/Edit 工具访问过某个文件,memory prefetch 就不会重复将该文件作为上下文附件注入。

discoveredSkillNames 追踪本轮已发现的 skill 名称,用于在遥测数据中标记 was_discovered 字段。它在每次 submitMessage 开始时被清空,但在 submitMessage 内部的两次 processUserInputContext 构建之间保持存活。

构造函数只做最小初始化,这是一个值得借鉴的设计实践:

// 文件:src/QueryEngine.ts

constructor(config: QueryEngineConfig) {
  this.config = config
  this.mutableMessages = config.initialMessages ?? []
  this.abortController = config.abortController ?? createAbortController()
  this.permissionDenials = []
  this.readFileState = config.readFileCache
  this.totalUsage = EMPTY_USAGE
}

没有任何异步操作、没有副作用、没有复杂的初始化逻辑。所有的"重活"都推迟到 submitMessage() 调用时才执行。这使得 QueryEngine 的实例化是廉价且可预测的。

4.3.2 系统提示词的动态组装

系统提示词的组装是 Claude Code 中涉及来源最多、组合规则最复杂的流程之一。最终发送给 API 的提示词可能由五个以上的来源合成,每个来源有各自的启用条件和叠加规则。

submitMessage 方法首先通过 fetchSystemPromptParts 并行获取三大基础组件:

// 文件:src/utils/queryContext.ts

export async function fetchSystemPromptParts({
  tools, mainLoopModel, additionalWorkingDirectories,
  mcpClients, customSystemPrompt,
}): Promise<{
  defaultSystemPrompt: string[]
  userContext: { [k: string]: string }
  systemContext: { [k: string]: string }
}> {
  const [defaultSystemPrompt, userContext, systemContext] =
    await Promise.all([
      customSystemPrompt !== undefined
        ? Promise.resolve([])
        : getSystemPrompt(tools, mainLoopModel,
            additionalWorkingDirectories, mcpClients),
      getUserContext(),
      customSystemPrompt !== undefined
        ? Promise.resolve({}) : getSystemContext(),
    ])
  return { defaultSystemPrompt, userContext, systemContext }
}

这里有一个关键的条件分支:当 SDK 调用者提供了 customSystemPrompt 时,默认系统提示词(getSystemPrompt)和系统上下文(getSystemContext)都会被跳过。这意味着自定义提示词是一种完全替换语义,而非追加。这样设计的原因是:SDK 调用者通常有自己完整的指令体系,Claude Code 的默认指令(如文件操作最佳实践、git 操作规范等)对他们来说可能是干扰而非帮助。

获取基础组件后,submitMessage 还会叠加若干可选层:

// 文件:src/QueryEngine.ts

// 叠加 coordinator 上下文(多 agent 协调模式下的额外指令)
const userContext = {
  ...baseUserContext,
  ...getCoordinatorUserContext(
    mcpClients,
    isScratchpadEnabled() ? getScratchpadDir() : undefined,
  ),
}

// 叠加 memory 机制提示词(当 SDK 调用者设置了自定义提示词
// 且显式启用了 memory 路径覆盖时注入)
const memoryMechanicsPrompt =
  customPrompt !== undefined && hasAutoMemPathOverride()
    ? await loadMemoryPrompt()
    : null

// 最终组装
const systemPrompt = asSystemPrompt([
  ...(customPrompt !== undefined
    ? [customPrompt] : defaultSystemPrompt),
  ...(memoryMechanicsPrompt ? [memoryMechanicsPrompt] : []),
  ...(appendSystemPrompt ? [appendSystemPrompt] : []),
])

这形成了一个三级叠加结构:基础提示词(或自定义提示词)位于最前面,memory 机制提示词在中间,appendSystemPrompt 在最后。其中 appendSystemPrompt 是唯一在自定义提示词模式下仍然生效的叠加层——它提供了一种安全的方式让调用者在不替换整个提示词的前提下添加额外指令。

getUserContext(位于 src/context.ts)负责加载用户级上下文,其核心内容是 CLAUDE.md 配置文件:

// 文件:src/context.ts

export const getUserContext = memoize(
  async (): Promise<{ [k: string]: string }> => {
    const shouldDisableClaudeMd =
      isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_CLAUDE_MDS) ||
      (isBareMode() && getAdditionalDirectoriesForClaudeMd().length === 0)
    const claudeMd = shouldDisableClaudeMd
      ? null
      : getClaudeMds(filterInjectedMemoryFiles(await getMemoryFiles()))
    setCachedClaudeMdContent(claudeMd || null)
    return {
      ...(claudeMd && { claudeMd }),
      currentDate: `Today's date is ${getLocalISODate()}.`,
    }
  },
)

注意 memoize 装饰器——用户上下文在整个对话期间只加载一次,后续调用直接返回缓存结果。同时,--bare 模式下 CLAUDE.md 的自动发现被跳过,但如果用户通过 --add-dir 显式指定了目录,那些目录中的 CLAUDE.md 仍然会被加载。这体现了"bare 意味着跳过我没要求的,而非忽略我明确要求的"设计原则。

getSystemContext 负责系统级上下文,主要是 git 仓库状态信息:

// 文件:src/context.ts

export const getSystemContext = memoize(
  async (): Promise<{ [k: string]: string }> => {
    const gitStatus =
      isEnvTruthy(process.env.CLAUDE_CODE_REMOTE) ||
      !shouldIncludeGitInstructions()
        ? null
        : await getGitStatus()
    return {
      ...(gitStatus && { gitStatus }),
    }
  },
)

Git 状态包括当前分支、主分支名、最近 5 条 commit 和 git status --short 输出。status 输出有 2000 字符上限,超出则截断并提示模型使用 BashTool 获取完整信息。在 Claude Code Remote 模式下,git 状态获取被跳过以减少恢复(resume)时的开销。

4.3.3 Thinking 模式处理

Thinking 模式决定了模型是否在生成回复前进行内部推理。Claude Code 默认启用 adaptive 模式,让模型自行决定是否需要思考:

// 文件:src/QueryEngine.ts

const initialThinkingConfig: ThinkingConfig = thinkingConfig
  ? thinkingConfig
  : shouldEnableThinkingByDefault() !== false
    ? { type: 'adaptive' }
    : { type: 'disabled' }

query.ts 中有一段极具风格的注释,以"巫师法则"的口吻记录了 thinking block 的三条不可违反的规则。这些规则源于 Anthropic API 的硬性约束,如果违反将导致难以调试的 400 错误:

  1. 包含 thinking 或 redacted_thinking block 的消息,其所属查询的 max_thinking_length 必须大于 0
  2. thinking block 不得是消息的最后一个 block
  3. thinking block 必须在整个 assistant 轨迹期间原封不动地保留

第三条规则对模型降级场景有直接影响。Thinking block 的签名是模型特定的,将一个模型产生的 thinking block 发送给另一个模型会导致签名验证失败。因此在降级重试之前,需要调用 stripSignatureBlocks 清除所有签名。

4.3.4 工具调用的流式执行

工具执行是 query 循环中耗时最长的阶段之一。Claude Code 实现了两种并行度不同的执行策略,通过 runtime gate 控制切换:

传统批量执行。等待模型完整响应后,将所有 tool_use block 收集起来,通过 runTools 批量执行。这种方式的延迟等于模型生成时间加上所有工具执行时间的总和。

流式并行执行StreamingToolExecutor 在模型流式输出的同时就开始执行已完成的工具调用。当流式循环检测到一个新的 tool_use block 完整到达时,立即将其提交给执行器:

// 文件:src/query.ts(流式循环内)

if (streamingToolExecutor &&
    !toolUseContext.abortController.signal.aborted) {
  for (const toolBlock of msgToolUseBlocks) {
    streamingToolExecutor.addTool(toolBlock, message)
  }
}

// 在流式循环中消费已完成的结果
for (const result of streamingToolExecutor.getCompletedResults()) {
  if (result.message) {
    yield result.message
    toolResults.push(...)
  }
}

流式执行后,在流式循环结束后还需要通过 getRemainingResults() 消费尚未完成的工具结果。这个调用是阻塞的——它等待所有已提交的工具执行完毕。流式执行和批量执行最终都通过统一的 for await 循环消费更新,将工具结果消息 yield 给上游,并更新 toolResults 数组。

4.4 查询循环状态机

queryLoop 是整个 Claude Code 系统的核心调度器。理解它的关键在于理解 State 结构体和九种 continue 转换——它们共同构成了一个隐式的状态机。

4.4.1 State 结构体的设计哲学

// 文件:src/query.ts

type State = {
  messages: Message[]
  toolUseContext: ToolUseContext
  autoCompactTracking: AutoCompactTrackingState | undefined
  maxOutputTokensRecoveryCount: number
  hasAttemptedReactiveCompact: boolean
  maxOutputTokensOverride: number | undefined
  pendingToolUseSummary: Promise<ToolUseSummaryMessage | null> | undefined
  stopHookActive: boolean | undefined
  turnCount: number
  transition: Continue | undefined
}

这个结构体的设计反映了一个重要的工程原则:将循环的跨迭代状态集中管理。在引入 State 结构体之前,这些状态分散在多个独立变量中,每个 continue 站点需要单独更新 9 个变量。通过将它们收拢为一个对象,每个 continue 站点变成了一个清晰的 state = { ... } 赋值。源码注释中明确说明了这个重构动机:"Continue sites write state = { ... } instead of 9 separate assignments."

transition 字段记录上一次 continue 的原因,这个设计有双重用途。在生产逻辑中,它用于条件判断:例如当 transition.reason === 'collapse_drain_retry' 时,当前迭代知道上一轮已经尝试过 context collapse 排空,应该直接尝试 reactive compact 而非再次排空。在测试中,它作为断言目标:测试可以检查最终的 transition 来验证特定的恢复路径是否正确触发,而无需检查消息内容这种脆弱的断言方式。

4.4.2 完整的状态转换图

以下是 queryLoop 的完整状态转换图。每个节点代表循环中的一个阶段,每条边代表一种可能的转换路径。虚线表示需要满足特定条件才会触发的路径:

                        +------------------+
                        |   循环入口       |
                        |   state 解构     |
                        +--------+---------+
                                 |
                                 v
                   +-------------+-------------+
                   |    消息预处理流水线       |
                   |  snip -> microcompact     |
                   |  -> collapse -> autocompact|
                   +------+------+------+------+
                          |      |      |
                    压缩成功  压缩失败  无需压缩
                    yield msgs  更新     直接
                    重置tracking tracking 通过
                          |      |      |
                          v      v      v
                   +------+------+------+------+
                   |    阻塞限制检查           |
                   |  (auto-compact 关闭时)    |
                   +------+----+---------------+
                          |    |
                      超限    未超限
                      return  |
                      Terminal v
                   +----------+----------+
                   |    流式 API 调用    |
                   |  for await callModel|
                   +--+--+--+--+--+-----+
                      |  |  |  |  |
         +------------+  |  |  |  +------+
         |               |  |  |         |
         v               v  v  v         v
    流式降级       正常消息  tool_use  API错误(413/MOT)
    tombstone      yield    检测      扣留(withheld)
    + 重试                  |         |
         |               |  |         |
         v               v  v         v
    continue      needsFollowUp?  恢复策略选择
    (重试)         /        \     /   |   \
               false      true  CCD   RC   MOT
                 |          |    |    |     |
                 v          v    v    v     v
            StopHooks   工具执行  continue  continue
                |       runTools  (重试)   (恢复)
                v            |
          token budget  附件收集 + maxTurns检查
          检查               |
           / \               v
        stop  continue  state = next
         |        |     continue (next_turn)
         v        |
       return     +-------> continue
       Terminal            (token_budget)

图中标注 CCD、RC、MOT 分别代表 Context Collapse Drain、Reactive Compact 和 Max Output Tokens 三种恢复路径。

4.4.3 九种 Continue 原因详解

循环中有九个 continue 站点,每个都创建一个新的 State 对象并通过 transition.reason 标识转换原因。理解这九种转换是掌握 query 循环的核心:

transition.reason触发场景新状态的关键变化
next_turn工具执行完毕,需要将结果送回模型messages 追加工具结果,turnCount 递增
max_output_tokens_recovery模型输出被截断(尝试 1-3)messages 追加恢复提示词,recoveryCount 递增
max_output_tokens_escalate输出限制首次触发maxOutputTokensOverride 设为 64k
reactive_compact_retryprompt-too-long 后压缩成功messages 替换为压缩结果,hasAttemptedReactiveCompact 置 true
collapse_drain_retryprompt-too-long 后 collapse 排空messages 替换为排空结果
stop_hook_blockingStop Hook 返回阻塞错误messages 追加错误信息,stopHookActive 置 true
token_budget_continuationtoken 预算未耗尽messages 追加 nudge 消息
streaming_fallback流式传输中模型降级中间状态全部清空
model_fallbackAPI 抛出降级错误currentModel 切换,中间状态清空

每个 continue 站点在创建新 State 时,都会仔细决定哪些字段需要重置、哪些需要保留。例如 reactive_compact_retry 会将 hasAttemptedReactiveCompact 置为 true 以防止重复压缩,但会重置 maxOutputTokensRecoveryCount 因为压缩后上下文已经改变。而 stop_hook_blocking 会保留 hasAttemptedReactiveCompact 的当前值,因为源码注释中解释道:如果 compact 已经运行且无法恢复 prompt-too-long,重置该标志会导致无限循环。

4.5 停止条件的完整图谱

查询循环的终止条件是多维度、分层次的。理解每一种停止条件及其优先级顺序,对于预测 Claude Code 在各种边界情况下的行为至关重要。

4.5.1 用户中断(最高优先级)

用户按下 Ctrl+C 会触发 abort controller 的信号。系统在两个时机检查 abort 信号:流式传输完成后和工具执行完成后。中断处理会为所有未完成的 tool_use block 生成错误结果,确保 API 交互的完整性,然后返回 { reason: 'aborted_streaming' }{ reason: 'aborted_tools' }

4.5.2 模型自然停止

最简单的停止条件:模型输出完成且没有请求任何工具调用。此时 needsFollowUpfalse,循环进入停止检查的级联流程——先检查错误恢复、再执行 Stop Hooks、最后检查 Token Budget。

4.5.3 Max Turns 达到

// 文件:src/query.ts

if (maxTurns && nextTurnCount > maxTurns) {
  yield createAttachmentMessage({
    type: 'max_turns_reached',
    maxTurns,
    turnCount: nextTurnCount,
  })
  return { reason: 'max_turns', turnCount: nextTurnCount }
}

Max turns 检查在工具执行完毕后、state 转移之前进行。通过 attachment 消息通知 SDK 层,这使得 SDK 消费者可以区分"模型主动停止"和"达到轮次上限而被强制停止"两种场景。

4.5.4 Max Output Tokens 的三级恢复

当模型的单次输出被截断(apiError === 'max_output_tokens'),系统启动精心设计的三级恢复策略。这个被截断的错误消息会被扣留(withheld),不立即 yield 给 SDK 消费者。

第一级:静默升级。如果当前使用的是默认的 8k 输出限制且尚未尝试过升级,系统直接将 maxOutputTokensOverride 设为 ESCALATED_MAX_TOKENS(64k)并重试。这一级完全透明——不注入任何消息,模型不知道发生了什么,SDK 消费者也看不到任何异常。

第二级:多轮续写。如果升级后仍然被截断(或者升级功能未启用),系统注入一条精心措辞的恢复提示词,最多尝试 3 次。提示词明确指示模型不要道歉、不要复述已完成的内容,直接从断点处继续,并将剩余工作拆分为更小的块。这段措辞是经过多次迭代优化的产物。

第三级:彻底放弃。如果 3 次恢复都未能解决问题,系统释放之前扣留的错误消息,让用户看到截断发生了。

4.5.5 Prompt Too Long 的分级恢复

当上下文超出模型窗口导致 API 返回 413 错误时,恢复策略同样是分级的:

第一级:Context Collapse 排空。如果当前启用了 context collapse 功能且上一轮的 transition 不是 collapse_drain_retry(避免无限循环),系统调用 recoverFromOverflow 将所有暂存的折叠提交。这是成本最低的恢复手段——不需要 API 调用,只是将已经计算好的折叠结果应用到消息数组上。

第二级:Reactive Compact。如果排空不够或排空后重试仍然 413,系统触发完整的反应式压缩。这需要一次额外的 API 调用来生成摘要,但能大幅缩减上下文。hasAttemptedReactiveCompact 标志确保每个循环迭代最多尝试一次反应式压缩,防止在上下文不可恢复地过大时陷入无限压缩-重试循环。

第三级:放弃恢复。如果两种手段都失败了,释放扣留的错误消息并返回 { reason: 'prompt_too_long' }。此时会调用 executeStopFailureHooks 通知 hook 系统,但特别注意不会进入正常的 Stop Hook 流程——源码注释中解释道,在 prompt-too-long 的情况下执行 stop hooks 会注入更多 token,形成死亡螺旋。

4.5.6 Stop Hooks 扩展点

Stop Hooks 位于 src/query/stopHooks.ts,提供了一个强大的扩展机制。每当模型自然停止(没有工具调用)时,系统会执行用户配置的 Stop Hook 脚本。这些脚本可以检查模型输出的质量并返回三种结果:

成功:不做任何事,正常进入后续的 token budget 检查。

阻塞错误(Blocking Error):表示模型的输出不符合预期,需要修正。错误信息被注入到对话中作为新的用户消息,循环 continue 让模型看到错误并修正。stopHookActive 标志会被设为 true,确保 hook 在活跃状态下的后续迭代不会重复触发同一 hook。

阻止继续(Prevent Continuation):强制终止整个查询,不给模型修正的机会。这通常用于安全约束——例如检测到模型试图执行被禁止的操作。

handleStopHooks 函数本身也是一个 AsyncGenerator,它将 hook 执行过程中产生的进度消息、成功/失败消息透传给上游。这意味着 SDK 消费者可以实时看到 hook 的执行进度,而非只看到最终结果。

除了 Stop Hooks,系统还在同一位置执行若干后台任务:memory 提取(executeExtractMemories)、自动 dream(executeAutoDream)、prompt suggestion 和 job classifier。这些任务都是 fire-and-forget 的,不阻塞主循环。在 --bare 模式下它们全部被跳过,确保脚本化调用的最小开销。

4.5.7 阻塞限制(Blocking Limit)

在 auto-compact 关闭的情况下,系统会在 API 调用前检查当前 token 数是否超过阻塞限制。阻塞限制等于有效上下文窗口减去一个小缓冲区(MANUAL_COMPACT_BUFFER_TOKENS = 3,000),这个缓冲区为用户手动执行 /compact 命令预留了空间。如果超限,系统直接返回 prompt-too-long 错误而不发起 API 调用。

但这个检查在多种情况下会被跳过:刚执行完压缩时(压缩结果已验证在阈值内)、reactive compact 启用时(让 API 自己报 413 然后触发反应式恢复)、context collapse 启用时(collapse 有自己的溢出恢复流程)。

4.6 AsyncGenerator 的精妙之处

4.6.1 为什么不用 Promise

query 函数的签名体现了整个 Claude Code 的核心架构决策:

// 文件:src/query.ts

export async function* query(
  params: QueryParams,
): AsyncGenerator<
  | StreamEvent
  | RequestStartEvent
  | Message
  | TombstoneMessage
  | ToolUseSummaryMessage,
  Terminal
> {

这是一个返回 AsyncGenerator 的异步生成器函数。yield 类型是多种消息事件的联合类型,return 类型是 Terminal(终止原因对象)。选择 AsyncGenerator 而非传统的 Promise 或回调模式,有三个根本性的原因:

实时流式输出。一次查询可能包含多次 API 调用(模型输出 -> 工具执行 -> 模型继续输出 -> ...),整个过程可能持续数十秒甚至数分钟。如果使用 Promise,调用者必须等待整个查询链完全结束后才能获得任何结果。AsyncGenerator 允许每产生一个事件(一个流式 token、一个工具调用结果、一个系统通知)就立即 yield 给消费者。这是实现实时 UI 更新的基础——用户可以看到模型"正在思考"、"正在执行文件编辑"等中间状态。

优雅的取消语义。Generator 协议天然支持从外部终止迭代——调用 .return() 方法即可。当用户按下 Ctrl+C 时,上层代码调用 generator 的 .return() 会导致 generator 内部的 for await 循环提前结束。更重要的是,queryLoop 内部使用了 using 声明来管理 pendingMemoryPrefetch 等资源,generator 的终止会自动触发这些资源的 dispose 逻辑。相比之下,纯 AbortController 方案需要开发者手动在每个资源上注册 abort listener,更容易遗漏。

天然的背压控制。AsyncGenerator 的 for await...of 消费模式意味着消费者在处理完当前事件之前,生成器不会推送下一个事件。这在 SDK 场景中至关重要:如果 SDK 消费者正在进行耗时的消息持久化操作,query 循环会自动暂停而非将事件堆积在内存中。这种机制完全不需要额外的缓冲区管理代码。

4.6.2 Yield 的消息类型全景

生成器 yield 的消息类型覆盖了查询生命周期中的所有可观测事件:

类型来源消费方式
StreamEventAPI 流式响应UI 渲染实时 token
RequestStartEvent每次 API 调用开始UI 显示"模型正在思考"
Message (assistant)模型完整响应追加到消息历史并持久化
Message (user)工具执行结果追加到消息历史,下一轮发送给 API
Message (system)compact boundary 等标记压缩边界,触发 UI 刷新
TombstoneMessage流式降级后通知 UI 移除已失效的消息
ToolUseSummaryMessageHaiku 异步生成在移动端 UI 显示工具使用摘要

Tombstone 消息是一个值得特别说明的设计。在流式降级场景中,部分 assistant 消息已经被 yield 给下游并可能已经渲染在 UI 上。这些消息中可能包含不完整的 thinking block,其签名对于降级后的模型是无效的。Tombstone 消息告诉下游"这条消息已经作废,请将其从所有存储和显示中移除"。

4.6.3 双层生成器的委托设计

query 函数本身只是一个薄包装层,通过 yield*queryLoop 的所有产出透传给外部消费者:

// 文件:src/query.ts

export async function* query(
  params: QueryParams,
): AsyncGenerator<...> {
  const consumedCommandUuids: string[] = []
  const terminal = yield* queryLoop(params, consumedCommandUuids)
  for (const uuid of consumedCommandUuids) {
    notifyCommandLifecycle(uuid, 'completed')
  }
  return terminal
}

这个薄包装层的存在有一个精确的理由:命令生命周期通知。当查询正常完成时,query 会通知所有被消费的命令已经完成执行。但如果 queryLoop 因为 .return()(用户取消)或 .throw()(未捕获错误)异常退出,yield* 之后的代码不会执行——这些命令不会收到 "completed" 通知。这正是期望的行为:未完成的命令确实没有完成,不应该被标记为已完成。

4.6.4 三层生成器管道

在完整的 SDK 场景中,实际形成了一条三层的生成器管道:

queryLoop (产出原始事件:流式 token、内部消息、工具结果)
  -> query (+ 命令生命周期管理)
    -> QueryEngine.submitMessage (+ SDK 消息格式转换 + 持久化)
      -> SDK 消费者(如桌面应用、cowork)

每一层都是一个 AsyncGenerator,通过 yield*for await 进行连接。每一层只关注自己的职责:queryLoop 不知道消息需要持久化,query 不知道 SDK 消息格式,QueryEngine 不知道核心循环的状态机逻辑。这种分层使得每一层都可以独立测试和演进。

4.7 设计决策深度分析

命令式状态机的取舍

queryLoop 选择了命令式的 while(true) + continue/return 模式来实现状态机,而非使用显式的状态机库(如 XState 或 Robot)。这个选择并非随意为之——源码中 QueryConfig 的注释暗示了未来的演进方向:"Separating these from the per-iteration State struct and the mutable ToolUseContext makes future step() extraction tractable -- a pure reducer can take (state, event, config) where config is plain data."

这说明团队有意识地在向"纯 reducer"模式演进,但当前选择命令式实现有几个务实的原因:一是性能——在每秒可能迭代数次的热循环中,状态机库的调度开销不可忽视;二是可调试性——断点可以直接打在 continue 行上,比在状态机的 transition handler 中打断点更直观;三是 9 个 continue 站点的语义差异巨大,用 transition.reason 做区分比固定的状态枚举更灵活。

代价是显而易见的:1730 行的单文件使得控制流难以一眼追踪。但 State 结构体和 transition 字段的引入已经大幅改善了这个问题。

消息不可变原则与缓存一致性

流式循环中有一个容易被忽略的微妙设计:在 yield 消息之前对 tool_use 的 input 做 backfill 时,代码会克隆消息而非原地修改。原始消息被原封不动地推入 assistantMessages 数组,后续会被发送回 API。源码注释直白地解释了原因:"mutating it would break prompt caching (byte mismatch)"。

API 的 prompt cache 是按字节匹配的。如果修改了已发送消息的内容,下次发送时这条消息的字节会不同,导致缓存未命中,需要重新处理整个前缀。对于包含数千 token 系统提示词的请求来说,这意味着每次请求都要多花费一次缓存创建的成本。

扣留(Withholding)模式的必要性

错误消息的扣留机制值得单独讨论。在没有扣留的情况下,SDK 消费者(如桌面应用)会在收到任何带有 error 字段的消息时立即终止会话。但 query 循环的恢复逻辑(collapse drain、reactive compact、MOT escalation)需要在错误发生后继续运行。如果错误消息立即被 yield,SDK 消费者终止了会话,恢复循环就失去了听众——它可能成功恢复了,但没有人在接收它的产出。

扣留模式解决了这个矛盾:可恢复的错误消息被暂时搁置,只有在所有恢复手段都失败后才被释放。对 SDK 消费者来说,它要么看到恢复成功后的正常响应,要么看到确实不可恢复的最终错误——中间的试探过程完全不可见。

QueryConfig 的快照语义

buildQueryConfig()queryLoop 入口处被调用一次,将各种 runtime gate 的状态快照为不可变配置:

// 文件:src/query/config.ts

export function buildQueryConfig(): QueryConfig {
  return {
    sessionId: getSessionId(),
    gates: {
      streamingToolExecution:
        checkStatsigFeatureGate_CACHED_MAY_BE_STALE(
          'tengu_streaming_tool_execution2',
        ),
      emitToolUseSummaries: isEnvTruthy(
        process.env.CLAUDE_CODE_EMIT_TOOL_USE_SUMMARIES,
      ),
      isAnt: process.env.USER_TYPE === 'ant',
      fastModeEnabled: !isEnvTruthy(
        process.env.CLAUDE_CODE_DISABLE_FAST_MODE,
      ),
    },
  }
}

快照语义确保了一次查询中所有迭代使用相同的 gate 值。如果 gate 在查询中途被远程更新(Statsig 的特性),不同迭代使用不同的 gate 值可能导致不一致的行为——例如第一轮使用流式工具执行,第二轮切换到批量执行,就可能产生状态不匹配。

注释中特别强调了 feature() 调用(Bun 的编译期 tree-shaking 边界)被刻意排除在 QueryConfig 之外。这些调用必须留在 if 条件中才能被 Bun 的 dead code elimination 正确处理——如果将它们的结果存入变量,tree-shaker 就无法追踪条件分支,导致被排除的代码泄漏到外部构建中。

4.8 本章小结

Query 引擎是 Claude Code 的心脏,本章从架构分层、类型定义、状态管理和控制流四个维度,完整剖析了它的设计与实现。

两层架构的分工QueryEngine.ts 作为会话层,管理对话生命周期、组装系统提示词、处理用户输入和持久化消息;query.ts 作为循环层,实现核心的查询-工具-响应循环。这种分层使得核心逻辑可以被 REPL 和 SDK 两种模式复用。

消息预处理流水线。五级预处理(compact 截取、tool result 预算、snip、microcompact、context collapse)按照成本从低到高的顺序执行。轻量级手段优先,只有在廉价手段不足时才触发昂贵的 API 压缩。Auto-compact 的断路器模式(3 次失败上限)是由真实遥测数据驱动的决策。

状态机的九种转换queryLoop 通过 State 结构体和九种 continue 路径构成一个隐式状态机。transition.reason 字段既服务于生产逻辑(条件判断),又服务于测试(断言目标),是一个优雅的双重用途设计。

多层停止条件。从用户中断到模型自然停止,从 max turns 到 token budget,从 max output tokens 的三级恢复到 prompt-too-long 的分级策略,每种停止条件都有明确的优先级和恢复流程。错误消息的扣留机制确保了恢复过程对 SDK 消费者完全透明。

AsyncGenerator 的三重价值。实时流式输出、优雅取消和背压控制,这三个需求被 AsyncGenerator 的原生语义完美覆盖。三层生成器管道(queryLoop -> query -> QueryEngine)在保持关注点分离的同时实现了事件的无损透传。

理解了 Query 引擎的设计,你就握住了 Claude Code 的脉搏。接下来的第五章,我们将深入流式传输层,探究 callModel(即 queryModelWithStreaming)内部是如何将 Anthropic API 的 Server-Sent Events 流转化为 TypeScript 的异步迭代器,以及流式响应在传输过程中经历的一系列转换与增强。