核心循环解析:QueryEngine 如何驱动一次任务完成
QueryEngine 为什么是核心中的核心
如果只能选一个文件代表 Claude Code 的灵魂,那大概率就是 QueryEngine.ts。
因为它负责的不是某个局部能力,而是整个任务生命周期:
- 接收用户输入
- 组装上下文
- 驱动模型调用
- 处理中间工具执行
- 维护会话状态
- 把任务一直推进到结束
这就是典型的 Agent 主循环。
它管理的是“会话”,不是“一次请求”
源码中的注释已经把定位写得很明确:
One QueryEngine per conversation.
这句话很关键。
说明 QueryEngine 不是一次性 request handler,而是一个围绕会话长期存在的对象。
因此它会保留很多跨轮次状态,例如:
mutableMessagespermissionDenialsreadFileStatetotalUsage- 发现过的 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()。
这里可以把一次任务粗略拆成下面几段:
- 读取当前配置和状态
- 设置工作目录与 session 环境
- 包装工具权限判断逻辑
- 准备系统提示词与上下文
- 调用底层 query 流程与模型交互
- 在模型输出过程中处理工具调用和消息追加
- 统计 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 里这条闭环大致是:
- 模型根据系统提示与历史消息作出决策
- 决策可能包含工具调用
- 工具调用前先经过权限判断
- 工具执行后把结果转成消息
- 这些消息再次进入会话历史
- 模型根据新结果继续下一轮
也就是说,QueryEngine 不是简单地“把工具借给模型”,而是在负责整个闭环编排。
这条闭环为什么重要
因为只有形成闭环,系统才具备真正的纠错能力。
举个最简单的例子:
- 模型先猜某个 bug 在
api.ts - 读取文件后发现判断不成立
- 再搜索相关调用点
- 最后才定位到真实问题
如果没有“工具结果回流再决策”的循环,这种过程根本不可能发生。
为什么它要持有这么多上下文对象
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 主循环的依赖总表。
它把模型循环真正依赖的外部世界,都显式列出来了。
从架构关系上看它处在什么位置
它处理的不只是成功路径
源码里还能看到很多防护性逻辑:
abortControllerorphanedPermissionsnipReplay- usage 统计
- API 错误分类
- permission denial 跟踪
这说明 Claude Code 的主循环不是理想化 demo,而是一个必须应对长会话、中断、失败、压缩、恢复等现实问题的工程实现。
一次任务结束后,哪些状态还会留下来
这是会话型 Agent 和一次性脚本最大的区别之一。
任务完成后,至少还有这些东西会保留在会话里:
- 消息历史
- 已知权限拒绝信息
- 文件读取缓存
- usage 统计
- 某些 memory / skill 发现状态
这也是为什么下一轮任务能“接着上轮继续聊”。
一个更准确的心智模型
理解 QueryEngine,最好的方式不是把它看成“请求处理器”,而是把它看成:
Claude Code 会话级运行时中的任务编排器。
它向上连接用户输入和 REPL,向下连接模型、工具、权限、上下文与状态系统。
小结
如果说 main.tsx 决定“这次会话怎么启动”,那 QueryEngine.ts 决定的就是:
这次任务接下来到底怎样一步一步做完。
所以真正理解 Claude Code,绕不过 QueryEngine。