核心机制

核心循环解析:QueryEngine 如何驱动一次任务完成

QueryEngine 为什么是核心中的核心

如果只能选一个文件代表 Claude Code 的灵魂,那大概率就是 QueryEngine.ts

因为它负责的不是某个局部能力,而是整个任务生命周期:

  • 接收用户输入
  • 组装上下文
  • 驱动模型调用
  • 处理中间工具执行
  • 维护会话状态
  • 把任务一直推进到结束

这就是典型的 Agent 主循环。

加载图表中...

它管理的是“会话”,不是“一次请求”

源码中的注释已经把定位写得很明确:

One QueryEngine per conversation.

这句话很关键。
说明 QueryEngine 不是一次性 request handler,而是一个围绕会话长期存在的对象。

因此它会保留很多跨轮次状态,例如:

  • mutableMessages
  • permissionDenials
  • readFileState
  • totalUsage
  • 发现过的 skill 名称
  • 已加载的 memory 路径

这也是 Claude Code 能连续工作的基础。

对应源码片段

export class QueryEngine {
  private mutableMessages: Message[]
  private abortController: AbortController
  private permissionDenials: SDKPermissionDenial[]
  private totalUsage: NonNullableUsage
  private readFileState: FileStateCache
  private discoveredSkillNames = new Set<string>()
  private loadedNestedMemoryPaths = new Set<string>()
}

只看成员变量就能看出,它明显是“会话对象”:

  • 有消息历史
  • 有权限拒绝记忆
  • 有 usage 累计
  • 有文件缓存
  • 有 skill / memory 发现状态

这就决定了它天然跨轮次存在。

这里最容易被忽略的一点

很多人会把主循环理解成“while 模型没结束就继续”。
但 Claude Code 里的主循环远不止如此,它还要同时处理:

  • 会话历史的追加与标准化
  • 工具调用前后的权限判断
  • 部分输出和最终输出的区分
  • usage、成本和预算更新
  • 中断、恢复、压缩等运行时边界

所以 QueryEngine 更像“编排层”,而不是简单循环。

submitMessage() 是真正的任务入口

用户每提交一次消息,最终都会进入 submitMessage()
这里可以把一次任务粗略拆成下面几段:

  1. 读取当前配置和状态
  2. 设置工作目录与 session 环境
  3. 包装工具权限判断逻辑
  4. 准备系统提示词与上下文
  5. 调用底层 query 流程与模型交互
  6. 在模型输出过程中处理工具调用和消息追加
  7. 统计 usage、成本、边界状态

所以 submitMessage() 本质上就是“启动一轮 agent run”。

对应源码片段

async *submitMessage(
  prompt: string | ContentBlockParam[],
  options?: { uuid?: string; isMeta?: boolean },
): AsyncGenerator<SDKMessage, void, unknown> {
  const {
    cwd,
    commands,
    tools,
    mcpClients,
    verbose = false,
    thinkingConfig,
    maxTurns,
    maxBudgetUsd,
  } = this.config

  this.discoveredSkillNames.clear()
  setCwd(cwd)
  const persistSession = !isSessionPersistenceDisabled()
}

这段代码很能说明 submitMessage() 的定位:

  • 它不是只接收 prompt
  • 它会同时读取 tools、commands、mcpClients、budget、thinking 等运行时资源
  • 它一开始就会处理 cwd 和 session 级状态

所以它本质上是在开启一次完整任务,而不是发一个普通 API 请求。

加载图表中...

它不是只管模型,还要管工具调用结果回流

这类系统最关键的一点,是模型和工具之间必须形成闭环。

Claude Code 里这条闭环大致是:

  1. 模型根据系统提示与历史消息作出决策
  2. 决策可能包含工具调用
  3. 工具调用前先经过权限判断
  4. 工具执行后把结果转成消息
  5. 这些消息再次进入会话历史
  6. 模型根据新结果继续下一轮

也就是说,QueryEngine 不是简单地“把工具借给模型”,而是在负责整个闭环编排。

这条闭环为什么重要

因为只有形成闭环,系统才具备真正的纠错能力。

举个最简单的例子:

  1. 模型先猜某个 bug 在 api.ts
  2. 读取文件后发现判断不成立
  3. 再搜索相关调用点
  4. 最后才定位到真实问题

如果没有“工具结果回流再决策”的循环,这种过程根本不可能发生。

为什么它要持有这么多上下文对象

QueryEngineConfig 里能看到它依赖非常多资源:

  • tools
  • commands
  • mcpClients
  • agents
  • getAppState / setAppState
  • readFileCache
  • thinkingConfig
  • budget 限制
  • customSystemPrompt

这说明它不是一个纯函数式的执行器,而是会话运行时的调度中心。

换句话说,Claude Code 的大部分高级能力,最终都会在这里汇流。

对应源码片段

export type QueryEngineConfig = {
  cwd: string
  tools: Tools
  commands: Command[]
  mcpClients: MCPServerConnection[]
  agents: AgentDefinition[]
  canUseTool: CanUseToolFn
  getAppState: () => AppState
  setAppState: (f: (prev: AppState) => AppState) => void
  readFileCache: FileStateCache
  customSystemPrompt?: string
  appendSystemPrompt?: string
  thinkingConfig?: ThinkingConfig
  maxTurns?: number
  maxBudgetUsd?: number
}

QueryEngineConfig 几乎可以被看成 Claude Code 主循环的依赖总表。
它把模型循环真正依赖的外部世界,都显式列出来了。

从架构关系上看它处在什么位置

加载图表中...

它处理的不只是成功路径

源码里还能看到很多防护性逻辑:

  • abortController
  • orphanedPermission
  • snipReplay
  • usage 统计
  • API 错误分类
  • permission denial 跟踪

这说明 Claude Code 的主循环不是理想化 demo,而是一个必须应对长会话、中断、失败、压缩、恢复等现实问题的工程实现。

一次任务结束后,哪些状态还会留下来

这是会话型 Agent 和一次性脚本最大的区别之一。
任务完成后,至少还有这些东西会保留在会话里:

  • 消息历史
  • 已知权限拒绝信息
  • 文件读取缓存
  • usage 统计
  • 某些 memory / skill 发现状态

这也是为什么下一轮任务能“接着上轮继续聊”。

一个更准确的心智模型

理解 QueryEngine,最好的方式不是把它看成“请求处理器”,而是把它看成:

Claude Code 会话级运行时中的任务编排器。

它向上连接用户输入和 REPL,向下连接模型、工具、权限、上下文与状态系统。

小结

如果说 main.tsx 决定“这次会话怎么启动”,那 QueryEngine.ts 决定的就是:

这次任务接下来到底怎样一步一步做完。

所以真正理解 Claude Code,绕不过 QueryEngine