QueryEngine 请求生命周期(上)

📑 目录

在 Claude Code 的架构中,QueryEngine 是整个对话系统的核心引擎。它负责将用户的自然语言输入转化为可发送给 LLM 的结构化请求,并在收到响应后驱动工具执行循环。一个完整的请求生命周期可以被清晰地划分为上半程(请求准备)和下半程(响应处理与工具执行)。

本文聚焦上半程,深入解析从用户按下回车键到 HTTP 请求真正发出之间的完整链路。我们将沿着 QueryEngine.submitMessage() 的执行路径,逐层拆解请求入口、上下文收集、消息队列构建、System Prompt 组装、Token 预算检查等关键环节。

一、请求入口:submitMessage 的调用链

1.1 QueryEngine 的定位与职责

QueryEngine 是一个独立的类,每个对话实例对应一个 QueryEngine 对象。它的核心职责在源码注释中被精确定义:

"QueryEngine owns the query lifecycle and session state for a conversation. It extracts the core logic from ask() into a standalone class that can be used by both the headless/SDK path and (in a future phase) the REPL."

—— QueryEngine.ts 第 98~103 行

QueryEngine 内部维护着会话语义状态:

状态字段类型说明
mutableMessagesMessage[]会话消息历史,跨 turns 持久化
abortControllerAbortController用于取消正在进行的请求
permissionDenialsSDKPermissionDenial[]记录本 turn 中被拒绝的工具调用
readFileStateFileStateCache文件读取状态缓存
totalUsageNonNullableUsage累积的 API Token 使用量

1.2 输入预处理流程

当用户提交一条消息时,调用链从 submitMessage() 开始:

flowchart TD
    A[用户输入] --> B[submitMessage
prompt: string | ContentBlockParam[]] B --> C[解析配置参数
cwd/tools/commands/mcpClients 等] C --> D[重置 turn 级状态
discoveredSkillNames.clear] D --> E[设置工作目录
setCwd] E --> F[构建 wrappedCanUseTool
追踪权限拒绝] F --> G[确定 mainLoopModel
userSpecifiedModel / getMainLoopModel] G --> H[确定 thinkingConfig
adaptive / disabled] H --> I[获取 System Prompt 组件
fetchSystemPromptParts]

submitMessage 接收的参数 prompt 可以是纯文本字符串,也可以是包含图片等多模态内容的 ContentBlockParam[] 数组。在方法内部,首先进行大量的配置解构(第 118~145 行):

const {
  cwd,
  commands,
  tools,
  mcpClients,
  verbose = false,
  thinkingConfig,
  maxTurns,
  maxBudgetUsd,
  taskBudget,
  canUseTool,
  customSystemPrompt,
  appendSystemPrompt,
  // ... 更多配置
} = this.config

这一段代码体现了 QueryEngineConfig 的丰富性——它不仅包含工具集和命令集,还涵盖了预算控制(maxBudgetUsdtaskBudget)、模型选择(userSpecifiedModelfallbackModel)、输出格式(jsonSchema)等大量运行时参数。

1.3 权限追踪包装器

一个值得注意的细节是 wrappedCanUseTool 的构建(第 158~180 行)。Claude Code 的工具使用需要经过权限系统的审批,canUseTool 是外部注入的审批函数。QueryEngine 将其包装为一个新函数,在调用原函数后追踪拒绝记录:

const wrappedCanUseTool: CanUseToolFn = async (tool, input, toolUseContext, assistantMessage, toolUseID, forceDecision) => {
  const result = await canUseTool(tool, input, toolUseContext, assistantMessage, toolUseID, forceDecision)
  if (result.behavior !== 'allow') {
    this.permissionDenials.push({
      tool_name: sdkCompatToolName(tool.name),
      tool_use_id: toolUseID,
      tool_input: input,
    })
  }
  return result
}

这种装饰器模式的运用,使得权限审计数据能够在不修改原始审批逻辑的前提下被收集,最终上报到 SDK 的 result 消息中。

二、上下文收集:System Prompt 的三层拼图

在请求真正发出之前,QueryEngine 需要组装完整的上下文信息。这通过 fetchSystemPromptParts() 函数完成,它返回三个关键组件:

  • defaultSystemPrompt:默认的系统提示词
  • userContext:用户级上下文(CLAUDE.md、当前日期)
  • systemContext:系统级上下文(Git 状态、缓存破坏器)

2.1 fetchSystemPromptParts 的并行获取

const [defaultSystemPrompt, userContext, systemContext] = await Promise.all([
  customSystemPrompt !== undefined
    ? Promise.resolve([])
    : getSystemPrompt(tools, mainLoopModel, additionalWorkingDirectories, mcpClients),
  getUserContext(),
  customSystemPrompt !== undefined ? Promise.resolve({}) : getSystemContext(),
])

—— src/utils/queryContext.ts 第 45~53 行

这是一个经典的并行 I/O 模式。三个上下文来源独立获取,通过 Promise.all 并发执行。值得注意的是,如果用户指定了 customSystemPrompt,则默认的 getSystemPromptgetSystemContext 会被跳过——这是性能优化,避免生成一个不会被使用的默认提示词。

2.2 System Context:Git 状态快照

getSystemContext() 定义于 src/context.ts,它的核心任务是获取当前代码仓库的 Git 状态:

export const getSystemContext = memoize(async (): Promise<{[k: string]: string}> => {
  const gitStatus =
    isEnvTruthy(process.env.CLAUDE_CODE_REMOTE) || !shouldIncludeGitInstructions()
      ? null
      : await getGitStatus()
  // ...
  return {
    ...(gitStatus && { gitStatus }),
    ...(feature('BREAK_CACHE_COMMAND') && injection
      ? { cacheBreaker: `[CACHE_BREAKER: ${injection}]` }
      : {}),
  }
})

—— src/context.ts 第 66~88 行

getSystemContext 使用了 memoize 进行缓存,这意味着在一个会话周期内,Git 状态只会被获取一次。getGitStatus() 内部执行了多条 Git 命令的并行查询:

const [branch, mainBranch, status, log, userName] = await Promise.all([
  getBranch(),
  getDefaultBranch(),
  execFileNoThrow(gitExe(), ['--no-optional-locks', 'status', '--short'], ...),
  execFileNoThrow(gitExe(), ['--no-optional-locks', 'log', '--oneline', '-n', '5'], ...),
  execFileNoThrow(gitExe(), ['config', 'user.name'], ...),
])

—— src/context.ts 第 42~60 行

获取的信息被格式化为一段结构化的文本,包含当前分支、主分支、Git 用户名、文件状态(截断至 2000 字符)以及最近 5 条提交记录。这段文本以快照形式注入到 System Prompt 中,让 Claude 在回答时能够感知代码仓库的当前状态。

2.3 User Context:CLAUDE.md 与当前日期

getUserContext() 同样使用 memoize 缓存,它负责加载项目中的 CLAUDE.md 文件和当前日期:

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()}.`,
  }
})

—— src/context.ts 第 93~118 行

CLAUDE.md 是 Claude Code 的一个重要特性——用户可以在项目根目录放置这个文件,向 Claude 提供项目特定的指令、编码规范、架构说明等。getClaudeMds() 会遍历工作目录查找这些文件,并通过 filterInjectedMemoryFiles 过滤掉已注入的记忆文件,避免重复。

2.4 上下文收集的完整流程

flowchart TD
    subgraph "System Context [src/context.ts]"
        A1[getSystemContext
memoize缓存] --> A2{CLAUDE_CODE_REMOTE?} A2 -->|是| A3[返回空] A2 -->|否| A4[getGitStatus] A4 --> A5[并行执行 git 命令] A5 --> A6[格式化 git 状态文本] A1 --> A7{Break Cache?} A7 -->|是| A8[注入 cacheBreaker] end subgraph "User Context [src/context.ts]" B1[getUserContext
memoize缓存] --> B2{禁用 CLAUDE.md?} B2 -->|是| B3[跳过] B2 -->|否| B4[getMemoryFiles] B4 --> B5[getClaudeMds] B5 --> B6[setCachedClaudeMdContent] B1 --> B7[getLocalISODate] end subgraph "System Prompt Parts [src/utils/queryContext.ts]" C1[fetchSystemPromptParts] --> C2[Promise.all 并行] C2 --> A1 C2 --> B1 C2 --> C3[getSystemPrompt] end

三、System Prompt 的组装策略

获取到三个组件后,QueryEngine 开始组装最终的 System Prompt。这个过程比想象中复杂,因为 Claude Code 支持多种 System Prompt 的覆盖和叠加策略。

3.1 多层 System Prompt 的优先级

buildEffectiveSystemPrompt() 函数定义了 System Prompt 的优先级体系(src/utils/systemPrompt.ts 第 29~101 行):

优先级来源行为
0overrideSystemPrompt完全替换所有其他提示词
1Coordinator 模式使用协调器专用提示词
2Agent 提示词Proactive 模式下追加,其他模式替换默认
3customSystemPrompt用户自定义提示词
4defaultSystemPrompt标准 Claude Code 提示词
末尾appendSystemPrompt始终追加(除非 override 存在)

QueryEngine.submitMessage() 中,实际的组装逻辑如下(第 220~238 行):

const {
  defaultSystemPrompt,
  userContext: baseUserContext,
  systemContext,
} = await fetchSystemPromptParts({...})

// 合并 Coordinator 模式的 userContext
const userContext = {
  ...baseUserContext,
  ...getCoordinatorUserContext(mcpClients, isScratchpadEnabled() ? getScratchpadDir() : undefined),
}

// 条件性加载 memory-mechanics 提示词
const memoryMechanicsPrompt =
  customPrompt !== undefined && hasAutoMemPathOverride()
    ? await loadMemoryPrompt()
    : null

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

3.2 动态边界与缓存控制

Claude Code 的 System Prompt 中还包含一个重要的动态边界标记

export const SYSTEM_PROMPT_DYNAMIC_BOUNDARY = '__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__'

—— src/constants/prompts.ts 第 48 行

这个标记将 System Prompt 分为静态部分(跨组织可缓存)和动态部分(用户/会话特定)。在 splitSysPromptPrefix() 函数中(src/utils/api.ts 第 317~435 行),这段逻辑被用来决定 API 调用时的缓存策略:

  • 边界之前的内容(标准指令、工具说明)使用 cacheScope: 'global'
  • 边界之后的内容(Git 状态、CLAUDE.md)不使用全局缓存

这种设计极大地提升了长会话的缓存命中率,因为静态部分在多次调用中保持不变,而动态部分虽然变化,但占比较小。

3.3 System Prompt 的内容来源

默认的 System Prompt 由 getSystemPrompt() 生成(src/constants/prompts.ts),它包含:

  1. Attribution Header:计费归属标识
  2. System Prompt Prefix:CLI 前缀配置
  3. 核心指令:Claude Code 的身份定义和行为准则
  4. 工具说明:当前可用工具的详细描述(通过 tool.prompt() 动态生成)
  5. 输出风格配置:基于用户设置的输出格式
  6. 危险操作指令CYBER_RISK_INSTRUCTION 安全警告
  7. 动态内容:通过 SYSTEM_PROMPT_DYNAMIC_BOUNDARY 分隔的上下文

四、消息队列构建:从用户输入到 API 消息

4.1 processUserInput:输入解析与附件处理

System Prompt 组装完成后,QueryEngine 调用 processUserInput() 处理用户输入。这个函数(src/utils/processUserInput/processUserInput.ts)负责:

  • 解析斜杠命令(如 /compact/clear
  • 处理粘贴内容和图片附件
  • 构建用户消息对象
  • 执行用户提交的 hooks
const {
  messages: messagesFromUserInput,
  shouldQuery,
  allowedTools,
  model: modelFromUserInput,
  resultText,
} = await processUserInput({
  input: prompt,
  mode: 'prompt',
  context: {...processUserInputContext, messages: this.mutableMessages},
  messages: this.mutableMessages,
  uuid: options?.uuid,
  isMeta: options?.isMeta,
  querySource: 'sdk',
})

—— QueryEngine.ts 第 319~330 行

processUserInput 的返回值 shouldQuery 是一个关键标志。如果用户输入的是纯本地命令(如 /help 显示帮助信息),shouldQueryfalseQueryEngine 会直接返回结果而不调用 LLM。

4.2 消息的类型体系

Claude Code 的消息系统不是简单的字符串数组,而是一个丰富的类型系统(src/types/message.ts)。关键消息类型包括:

类型用途
UserMessage用户输入,支持文本、图片、工具结果
AssistantMessage模型输出,包含文本块和 tool_use 块
AttachmentMessage附件消息(结构化输出、记忆文件等)
SystemMessage系统级消息(compact 边界、API 错误等)
ProgressMessage进度消息(工具执行状态)
TombstoneMessage墓碑消息(用于删除历史消息)

用户输入被处理后,会生成一个或多个消息对象,并被推入 mutableMessages 数组(第 333 行):

this.mutableMessages.push(...messagesFromUserInput)

4.3 工具结果的回流消息

在之前的对话 turns 中,如果模型发起了 tool_use,其对应的 tool_result 会作为 UserMessage 回流到消息队列中。这些消息的结构如下:

{
  type: 'user',
  message: {
    role: 'user',
    content: [{
      type: 'tool_result',
      tool_use_id: 'tool_use_xxx',
      content: '命令执行结果...',
      is_error: false
    }]
  }
}

这种设计遵循了 OpenAI / Anthropic Messages API 的规范,使得工具调用形成了一个清晰的请求-响应循环:assistant 发起 tool_use → 系统执行工具 → user 返回 tool_resultassistant 基于结果继续推理。

4.4 System Init 消息

在消息队列构建完成后、query() 调用之前,QueryEngine 还会生成一条 system/init 消息(第 399~403 行):

yield buildSystemInitMessage({
  tools,
  mcpClients,
  model: mainLoopModel,
  permissionMode: initialAppState.toolPermissionContext.mode as PermissionMode,
  commands,
  agents,
  skills,
  plugins: enabledPlugins,
  fastMode: initialAppState.fastMode,
})

—— src/utils/messages/systemInit.ts 第 35~73 行

这条消息不发送给 LLM,而是作为 SDK 流的第一条消息,携带会话元数据(当前工作目录、可用工具列表、模型名称、权限模式、斜杠命令等),供远程客户端(如 Claude Desktop)用来渲染 UI 界面。

五、发送前的最后准备:query() 的内部世界

5.1 query() 函数的入口

QueryEngine 调用 query() 时(src/query.ts),真正的请求组装才刚刚开始。query() 接收的参数中包含了之前准备的所有内容:

for await (const message of query({
  messages,
  systemPrompt,
  userContext,
  systemContext,
  canUseTool: wrappedCanUseTool,
  toolUseContext: processUserInputContext,
  fallbackModel,
  querySource: 'sdk',
  maxTurns,
  taskBudget,
})) {
  // 处理流式返回...
}

5.2 上下文压缩链路

在将消息发送给 API 之前,query() 会执行一系列上下文压缩操作,确保消息总量不超过模型的上下文窗口。这个过程涉及多个阶段:

flowchart LR
    A[原始消息数组] --> B[applyToolResultBudget
工具结果预算] B --> C[snipCompactIfNeeded
历史裁剪] C --> D[microcompact
微压缩] D --> E[contextCollapse
上下文折叠] E --> F[autocompact
自动压缩] F --> G[最终消息数组]

阶段 1:工具结果预算(applyToolResultBudget)

大型工具(如 BashTool 执行 cat 大文件)可能产生海量的输出。applyToolResultBudget() 对每条工具结果的内容长度进行限制,避免单条消息耗尽上下文窗口。

阶段 2:历史裁剪(snipCompactIfNeeded)

HISTORY_SNIP 特性开启时,系统会裁剪掉过时的历史消息,释放 Token 空间。被裁剪的消息会被替换为一条 compact_boundary 系统消息。

阶段 3:微压缩(microcompact)

microcompact 是一种轻量级的压缩策略,主要针对冗余的工具调用结果进行合并和摘要。

阶段 4:上下文折叠(contextCollapse)

CONTEXT_COLLAPSE 特性支持更激进的上下文折叠,将多个 turns 的对话历史压缩为结构化摘要。

阶段 5:自动压缩(autocompact)

如果经过以上步骤后,消息总量仍然超过 getAutoCompactThreshold() 计算的阈值(默认上下文窗口减去 13,000 tokens 的缓冲区),系统会触发 autocompact。这个过程调用一个专门的压缩模型(通常是轻量级模型)对历史对话生成摘要,然后用摘要替换原始消息。

const { compactionResult, consecutiveFailures } = await deps.autocompact(
  messagesForQuery,
  toolUseContext,
  { systemPrompt, userContext, systemContext, toolUseContext, forkContextMessages: messagesForQuery },
  querySource,
  tracking,
  snipTokensFreed,
)

—— src/query.ts 第 420~431 行

5.3 Token 预算检查

Claude Code 实现了双重预算控制机制:

硬阻断检查(Blocking Limit)

当自动压缩关闭时,如果当前 Token 使用量超过手动压缩阈值(默认上下文窗口减去 3,000 tokens),系统会直接返回错误,阻止 API 调用:

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' }
}

—— src/query.ts 第 585~604 行

任务预算检查(Task Budget)

tokenBudget.ts 实现了基于输出 Token 预算的自动延续机制。当 feature('TOKEN_BUDGET') 开启时,系统会追踪当前 turn 消耗的 Token 数量:

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), ... }
  }
  // ...
}

—— src/query/tokenBudget.ts 第 35~82 行

这里的逻辑非常精巧:系统不仅检查是否超过预算的 90%(COMPLETION_THRESHOLD = 0.9),还会检测边际收益递减——如果连续 3 次延续后的 Token 增量都小于 500,则认为继续生成的价值有限,主动停止。

5.4 最终消息组装与 API 调用

经过压缩和预算检查后,query() 执行最终的请求参数组装。关键步骤包括:

追加 System Context

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

—— src/query.ts 第 415 行

appendSystemContextsrc/utils/api.ts 第 437~447 行)将系统上下文(Git 状态等)以键值对格式追加到 System Prompt 末尾:

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

前置 User Context

messages: prependUserContext(messagesForQuery, userContext),

—— src/query.ts 第 625 行

prependUserContextsrc/utils/api.ts 第 449~470 行)将用户上下文注入为一条特殊的 system-reminder 消息,放在消息数组的最前面:

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')}\n\nIMPORTANT: this context may or may not be relevant to your tasks...\n</system-reminder>\n`,
      isMeta: true,
    }),
    ...messages,
  ]
}

这种设计巧妙地将上下文信息作为用户消息注入,而非 System Prompt 的一部分。这有两个好处:

  1. 避免污染缓存:System Prompt 的修改会破坏 prompt caching,而用户消息不会
  2. 灵活性:User Context 可以每 turn 更新,而 System Prompt 相对静态

API 调用参数

最终,所有准备好的数据被打包为 callModel 的调用参数:

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,
    toolChoice: undefined,
    fallbackModel,
    querySource,
    agents: toolUseContext.options.agentDefinitions.activeAgents,
    maxOutputTokensOverride,
    mcpTools: appState.mcp.tools,
    queryTracking,
    effortValue: appState.effortValue,
    skipCacheWrite,
    ...(params.taskBudget && {
      taskBudget: { total: params.taskBudget.total, ...(taskBudgetRemaining !== undefined && { remaining: taskBudgetRemaining }) },
    }),
  },
})) {
  // 流式处理...
}

—— src/query.ts 第 622~672 行

六、上半程总结

回顾整个上半程,从用户输入到 API 调用,Claude Code 的请求生命周期展现了工程化的深度:

  1. 模块化设计fetchSystemPromptPartsbuildEffectiveSystemPromptprocessUserInputquery 各司其职,通过清晰的接口协作
  2. 性能优化memoize 缓存、并行 I/O、prompt caching 的动态边界设计,处处体现对延迟和成本的考量
  3. 鲁棒性:多层次的上下文压缩(snip → microcompact → autocompact)、Token 预算检查、fallback 模型切换,确保长会话的稳定性
  4. 可扩展性:通过 customSystemPromptappendSystemPromptmemoryMechanicsPrompt 等机制,支持 SDK 调用者的深度定制

callModel 的 HTTP 请求最终发出时,一条精心组装的消息已经准备就绪:System Prompt 定义了 Claude 的身份和能力边界,User Context 提供了项目特定的知识,消息队列承载了对话的历史与工具执行的结果,而各种压缩和预算机制则确保了这条消息既能充分利用上下文窗口,又不会超出模型的处理能力。

这就是 QueryEngine 的上半程——不是简单的字符串拼接,而是一个复杂的状态转换管道。在下一篇文章中,我们将继续探索下半程:LLM 的流式响应处理、工具执行的并行调度、以及 QueryEngine 如何将这些结果编织成完整的对话体验。