QueryEngine 请求生命周期(下)

📑 目录

在上篇文章中,我们完整梳理了 QueryEngine 请求生命周期的上半程——从用户输入到请求组装、上下文压缩、Token 预算检查,直到最终调用 callModel() 将请求发往 LLM API。本文将承接这一脉络,深入解析下半程:LLM 返回的流式响应如何处理?Tool Call 如何从 SSE 片段中被提取并组装?当网络抖动、模型超时或 Prompt 过长时,系统如何优雅地降级与恢复?以及当一轮对话结束后,Stop Hooks 如何决定对话是否继续推进?

这两个阶段合起来,才构成 QueryEngine 完整的请求生命周期闭环。

一、流式响应处理:SSE 到终端的实时链路

1.1 SSE 接收与事件类型

Claude Code 通过 Anthropic SDK 的流式接口与 LLM 通信,底层传输协议是 SSE(Server-Sent Events)。在 query.ts 中,流式响应的入口位于第 700 行附近的 callModel() 调用:

// src/query.ts:700-720
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: { /* ... */ },
})) {
  // 处理每一条流式消息
}

SSE 流中的每一条事件都会被解析为特定的消息类型。QueryEngine 内部定义了统一的 StreamEvent 类型来封装这些原始事件,主要包括以下几种:

事件类型含义处理位置
message_start新消息开始,重置当前消息的 usage 统计QueryEngine.ts:800-805
content_block_start新内容块开始(text / tool_use / thinking)流式底层处理
content_block_delta内容块增量更新流式底层处理
content_block_stop内容块结束流式底层处理
message_delta消息级增量(如 stop_reason 更新)QueryEngine.ts:806-812
message_stop消息结束,累加 usage 到总计QueryEngine.ts:813-818

1.2 增量内容解析与实时渲染

流式响应的核心价值在于低延迟的实时反馈。Claude Code 并不等待完整响应返回后再渲染,而是在每一个 content_block_delta 到达时就立即处理。

QueryEngine.ts 中,流式事件通过 switch-case 分发到不同的处理分支。对于 stream_event 类型的消息,系统会执行以下操作:

// src/QueryEngine.ts:793-825(简化示意)
case 'stream_event':
  if (message.event.type === 'message_start') {
    currentMessageUsage = EMPTY_USAGE
    currentMessageUsage = updateUsage(
      currentMessageUsage,
      message.event.message.usage,
    )
  }
  if (message.event.type === 'message_delta') {
    currentMessageUsage = updateUsage(
      currentMessageUsage,
      message.event.usage,
    )
    // 关键:stop_reason 只在 message_delta 中才能拿到真实值
    if (message.event.delta.stop_reason != null) {
      lastStopReason = message.event.delta.stop_reason
    }
  }
  if (message.event.type === 'message_stop') {
    this.totalUsage = accumulateUsage(
      this.totalUsage,
      currentMessageUsage,
    )
  }
  break

这段代码有几个值得注意的设计细节:

  1. Usage 的层级累加currentMessageUsage 跟踪单条消息的 Token 消耗,totalUsage 跟踪整个会话的累计消耗。这种分层设计使得预算检查可以在任意粒度上进行。

  2. stop_reason 的延迟捕获:在 content_block_stop 时,stop_reason 可能为 null,真实的停止原因要到 message_delta 才会送达。QueryEngine 特意在这一步捕获并保存,否则 result.stop_reason 将永远是 null

  3. includePartialMessages 选项:当 SDK 调用方需要原始流事件时,QueryEngine 会将每个 stream_event 原样 yield 出去,供上层消费。

1.3 内容类型判断:文本 vs Tool Call

在流式接收过程中,系统需要实时判断当前正在接收的内容类型。Anthropic 的响应格式中,每个 content_block 都有一个明确的 type 字段:

  • text:普通文本内容,直接渲染到终端
  • tool_use:Tool Call 的开始,需要进入工具解析流程
  • thinking / redacted_thinking:思考块,按规则保留或隐藏

query.ts 的流式循环中,当检测到 assistant 类型的消息时,系统会提取其中所有的 tool_use 块:

// src/query.ts:750-760
if (message.type === 'assistant') {
  assistantMessages.push(message)

  const msgToolUseBlocks = message.message.content.filter(
    content => content.type === 'tool_use',
  ) as ToolUseBlock[]
  if (msgToolUseBlocks.length > 0) {
    toolUseBlocks.push(...msgToolUseBlocks)
    needsFollowUp = true  // 标记需要后续工具执行
  }
}

这里的关键变量是 needsFollowUp。一旦有任何 tool_use 块出现,系统就知道这轮对话不会以纯文本结束,而是需要进入工具执行阶段,执行完成后再将结果送回 LLM 进行下一轮推理。

flowchart TD
    A[SSE 事件到达] --> B{事件类型?}
    B -->|message_start| C[重置 usage 计数]
    B -->|content_block_delta| D{内容块类型?}
    D -->|text| E[实时渲染到终端]
    D -->|tool_use| F[提取 Tool Call 片段]
    D -->|thinking| G[按规则处理思考块]
    B -->|message_delta| H[捕获 stop_reason]
    B -->|message_stop| I[累加 usage 到总计]
    F --> J[设置 needsFollowUp = true]
    J --> K[流结束 → 进入 Tool 执行]
    E --> L[流结束 → 直接进入 Stop Hooks]

二、Tool Call 解析:从流式片段到完整调用

2.1 Tool Call 的流式组装

与一次性返回完整 JSON 不同,Anthropic 的 tool_use 块在 SSE 流中是分段到达的。一个完整的 Tool Call 可能由以下事件序列组成:

content_block_start: { type: "tool_use", name: "Read", id: "toolu_01xxx" }
content_block_delta: { partial_json: "{\"file_path\": \"/" }
content_block_delta: { partial_json: "tmp/test.txt" }
content_block_delta: { partial_json: "\"}" }
content_block_stop

Claude Code 的底层 SDK 负责将这些片段拼接成完整的 JSON 对象。当 content_block_stop 到达时,block.input 已经被解析为一个完整的 JavaScript 对象。在 query.ts 中,系统此时会执行 input backfill——即工具定义的 backfillObservableInput 钩子:

// src/query.ts:720-745
if (message.type === 'assistant') {
  let clonedContent: typeof message.message.content | undefined
  for (let i = 0; i < message.message.content.length; i++) {
    const block = message.message.content[i]!
    if (
      block.type === 'tool_use' &&
      typeof block.input === 'object' &&
      block.input !== null
    ) {
      const tool = findToolByName(
        toolUseContext.options.tools,
        block.name,
      )
      if (tool?.backfillObservableInput) {
        const originalInput = block.input as Record<string, unknown>
        const inputCopy = { ...originalInput }
        tool.backfillObservableInput(inputCopy)
        // 只有当新增了字段时才 yield clone
        const addedFields = Object.keys(inputCopy).some(
          k => !(k in originalInput),
        )
        if (addedFields) {
          clonedContent ??= [...message.message.content]
          clonedContent[i] = { ...block, input: inputCopy }
        }
      }
    }
  }
  if (clonedContent) {
    yieldMessage = {
      ...message,
      message: { ...message.message, content: clonedContent },
    }
  }
}

这段代码的设计非常精妙:

  • Immutable 原则:原始 message 对象不直接修改,因为这条消息会流回 API 作为下一轮的上下文。任何字节级的变化都会破坏 Prompt Caching。
  • 按需克隆:只有当 backfillObservableInput 真正添加了新字段时,才创建克隆消息用于 yield 和转录。
  • Observable Input:某些工具(如文件操作工具)需要在输入被确认后展开相对路径、解析符号链接等,这些派生字段对后续执行有用,但不应被 API 看到。

2.2 参数 JSON 解析与校验

Tool Call 的参数是一个 JSON 对象。Claude Code 使用 Zod 进行运行时类型校验。当参数不符合工具的 Schema 时,会触发 formatZodValidationError 函数生成友好的错误提示:

// src/utils/toolErrors.ts:65-115
export function formatZodValidationError(
  toolName: string,
  error: ZodError,
): string {
  const missingParams = error.issues
    .filter(
      err =>
        err.code === 'invalid_type' &&
        err.message.includes('received undefined'),
    )
    .map(err => formatValidationPath(err.path))

  const unexpectedParams = error.issues
    .filter(err => err.code === 'unrecognized_keys')
    .flatMap(err => err.keys)

  const typeMismatchParams = error.issues
    .filter(
      err =>
        err.code === 'invalid_type' &&
        !err.message.includes('received undefined'),
    )
    .map(err => {
      const typeErr = err as { expected: string }
      const receivedMatch = err.message.match(/received (\w+)/)
      const received = receivedMatch ? receivedMatch[1] : 'unknown'
      return {
        param: formatValidationPath(err.path),
        expected: typeErr.expected,
        received,
      }
    })

  // 构建人类可读的错误消息
  const errorParts = []
  if (missingParams.length > 0) {
    errorParts.push(...missingParams.map(
      param => `The required parameter \`${param}\` is missing`,
    ))
  }
  if (unexpectedParams.length > 0) {
    errorParts.push(...unexpectedParams.map(
      param => `An unexpected parameter \`${param}\` was provided`,
    ))
  }
  if (typeMismatchParams.length > 0) {
    errorParts.push(...typeMismatchParams.map(
      ({ param, expected, received }) =>
        `The parameter \`${param}\` type is expected as \`${expected}\` but provided as \`${received}\``,
    ))
  }

  return errorParts.length > 0
    ? `${toolName} failed due to the following ${errorParts.length > 1 ? 'issues' : 'issue'}:\n${errorParts.join('\n')}`
    : error.message
}

这个错误格式化器将 Zod 的校验失败转换为 LLM 能够理解的结构化反馈,使得模型在下一轮可以尝试修正参数。

2.3 StreamingToolExecutor:工具执行的并行化

Claude Code 引入了一个重要的优化机制——StreamingToolExecutor。传统的工具执行需要等待完整的 assistant 消息到达后才能开始,而 StreamingToolExecutor 允许在流式接收过程中就提前启动工具

// src/query.ts:600-610
const useStreamingToolExecution = config.gates.streamingToolExecution
let streamingToolExecutor = useStreamingToolExecution
  ? new StreamingToolExecutor(
      toolUseContext.options.tools,
      canUseTool,
      toolUseContext,
    )
  : null

当流中出现 tool_use 块时,StreamingToolExecutor 立即将其加入执行队列:

// src/query.ts:755-760
if (streamingToolExecutor && !toolUseContext.abortController.signal.aborted) {
  for (const toolBlock of msgToolUseBlocks) {
    streamingToolExecutor.addTool(toolBlock, message)
  }
}

与此同时,执行器会在后台消费已完成的工具结果:

// src/query.ts:763-772
if (streamingToolExecutor && !toolUseContext.abortController.signal.aborted) {
  for (const result of streamingToolExecutor.getCompletedResults()) {
    if (result.message) {
      yield result.message
      toolResults.push(
        ...normalizeMessagesForAPI(
          [result.message],
          toolUseContext.options.tools,
        ).filter(_ => _.type === 'user'),
      )
    }
  }
}

这种设计将网络等待时间(LLM 生成剩余内容)与工具执行时间(读取文件、执行命令等)重叠,显著降低了端到端延迟。

三、错误处理与恢复:韧性设计的艺术

3.1 网络错误重试策略

LLM API 调用不可避免地会遇到网络波动。Claude Code 在 withRetry.ts 中实现了一套精细的重试机制:

// src/services/api/withRetry.ts:15-25
const DEFAULT_MAX_RETRIES = 10
const FLOOR_OUTPUT_TOKENS = 3000
const MAX_529_RETRIES = 3
export const BASE_DELAY_MS = 500

// 前景查询源(用户在等待结果)才会重试 529
const FOREGROUND_529_RETRY_SOURCES = new Set<QuerySource>([
  'repl_main_thread',
  'sdk',
  'agent:custom',
  'compact',
  // ... 其他前景源
])

重试策略按错误类型分层处理:

错误类型重试策略说明
529 (Overloaded)前景源最多重试 3 次,指数退避后台任务(如摘要生成)立即失败,避免加重网关压力
429 (Rate Limit)按 Retry-After 头等待后重试清缓存后重新获取凭据
Connection Error最多 10 次,指数退避至 5 分钟持久化重试模式(CLAUDE_CODE_UNATTENDED_RETRY)下可无限重试
401 (Auth)触发 OAuth 刷新,然后重试支持 Claude AI 和企业订阅的自动续期

QueryEngine.ts 中,API 错误以 system 消息的 api_error 子类型形式 yield 出去,包含重试计数和延迟信息:

// src/QueryEngine.ts:880-890
if (message.subtype === 'api_error') {
  yield {
    type: 'system',
    subtype: 'api_retry' as const,
    attempt: message.retryAttempt,
    max_retries: message.maxRetries,
    retry_delay_ms: message.retryInMs,
    error_status: message.error.status ?? null,
    error: categorizeRetryableAPIError(message.error),
    // ...
  }
}

3.2 模型降级:Fallback 策略

当主模型(如 claude-sonnet-4-20250514)因高负载不可用时,Claude Code 支持自动降级到备用模型(Fallback Model):

// src/query.ts:920-960
catch (innerError) {
  if (innerError instanceof FallbackTriggeredError && fallbackModel) {
    currentModel = fallbackModel
    attemptWithFallback = true

    // 清除已产生的 assistant 消息和 tool 结果
    yield* yieldMissingToolResultBlocks(
      assistantMessages,
      'Model fallback triggered',
    )
    assistantMessages.length = 0
    toolResults.length = 0
    toolUseBlocks.length = 0
    needsFollowUp = false

    // 丢弃 StreamingToolExecutor 的 pending 结果
    if (streamingToolExecutor) {
      streamingToolExecutor.discard()
      streamingToolExecutor = new StreamingToolExecutor(/* ... */)
    }

    // 处理 Thinking 签名:不同模型的签名不兼容,需要 strip
    if (process.env.USER_TYPE === 'ant') {
      messagesForQuery = stripSignatureBlocks(messagesForQuery)
    }

    yield createSystemMessage(
      `Switched to ${renderModelName(innerError.fallbackModel)} due to high demand for ${renderModelName(innerError.originalModel)}`,
      'warning',
    )
    continue  // 重试循环
  }
  throw innerError
}

降级策略的清理工作非常彻底:

  1. Tombstone 已产生的消息:为部分生成的消息(尤其是 thinking 块)发送 tombstone,防止它们以无效状态进入后续轮次。
  2. 重置执行状态:清空所有累积的 assistant 消息、tool 结果和执行器状态。
  3. 签名剥离:某些模型(如 Capybara 实验模型)的 thinking 签名与其他模型不兼容,降级时需要剥离,否则会触发 API 400 错误。

3.3 Prompt 过长与最大输出 Token 的恢复

当遇到 prompt_too_longmax_output_tokens 错误时,QueryEngine 不会立即失败,而是尝试自动恢复

Prompt Too Long 恢复链

// src/query.ts:1000-1050
if (isWithheld413) {
  // 第一步:尝试 Context Collapse 的 drain 恢复
  if (contextCollapse && state.transition?.reason !== 'collapse_drain_retry') {
    const drained = contextCollapse.recoverFromOverflow(
      messagesForQuery,
      querySource,
    )
    if (drained.committed > 0) {
      state = { /* ... */ transition: { reason: 'collapse_drain_retry' } }
      continue
    }
  }
}

// 第二步:尝试 Reactive Compact
if ((isWithheld413 || isWithheldMedia) && reactiveCompact) {
  const compacted = await reactiveCompact.tryReactiveCompact({ /* ... */ })
  if (compacted) {
    state = { /* ... */ transition: { reason: 'reactive_compact_retry' } }
    continue
  }
}

Max Output Tokens 恢复链

// src/query.ts:1070-1100
if (isWithheldMaxOutputTokens(lastMessage)) {
  // 第一步:尝试从 8k 升级到 64k
  if (capEnabled && maxOutputTokensOverride === undefined) {
    state = { /* ... */ maxOutputTokensOverride: ESCALATED_MAX_TOKENS }
    continue
  }

  // 第二步:多轮恢复提示,最多 3 次
  if (maxOutputTokensRecoveryCount < MAX_OUTPUT_TOKENS_RECOVERY_LIMIT) {
    const recoveryMessage = createUserMessage({
      content: `Output token limit hit. Resume directly — no apology...`,
      isMeta: true,
    })
    state = {
      messages: [...messagesForQuery, ...assistantMessages, recoveryMessage],
      maxOutputTokensRecoveryCount: maxOutputTokensRecoveryCount + 1,
      // ...
    }
    continue
  }
}

这种分层恢复设计确保了系统在各种边缘情况下都能尽量自我愈合,而不是把错误抛给用户。

3.4 无效响应的处理

如果 callModel() 抛出了未被捕获的异常(这通常意味着 SDK 内部的 Bug),QueryEngine 会执行兜底处理:

// src/query.ts:970-995
catch (error) {
  logError(error)
  const errorMessage = error instanceof Error ? error.message : String(error)

  // 为已 emit 的 tool_use 块生成 tool_result,防止 API 状态不一致
  yield* yieldMissingToolResultBlocks(assistantMessages, errorMessage)

  // 生成一个 synthetic assistant 消息来 surface 错误
  yield createAssistantAPIErrorMessage({
    content: errorMessage,
  })

  return { reason: 'model_error', error }
}

关键点在于 yieldMissingToolResultBlocks:如果流已经 emit 了 tool_use 块但随后发生了错误,API 状态要求每个 tool_use 必须有对应的 tool_result。这个函数会为所有未完成的 tool use 生成错误类型的 tool result,保持对话状态的一致性。

四、停止钩子(Stop Hooks):对话继续的裁判

4.1 Stop Hooks 的设计与触发时机

Stop Hooks 是 Claude Code 中一个独特而强大的机制。它们位于每轮对话的末尾,在 assistant 响应完成(且没有 tool_use 需要执行)之后执行。其核心逻辑在 query/stopHooks.ts 中实现:

// src/query/stopHooks.ts:35-50
export async function* handleStopHooks(
  messagesForQuery: Message[],
  assistantMessages: AssistantMessage[],
  systemPrompt: SystemPrompt,
  userContext: { [k: string]: string },
  systemContext: { [k: string]: string },
  toolUseContext: ToolUseContext,
  querySource: QuerySource,
  stopHookActive?: boolean,
): AsyncGenerator<StreamEvent | RequestStartEvent | Message | /* ... */,
  StopHookResult
> {
  const hookStartTime = Date.now()

  const stopHookContext: REPLHookContext = {
    messages: [...messagesForQuery, ...assistantMessages],
    systemPrompt,
    userContext,
    systemContext,
    toolUseContext,
    querySource,
  }
  // ...
}

Stop Hooks 的触发条件是:

  1. 当前轮次的 assistant 消息已完整接收
  2. 该消息不包含 tool_use 块(即 needsFollowUp === false
  3. 该消息不是 API 错误消息
sequenceDiagram
    participant QE as QueryEngine
    participant LLM as LLM API
    participant SH as StopHooks
    participant TE as ToolExecution

    QE->>LLM: 发送请求
    LLM-->>QE: 流式响应
    alt 包含 tool_use
        QE->>TE: 执行工具
        TE-->>QE: 工具结果
        QE->>LLM: 下一轮请求
    else 纯文本响应
        QE->>SH: 触发 Stop Hooks
        SH->>SH: 执行用户配置的 hooks
        alt preventContinuation
            SH-->>QE: 阻止继续
            QE->>QE: 返回 completed
        else blockingErrors
            SH-->>QE: 阻塞错误
            QE->>LLM: 将错误送入下一轮
        else 通过
            SH-->>QE: 允许继续
            QE->>QE: 返回 completed
        end
    end

4.2 优雅停止 vs 强制停止

Stop Hooks 支持两种停止语义:

优雅停止(Prevent Continuation)

当 Stop Hook 判定对话应该终止时(例如,检测到任务已完成或用户明确要求停止),它会设置 preventContinuation = true

// src/query/stopHooks.ts:175-185
if (result.preventContinuation) {
  preventedContinuation = true
  stopReason = result.stopReason || 'Stop hook prevented continuation'
  yield createAttachmentMessage({
    type: 'hook_stopped_continuation',
    message: stopReason,
    hookName: 'Stop',
    toolUseID: stopHookToolUseID,
    hookEvent: 'Stop',
  })
}

这种停止是不可逆的。一旦触发,handleStopHooks 立即返回 { preventContinuation: true },QueryEngine 随后以 reason: 'stop_hook_prevented' 结束当前 turn。

阻塞错误(Blocking Errors)

当 Stop Hook 检测到需要修正的问题(例如,代码风格检查失败、测试未通过),但不会阻止对话继续时,它会生成阻塞错误:

// src/query/stopHooks.ts:165-175
if (result.blockingError) {
  const userMessage = createUserMessage({
    content: getStopHookMessage(result.blockingError),
    isMeta: true, // 对模型隐藏,仅在 summary 中显示
  })
  blockingErrors.push(userMessage)
  yield userMessage
  hasOutput = true
  hookErrors.push(result.blockingError.blockingError)
}

阻塞错误会以 user 消息的形式注入对话上下文,让 LLM 在下一轮有机会修正问题。这与优雅停止的关键区别在于——对话会继续推进。

4.3 Stop Hooks 的并行执行与结果聚合

Stop Hooks 实际上是并行执行的。executeStopHooks 返回一个 AsyncGenerator,每个 hook 的结果被逐个消费:

// src/query/stopHooks.ts:135-200
for await (const result of generator) {
  if (result.message) {
    yield result.message
    if (result.message.type === 'progress' && result.message.toolUseID) {
      stopHookToolUseID = result.message.toolUseID
      hookCount++
    }
    if (result.message.type === 'attachment') {
      const attachment = result.message.attachment
      // 分类处理 hook 的各种结果类型
      if (attachment.type === 'hook_non_blocking_error') {
        hookErrors.push(attachment.stderr || `Exit code ${attachment.exitCode}`)
      } else if (attachment.type === 'hook_error_during_execution') {
        hookErrors.push(attachment.content)
      } else if (attachment.type === 'hook_success') {
        if (attachment.stdout?.trim() || attachment.stderr?.trim()) {
          hasOutput = true
        }
      }
    }
  }
  // ...
}

执行完成后,如果 hooks 产生了任何输出,系统会生成一个 Stop Hook Summary Message

// src/query/stopHooks.ts:240-255
if (hookCount > 0) {
  yield createStopHookSummaryMessage(
    hookCount,
    hookInfos,
    hookErrors,
    preventedContinuation,
    stopReason,
    hasOutput,
    'suggestion',
    stopHookToolUseID,
  )

  if (hookErrors.length > 0) {
    const expandShortcut = getShortcutDisplay(
      'app:toggleTranscript',
      'Global',
      'ctrl+o',
    )
    toolUseContext.addNotification?.({
      key: 'stop-hook-error',
      text: `Stop hook error occurred · ${expandShortcut} to see`,
      priority: 'immediate',
    })
  }
}

这个 summary 对用户可见,提供了 hooks 执行结果的概览,包括每个 hook 的命令、执行时长、错误信息等。

4.4 Teammate 模式下的扩展 Hooks

在 Teammate(多 Agent 协作)模式下,Stop Hooks 之后还会执行 TaskCompleted HooksTeammateIdle Hooks

// src/query/stopHooks.ts:280-350
if (isTeammate()) {
  const teammateName = getAgentName() ?? ''
  const teamName = getTeamName() ?? ''

  // 为所有进行中的任务运行 TaskCompleted hooks
  const tasks = await listTasks(taskListId)
  const inProgressTasks = tasks.filter(
    t => t.status === 'in_progress' && t.owner === teammateName
  )
  for (const task of inProgressTasks) {
    const taskCompletedGenerator = executeTaskCompletedHooks(
      task.id, task.subject, task.description,
      teammateName, teamName, /* ... */
    )
    // ...
  }

  // 运行 TeammateIdle hooks
  const teammateIdleGenerator = executeTeammateIdleHooks(
    teammateName, teamName, /* ... */
  )
  // ...
}

这些扩展 hooks 使得多 Agent 协作时的状态同步和任务交接能够自动化完成。

五、完整链路:从响应到下一轮请求

现在,让我们将上半程和下半程连接起来,看看一个完整的请求生命周期是如何闭环的。

5.1 无 Tool Call 的纯文本路径

用户输入
  → processUserInput 处理(第11篇)
  → 上下文压缩 / Snip / Microcompact(第11篇)
  → callModel() 发送请求(第11篇)
  → SSE 流式接收(本文 §1)
  → 内容类型判断:纯文本(本文 §1.3)
  → needsFollowUp = false
  → 检查 maxBudgetUsd / maxTurns(本文 §3)
  → handleStopHooks()(本文 §4)
  → 通过 / prevent / blocking
  → yield result 消息
  → 本轮结束

5.2 含 Tool Call 的循环路径

用户输入
  → processUserInput 处理
  → 上下文压缩
  → callModel() 发送请求
  → SSE 流式接收
  → 检测到 tool_use 块(本文 §2)
  → needsFollowUp = true
  → StreamingToolExecutor 并行执行工具(本文 §2.3)
  → 收集 toolResults
  → 将 assistantMessages + toolResults 拼接为新的 messages
  → state = { messages: [...], ... }(本文 §3)
  → continue(进入下一轮 queryLoop)
  → callModel() 再次发送请求
  → ...(循环直到 needsFollowUp = false)

5.3 关键状态机转换

query.ts 中的 queryLoop 本质上是一个状态机,每次 continue 都代表一次状态转换:

触发条件下一状态说明
needsFollowUp === trueTool 执行 → 继续循环最常见的 Agent 推理循环
stopHookResult.preventContinuation终止,reason: 'stop_hook_prevented'Hook 要求停止
stopHookResult.blockingErrors.length > 0注入错误消息 → 继续循环Hook 发现问题,让 LLM 修正
isWithheld413 && collapse drain 成功transition: 'collapse_drain_retry'Prompt 过长恢复
isWithheld413 && reactive compact 成功transition: 'reactive_compact_retry'上下文压缩恢复
isWithheldMaxOutputTokens && 可恢复transition: 'max_output_tokens_recovery'输出超限恢复
maxBudgetUsd 超出终止,subtype: 'error_max_budget_usd'预算耗尽
maxTurns 达到终止,subtype: 'error_max_turns'轮次上限

六、总结

QueryEngine 的下半程展示了 Claude Code 作为生产级 AI Agent 框架的深厚工程功底:

  1. 流式响应处理:通过 SSE 实时消费、增量解析、即时渲染,将用户等待时间最小化。

  2. Tool Call 解析:在流中完成 Tool Call 的组装、Backfill 和校验,配合 StreamingToolExecutor 实现工具执行的零等待并行化。

  3. 错误恢复的多层防线:从网络重试、模型降级,到 Prompt 过长和输出超限的自动恢复,系统在各种异常路径上都保留了继续推进的可能性。

  4. Stop Hooks 的灵活控制:通过优雅停止和阻塞错误两种语义,让用户和系统都能参与到对话何时结束、何时继续的决策中。

  5. 与上半程的无缝衔接queryLoopwhile (true) 循环将请求组装(上半程)和响应处理(下半程)编织成一个完整的 Agent 推理循环。

理解了这个完整生命周期,你就能把握 Claude Code 的核心调度逻辑——这也是构建任何复杂 AI Agent 系统都必须面对的课题:如何在流式不确定性中保持状态一致,如何在错误边缘优雅恢复,以及如何让系统知道"何时该停下来"。


本系列文章导航:

  • 第 11 篇:《QueryEngine 请求生命周期(上)》——请求组装、上下文压缩、Token 预算
  • 第 12 篇:《QueryEngine 请求生命周期(下)》——流式响应、Tool Call、错误恢复、Stop Hooks(本文)