第九篇:query.ts —— Claude Code 与 Anthropic API 的对话层

第九篇:query.ts ------ Claude Code 与 Anthropic API 的对话层

源码位置:src/query.ts(约500行核心逻辑)|难度:高级

一、引言:为什么 query.ts 是神经末梢

前八篇我们拆解了 Claude Code 的架构、CLI、Handler、QueryEngine,但一直有一个关键问题没回答:Claude Code 究竟是怎么和 Anthropic API 对话的?

答案就在 src/query.ts 里。这个文件虽然只有约500行,却是整个系统的"神经末梢"------它负责:

  1. 构建 API 请求:把内部消息格式转换成 Anthropic Messages API 需要的格式
  2. 处理流式响应:逐块解析 SSE 事件流
  3. 错误处理与重试:速率限制、网络超时、服务端错误
  4. 工具执行编排:协调工具调用和结果回传
  5. 上下文窗口管理:自动压缩(auto-compact)、快照(snip)、上下文折叠

💡 设计亮点query() 函数是一个 AsyncGenerator ,通过 yield 逐步向外推送事件(文本、工具调用、用量等),而不是等整个处理完成后一次性返回。这使得 UI 可以实时渲染流式输出。


二、query.ts 在架构中的位置

复制代码
用户输入
   ↓
CLI / REPL
   ↓
QueryEngine.ts  ← 第八篇讲的,负责对话状态管理
   ↓
query.ts        ← 本篇主角,负责 API 通信
   ↓
Anthropic Messages API

核心职责划分

  • QueryEngine:管理对话状态(messages 数组)、构建上下文(CLAUDE.md、git 状态)、调用 query()
  • query.ts:纯粹负责 API 通信,不包含业务逻辑

这种分离使得:

  • QueryEngine 可以专注业务(权限、成本追踪、会话持久化)
  • query.ts 可以专注通信(流式解析、重试、错误分类)

三、核心函数:query()

query() 是 query.ts 的唯一导出函数,签名如下:

typescript 复制代码
export async function* query(
  params: QueryParams,
): AsyncGenerator<
  | StreamEvent
  | RequestStartEvent
  | Message
  | TombstoneMessage
  | ToolUseSummaryMessage,
  Terminal
>

QueryParams:输入参数

字段 说明
messages 对话历史(Message\[\])
systemPrompt 系统提示词(SystemPrompt 类型)
userContext 用户上下文(CLAUDE.md 等)
systemContext 系统上下文(git status 等)
canUseTool 权限检查函数
toolUseContext 工具执行上下文
fallbackModel 备用模型(速率限制时切换)
querySource 请求来源(REPL/SDK/agent 等)
maxOutputTokensOverride 输出 token 上限覆盖
maxTurns 最大对话轮次
taskBudget 任务预算(API beta 功能)

四、query 循环:一步步拆解

query() 内部是一个 无限循环while (true)),每次循环代表一次 API 调用。循环退出的条件:

  • 模型返回 stop_reason: "end_turn"(不需要调用工具)
  • 达到 maxTurns 上限
  • 用户取消(AbortController)
  • 发生不可恢复错误

循环体的 12 个关键步骤

1. 解构状态(State Destructuring)
typescript 复制代码
let { toolUseContext } = state
const {
  messages,
  autoCompactTracking,
  maxOutputTokensRecoveryCount,
  hasAttemptedReactiveCompact,
  maxOutputTokensOverride,
  pendingToolUseSummary,
  stopHookActive,
  turnCount,
} = state

每次循环开始时,从 state 对象中解构出所有可变状态。这样做的好处:

  • 裸变量名访问 :循环体内直接用 messagestoolUseContext,不需要 state.messages
  • Continue 站点统一 :所有 continue 跳转都通过 state = { ... } 重新赋值
2. 技能发现预取(Skill Discovery Prefetch)
typescript 复制代码
const pendingSkillPrefetch = skillPrefetch?.startSkillDiscoveryPrefetch(
  null,
  messages,
  toolUseContext,
)

优化亮点 :技能发现(从项目提取可复用 prompt)在模型流式输出和工具执行期间并行运行,而不是阻塞等待。这隐藏了 97% 的空结果发现(production 数据)。

3. 上下文预处理

在调用 API 之前,需要对消息进行多处预处理:

a) 工具结果预算强制(applyToolResultBudget)

typescript 复制代码
messagesForQuery = await applyToolResultBudget(
  messagesForQuery,
  toolUseContext.contentReplacementState,
  persistReplacements
    ? records => void recordContentReplacement(records, toolUseContext.agentId).catch(logError)
    : undefined,
  new Set(
    toolUseContext.options.tools
      .filter(t => !Number.isFinite(t.maxResultSizeChars))
      .map(t => t.name),
  ),
)

问题:工具执行结果可能非常大(例如读取一个 10MB 的文件),直接发给 API 会撑爆上下文窗口。

解决方案

  • 对未设置 maxResultSizeChars 的工具,强制截断结果
  • contentReplacementState 记录替换(这样在 resume 时还能恢复完整内容)

b) Snip 压缩(Snip Compaction)

typescript 复制代码
if (feature('HISTORY_SNIP')) {
  const snipResult = snipModule!.snipCompactIfNeeded(messagesForQuery)
  messagesForQuery = snipResult.messages
  snipTokensFreed = snipResult.tokensFreed
}

Snip 原理 :移除早期对话中的冗余工具调用结果(例如,已经读取过的文件内容),但保留:

  • 关键决策点(用户指令、模型回复)
  • 工具调用的 元数据(工具名、参数),这样模型知道"这个文件我已经读过了"

c) 微压缩(Microcompact)

typescript 复制代码
const microcompactResult = await deps.microcompact(
  messagesForQuery,
  toolUseContext,
  querySource,
)
messagesForQuery = microcompactResult.messages

Microcompact 是什么 :一种轻量级压缩,只移除连续重复内容(例如,模型多次调用同一个工具,返回相同结果)。

与普通压缩的区别

  • 普通压缩(Auto-compact):用 Haiku 生成摘要,替换整个对话历史
  • 微压缩:只移除重复块,不生成摘要,延迟更低

d) 上下文折叠(Context Collapse)

typescript 复制代码
if (feature('CONTEXT_COLLAPSE') && contextCollapse) {
  const collapseResult = await contextCollapse.applyCollapsesIfNeeded(
    messagesForQuery,
    toolUseContext,
    querySource,
  )
  messagesForQuery = collapseResult.messages
}

上下文折叠 :将已解决的子任务折叠成一个摘要块。例如:

  • 原始:10 轮对话都在调试一个 bug
  • 折叠后:一个 [COLLAPSED: debug X bug → fixed]

这使得模型能"记住"完成了什么,但不会为已完成的事情浪费 token。

4. 构建完整系统提示词
typescript 复制代码
const fullSystemPrompt = asSystemPrompt(
  appendSystemContext(systemPrompt, systemContext),
)

系统提示词的组成

  1. 基础系统提示词systemPrompt):从 system.md 文件加载的核心指令
  2. 用户上下文userContext):CLAUDE.md 的内容
  3. 系统上下文systemContext):git status、当前日期等

appendSystemContext() 负责把这三部分拼接成一个字符串(或数组,如果 API 支持多段系统提示词)。

5. 自动压缩(Auto-compact)
typescript 复制代码
const { compactionResult, consecutiveFailures } = await deps.autocompact(
  messagesForQuery,
  toolUseContext,
  {
    systemPrompt,
    userContext,
    systemContext,
    toolUseContext,
    forkContextMessages: messagesForQuery,
  },
  querySource,
  tracking,
  snipTokensFreed,
)

Auto-compact 触发条件(满足任一):

  • 上下文窗口使用率 > 95%
  • 单次对话超过 50 轮
  • 用户手动触发 /compact

压缩过程

  1. 用 Haiku(最快的 Claude 模型)读取完整对话历史
  2. 生成摘要(保留关键决策、移除冗余细节)
  3. 用摘要替换原始消息
  4. 返回 compactionResult(包含压缩前后的 token 数、摘要消息)

如果压缩失败

  • consecutiveFailures 记录连续失败次数
  • 失败 3 次后,断路器触发,不再尝试压缩
  • 用户会看到警告:"上下文窗口已满,但自动压缩失败"
6. 阻塞限制检查(Blocking Limit Check)
typescript 复制代码
if (!compactionResult && querySource !== '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' }
  }
}

为什么需要阻塞限制

  • 如果上下文窗口已经 100% 满了,API 会返回 413 错误
  • 与其让 API 拒绝请求,不如在客户端提前拦截,给用户一个友好的错误信息

跳过检查的情况

  • 刚完成压缩(compactionResult 存在):压缩后的上下文肯定在限制内
  • 压缩/会话记忆查询:这些是 fork 的子进程,它们继承完整对话,如果阻塞会死锁
7. 模型选择(Model Selection)
typescript 复制代码
let currentModel = getRuntimeMainLoopModel({
  permissionMode,
  mainLoopModel: toolUseContext.options.mainLoopModel,
  exceeds200kTokens:
    permissionMode === 'plan' &&
    doesMostRecentAssistantMessageExceed200k(messagesForQuery),
})

模型选择逻辑

  • 默认:使用用户配置的 mainLoopModel(例如 claude-sonnet-4-20250514
  • Plan 模式:如果对话超过 200k tokens,自动切换到 Opus(更强大)
  • 速率限制触发:切换到 fallbackModel
8. API 调用循环(Streaming Loop)
typescript 复制代码
try {
  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,
          querySource,
          // ... 其他参数
        },
      })) {
        // 处理流式响应...
      }
    } catch (innerError) {
      if (innerError instanceof FallbackTriggeredError && fallbackModel) {
        // 切换到备用模型,重试
        currentModel = fallbackModel
        attemptWithFallback = true
        continue
      }
      throw innerError
    }
  }
}

callModel() 是什么

  • 这是一个依赖注入 的抽象(通过 deps.callModel 传入)
  • 实际实现在 src/services/api/callModel.ts,负责:
    • 发起 HTTPS 请求到 https://api.anthropic.com/v1/messages
    • 解析 SSE 流
    • 处理速率限制(429)重试

流式响应处理 (在 for await 循环内):

typescript 复制代码
for await (const message of deps.callModel(...)) {
  // message 的类型:
  // - AssistantMessage(模型生成的文本/工具调用)
  // - StreamEvent(流式事件,例如 'content_block_delta')
  // - RequestStartEvent(请求开始事件,用于性能追踪)

  // 1. 回填工具输入(backfillObservableInput)
  if (message.type === 'assistant') {
    // 某些工具(例如 Read)会在执行时展开文件路径
    // 这里把展开后的路径回填到消息中,方便 UI 显示
  }

  // 2.  withholding 可恢复错误
  // ( prompt-too-long、max-output-tokens 等)
  let withheld = false
  if (reactiveCompact?.isWithheldPromptTooLong(message)) {
    withheld = true
  }
  if (isWithheldMaxOutputTokens(message)) {
    withheld = true
  }
  if (!withheld) {
    yield yieldMessage
  }

  // 3. 收集工具调用
  if (message.type === 'assistant') {
    assistantMessages.push(message)
    const toolUseBlocks = message.message.content.filter(
      content => content.type === 'tool_use',
    )
    if (toolUseBlocks.length > 0) {
      toolUseBlocks.push(...toolUseBlocks)
      needsFollowUp = true
    }
  }

  // 4. 流式工具执行
  if (streamingToolExecutor) {
    for (const toolBlock of msgToolUseBlocks) {
      streamingToolExecutor.addTool(toolBlock, message)
    }
    // 检查是否有完成的工具,立即 yield 结果
    for (const result of streamingToolExecutor.getCompletedResults()) {
      if (result.message) {
        yield result.message
      }
    }
  }
}

流式工具执行(StreamingToolExecutor)

  • 传统方式:等模型返回所有工具调用 → 逐个执行 → 返回结果
  • 流式方式:模型刚返回一个工具调用 → 立即开始执行 → 执行完立即 yield 结果
  • 好处:用户能更早看到工具执行结果(减少感知延迟)
9. 错误恢复(Error Recovery)

API 调用可能失败,query.ts 实现了多层恢复机制

a) 速率限制(429)恢复

  • 自动重试,指数退避(1s → 2s → 4s → ...)
  • 最多重试 3 次
  • 如果 3 次都失败,切换到 fallbackModel

b) Prompt-too-long(413)恢复

typescript 复制代码
if (isWithheld413) {
  // 第一优先:上下文折叠排水(drain collapsed contexts)
  if (contextCollapse && state.transition?.reason !== 'collapse_drain_retry') {
    const drained = contextCollapse.recoverFromOverflow(messagesForQuery, querySource)
    if (drained.committed > 0) {
      // 重试,使用折叠后的消息
      state = { ...state, messages: drained.messages, transition: { reason: 'collapse_drain_retry' } }
      continue
    }
  }

  // 第二优先:响应式压缩(reactive compact)
  if (reactiveCompact) {
    const compacted = await reactiveCompact.tryReactiveCompact({ ... })
    if (compacted) {
      // 重试,使用压缩后的消息
      state = { ...state, messages: postCompactMessages, transition: { reason: 'reactive_compact_retry' } }
      continue
    }
  }

  // 恢复失败:返回错误
  yield lastMessage
  return { reason: 'prompt_too_long' }
}

c) Max-output-tokens 恢复

typescript 复制代码
if (isWithheldMaxOutputTokens(lastMessage)) {
  // 第一优先:升级输出 token 上限(8k → 64k)
  if (maxOutputTokensOverride === undefined) {
    state = {
      ...state,
      maxOutputTokensOverride: ESCALATED_MAX_TOKENS,
      transition: { reason: 'max_output_tokens_escalate' },
    }
    continue
  }

  // 第二优先:注入恢复提示词,让模型"接着说"
  if (maxOutputTokensRecoveryCount < MAX_OUTPUT_TOKENS_RECOVERY_LIMIT) {
    const recoveryMessage = createUserMessage({
      content: `Output token limit hit. Resume directly --- no apology, no recap...`,
      isMeta: true,
    })
    state = {
      ...state,
      messages: [...messagesForQuery, ...assistantMessages, recoveryMessage],
      transition: { reason: 'max_output_tokens_recovery', attempt: ... },
    }
    continue
  }
}

d) 图片/PDF 过大恢复

typescript 复制代码
if (isWithheldMedia && reactiveCompact) {
  // 用响应式压缩移除过大的媒体文件
  const compacted = await reactiveCompact.tryReactiveCompact({ ... })
  if (compacted) {
    // 重试,不带媒体文件
    continue
  }
}
10. 工具执行

如果模型返回了工具调用(needsFollowUp === true),query.ts 会:

typescript 复制代码
const toolUpdates = streamingToolExecutor
  ? streamingToolExecutor.getRemainingResults()
  : runTools(toolUseBlocks, assistantMessages, canUseTool, toolUseContext)

for await (const update of toolUpdates) {
  if (update.message) {
    yield update.message
    toolResults.push(update.message)
  }
}

工具执行结果

  • 每个工具的执行结果被封装成 tool_result 消息
  • 追加到 toolResults 数组
  • 在下一轮循环中,这些结果会被加入 messagesForQuery
11. 后置钩子(Post-sampling Hooks)
typescript 复制代码
if (assistantMessages.length > 0) {
  void executePostSamplingHooks(
    [...messagesForQuery, ...assistantMessages],
    systemPrompt,
    userContext,
    systemContext,
    toolUseContext,
    querySource,
  )
}

Post-sampling Hooks 是什么

  • 在模型响应完成后、工具执行前,执行用户定义的钩子
  • 用途:记录日志、修改工具输入、阻止某些工具调用
12. 循环继续条件检查

在每次循环结束前,检查是否需要继续:

typescript 复制代码
// 检查 maxTurns
if (maxTurns && nextTurnCount > maxTurns) {
  yield createAttachmentMessage({
    type: 'max_turns_reached',
    maxTurns,
    turnCount: nextTurnCount,
  })
  return { reason: 'max_turns', turnCount: nextTurnCount }
}

// 检查 token 预算(feature flag: TOKEN_BUDGET)
if (feature('TOKEN_BUDGET')) {
  const decision = checkTokenBudget(...)
  if (decision.action === 'continue') {
    // 注入提示词,让模型继续
    state = {
      ...state,
      messages: [...messagesForQuery, ...assistantMessages, createUserMessage({ content: decision.nudgeMessage, isMeta: true })],
      transition: { reason: 'token_budget_continuation' },
    }
    continue
  }
}

// 继续下一轮
state = {
  messages: [...messagesForQuery, ...assistantMessages, ...toolResults],
  toolUseContext: toolUseContextWithQueryTracking,
  turnCount: nextTurnCount,
  transition: { reason: 'next_turn' },
}

五、高级特性

1. 异步生成器(AsyncGenerator)的优势

为什么 query() 返回 AsyncGenerator 而不是 Promise

对比

typescript 复制代码
// 方式 1:Promise(一次性返回)
const result = await query(params)
// 要等整个模型响应完成后,才能拿到结果

// 方式 2:AsyncGenerator(流式返回)
for await (const event of query(params)) {
  // 模型每生成一个 token,就能立即拿到
  // UI 可以实时渲染流式输出
}

实际效果

  • 用户看到"打字机效果"(字符逐个出现)
  • 工具可以在模型还在生成时就提前执行(流式工具执行)
  • 取消请求可以立即响应(不需要等当前轮次结束)

2. 状态机设计(State Machine)

query.ts 的核心是一个状态机 ,状态存储在 State 类型中:

typescript 复制代码
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
}

Continue 类型

typescript 复制代码
type Continue = {
  reason:
    | 'next_turn'
    | 'collapse_drain_retry'
    | 'reactive_compact_retry'
    | 'max_output_tokens_escalate'
    | 'max_output_tokens_recovery'
    | 'stop_hook_blocking'
    | 'token_budget_continuation'
}

每次 continue 跳转,都会记录 transition.reason,这样:

  • 调试:可以精确到知道是哪一步触发了重试
  • 测试:可以断言恢复路径是否按预期执行

3. 内存预取(Memory Prefetch)

typescript 复制代码
using pendingMemoryPrefetch = startRelevantMemoryPrefetch(
  state.messages,
  state.toolUseContext,
)

问题 :如果项目中有 100 个 CLAUDE.md 记忆文件,每次对话都要读取它们,会很慢。

解决方案

  • 第一次 API 调用时启动预取(异步)
  • 如果预取在本轮结束前完成,立即注入到对话中
  • 如果预取在下一轮完成,那时再注入
  • using 关键字确保预取在生成器退出时自动清理

4. 任务预算(Task Budget)

typescript 复制代码
if (params.taskBudget) {
  const preCompactContext = finalContextTokensFromLastResponse(messagesForQuery)
  taskBudgetRemaining = Math.max(
    0,
    (taskBudgetRemaining ?? params.taskBudget.total) - preCompactContext,
  )
}

Task Budget 是什么

  • Anthropic API 的 beta 功能(task_budget
  • 限制整个 agentic turn 的 token 消耗(而不是单轮)
  • 在压缩边界处跨压缩持久化(普通 token 计数在压缩后会丢失)

六、错误处理详解

错误分类

错误类型 是否可重试 恢复策略
429(速率限制) 指数退避重试 → 切换备用模型
500/502/503(服务端错误) 指数退避重试
413(prompt-too-long) ⚠️ 上下文折叠排水 → 响应式压缩 → 返回错误
max_output_tokens ⚠️ 升级输出上限 → 注入恢复提示词 → 返回错误
图片/PDF 过大 ⚠️ 响应式压缩(移除媒体)→ 返回错误
401(认证失败) 立即返回错误
400(请求格式错误) 立即返回错误
网络超时 重试(最多 3 次)

错误 withholding 机制

问题 :某些错误(例如 413)是可以恢复 的,但如果在流式传输过程中立即 yield 错误,SDK 消费者(例如 cowork/desktop)会立即终止会话

解决方案

  • 在流式循环中,** withholding** 可恢复错误(不 yield
  • 循环继续,尝试恢复策略(折叠/压缩)
  • 如果恢复成功,错误被丢弃(用户无感知)
  • 如果恢复失败,错误才被 yield(用户看到错误信息)

实现

typescript 复制代码
let withheld = false
if (reactiveCompact?.isWithheldPromptTooLong(message)) {
  withheld = true
}
if (isWithheldMaxOutputTokens(message)) {
  withheld = true
}
if (!withheld) {
  yield yieldMessage
}

七、性能优化

1. Memoize 缓存

typescript 复制代码
const fullSystemPrompt = asSystemPrompt(
  appendSystemContext(systemPrompt, systemContext),
)

appendSystemContext() 内部用了 lodash-es/memoize,在一次对话会话内只计算一次。git status 这种耗时操作不会每次都执行。

2. Dump Prompts Fetch

typescript 复制代码
const dumpPromptsFetch = config.gates.isAnt
  ? createDumpPromptsFetch(toolUseContext.agentId ?? config.sessionId)
  : undefined

问题 :每次 API 调用都要构建完整的请求体(约 700KB),如果会话很长,内存中会保留所有历史请求体(可能达到 500MB)。

解决方案

  • createDumpPromptsFetch() 创建一个闭包 ,只捕获最新的请求体
  • 内存占用从 500MB 降到 700KB

3. 流式工具执行

传统方式:

复制代码
模型返回工具调用 → 等待所有工具执行完 → 返回结果 → 下一轮

流式方式:

复制代码
模型返回第一个工具调用 → 立即执行 → 执行完立即 yield 结果
                      → 同时模型继续返回第二个工具调用 → 立即执行...

好处 :工具执行和模型生成并行,总延迟更低。


八、总结:query.ts 的设计哲学

读完 src/query.ts 的约 500 行代码,有三个设计决策令人印象深刻:

  1. AsyncGenerator 而非回调

    • 通过异步生成器逐步 yield 事件,使得 REPL UI 可以实时渲染流式输出
    • SDK 消费者也能灵活处理中间事件(例如,记录日志、取消请求)
  2. 多层恢复机制

    • Prompt-too-long:折叠排水 → 响应式压缩 → 返回错误
    • Max-output-tokens:升级上限 → 注入恢复提示词 → 返回错误
    • 速率限制:指数退避 → 切换备用模型
    • 每层恢复都是独立的,互不干扰,且可以按任意顺序组合
  3. 特性门控(Feature Gating)

    • 所有实验性特性(Snip、Microcompact、Context Collapse、Token Budget)都用 feature() 包裹
    • 在生产构建中,未启用的特性代码会被 bun:bundle 完全移除(tree-shaking)
    • 这使得 query.ts 能同时服务:
      • 稳定版(只有核心功能)
      • 内测版(启用所有实验性特性)
      • Ant 内部版(额外的调试和遥测)

下一篇预告 :我们将深入 src/services/api/callModel.ts ------ 真正发起 HTTPS 请求、解析 SSE 流、处理速率限制的那一层,看看"200ms 首字延迟"是怎么做到的。


Claude Code 源码分析系列 · 第九篇