Appearance
第4章 Query 引擎:Agent 的心脏
"The heart of software is its loop." -- Gerald Weinberg, The Psychology of Computer Programming
本章要点
query.ts的AsyncGenerator编排模式:为什么选择生成器而非 PromiseQueryEngine.ts的会话生命周期管理与系统提示词动态组装- 查询循环状态机的完整状态转换图谱
- Auto-compact、Token Budget、Stop Hooks 三大停止与续行机制的协同运作
queryLoop中State结构体的精妙设计与九种 continue 语义- 错误恢复的分级策略:从扣留到降级,从压缩到放弃
如果说 CLI 启动流程是 Claude Code 的骨骼,那么 Query 引擎就是它的心脏。每一次用户提问,每一次工具调用,每一次模型响应,都由这颗心脏驱动完成。在本章中,我们将深入 query.ts 和 QueryEngine.ts 这两个核心文件,从类型定义到状态机实现,从消息预处理到错误恢复,逐层揭示 Claude Code 最核心的运行机制。
4.1 总览:两层架构的分工
以下架构图展示了 QueryEngine 和 query 两层的分工及数据流向:
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,中间产物(流式事件、工具结果、系统消息)通过 yield 从 query 流出到 QueryEngine,再经过格式转换后流向最终的 SDK 消费者。这种单向数据流极大地简化了心智模型,避免了双向通信带来的复杂性。
4.2 query.ts:高层编排
query.ts 是整个 Claude Code 中最长的单文件,约 1730 行。它的核心是一个 while(true) 循环,通过九种不同的 continue 路径和多种 return 路径实现完整的查询生命周期管理。我们从类型定义开始,逐步深入每一个关键环节。
4.2.1 QueryParams 类型定义的关键字段
query 函数的入参通过 QueryParams 类型定义,它携带了一次查询所需的全部上下文。这个类型是理解 query 系统的入口点:
typescript
// 文件: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 标识查询的来源(如 sdk、repl_main_thread、compact、session_memory 等),这个字段在后续的逻辑分支中频繁出现,用于区分主线程查询和各种派生查询(如压缩子 agent)。
依赖注入类。deps 字段是整个设计中最值得关注的架构决策之一。通过 QueryDeps 接口,测试可以直接注入 mock 实现,而生产环境则使用 productionDeps() 返回真实依赖:
typescript
// 文件: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 个依赖上以验证模式的可行性,未来会逐步扩展到 runTools、handleStopHooks 等更多依赖。
4.2.2 消息标准化流程
以下流程图展示了消息在进入 API 调用之前经历的五级预处理流水线:
在每一轮循环迭代开始时,消息数组需要经过一条多级预处理流水线。每一级都可能缩减消息数组的大小或修改其内容,最终目标是将消息控制在模型上下文窗口的安全范围内。这是 Claude Code 处理长对话的核心策略。
第一级:Compact 边界截取。调用 getMessagesAfterCompactBoundary(messages) 截取最近一次压缩边界之后的消息。压缩边界是一个特殊的系统消息,标记着"从这里开始才是有效对话"。这一步确保了压缩前的原始历史不会被重复发送给 API。