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

图里的 messages 会逐轮累积,不是每轮重新创建一份只有新结果的消息:
turnCount = 1// messages.length => 1// [用户问题]turnCount = 2// messages.length => 3// [用户问题, Grep tool_use, Grep tool_result]turnCount = 3// messages.length => 5// [用户问题,// Grep tool_use, Grep tool_result,// Read tool_use, Read tool_result]这里最关键的一点是:
模型只会生成「我要调用
Grep」这样的tool_use。真正搜索文件的是 Claude Code 进程,不是模型。
因此,一次用户输入不等于一次模型请求。
本文的例子中,用户只输入了一句话,但 queryLoop() 请求了三次模型。前两次模型要求使用 Tool,第三次才产生最终答案。
主循环可以精简成下面的伪代码:
// 等价简版,用于理解主线,不是源码原文asyncfunction* simplifiedQueryLoop(state) {while (true) {// 1. 用当前 messages 请求模型const assistantMessages = []forawait (const message of callModel(state.messages) ) {yield messageif (message.type === 'assistant') { assistantMessages.push(message) } }// 2. 找出模型真正返回的 Tool Useconst toolUseBlocks = findToolUseBlocks(assistantMessages)// 3. 没有 Tool Use,任务结束if (toolUseBlocks.length === 0) {return {reason: 'completed', } }// 4. 执行 Tool,逐个收集 Tool Resultconst toolResults = []forawait (const update of runTools(toolUseBlocks) ) {if (update.message) { toolResults.push(update.message) } }// 5. 把模型消息和 Tool Result 交给下一轮 state = { ...state,messages: [ ...state.messages, ...assistantMessages, ...toolResults, ],turnCount: state.turnCount + 1, } }}后面的大部分源码,都是在给这五步增加生产环境需要的能力,例如权限检查、流式输出、上下文压缩、Fallback、Hook 和中止处理。这一篇只看主循环,其他机制后面再展开。
这篇文章专注于主循环的流程,主要是三点:
模型怎样产生 tool_use;Claude Code 怎样执行 Tool 并生成 tool_result;新消息怎样进入下一轮,循环最后怎样结束。
循环入口
交互模式下,REPL 会把第一篇组装好的消息、Prompt 和 ToolUseContext 传给 query():
// src/screens/REPL.tsx:2793-2803forawait (const event of query({messages: messagesIncludingNewMessages,// => [{// type: 'user',// message: {// role: 'user',// content: '检查登录模块',// },// }] systemPrompt, userContext, systemContext, canUseTool, toolUseContext,// => {// options: {// mainLoopModel: 'claude-sonnet-4-6',// tools: [// { name: 'Read', ... },// { name: 'Edit', ... },// { name: 'Grep', ... },// { name: 'Glob', ... },// { name: 'Bash', ... },// ],// commands: [...],// thinkingConfig: { type: 'adaptive' },// },// abortController: AbortController,// getAppState: () => AppState,// setAppState: function,// }// 其中 getAppState().toolPermissionContext.mode => 'plan'querySource: getQuerySourceForREPL(),// => 'repl_main_thread' })) { onQueryEvent(event)}onQueryEvent(event) 只负责把 query() 产生的事件更新到终端界面,例如显示流式文本或追加完整消息。它不调用模型、不执行 Tool,也不决定循环是否继续,因此这里不再展开。
看一下 query(),源码位置:src/query.ts:219-239
REPL、Print 或 SDK 最终会消费 query() 产生的事件。query() 本身很短:
exportasyncfunction* query(params) {const consumedCommandUuids = []const terminal =yield* queryLoop(params, consumedCommandUuids)for (const uuid of consumedCommandUuids) { notifyCommandLifecycle(uuid, 'completed') }return terminal}两个函数的分工如下:
query() | queryLoop() 返回后完成命令清理 |
queryLoop() |
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):
asyncfunction* queryLoop(params) {let state = {messages: params.messages,// => [{// type: 'user',// message: {// role: 'user',// content: '检查登录模块',// },// }]toolUseContext: params.toolUseContext,// => 包含模型、Tools、权限状态和 abortControllermaxOutputTokensOverride: params.maxOutputTokensOverride,// 重试时临时提高模型输出上限autoCompactTracking: undefined,// 记录自动压缩后又运行了多少轮stopHookActive: undefined,// 标记当前是否由 Stop Hook 触发续行maxOutputTokensRecoveryCount: 0,// 限制输出截断后的自动续写次数hasAttemptedReactiveCompact: false,// 防止上下文溢出后反复压缩turnCount: 1,// => 当前是第 1 轮循环pendingToolUseSummary: undefined,// 保存后台生成的 Tool 摘要transition: undefined,// 记录上一轮为什么继续// => 第一轮还没有发生状态转换 }while (true) {const { messages, turnCount, } = state// 整理消息、调用模型、执行 Tool…… }}这里才第一次正式进入 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,只看三步的嵌套关系:
while (true) {const messagesForQuery = [ ...getMessagesAfterCompactBoundary( state.messages, ), ]// => [{// type: 'user',// message: {// role: 'user',// content: '检查登录模块',// },// }]const assistantMessages = []const toolUseBlocks = []const toolResults = []let needsFollowUp = false// 第一步:调用模型,开始消费这一次模型请求的输出流forawait (const message of deps.callModel({messages: prependUserContext( messagesForQuery, userContext, ),systemPrompt: fullSystemPrompt,tools: toolUseContext.options.tools,// => [{ name: 'Read', ... }, { name: 'Grep', ... }, ...]options: {model: currentModel,// => 'claude-sonnet-4-6' },// 其他请求参数…… }) ) {yield message// 第二步:仍在 for await 内// 每拿到一条 message,就检查其中有没有 tool_useif (message.type === 'assistant') { assistantMessages.push(message)const blocks = message.message.content.filter(content => content.type === 'tool_use', )// 本轮模型决定搜索代码时:// => [{// type: 'tool_use',// id: 'toolu_01',// name: 'Grep',// input: {// pattern: 'login|auth',// // 在文件内容中匹配 login 或 auth//// path: 'src',// // 只搜索 src 目录//// output_mode: 'files_with_matches',// // 只返回匹配到的文件路径,不返回文件内容// },// }] toolUseBlocks.push(...blocks)if (blocks.length > 0) { needsFollowUp = true } } } // 模型输出流到这里已经全部结束if (!needsFollowUp) {// 没有 tool_use:进入恢复和结束检查// 中间的恢复分支省略;正常情况最终返回:return { reason: 'completed' } }// 第三步:在 for await 外面// 只有发现 tool_use 才会执行 Tool// 这里的 Grep Tool 底层使用 ripgrep:// 在 src 中搜索内容匹配 login|auth 的文件。const toolUpdates = runTools( toolUseBlocks,// => [{ id: 'toolu_01', name: 'Grep', ... }] assistantMessages, canUseTool, toolUseContext, )forawait (const update of toolUpdates) {if (update.message) {// runTools() 已经执行完 Grep。// normalizeMessagesForAPI() 不执行 Tool,// 只把结果整理成下一次模型请求需要的消息格式。 toolResults.push( ...normalizeMessagesForAPI( [update.message], toolUseContext.options.tools, ).filter(message => message.type === 'user'), )// => [{// type: 'user',// message: {// role: 'user',// content: [{// type: 'tool_result',// tool_use_id: 'toolu_01',// content: 'Found 1 file\nsrc/auth/login.ts',// }],// },// }] } } state = { ...state,messages: [ ...messagesForQuery, ...assistantMessages, ...toolResults, ],// => 原问题// + assistant 的 tool_use// + user 的 tool_resultturnCount: state.turnCount + 1,// 1 + 1 => 2transition: {reason: 'next_turn', }, }// 到达 while 底部,开始下一轮}这里只需要知道:相同的 id 和 tool_use_id 表示「这是那次 Tool 调用的结果」。具体生成和异常修复后续再讲。
第二轮:不是新流程,而是同一个 while 再跑一次
下面省略第一轮已经讲过的参数和异常分支,只保留第二轮发生变化的值:
while (true) {const { messages, turnCount, } = state// 第二轮刚开始// turnCount => 2// messages.length => 3// messages 中依次是:// 1. 用户输入「检查登录模块」// 2. 模型请求执行 Grep// 3. Grep 返回「src/auth/login.ts」const messagesForQuery = [ ...getMessagesAfterCompactBoundary(messages), ]const assistantMessages = []const toolUseBlocks = []const toolResults = []forawait (const message of deps.callModel({messages: prependUserContext( messagesForQuery, userContext, ),// => 这次发给模型的是上面的 3 条消息// 模型已经知道 Grep 找到了 src/auth/login.ts,// 但还没看到文件内容。// 其他参数…… }) ) {if (message.type === 'assistant') {const blocks = message.message.content.filter(block => block.type === 'tool_use', )// blocks => [{// type: 'tool_use',// id: 'toolu_02',// name: 'Read',// input: {// file_path: 'src/auth/login.ts',// },// }] toolUseBlocks.push(...blocks) assistantMessages.push(message) } }const toolUpdates = runTools( toolUseBlocks, assistantMessages, canUseTool, toolUseContext,// 其他参数…… )// runTools() 此时真正执行 Read:// 打开 src/auth/login.ts,读取文件内容。forawait (const update of toolUpdates) {if (update.message) { toolResults.push( ...normalizeMessagesForAPI( [update.message], toolUseContext.options.tools, ).filter(message => message.type === 'user'), )// toolResults => [{// type: 'user',// message: {// role: 'user',// content: [{// type: 'tool_result',// tool_use_id: 'toolu_02',// content: 'export async function login(...) {\n ...\n}',// }],// },// }] } } state = { ...state,messages: [ ...messagesForQuery, // 原来的 3 条 ...assistantMessages, // 新增 Read Tool Use ...toolResults, // 新增 Read Tool Result ],// messages.length => 3 + 1 + 1 => 5turnCount: turnCount + 1,// 2 + 1 => 3transition: {reason: 'next_turn', }, }}这里真正值得注意的不是「第二轮又执行了一个 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,而是直接回答。下面使用等价简版,只保留正常路径,省略流式回退和可恢复错误暂缓输出等分支:
while (true) {const { messages, stopHookActive, turnCount, } = state// 第三轮刚开始// turnCount => 3// messages.length => 5// 相比第二轮,新增了 Read Tool Use 和 Read Tool Result。const messagesForQuery = [ ...getMessagesAfterCompactBoundary(messages), ]const assistantMessages = []const toolUseBlocks = []let needsFollowUp = falseforawait (const message of deps.callModel({messages: prependUserContext( messagesForQuery, userContext, ),// => 5 条消息,最后一条包含 login.ts 的源码// 其他参数…… }) ) {yield message// 正常路径下,最终文本立即交给 REPL 或 Print 层显示。if (message.type === 'assistant') { assistantMessages.push(message)// assistantMessages => [{// type: 'assistant',// message: {// role: 'assistant',// content: [{// type: 'text',// text: '登录模块先校验凭证,然后创建会话……',// }],// },// }]const blocks = message.message.content.filter(content => content.type === 'tool_use', )// blocks => []if (blocks.length > 0) { toolUseBlocks.push(...blocks) needsFollowUp = true }// blocks.length > 0 => false// needsFollowUp 仍然是 false } }if (!needsFollowUp) {// => true,因此不会进入后面的 runTools()const lastMessage = assistantMessages.at(-1)// => 上面的最终文本消息,不是 API 错误// 这里还有上下文溢出和输出截断等恢复分支。// 本文这次运行没有触发,继续向下执行。const stopHookResult = yield* handleStopHooks( messagesForQuery, assistantMessages, systemPrompt, userContext, systemContext, toolUseContext, querySource, stopHookActive, )// stopHookResult.preventContinuation => false// stopHookResult.blockingErrors => []// 因此不会进入 Stop Hook 的中止或续行分支。// Token Budget 还可能要求继续。// 本文示例也没有触发这条分支。return {reason: 'completed', } }// 只有 needsFollowUp 为 true,代码才会走到这里执行 Tool。const toolUpdates = runTools(/* ... */)}注意,模型生成的最终文本已经在 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:
// src/query.ts:554-558// stop_reason === 'tool_use' 并不可靠,// 它不一定每次都被正确设置。let needsFollowUp = false源码里没有写下面这种判断:
if (message.message.stop_reason === 'tool_use') { needsFollowUp = true}实际使用的是 src/query.ts:826-835:代码在模型流中检查每一条 AssistantMessage,直接查找其中的 tool_use 内容块:
// src/query.ts:826-835const msgToolUseBlocks = message.message.content.filter(content => content.type === 'tool_use', )if (msgToolUseBlocks.length > 0) { toolUseBlocks.push(...msgToolUseBlocks) needsFollowUp = true}第三轮模型只返回文本:
msgToolUseBlocks// => []needsFollowUp// => false因为没有进入赋值为 true 的分支,needsFollowUp 保持初始值 false。模型流结束后,代码便进入正常结束判断:
if (!needsFollowUp) {// needsFollowUp => false// 恢复机制和 Stop Hook 均未要求继续return {reason: 'completed', }}结束检查全部放行后,return 会直接跳出 while (true),同时结束 queryLoop()。
2. 达到调用方设置的 maxTurns
源码位置:
src/query.ts:252-260:从query()传入的参数中取出maxTurns;src/query.ts:1679-1728:Tool 执行结束后、创建下一轮State之前检查它。
前面的主框架为了突出 Tool 循环,省略了这段限制代码。把它放回原来的位置,关系如下:
asyncfunction* queryLoop(params) {const { maxTurns,// 其他参数…… } = params// Print、SDK 或子 Agent 可以传入这个值。// 例如:maxTurns => 3// ...while (true) {// 1. 请求模型// 2. 收集 Tool Use// 3. 执行 Tool,得到 toolResults// Tool 已经执行完,准备进入下一轮const nextTurnCount = turnCount + 1// turnCount => 3// nextTurnCount => 4if (maxTurns && nextTurnCount > maxTurns) {// maxTurns => 3// 4 > 3 => trueyield createAttachmentMessage({type: 'max_turns_reached', maxTurns,turnCount: nextTurnCount, })return {reason: 'max_turns',turnCount: nextTurnCount, } }// 没超过上限,才会创建下一轮 Stateconst next = {messages: [ ...messagesForQuery, ...assistantMessages, ...toolResults, ],turnCount: nextTurnCount,// 其他字段…… } state = next }}它检查的不是「当前已经执行了几轮」,而是「是否还允许进入下一轮」。例如 maxTurns = 3,当前已经是第 3 轮,模型又请求了 Tool,nextTurnCount 就是 4,源码会阻止第 4 次模型请求。
但 maxTurns 是可选参数。交互模式没有设置它时:
maxTurns// => undefinedif (maxTurns && nextTurnCount > maxTurns) {// 不会进入}因此源码没有为普通 Tool 循环设置一个统一的硬上限。
3. 用户或调用方主动中止
ToolUseContext 中保存了 AbortController。用户在交互界面中按下中断键,或者 SDK 主动取消任务后:
toolUseContext.abortController.signal.aborted// => true模型流或 Tool 执行分支检测到这个值后,会停止当前工作并返回对应的中止原因。
另外,即使模型没有继续调用 Tool,Claude Code 也不一定立即结束:上下文恢复、输出截断恢复或 Stop Hook 仍可能要求进入下一轮。这些机制将在后续文章中展开。
小结
Claude Code 的 Agent 主循环可以压缩成下面这段:
while (true) {const assistantMessages = []forawait (const message of callModel(state.messages) ) {yield messageif (message.type === 'assistant') { assistantMessages.push(message) } }const toolUseBlocks = findToolUseBlocks(assistantMessages)if (toolUseBlocks.length === 0) {// 生产代码还会执行恢复与 Stop Hook 检查return {reason: 'completed', } }const toolResults = []forawait (const update of runTools(toolUseBlocks) ) {if (update.message) { toolResults.push(update.message) } }const next = {messages: [ ...state.messages, ...assistantMessages, ...toolResults, ],toolUseContext: state.toolUseContext,turnCount: state.turnCount + 1,transition: {reason: 'next_turn', }, } state = next}真正需要记住的是四点:
query()调用queryLoop()、透传它产生的事件,并在结束后完成 Command 状态清理;queryLoop()才负责模型与 Tool 的多轮循环;模型提出 Tool Use,Claude Code 执行后把 Tool Result 放回消息; 没有新的 Tool Use,并且结束检查放行后,循环返回 completed;State把消息、工具上下文和轮次一起交给下一轮。
附录:看懂 function*、yield 和 await
生成器函数平常用的不多,这里也补充下。
阅读 query() 和 queryLoop() 时,最容易混淆的是下面几种写法:
function* generator() {}asyncfunctionasyncFunction() {}asyncfunction* asyncGenerator() {}先记住一句话:
普通函数一次返回一个结果;生成器可以暂停多次,每次返回一部分结果。
普通函数和 function* 有什么区别
普通函数从头执行到 return,一次性结束:
functiongetNumbers() {return [1, 2, 3]}const numbers = getNumbers()// => [1, 2, 3]函数名旁边多一个 *,表示它是生成器函数:
function* getNumbers() {yield1yield2return3}调用生成器函数时,不会立刻执行完整函数,而是先得到一个迭代器:
const iterator = getNumbers()iterator.next()// => { value: 1, done: false }iterator.next()// => { value: 2, done: false }iterator.next()// => { value: 3, done: true }每次调用 next(),函数都会从上次暂停的位置继续运行:
yield 1:交出1,函数暂停;再次调用 next():从yield 1后面继续;return 3:函数彻底结束,done变成true。
所以 yield 和 return 的区别是:
yield value | |
return value |
async function* 又多了什么
queryLoop() 不仅要分多次产生结果,还要等待模型和 Tool,因此它同时需要 async 和 *:
asyncfunction* queryLoop() {const message = await requestModel()yield messagereturn {reason: 'completed', }}两部分分别表示:
async:函数内部可以使用await等待异步操作;*:函数可以多次yield,逐步向外发送结果。
因此,async function* 可以理解为「异步的分批返回函数」。
await 等待的是什么
await 等待的是 Promise:
const file = await readFileAsync()执行到这里时,当前函数先暂停;Promise 完成后,函数再从这一行继续。它暂停的是当前异步函数,不会阻塞整个 JavaScript 进程。
普通 async function 最终返回一个 Promise:
asyncfunctiongetAnswer() {return42}const answer = await getAnswer()// => 42而 async function* 返回的不是一个最终 Promise,而是 AsyncGenerator:
const stream = queryLoop()// => AsyncGenerator所以不能用一次 await queryLoop() 取得全部消息。需要不断向生成器索要下一条结果。
为什么是 for await...of
异步生成器的每次 next() 都返回 Promise:
const iterator = queryLoop()await iterator.next()// => { value: 第一条事件, done: false }await iterator.next()// => { value: 第二条事件, done: false }for await...of 就是把「反复调用 next(),并等待每个 Promise」写成循环:
forawait (const event of queryLoop()) {console.log(event)}它大致相当于:
const iterator = queryLoop()while (true) {const item = await iterator.next()if (item.done) {break }const event = item.valueconsole.log(event)}因此:
for...of用来遍历同步数据;for await...of用来遍历异步产生的数据;这里的 await表示每一轮都要等待下一条事件到达。
模型的文本、Tool Use 和 Tool Result 不是同时产生的,所以 queryLoop() 很适合用这种方式逐条输出。
yield* 为什么又多一个 *
yield* 表示「把另一个生成器产生的内容全部转交出去」。
先看一个简化例子:
asyncfunction* child() {yield'第一条消息'yield'第二条消息'return'child 已结束'}asyncfunction* parent() {const result = yield* child()console.log(result)// => 'child 已结束'}parent() 做了两件事:
child()每次yield的消息,都继续成为parent()的输出;child()最后的return值,保存到result。
这正是 query() 中的写法:
const terminal =yield* queryLoop(params, consumedCommandUuids)对应关系是:
queryLoop()产生的文本、Tool Use 和 Tool Result,继续由query()向上输出;queryLoop()最后返回的{ reason: 'completed' },保存到terminal;query()完成 Command 状态清理后,再return terminal。
把这些语法放回本文
exportasyncfunction* query(params) {const terminal =yield* queryLoop(params)return terminal}forawait (const event of query(params)) { render(event)}可以按下面的顺序理解:
async function* query():这是一个会异步、分批产生事件的函数;yield* queryLoop():把主循环产生的事件原样向上传;for await...of:上层等待并逐条消费这些事件;yield:产生一条过程事件;return:生成器结束,并返回最终状态。
最后注意一个细节:for await...of 只能遍历 yield 出来的过程值,不会把生成器最后的 return 值赋给循环变量。需要取得最终 return 值时,要像 query() 一样使用 yield*,或者手动读取 iterator.next() 返回的 { value, done }。
夜雨聆风