AI Agent 这个概念近两年被讲了很多遍,但真正实现一个可靠的 Agent 循环,远比说起来复杂。它不只是「调一下 API 拿到回复」这么简单,而是要处理流式输出、工具调用、多轮状态保持、上下文压缩、错误恢复、会话持久化......每一项单独拿出来都是工程难题,更别提把它们组合起来了。
Claude Code 把这套核心逻辑集中在两个文件里:src/QueryEngine.ts 和 src/query.ts。本文带大家深入这两个文件,把整个查询引擎的设计说清楚。
一、两个文件,两层职责
大家可能会思考,为什么要用两个文件?QueryEngine.ts 和 query.ts 的分工是什么?
这里有一个非常清晰的职责划分------它们分别负责两个不同的「时间范围」的状态。
QueryEngine.ts------会话级别的状态管理器
QueryEngine 是一个类,它的实例代表一次完整的对话生命周期,从第一条消息到会话结束。它维护:
mutableMessages:整个对话的消息历史totalUsage:跨轮次累计的 token 用量permissionDenials:本次会话里所有被拒绝的工具调用记录readFileState:文件状态缓存,避免重复读取
一个会话,一个 QueryEngine 实例。多轮对话的状态都存在这里,不会在轮次切换时丢失。
query.ts------单轮次的 API 调用与工具执行引擎
query() 函数只关心「这一轮」的执行:接收当前消息列表,向 Claude API 发起请求,处理流式响应,按需执行工具,直到本轮对话自然结束。它是无状态的------每次调用都基于传入的消息列表,完全从外部接收上下文。
打个比方:如果 QueryEngine 是一场马拉松全程的计时员,负责记录每一公里的成绩、累计时间、总配速,那 query() 就是每隔一公里拍照存档的摄影师,只关心当下这张照片拍好了没有。
二、AsyncGenerator:流式输出的底层机制
在看 query() 的实现之前,有一件事情值得先搞清楚------为什么 Claude 的回复是一个字一个字地流出来,而不是等全部生成完才显示?
答案藏在函数签名里:
typescript
export async function* query(
params: QueryParams,
): AsyncGenerator<StreamEvent | Message | ..., Terminal>
async function* 声明的是一个异步生成器函数 。它不会一次性计算出所有结果再返回,而是每次 yield 一个值,调用方用 for await (const message of query(...)) 来消费这些值,每收到一个就立刻处理。
这和传统的「等全部完成再返回」有本质区别,就像现场直播和录播的区别一样。录播是等节目录完再播;直播是边录边播,观众零延迟感知内容。AsyncGenerator 就是这套「边生成边消费」机制的技术实现。
整个 Claude Code 的消息管道都建立在这个模式上:
Claude API 流式响应
↓ AsyncGenerator
query() 产出消息片段
↓ AsyncGenerator
QueryEngine.submitMessage() 产出 SDKMessage
↓ AsyncGenerator
REPL 消费,Ink 渲染到终端
每一层都是 AsyncGenerator,像接力赛一样把数据往下传,没有任何一层需要等待「上游全部完成」。
三、queryLoop:真正的主循环
query() 内部调用的是 queryLoop(),这才是核心主循环。它的结构是一个 while (true),每次迭代经历四个阶段,如图所示:
#mermaid-svg-7wdYh7vZFoZPxgyZ{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-7wdYh7vZFoZPxgyZ .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-7wdYh7vZFoZPxgyZ .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-7wdYh7vZFoZPxgyZ .error-icon{fill:#552222;}#mermaid-svg-7wdYh7vZFoZPxgyZ .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-7wdYh7vZFoZPxgyZ .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-7wdYh7vZFoZPxgyZ .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-7wdYh7vZFoZPxgyZ .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-7wdYh7vZFoZPxgyZ .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-7wdYh7vZFoZPxgyZ .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-7wdYh7vZFoZPxgyZ .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-7wdYh7vZFoZPxgyZ .marker{fill:#333333;stroke:#333333;}#mermaid-svg-7wdYh7vZFoZPxgyZ .marker.cross{stroke:#333333;}#mermaid-svg-7wdYh7vZFoZPxgyZ svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-7wdYh7vZFoZPxgyZ p{margin:0;}#mermaid-svg-7wdYh7vZFoZPxgyZ .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-7wdYh7vZFoZPxgyZ .cluster-label text{fill:#333;}#mermaid-svg-7wdYh7vZFoZPxgyZ .cluster-label span{color:#333;}#mermaid-svg-7wdYh7vZFoZPxgyZ .cluster-label span p{background-color:transparent;}#mermaid-svg-7wdYh7vZFoZPxgyZ .label text,#mermaid-svg-7wdYh7vZFoZPxgyZ span{fill:#333;color:#333;}#mermaid-svg-7wdYh7vZFoZPxgyZ .node rect,#mermaid-svg-7wdYh7vZFoZPxgyZ .node circle,#mermaid-svg-7wdYh7vZFoZPxgyZ .node ellipse,#mermaid-svg-7wdYh7vZFoZPxgyZ .node polygon,#mermaid-svg-7wdYh7vZFoZPxgyZ .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-7wdYh7vZFoZPxgyZ .rough-node .label text,#mermaid-svg-7wdYh7vZFoZPxgyZ .node .label text,#mermaid-svg-7wdYh7vZFoZPxgyZ .image-shape .label,#mermaid-svg-7wdYh7vZFoZPxgyZ .icon-shape .label{text-anchor:middle;}#mermaid-svg-7wdYh7vZFoZPxgyZ .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-7wdYh7vZFoZPxgyZ .rough-node .label,#mermaid-svg-7wdYh7vZFoZPxgyZ .node .label,#mermaid-svg-7wdYh7vZFoZPxgyZ .image-shape .label,#mermaid-svg-7wdYh7vZFoZPxgyZ .icon-shape .label{text-align:center;}#mermaid-svg-7wdYh7vZFoZPxgyZ .node.clickable{cursor:pointer;}#mermaid-svg-7wdYh7vZFoZPxgyZ .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-7wdYh7vZFoZPxgyZ .arrowheadPath{fill:#333333;}#mermaid-svg-7wdYh7vZFoZPxgyZ .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-7wdYh7vZFoZPxgyZ .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-7wdYh7vZFoZPxgyZ .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-7wdYh7vZFoZPxgyZ .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-7wdYh7vZFoZPxgyZ .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-7wdYh7vZFoZPxgyZ .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-7wdYh7vZFoZPxgyZ .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-7wdYh7vZFoZPxgyZ .cluster text{fill:#333;}#mermaid-svg-7wdYh7vZFoZPxgyZ .cluster span{color:#333;}#mermaid-svg-7wdYh7vZFoZPxgyZ div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-7wdYh7vZFoZPxgyZ .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-7wdYh7vZFoZPxgyZ rect.text{fill:none;stroke-width:0;}#mermaid-svg-7wdYh7vZFoZPxgyZ .icon-shape,#mermaid-svg-7wdYh7vZFoZPxgyZ .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-7wdYh7vZFoZPxgyZ .icon-shape p,#mermaid-svg-7wdYh7vZFoZPxgyZ .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-7wdYh7vZFoZPxgyZ .icon-shape .label rect,#mermaid-svg-7wdYh7vZFoZPxgyZ .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-7wdYh7vZFoZPxgyZ .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-7wdYh7vZFoZPxgyZ .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-7wdYh7vZFoZPxgyZ :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
否
end_turn / 达到限制 / 中断
max_output_tokens 截断
循环开始
上下文预处理
工具结果截断 / microcompact
/ 自动压缩检测
向 Claude API 发起流式请求
携带 system prompt + 消息历史
处理流式响应
yield StreamEvent
渲染文字 / thinking block
响应包含 tool_use?
runTools()
执行工具调用
yield 进度消息
把 tool_result 追加到消息列表
进入下一次循环
终止条件检查
返回 Terminal 状态
自动恢复
调大 max_tokens
重新请求(最多3次)
阶段一:上下文预处理
在向 API 发请求之前,先对消息列表做一系列「瘦身」操作(详见第四篇):
typescript
messagesForQuery = await applyToolResultBudget(...) // 超大工具结果存磁盘
messagesForQuery = snipModule.snipCompactIfNeeded(...) // 历史片段压缩
messagesForQuery = await deps.microcompact(...) // 工具调用摘要
// 如果 context 接近满:触发 autoCompact
这些预处理保证每次 API 请求的 context 在上限范围内,同时尽量保留最有价值的历史信息。
阶段二:API 调用
向 Claude API 发起流式请求,yield 出 stream_event 类型的消息(包含 message_start、content_block_delta、message_delta、message_stop 等事件),让上层可以实时渲染流式文字和 thinking block。
阶段三:工具执行
如果 Claude 的响应里包含 tool_use 块,就调用 runTools() 执行。工具可能是本地工具(BashTool、FileReadTool 等)、MCP 工具,或者子代理(AgentTool)。执行完毕后,结果打包成 tool_result 消息追加到列表,继续下一次循环,把工具结果送回 Claude 让它继续推理。
阶段四:终止判断
| 终止条件 | 说明 |
|---|---|
Claude 返回纯文字,没有 tool_use |
正常结束,stop_reason = end_turn |
达到 maxTurns 限制 |
超出最大轮次 |
maxBudgetUsd 超出 |
花费超出预算上限 |
用户触发 AbortController |
Ctrl+C 或界面停止按钮 |
| API 返回不可重试的错误 | 网络或服务端错误 |
还有一种特殊情况------max_output_tokens 错误:模型生成了很长的回复,触碰了每次 API 调用的输出上限,回复被截断了。这时 queryLoop 不会报错退出,而是自动恢复 :把 maxOutputTokensOverride 调大,重新发请求,让模型续写截断的内容。最多尝试 3 次,代码里的常量写得非常清楚:
typescript
const MAX_OUTPUT_TOKENS_RECOVERY_LIMIT = 3
值得注意的是,这个被截断的中间状态消息不会 yield 给上层(用户完全感知不到),只有最终拼接完整的消息才会流出去。
四、循环的状态机设计
queryLoop 里有一个专门的 State 类型,携带所有跨迭代的可变状态:
typescript
type State = {
messages: Message[]
toolUseContext: ToolUseContext
autoCompactTracking: AutoCompactTrackingState | undefined
maxOutputTokensRecoveryCount: number // 截断恢复次数
hasAttemptedReactiveCompact: boolean // 是否已尝试响应式压缩
maxOutputTokensOverride: number | undefined
pendingToolUseSummary: Promise<...> | undefined
stopHookActive: boolean | undefined
turnCount: number
transition: Continue | undefined // 上一次为什么「继续」,调试用
}
transition 字段特别有意思------它记录了上一次迭代为什么选择继续循环,而不是退出。这对测试和调试非常有用:测试可以断言「这次循环是因为工具调用而继续的」,而不用去检查消息内容。这是状态机设计里的好实践------把「为什么发生」和「发生了什么」都记录下来。
五、QueryEngine.submitMessage():一次完整的用户输入处理
回到 QueryEngine,submitMessage() 是用户每次输入后调用的方法。它是整个会话管理的核心,流程如下:
调用方 transcript 文件 query 循环 processUserInput submitMessage 用户输入 调用方 transcript 文件 query 循环 processUserInput submitMessage 用户输入 #mermaid-svg-WJnC96LR0pFIqOt5{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-WJnC96LR0pFIqOt5 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-WJnC96LR0pFIqOt5 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-WJnC96LR0pFIqOt5 .error-icon{fill:#552222;}#mermaid-svg-WJnC96LR0pFIqOt5 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-WJnC96LR0pFIqOt5 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-WJnC96LR0pFIqOt5 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-WJnC96LR0pFIqOt5 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-WJnC96LR0pFIqOt5 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-WJnC96LR0pFIqOt5 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-WJnC96LR0pFIqOt5 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-WJnC96LR0pFIqOt5 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-WJnC96LR0pFIqOt5 .marker.cross{stroke:#333333;}#mermaid-svg-WJnC96LR0pFIqOt5 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-WJnC96LR0pFIqOt5 p{margin:0;}#mermaid-svg-WJnC96LR0pFIqOt5 .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-WJnC96LR0pFIqOt5 text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-WJnC96LR0pFIqOt5 .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-WJnC96LR0pFIqOt5 .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-WJnC96LR0pFIqOt5 .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-WJnC96LR0pFIqOt5 .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-WJnC96LR0pFIqOt5 #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-WJnC96LR0pFIqOt5 .sequenceNumber{fill:white;}#mermaid-svg-WJnC96LR0pFIqOt5 #sequencenumber{fill:#333;}#mermaid-svg-WJnC96LR0pFIqOt5 #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-WJnC96LR0pFIqOt5 .messageText{fill:#333;stroke:none;}#mermaid-svg-WJnC96LR0pFIqOt5 .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-WJnC96LR0pFIqOt5 .labelText,#mermaid-svg-WJnC96LR0pFIqOt5 .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-WJnC96LR0pFIqOt5 .loopText,#mermaid-svg-WJnC96LR0pFIqOt5 .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-WJnC96LR0pFIqOt5 .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-WJnC96LR0pFIqOt5 .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-WJnC96LR0pFIqOt5 .noteText,#mermaid-svg-WJnC96LR0pFIqOt5 .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-WJnC96LR0pFIqOt5 .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-WJnC96LR0pFIqOt5 .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-WJnC96LR0pFIqOt5 .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-WJnC96LR0pFIqOt5 .actorPopupMenu{position:absolute;}#mermaid-svg-WJnC96LR0pFIqOt5 .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-WJnC96LR0pFIqOt5 .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-WJnC96LR0pFIqOt5 .actor-man circle,#mermaid-svg-WJnC96LR0pFIqOt5 line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-WJnC96LR0pFIqOt5 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} prompt 字符串识别 slash 命令是否需要 API 查询先写入用户消息(不等 API 响应)开始流式调用yield SDKMessage逐步写入 assistant 消息yield SDKMessage循环结束,返回 Terminalyield result 消息
这里有一个细节值得特别关注------为什么在调用 API 之前就先写入 transcript?
代码注释里有一段解释这个设计决策的文字,来自一个真实踩过的坑:
If the process is killed before that (e.g. user clicks Stop in cowork seconds after send), the transcript is left with only queue-operation entries... and --resume fails with "No conversation found". Writing now makes the transcript resumable from the point the user message was accepted, even if no API response ever arrives.
意思是:如果用户发完消息就立刻点了停止,进程在 API 响应到来之前就被杀死了,transcript 里就只有系统消息,没有用户消息。下次用 --resume 恢复时,系统找不到有效的对话记录,恢复失败。
先写入用户消息,再发 API 请求,就彻底解决了这个问题------哪怕 API 永远没有响应,会话依然是可恢复的。
六、权限拒绝的追踪与汇报
QueryEngine 在 canUseTool 的基础上加了一个包装层:
typescript
const wrappedCanUseTool: CanUseToolFn = async (...args) => {
const result = await canUseTool(...args)
// 记录所有被拒绝的工具调用
if (result.behavior !== 'allow') {
this.permissionDenials.push({
tool_name: sdkCompatToolName(tool.name),
tool_use_id: toolUseID,
tool_input: input,
})
}
return result
}
每次工具调用被拒绝,就往 permissionDenials 里追加一条记录。会话结束时,这些记录会通过 result.permission_denials 字段一并返回给 SDK 调用方。
这个设计对审计场景非常有价值:运行完一次 Agent 任务,可以查看「哪些工具调用因为权限不足被拦住了」,用来调整权限配置或理解任务失败的原因。
七、Token Budget 自动续写
最后介绍一个很实用的功能:Token Budget 自动续写,来自 src/query/tokenBudget.ts。
Claude Code 支持用户指定一个 token 预算(比如 +500k 的语法),当模型的输出接近 max_tokens 时,checkTokenBudget() 会检测到这个情况并自动发起续写请求,让模型把没说完的话续完,而不是直接截断。
incrementBudgetContinuationCount() 追踪续写次数,防止无限续写。大家如果遇到需要生成很长文档或分析报告的场景,这个机制会自动在背后帮大家打通多个轮次,不需要手动复制粘贴「继续」。
学习完本章内容,大家应该对以下问题有了清晰的答案:
QueryEngine和query()是什么关系------前者管会话状态,后者管单轮执行AsyncGenerator为什么是整套流式管道的基础------每一层都是生成器,边产出边消费queryLoop的while (true)里,四个阶段分别做什么- 为什么在 API 调用之前就先写 transcript------防止进程中途被杀死导致无法 resume
- 权限拒绝记录和 Token Budget 续写分别服务哪些工程场景
接下来,带大家进入第三篇------工具系统,拆解 53 个工具背后的统一框架是怎么设计的。