title: 2、CC 的心脏:queryLoop Agent 主循环
上一篇最后停在 launchRepl() 和 runHeadless():一次会话已经准备好了,模型、权限、Tools、Skills、Agents 和 MCP 也已经装进了运行环境。
这篇文章讲一个最核心的问题:假设用户只输入「检查登录模块」,Claude Code 为什么会自己搜索文件、读取源码,最后再给出答案?
答案就在 src/query.ts 的 queryLoop() 中。
它做的事情其实很朴素:
- 把当前消息发给模型;
- 模型如果要求调用 Tool,Claude Code 就执行 Tool;
- 把 Tool Result 放进消息,再次请求模型;
- 模型不再调用 Tool 时,结束循环。

图里的 messages 会逐轮累积,不是每轮重新创建一份只有新结果的消息:
1 | turnCount = 1 |
这里最关键的一点是:
模型只会生成「我要调用
Grep」这样的tool_use。真正搜索文件的是 Claude Code 进程,不是模型。
因此,一次用户输入不等于一次模型请求。
本文的例子中,用户只输入了一句话,但 queryLoop() 请求了三次模型。前两次模型要求使用 Tool,第三次才产生最终答案。
主循环可以精简成下面的伪代码:
1 | // 等价简版,用于理解主线,不是源码原文 |
后面的大部分源码,都是在给这五步增加生产环境需要的能力,例如权限检查、流式输出、上下文压缩、Fallback、Hook 和中止处理。这一篇只看主循环,其他机制后面再展开。
这篇文章专注于主循环的流程,主要是三点:
- 模型怎样产生
tool_use; - Claude Code 怎样执行 Tool 并生成
tool_result; - 新消息怎样进入下一轮,循环最后怎样结束。
循环入口
交互模式下,REPL 会把第一篇组装好的消息、Prompt 和 ToolUseContext 传给 query():
1 | // src/screens/REPL.tsx:2793-2803 |
onQueryEvent(event) 只负责把 query() 产生的事件更新到终端界面,例如显示流式文本或追加完整消息。它不调用模型、不执行 Tool,也不决定循环是否继续,因此这里不再展开。
看一下 query(),源码位置:src/query.ts:219-239
REPL、Print 或 SDK 最终会消费 query() 产生的事件。query() 本身很短:
1 | export async function* query(params) { |
两个函数的分工如下:
| 函数 | 作用 |
|---|---|
query() |
包住一次查询的生命周期,并在 queryLoop() 返回后完成命令清理 |
queryLoop() |
调模型、执行 Tool、更新状态并决定继续或结束 |
yield* 在这里同时做两件事:
queryLoop()每次yield出来的模型文本、Tool Use 和 Tool Result,继续向上传给 REPL 或 SDK;queryLoop()最后return的结束原因,保存到terminal。
接着看 queryLoop(),源码位置:src/query.ts:241-321
query() 执行 yield* queryLoop(...) 后,控制权就进入了 queryLoop()。这个函数先创建 state,然后才进入 while (true):
1 | async function* queryLoop(params) { |
这里才第一次正式进入 while (true)。第一轮使用上面刚创建的 state;如果模型调用了 Tool,循环底部会把 AssistantMessage 和 Tool Result 写入新的 state。代码运行到 while 底部后,自然回到顶部开始下一轮,这条正常路径没有显式执行 continue。
接下来看 while (true) 内部具体发生了什么:
第一轮:把「想查什么」变成「查到了什么」
第一轮开始时只有用户问题;结束时新增一条包含 tool_use 的 AssistantMessage 和一条包含 tool_result 的 UserMessage。
源码位置:src/query.ts:307、src/query.ts:650-845、src/query.ts:1360-1409、src/query.ts:1704-1728
下面先按非流式 Tool 执行分支,省略错误恢复、压缩和 Hook,只看三步的嵌套关系:
1 | while (true) { |
这里只需要知道:相同的 id 和 tool_use_id 表示「这是那次 Tool 调用的结果」。具体生成和异常修复后续再讲。
第二轮:不是新流程,而是同一个 while 再跑一次
下面省略第一轮已经讲过的参数和异常分支,只保留第二轮发生变化的值:
1 | while (true) { |
这里真正值得注意的不是「第二轮又执行了一个 Tool」,而是源码里没有写死「Grep 后必须 Read」。第二轮把 Grep 的结果重新交给模型,由模型判断下一步需要读取文件,再生成 Read Tool Use。
这也是它和传统程序最大的不同:执行步骤不必全部提前写死,一部分决策交给模型。
第二轮结束时,模型生成的 Read Tool Use 和读取到的源码都已经进入 state.messages。第三次循环拿到这 5 条消息后,就可以基于源码给出结论。
第三轮:同一个 while 走到结束分支
源码位置:src/query.ts:558-834、src/query.ts:1062-1357
第三轮仍然执行同一段代码。不同的是,这次模型已经看过 login.ts 的源码,不再请求 Tool,而是直接回答。下面使用等价简版,只保留正常路径,省略流式回退和可恢复错误暂缓输出等分支:
1 | while (true) { |
注意,模型生成的最终文本已经在 for await 中被 yield 给上层。最后的 { reason: 'completed' } 不是模型答案,而是 queryLoop() 返回给 query() 的结束状态。
因此,「没有 Tool Use」只表示可以尝试结束。上下文恢复、输出恢复、Stop Hook 和 Token Budget 都放行后,循环才会真正返回 completed。
到这里,queryLoop() 的正常主线就走完了:
它不断把「模型的 Tool Use」变成「真实的 Tool Result」,再把结果交还给模型;当模型不再调用 Tool,并且结束检查全部放行时,返回
completed。
再回头看完整流程图,左边的恢复分支暂时可以忽略。

循环什么时候结束
1. 模型不再调用 Tool,正常结束
源码位置:src/query.ts:554-558、src/query.ts:826-835、src/query.ts:1062
needsFollowUp 不是模型返回的字段,而是 queryLoop() 自己维护的局部变量。每次请求模型前,它先被初始化为 false:
1 | // src/query.ts:554-558 |
源码里没有写下面这种判断:
1 | if (message.message.stop_reason === 'tool_use') { |
实际使用的是 src/query.ts:826-835:代码在模型流中检查每一条 AssistantMessage,直接查找其中的 tool_use 内容块:
1 | // src/query.ts:826-835 |
第三轮模型只返回文本:
1 | msgToolUseBlocks |
因为没有进入赋值为 true 的分支,needsFollowUp 保持初始值 false。模型流结束后,代码便进入正常结束判断:
1 | if (!needsFollowUp) { |
结束检查全部放行后,return 会直接跳出 while (true),同时结束 queryLoop()。
2. 达到调用方设置的 maxTurns
源码位置:
src/query.ts:252-260:从query()传入的参数中取出maxTurns;src/query.ts:1679-1728:Tool 执行结束后、创建下一轮State之前检查它。
前面的主框架为了突出 Tool 循环,省略了这段限制代码。把它放回原来的位置,关系如下:
1 | async function* queryLoop(params) { |
它检查的不是「当前已经执行了几轮」,而是「是否还允许进入下一轮」。例如 maxTurns = 3,当前已经是第 3 轮,模型又请求了 Tool,nextTurnCount 就是 4,源码会阻止第 4 次模型请求。
但 maxTurns 是可选参数。交互模式没有设置它时:
1 | maxTurns |
因此源码没有为普通 Tool 循环设置一个统一的硬上限。
3. 用户或调用方主动中止
ToolUseContext 中保存了 AbortController。用户在交互界面中按下中断键,或者 SDK 主动取消任务后:
1 | toolUseContext.abortController.signal.aborted |
模型流或 Tool 执行分支检测到这个值后,会停止当前工作并返回对应的中止原因。
另外,即使模型没有继续调用 Tool,Claude Code 也不一定立即结束:上下文恢复、输出截断恢复或 Stop Hook 仍可能要求进入下一轮。这些机制将在后续文章中展开。
小结
Claude Code 的 Agent 主循环可以压缩成下面这段:
1 | while (true) { |
真正需要记住的是四点:
query()调用queryLoop()、透传它产生的事件,并在结束后完成 Command 状态清理;queryLoop()才负责模型与 Tool 的多轮循环;- 模型提出 Tool Use,Claude Code 执行后把 Tool Result 放回消息;
- 没有新的 Tool Use,并且结束检查放行后,循环返回
completed; State把消息、工具上下文和轮次一起交给下一轮。
附录:看懂 function*、yield 和 await
生成器函数平常用的不多,这里也补充下。
阅读 query() 和 queryLoop() 时,最容易混淆的是下面几种写法:
1 | function* generator() {} |
先记住一句话:
普通函数一次返回一个结果;生成器可以暂停多次,每次返回一部分结果。
普通函数和 function* 有什么区别
普通函数从头执行到 return,一次性结束:
1 | function getNumbers() { |
函数名旁边多一个 *,表示它是生成器函数:
1 | function* getNumbers() { |
调用生成器函数时,不会立刻执行完整函数,而是先得到一个迭代器:
1 | const iterator = getNumbers() |
每次调用 next(),函数都会从上次暂停的位置继续运行:
yield 1:交出1,函数暂停;- 再次调用
next():从yield 1后面继续; return 3:函数彻底结束,done变成true。
所以 yield 和 return 的区别是:
| 语法 | 作用 |
|---|---|
yield value |
交出一个中间结果,但函数还可以继续 |
return value |
交出最终结果,并结束函数 |
async function* 又多了什么
queryLoop() 不仅要分多次产生结果,还要等待模型和 Tool,因此它同时需要 async 和 *:
1 | async function* queryLoop() { |
两部分分别表示:
async:函数内部可以使用await等待异步操作;*:函数可以多次yield,逐步向外发送结果。
因此,async function* 可以理解为「异步的分批返回函数」。
await 等待的是什么
await 等待的是 Promise:
1 | const file = await readFileAsync() |
执行到这里时,当前函数先暂停;Promise 完成后,函数再从这一行继续。它暂停的是当前异步函数,不会阻塞整个 JavaScript 进程。
普通 async function 最终返回一个 Promise:
1 | async function getAnswer() { |
而 async function* 返回的不是一个最终 Promise,而是 AsyncGenerator:
1 | const stream = queryLoop() |
所以不能用一次 await queryLoop() 取得全部消息。需要不断向生成器索要下一条结果。
为什么是 for await...of
异步生成器的每次 next() 都返回 Promise:
1 | const iterator = queryLoop() |
for await...of 就是把「反复调用 next(),并等待每个 Promise」写成循环:
1 | for await (const event of queryLoop()) { |
它大致相当于:
1 | const iterator = queryLoop() |
因此:
for...of用来遍历同步数据;for await...of用来遍历异步产生的数据;- 这里的
await表示每一轮都要等待下一条事件到达。
模型的文本、Tool Use 和 Tool Result 不是同时产生的,所以 queryLoop() 很适合用这种方式逐条输出。
yield* 为什么又多一个 *
yield* 表示「把另一个生成器产生的内容全部转交出去」。
先看一个简化例子:
1 | async function* child() { |
parent() 做了两件事:
child()每次yield的消息,都继续成为parent()的输出;child()最后的return值,保存到result。
这正是 query() 中的写法:
1 | const terminal = |
对应关系是:
queryLoop()产生的文本、Tool Use 和 Tool Result,继续由query()向上输出;queryLoop()最后返回的{ reason: 'completed' },保存到terminal;query()完成 Command 状态清理后,再return terminal。
把这些语法放回本文
1 | export async function* query(params) { |
可以按下面的顺序理解:
async function* query():这是一个会异步、分批产生事件的函数;yield* queryLoop():把主循环产生的事件原样向上传;for await...of:上层等待并逐条消费这些事件;yield:产生一条过程事件;return:生成器结束,并返回最终状态。
最后注意一个细节:for await...of 只能遍历 yield 出来的过程值,不会把生成器最后的 return 值赋给循环变量。需要取得最终 return 值时,要像 query() 一样使用 yield*,或者手动读取 iterator.next() 返回的 { value, done }。
系列文更新中:cc.windliang.wang