Claude Code 101|03|QueryEngine
QueryEngine 是 Claude Code 的会话级控制器:它把用户输入、上下文、权限、transcript、流式事件和最终结果组织成一个可恢复的 conversation turn。
如果说 query.ts 是 Claude Code 的 Agent Loop 内核,那么 src/QueryEngine.ts 更像一个会话级控制器。它不直接定义“模型如何思考”,也不直接执行每一个工具;它负责把一次用户输入变成一个完整、可恢复、可审计、可以被 SDK 消费的 conversation turn。
这层抽象很重要。很多 Agent 框架会从一个简单接口开始:run(prompt)。这个接口适合演示,却不足以支撑真实产品。真实产品里的 Agent 需要持续会话、权限状态、文件读取缓存、transcript、流式事件、错误重试、预算控制、structured output、resume、fork、UI/SDK 双入口,以及一套稳定的外部消息协议。QueryEngine 处理的正是这些“模型调用之外”的工程问题。

本文看的是 QueryEngine 这层:它如何包住 Agent Loop,如何保存状态,如何把内部事件翻译成 SDK 消息,以及为什么它是 Claude Code 从 CLI 工具走向 Agent Runtime 的关键边界。
定位
QueryEngine 的职责可以概括为一句话:
QueryEngine 是一个 conversation object,它把多轮用户输入、上下文构建、query loop、transcript 写入和 SDK 事件输出绑定在一起。
这句话里有几个关键词。
第一,它是 conversation object,而不是纯函数。一个 QueryEngine 实例对应一个持续会话。它内部维护 mutableMessages、readFileState、permissionDenials、totalUsage、AbortController、模型配置、thinking 配置、已发现的 skill、已加载的 nested memory 路径等状态。
第二,它处理的是 turn,而不是 token。submitMessage() 表示用户给这个 conversation 追加一轮输入。这个函数会接收 prompt,处理 slash command 和附件,构建 prompt/context,进入 query(),消费流式事件,最后产出 result。
第三,它对外暴露的是 SDK 消息流,而不是内部实现细节。外部调用方不需要知道 query loop 里如何处理工具执行、fallback、compact、stream event、stop hook;它只需要消费 system/init、assistant/user/system/result 等 SDK 消息。
所以 QueryEngine 的位置大致是:
SDK / headless caller
│
▼
QueryEngine.submitMessage()
│
├─ processUserInput()
├─ fetchSystemPromptParts()
├─ recordTranscript()
├─ query()
└─ SDKMessage stream / result
这不是一个“更大的模型调用函数”,而是把 Agent Loop 产品化的一层壳。
每个 conversation 一个实例
src/QueryEngine.ts 的注释明确写出:一个 conversation 对应一个 QueryEngine;每次 submitMessage() 都是在同一个 conversation 内开启新的 turn。这个设计让会话状态可以跨 turn 保留。
保留状态至少解决了四类问题。
第一是消息历史。mutableMessages 保存 conversation 到当前为止的消息序列。下一轮输入不是从空白 prompt 开始,而是在已有消息基础上继续。
第二是文件读取状态。readFileState 让系统知道哪些文件状态已经被读过、缓存过、参与过上下文构建。对 coding agent 来说,文件系统不是普通文本附件,而是会影响后续工具调用和上下文裁剪的运行时状态。
第三是权限反馈。permissionDenials 会记录工具权限拒绝,并最终体现在 SDK result 里。权限不是 UI 层临时弹窗,而是 Agent 执行轨迹的一部分。
第四是用量和成本。totalUsage 会跨流式事件累积,最终 result 带出 usage、modelUsage、total_cost_usd、duration_api_ms 等信息。对 SDK 调用方来说,这些是可观测性字段;对产品来说,这是预算、计费和调试的基础。
因此,QueryEngine 的状态不是实现细节,而是 Agent Runtime 的状态边界。
submitMessage 的主线
一次 submitMessage() 可以拆成九个阶段。
第一阶段:建立 turn 的基础环境。它会设置 cwd,决定是否持久化 session,记录开始时间,解析初始模型、thinking 配置和 app state。
第二阶段:包装权限函数。QueryEngine 接收外部传入的 canUseTool,再包一层 wrappedCanUseTool。当工具权限不是 allow 时,它把工具名、tool_use_id 和输入记录到 permissionDenials。这说明权限判断仍由宿主策略决定,但 QueryEngine 负责把结果纳入 SDK 可见的执行报告。
第三阶段:构建 prompt/context 前缀。它调用 fetchSystemPromptParts() 获取 defaultSystemPrompt、userContext、systemContext。如果有 customSystemPrompt,默认 system prompt 和 system context 会被替换掉;如果有 appendSystemPrompt,会追加到最终 system prompt;如果 SDK caller 显式启用了 memory path override,还会注入 memory mechanics prompt。
第四阶段:构造 ProcessUserInputContext。这个 context 包含当前 messages、工具列表、命令列表、MCP clients、模型、thinking、AppState getter/setter、AbortController、readFileState、skill/memory 触发集合等。换句话说,用户输入处理不是简单字符串预处理,而是一次带运行时状态的解析。
第五阶段:调用 processUserInput()。它会把 prompt 转成内部 messages,处理 slash command、local command、attachments、allowed tools、model override,并返回 shouldQuery。如果用户输入只是本地命令,shouldQuery=false,QueryEngine 不会进入模型循环。
第六阶段:把用户消息加入 mutableMessages,并在模型调用之前写 transcript。这一步非常关键:用户输入被接受后,先记录,再调用模型。即使之后进程被杀、网络失败、用户中止,session resume 仍然知道这一轮输入已经发生。
第七阶段:输出 system/init。QueryEngine 会加载 skills/plugins,然后 yield 一个 system init message,里面包含 cwd、session_id、tools、MCP servers、model、permission mode、slash commands、agents、skills、plugins、Claude Code version、output style、fast mode state 等。对远程客户端或 SDK consumer 来说,这是本轮流的元数据入口。
第八阶段:根据 shouldQuery 分叉。如果不需要模型,它会把本地命令输出包装成 SDK user/assistant 消息,再给出 success result。如果需要模型,它进入 query()。
第九阶段:消费 query() 的异步消息流。QueryEngine 一边转发可见消息,一边更新内部状态、写 transcript、统计 usage、捕获 stop reason、处理 compact boundary、structured output、max turns、budget、api retry 等控制事件。
伪代码可以写成这样:
class QueryEngine {
private mutableMessages: Message[]
private readFileState: FileStateCache
private permissionDenials: PermissionDenial[] = []
private totalUsage: Usage = emptyUsage()
private abortController: AbortController
async *submitMessage(prompt: string | ContentBlock[]) {
setCwd(this.config.cwd)
const model = resolveInitialModel(this.config)
const thinking = resolveThinkingConfig(this.config)
const appState = this.config.getAppState()
const canUseTool = this.wrapPermissionChecker(
this.config.canUseTool,
this.permissionDenials,
)
const promptParts = await fetchSystemPromptParts({
tools: this.config.tools,
mainLoopModel: model,
additionalWorkingDirectories: appState.additionalWorkingDirectories,
mcpClients: this.config.mcpClients,
customSystemPrompt: this.config.customSystemPrompt,
})
const systemPrompt = assembleSystemPrompt({
defaultSystemPrompt: promptParts.defaultSystemPrompt,
customSystemPrompt: this.config.customSystemPrompt,
memoryMechanicsPrompt: maybeLoadMemoryMechanicsPrompt(),
appendSystemPrompt: this.config.appendSystemPrompt,
})
const input = await processUserInput({
input: prompt,
messages: this.mutableMessages,
context: this.buildProcessUserInputContext({ model, thinking }),
querySource: 'sdk',
})
this.mutableMessages.push(...input.messages)
await recordTranscriptBeforeModelCall(this.mutableMessages)
yield buildSystemInitMessage({
tools: this.config.tools,
mcpClients: this.config.mcpClients,
model: input.model ?? model,
commands: this.config.commands,
agents: this.config.agents,
skills: await loadSkills(),
plugins: await loadPluginsFromCache(),
})
if (!input.shouldQuery) {
yield* this.replayLocalCommandOutput(input.messages)
yield this.successResult({ result: input.resultText })
return
}
for await (const event of query({
messages: [...this.mutableMessages],
systemPrompt,
userContext: promptParts.userContext,
systemContext: promptParts.systemContext,
canUseTool,
toolUseContext: this.buildToolUseContext(),
maxTurns: this.config.maxTurns,
taskBudget: this.config.taskBudget,
})) {
yield* this.consumeQueryEvent(event)
}
yield this.finalResult()
}
}
真实代码更复杂,但核心就是:QueryEngine 把用户输入处理、上下文前缀、持久化、query loop 和结果报告组织成一个 turn lifecycle。
Prompt 前缀不是简单拼字符串
src/utils/queryContext.ts 把 fetchSystemPromptParts() 单独放在一个文件里,注释里说得很清楚:这三个部分构成 API cache-key prefix,包括 system prompt、user context、system context。
这个细节说明 Claude Code 对 prompt 构建的看法不是“每次把所有东西 concat 成一个字符串”。更准确的结构是:
systemPrompt parts
userContext
systemContext
conversation messages
其中 system prompt 和 context 的组合会影响 API prompt cache。如果 custom system prompt 存在,默认 system prompt 和 system context 会被跳过,因为自定义 prompt 替代了默认策略;如果没有 custom prompt,就从工具列表、模型、额外工作目录、MCP clients 等信息构建默认 prompt。
QueryEngine 在这个基础上又做了两件事。
一是合并 coordinator user context。coordinator mode 打开时,它会把 MCP client 和 scratchpad 相关上下文注入 userContext。也就是说,QueryEngine 是 feature-gated runtime 和常规 query loop 的汇合点。
二是处理 memory mechanics prompt。只有当 SDK caller 提供 custom system prompt,并且显式设置 memory path override 时,QueryEngine 才注入 memory 使用规则。这避免了“自定义 system prompt 覆盖了默认记忆机制后,Agent 不知道该如何读写 memory”的问题。
这背后的工程原则是:prompt 构建应当有可复用的结构边界,而不是散落在 UI、SDK、工具和模型调用处。QueryEngine 负责把这些边界合成一次 query 可用的 cache-safe 前缀。
processUserInput 是输入编译器
从外部看,submitMessage(prompt) 收到的是字符串或 content blocks;进入 query() 前,它已经被编译成内部 messages 和运行时参数。
processUserInput() 处理的不只是普通文本。它可能识别 slash command,产生本地命令输出,修改 allowed tools,覆盖模型,插入 compact boundary,处理附件,触发 skill discovery 或 nested memory attachment。QueryEngine 给它的 ProcessUserInputContext 很重:里面有 messages、setMessages、tools、commands、MCP clients、AppState、readFileState、AbortController、file history updater、attribution updater 等。
这说明用户输入在 Agent 系统里不是“一段 prompt 文本”,而是一次命令事务。它可能改变会话状态、权限状态、模型选择和上下文结构。
QueryEngine 因此要在 processUserInput() 前后构造两次 context:第一次允许 slash command 等逻辑修改 mutableMessages;第二次在输入处理完成后,用更新后的 messages 和 model 重新创建 query loop 所需的 tool use context。
这个设计有一个很实际的好处:输入处理层可以拥有足够能力,但 query loop 收到的是已经规整过的状态,不需要知道用户原始输入里有多少 CLI/SDK/UI 语义。
Transcript 先于模型调用
QueryEngine 里最值得注意的设计之一,是用户消息会在进入 query() 前写入 transcript。
表面上,日志似乎应该在模型返回之后再写。但对可恢复 Agent 来说,transcript 不是事后日志,而是运行时状态。用户消息一旦被接受,就应该进入持久化会话,否则会出现一种危险状态:UI 或 SDK 认为这一轮已经发送,进程却在 API 返回前崩溃;resume 时系统找不到这轮输入,conversation chain 断裂。
代码注释里提到的场景很具体:如果只在 ask() yield assistant/user/compact_boundary 后才写 transcript,那么用户发送后立即停止进程时,session log 可能只有 queue-operation 条目,--resume 会认为没有 conversation。提前写入用户消息,就是为了让 resume 从“用户输入已被接受”的点继续。
这也解释了为什么 QueryEngine 对不同消息类型采用不同写入策略:
user input accepted -> blocking transcript write before model call
assistant streamed blocks -> fire-and-forget transcript write for latency
compact boundary -> flush preserved segment before boundary
progress / attachment -> inline record to avoid resume chain fork
final result -> flush buffered writes before process exits
这里的核心不是“记录日志”,而是维护一个可恢复的消息链。
system/init 是 SDK 的握手消息
在进入 query loop 或返回本地结果前,QueryEngine 会 yield buildSystemInitMessage() 生成的 system/init。这个消息里有很多看似元数据的字段:cwd、session_id、tools、MCP servers、model、permissionMode、slash_commands、apiKeySource、betas、claude_code_version、output_style、agents、skills、plugins、fast_mode_state。
这些字段的意义在于:SDK consumer 和远程客户端需要在看到 assistant 文本之前,先知道这个 session 的运行能力。
比如:
- 有哪些工具可以展示给用户;
- MCP server 是 connected 还是 pending;
- 当前模型和 fast mode 状态是什么;
- 权限模式如何影响工具执行;
- slash command、agents、skills、plugins 应该如何渲染或暴露。
这也是 QueryEngine 与纯 query() 的区别。query() 可以只关心 Agent Loop;QueryEngine 必须关心外部协议。它把内部 runtime 的能力面,翻译成 SDK 可以稳定消费的初始化事件。
本地命令和模型循环分叉
processUserInput() 返回 shouldQuery。这让 QueryEngine 支持一种很重要的分叉:不是每次用户输入都应该调用模型。
如果用户输入触发的是本地 slash command 或 compact summary 等逻辑,QueryEngine 会直接把本地输出转换成 SDK 消息,并返回 success result。这条路径仍然会写 transcript、输出 system/init、汇报 duration、usage、permission_denials、fast mode state 等字段,只是不进入 query()。
这体现了一个成熟 Agent Runtime 的边界:用户 turn 不等于模型请求。一个 turn 可能是本地命令、系统控制、状态查询、compact、权限处理,也可能是完整 Agent Loop。QueryEngine 统一这些情况,让调用方看到的是同一种 conversation stream。
事件消费:从内部消息到 SDK 消息
进入 query() 后,QueryEngine 的角色变成事件消费者。query() 会 yield 多种内部消息:assistant、user、system、progress、attachment、stream_event、tombstone、tool_use_summary 等。QueryEngine 对不同类型做不同处理。
assistant message 会进入 mutableMessages,并通过 normalizeMessage() 转成 SDK 可见消息。stream event 会用于更新 usage 和 stop reason;如果 includePartialMessages 打开,也会把原始 stream event 透出给 SDK。user message 通常是工具结果或 follow-up,会推进 turn count。progress 和 attachment 会写入 mutable messages,并在需要时记录 transcript,避免 resume 时消息链分叉。
system message 里最重要的是 compact boundary 和 api retry。compact boundary 会被转换成 SDK system message,同时 QueryEngine 会裁剪 mutableMessages 中 boundary 之前的内容,释放长会话里的内存;api error 会被转换成 SDK 的 api_retry,给客户端展示重试信息。
structured output 则通过 attachment 捕获。如果 SyntheticOutput tool 产出结构化数据,QueryEngine 会把它保存到 structuredOutputFromTool,最后放进 result 的 structured_output 字段。
这层消费逻辑说明:Agent Loop 的内部事件不能直接暴露给产品边界。QueryEngine 负责做协议化、归一化和状态副作用处理。
错误、预算和 stop reason
QueryEngine 不只返回成功结果,也定义了多种失败边界。
如果 query() 产出 max turns reached attachment,它会返回 error_max_turns,带上 turn count、usage、modelUsage、permission_denials、stop_reason 和错误文本。
如果 maxBudgetUsd 被触发,它会返回 error_max_budget_usd。这里预算检查发生在消费消息流的过程中,而不是模型调用前一次性判断,因为成本是在流式响应中累积出来的。
如果启用了 JSON schema / structured output,QueryEngine 会统计 SyntheticOutput tool 的调用次数。当超过 MAX_STRUCTURED_OUTPUT_RETRIES 后,返回 error_max_structured_output_retries。这说明 structured output 不是简单的“让模型输出 JSON”,而是带有 tool enforcement 和 retry ceiling 的运行时机制。
最后,如果 query loop 结束后找不到成功结果,它会返回 error_during_execution,并带上 turn-scoped errors。代码里还特别捕获 lastStopReason,因为 assistant message 在 content block stop 时可能还没有最终 stop reason,真正的 stop reason 要从 message_delta 里获取。
这些细节共同说明:QueryEngine 是 Agent Runtime 的结果判定层。它把内部复杂失败模式压缩成 SDK result subtype,让外部调用方可以稳定处理。
与 REPL 的边界
QueryEngine 很容易被误解成“Claude Code 的统一入口”。但交互式 REPL 并不简单地调用 QueryEngine。src/screens/REPL.tsx 有自己的输入队列、UI 状态、权限弹窗、stream event 渲染、command queue 和 bridge 逻辑,因此更直接地驱动 query()。
这不是重复实现,而是边界不同。
REPL 是交互式宿主。它要处理终端渲染、键盘输入、局部 UI 状态、权限确认、实时进度和用户体验。QueryEngine 是 headless / SDK / non-interactive 的会话控制器。它要提供较窄、较稳定、可程序化消费的消息流。
二者共享 Agent Loop,但不共享完整宿主。
这种分层对 Agent 产品很重要。模型循环应该是核心能力;CLI、TUI、SDK、后台任务、桌面 app 可以是不同宿主。QueryEngine 的存在,让 headless 和 SDK 路径不必复刻 TUI 的复杂性,也不必暴露 query loop 的所有内部结构。
ask 是 one-shot 包装
ask() 是 QueryEngine 上方的便利函数。它创建一个 QueryEngine,把 mutableMessages、read file cache、custom system prompt、thinking、budget、structured output、abort controller、orphaned permission 等配置传进去,然后 yield* engine.submitMessage()。
最后,ask() 会在 finally 中把 QueryEngine 内部的 read file state 写回外部缓存。
这个包装说明两件事。
第一,QueryEngine 是更底层的 conversation abstraction;ask() 是 one-shot 或 legacy/headless path 的便捷 API。
第二,即便 one-shot,也不意味着无状态。read file cache 仍然要被克隆进入 engine,并在结束后回写。这再次说明 coding agent 的“上下文”不是只存在于 prompt 字符串里,而存在于一组运行时缓存和状态对象里。
一个更完整的运行时草图
如果要把 QueryEngine 抽象成一个更通用的 Agent Harness,可以得到这样的接口:
type ConversationConfig = {
cwd: string
tools: Tool[]
commands: Command[]
mcpClients: McpClient[]
canUseTool: PermissionChecker
getAppState: () => AppState
setAppState: (f: (prev: AppState) => AppState) => void
initialMessages?: Message[]
readFileCache: FileStateCache
model?: string
customSystemPrompt?: string
appendSystemPrompt?: string
maxTurns?: number
maxBudgetUsd?: number
jsonSchema?: JsonSchema
}
class Conversation {
private messages: Message[]
private readFileState: FileStateCache
private usage: Usage
private permissionDenials: PermissionDenial[]
private abortController: AbortController
async *turn(input: UserInput): AsyncGenerator<SdkEvent> {
const runtime = await this.prepareRuntime(input)
await this.persistAcceptedInput(runtime.messages)
yield this.initEvent(runtime)
if (runtime.localOnly) {
yield* this.localEvents(runtime)
yield this.result(runtime)
return
}
for await (const event of this.agentLoop(runtime)) {
this.applyStateMutation(event)
await this.persistIfNeeded(event)
yield this.toSdkEvent(event)
if (this.hitBudgetLimit()) {
yield this.errorResult('budget')
return
}
}
yield this.resultFromLastMessage()
}
interrupt() {
this.abortController.abort()
}
snapshot(): ConversationSnapshot {
return {
messages: this.messages,
readFileState: this.readFileState,
usage: this.usage,
}
}
}
这个草图的重点不是复刻 Claude Code,而是说明一个可复用 Agent Harness 至少需要四层边界:输入编译、上下文构建、循环执行、事件/状态归一化。QueryEngine 正好位于这四层的交汇处。
工程启发
QueryEngine 给 Agent 系统设计带来几个直接启发。
第一,不要把 Agent API 设计成 run(prompt)。真实系统需要 conversation object:它保存消息、缓存、权限反馈、abort controller、预算、usage 和 transcript 指针。
第二,transcript 是运行时状态,不是日志附属品。对可恢复 Agent 来说,什么时候写、写哪些消息、哪些写入必须阻塞、哪些可以异步,都会影响 resume 的正确性。
第三,输入处理应当像编译器。用户输入可能改变模型、权限、上下文、命令结果和消息结构。把这些逻辑压进 prompt 拼接,会让系统不可维护。
第四,SDK 协议需要独立于内部事件。内部可以有 tombstone、attachment、progress、compact boundary、stream event;外部应该看到稳定的 SDKMessage 和 result subtype。
第五,交互式 UI 和 headless SDK 可以共享 Agent Loop,但不必共享宿主。REPL 适合直接管理 UI/permission/rendering;QueryEngine 适合提供程序化、可恢复、可审计的 conversation stream。
小结
QueryEngine 是 Claude Code 从“一个 Agent Loop”走向“一个 Agent Runtime”的关键层。它把用户输入、prompt/context、权限、transcript、query loop、stream event、compact、structured output、usage 和最终 result 放进同一个 turn lifecycle。
理解 QueryEngine,就能理解为什么成熟 Agent 系统的复杂度不只在模型调用,也不只在工具执行,而在会话控制:如何接受输入,如何持久化,如何恢复,如何汇报,如何让不同宿主共享同一个 Agent Loop。
这也是 Claude Code 101 前几篇之间的关系:Input Pipeline 解释输入如何进入系统;QueryEngine 解释输入如何变成可恢复的 conversation turn;下一篇 Agent Loop 则进入更内层,讨论模型、工具和多轮推理如何在 query() 中真正运行。