乐于分享
好东西不私藏

Claude Code 源码拆解第五篇:一次提问背后到底跑了几轮?query loop 才是 agent 真正的发动机

Claude Code 源码拆解第五篇:一次提问背后到底跑了几轮?query loop 才是 agent 真正的发动机

一次提问背后到底跑了几轮?query loop 才是 agent 真正的发动机

你以为它是在“自动思考”,其实它是在一轮一轮把任务继续推进。

很多人第一次用 Claude Code,都会有一种很强的错觉。

你发出去一句话,它不会像普通聊天机器人那样答一下就停,而是会自己继续往下干:读文件、调工具、拿结果、再继续分析,直到它觉得这轮任务真的结束了才停下来。

它好像真的会自己思考。

这句话不能说全错,但如果你想看懂 Claude Code 源码,就得把它拆开。

因为在代码层面,所谓“agent 感”并不神秘。它最核心的东西,不是某句 prompt,也不是某个工具,而是一条会持续推进的 query loop

第四篇我们刚讲完:上下文快炸时,Claude Code 会靠 compact 机制续命。这一篇要接着回答另一个更底层的问题:

上下文续命,是续给谁用的?

答案就是:续给这条 query loop 用的。

真正让 Claude Code 像 agent 的,不是单次响应,而是一条会持续推进任务的主循环

这篇你会看到什么

  • 为什么 agent 不是“一次请求 + 几个工具调用”
  • QueryEngine
     和 query() 分别负责什么
  • 一次提问内部如何经历 assistant -> tool_use -> tool_result -> continue
  • 为什么你会感觉它在“自己接着干”
  • stopHooks
    tokenBudget、恢复逻辑为什么会插进主循环

一、先打掉一个误解:agent 不是“一次请求”

很多文章讲 agent,都会默认一种简单模型:用户发消息,模型回答;如果要调工具,就调一下;拿到结果再答一次。

这个理解不完全错,但它太平了,也太像聊天机器人。Claude Code 源码里的真实情况是:一次提问,不一定只对应一次模型调用。

只要 assistant 消息里出现了 tool_use、stop hook 给了阻塞反馈、命中了 output token 上限、或者 token budget 认为“这轮还没到该收的时候”,它就不会停。

所以从工程视角看,agent 不是“一次调用”,而是:一次用户意图,驱动一条循环,直到它满足停止条件。

二、别在入口处迷路:QueryEngine 管开局,query() 管推进

如果你顺着源码找入口,最先会看到的是 src/QueryEngine.ts 里的 submitMessage()

但往下读会发现,QueryEngine 更像是会话总管,它负责接住用户输入、处理 slash command、本地输入预处理、组装 system prompt、user context、system context,准备 tools、MCP、skills、permission context 和 transcript 持久化。

真正的主循环在 src/query.ts 里:query() 只是薄封装,真正推进整轮任务的是 queryLoop()

这层分工为什么重要

QueryEngine 负责“这一轮怎么开局”,queryLoop() 负责“这一轮怎么推进到底”。如果所有逻辑都塞进入口函数里,后面 compact、fallback、tool execution、stop hooks 很快就会缠成一团。

三、先别急着看循环,先看它手里攥着什么状态

src/query.ts 里有两个很关键的类型:QueryParams 和 State

QueryParams 像开局条件,放的是进入这轮 query 时已经确定下来的东西,比如 messagessystemPromptuserContextsystemContextcanUseTooltoolUseContextfallbackModelquerySourcetaskBudget

State 则是循环运行态,放的是这条循环跑着跑着会变化的东西,比如 messagesautoCompactTrackingmaxOutputTokensRecoveryCounthasAttemptedReactiveCompactmaxOutputTokensOverridestopHookActiveturnCounttransition

翻成人话就是:QueryParams 决定“你带着什么出发”,State 决定“你现在跑到哪了”。

入口层负责组局,状态层负责记账,主循环负责把整轮任务真正推下去

四、真正让它像 agent 的,是这个 while (true)

queryLoop() 里最关键的一句代码,其实非常朴素:

while (true) {
  // ...
}

Claude Code 的 agent 感,基本就是从这里长出来的。

每次迭代,它都会做几件事:取出当前状态,对消息做预处理,判断是否需要 compact / microcompact / collapse,发起模型调用,收集 assistant 消息和 tool_use,执行工具拿到 tool_result,再判断是否继续下一轮或者结束。

它不是“跑一次”,而是“跑完一轮以后,再决定还要不要再跑一轮”。

这就是 agent 和普通问答的根本区别。

五、拆开看,一次提问在内部到底经历了什么

第一步:先把当前要发给模型的上下文准备好

在每一轮循环开始时,queryLoop() 会先从当前 state 里拿出 messagestoolUseContextautoCompactTracking 等运行态,然后对 messagesForQuery 做几层处理:

  • getMessagesAfterCompactBoundary(messages)
  • applyToolResultBudget(...)
  • snipCompactIfNeeded(...)
  • microcompact(...)
  • contextCollapse.applyCollapsesIfNeeded(...)

这说明 Claude Code 的主循环不是纯模型循环,而是上下文管理 + 模型采样 + 工具执行的混合循环。

翻成人话就是:模型每次开口之前,系统都先帮它把桌面收拾一遍,避免把一堆又脏又重的历史原样塞进去。

第二步:在真正发请求前,先看要不要自动压缩

在正式调用模型前,queryLoop() 会先执行一层自动压缩判断:

const { compactionResult, consecutiveFailures } = await deps.autocompact(...)

如果 compact 成功,它会记录压缩前后的 token 变化,重置 compact tracking,调用 buildPostCompactMessages(compactionResult) 重建压后的消息视图,把这些 post-compact messages 先 yield 出去,然后继续当前 query。

如果你把这一段看成真实工作流,其实很好懂:项目快聊崩了,系统先拉一个人出去整理纪要,再把整理后的结果塞回现场,然后原任务继续,不是整场会直接散掉。

第三步:发起一次模型流式采样

上下文准备好以后,主循环才真正进入模型调用:

for await (const message of deps.callModel({ ... })) {
  // ...
}

这里不是一次性拿完整结果,而是流式消费消息。好处很直接:UI 能更早看到 assistant 输出,一旦流里出现 tool_use,系统也能更早准备工具执行。

在这段逻辑里,Claude Code 会持续收集 assistantMessagestoolUseBlockstoolResults 和 needsFollowUp。其中 needsFollowUp 本质上就是这条循环的一个信号灯。

第四步:assistant 一旦请求工具,系统就把这轮变成多段式

当流式输出里出现 tool_use block 时,Claude Code 会把这些 block 收集进 toolUseBlocks。如果开启了 streaming tool execution,还会一边流一边往 StreamingToolExecutor 里塞。

这说明在 Claude Code 里,“assistant 说要调工具”和“工具真正跑完”不是同一个时刻。主循环会先收模型产出的工具请求,再执行工具,把结果转成 tool_result 风格的消息回填,然后继续下一轮模型调用。

一次用户提问内部,经常不是一来一回,而是多轮 assistant 和 tools 的接力

第五步:如果这轮没有 tool_use,才有资格讨论“结束”

源码里有个特别朴素但非常重要的判断点:if (!needsFollowUp)

背后的意思很直接:如果这一轮 assistant 没请求工具,那这轮才有可能是真的结束;如果请求了工具,那就别急着收尾,后面还要继续推进。

在系统内部,判断一轮是否结束的标准根本不是“assistant 有没有说完一句话”,而是这轮任务链条是不是已经闭环了。

六、为什么你会感觉它“自己接着干”

Claude Code 之所以看起来像“自己会继续工作”,不是因为它有什么神秘人格,而是因为它的主循环天然支持 continuation。

1. 工具调用 continuation

assistant 请求工具,工具跑完,结果回填,再继续问模型“接下来怎么办”。

2. max output tokens continuation

如果模型输出被截断,query.ts 不会立刻认输。它会先尝试把默认上限抬高重试;如果还不行,就注入一条 meta user message:

Output token limit hit. Resume directly — no apology, no recap...

翻成人话就是:别复述,别道歉,直接从刚才断掉的地方接着干。

3. stop hook continuation

handleStopHooks() 不是边角逻辑。它在 turn-end 阶段会跑 stop hooks,存 cache-safe params,触发 prompt suggestion,触发 extractMemoriesautoDream,还会处理 task completed、阻塞错误和 prevent continuation。

如果 stop hook 给了 blocking error,主循环不会简单退出,而是会把这些错误作为新的 meta 信息塞回消息列表,再继续下一轮。

4. token budget continuation

src/query/tokenBudget.ts 里也很有意思。它不是只会说“预算没了,停”,而是会根据当前 turn token 消耗判断:还没到 COMPLETION_THRESHOLD = 0.9,那就继续;如果已经连续推进了几次,而且新增 token 很少,说明开始边际递减了,就停。

也就是说,Claude Code 不只是看“还能不能继续”,还会看:继续到底值不值。

真正的 agent 主循环,不只是继续干,还要知道什么时候恢复、什么时候收住

七、还有个容易被忽略的点:别让主循环烂成 if/else 大泥球

src/query/config.ts 只有几十行,但非常值得讲。它做的是一件很工程化、但很重要的事:把 query 级别的不可变配置在入口处一次性快照。

比如 sessionIdstreamingToolExecutionemitToolUseSummariesisAntfastModeEnabled 这些东西,都收进了 QueryConfig

这样主循环里会随着迭代变化的东西放进 State,相对稳定的配置放进 QueryConfig,feature gate 该内联的继续内联。否则整条 loop 很快就会烂成条件分支大泥球。

八、QueryEngine 这层不是绕,它是在替主循环挡杂事

很多人看到 QueryEngine -> query() 这种两层结构,会下意识觉得有点绕。但它其实很有必要。

QueryEngine 负责的是一次用户输入进入系统之前和之后的外围事务:处理用户输入和 slash command,记录 transcript,保证 /resume 不会断档,初始化 system prompt、memory prompt、skills、plugins,构建 ProcessUserInputContext,以及决定这次输入到底要不要真的进入主循环。

你可以把它理解成:QueryEngine 是 orchestration,queryLoop() 是 runtime。

九、它什么时候才算“真结束”,其实不是一个按钮说了算

如果你把整段源码看完,会发现 Claude Code 并没有一个单一的“stop”按钮。它的停止,是很多条件共同决定的:

  • 没有新的 tool_use 了
  • stop hooks 没有阻塞
  • 没有 max output tokens recovery 要继续
  • 没有 reactive compact 要重试
  • token budget 认为可以收工了
  • 用户也没有中途打断

这很像真实工作流。因为一个长任务到底什么时候算“完成”,本来就不应该只由模型一句话决定。

十、这篇最值得带走的,不是某个函数名,而是一个视角

Claude Code 真正的内核,不是 REPL,不是 SDK,不是单个工具,而是 query loop。

工具只是这条循环里会被调用的能力。compact 是这条循环快撑不住时的续命机制。memory 是这条循环跨回合后的延伸。而让它表现出“会自己接着干”的那个根,恰恰是这条循环本身。

你把这层看懂了,再回头看很多 agent 产品,就会少掉很多幻觉。所谓 agent 的差距,很多时候不在“模型会不会说”,而在会不会把一次任务拆成多轮推进、会不会在工具和模型之间稳定来回切换、会不会在中途出错时继续把任务接下去、会不会在该停的时候真的停住。

十一、对普通用户和开发者,有什么直接启发

  • 不要把 agent 理解成“更长的 prompt”,真正的 agent 更像一条带状态的循环。
  • 工具调用不是重点,循环调度才是重点。
  • “会继续干”背后一定有 continuation 机制。
  • 设计自己的 agent 时,先想停止条件,再想提示词。

结语

这次我们讲的是:上下文快炸时,Claude Code 怎么把 agent 救回来。第五篇更底层的答案是:救回来以后,到底是谁继续往下跑。

答案不是某个工具,不是某个 prompt,而是这条 query loop。它决定了一次提问不会只停在“一次响应”,而会在 assistant、tool_use、tool_result、恢复逻辑和停止条件之间,一轮一轮把任务推下去。

第三篇讲上下文为什么会脏,第四篇讲上下文快炸时怎么续命,第五篇讲续命之后系统到底靠什么继续跑。这三篇合起来,正说明一件事:agent 不是更长的 prompt,而是一套能持续推进任务的循环系统。

关键源码位置

  • src/QueryEngine.ts:209-551
  • src/query.ts:181-217
  • src/query.ts:241-468
  • src/query.ts:551-1062
  • src/query.ts:1185-1349
  • src/query.ts:1368-1725
  • src/query/config.ts:8-46
  • src/query/stopHooks.ts:65-284
  • src/query/tokenBudget.ts:3-93