Agent 循环总览
你在输入框敲了句话回车——Agent 内部那个"圈"就开始转了。今天从宏观讲清这个循环怎么转、什么时候停。源码细节留到 Day 06。昨天(Day 02)界面挂起来了,今天讲界面背后那颗"心脏"怎么跳,Day 05 会把它和 UI、工具串成一次完整对话。
一次 turn(回合)是什么
颜色 改成 color"。他一开始根本不知道有哪些文件、每个文件里在第几行——必须先搜、再读、再改、再复查。你不可能指望他不看现场就一次说全。Agent 也一样:它得反复"动手看现场"才能把事办对,所以需要一个循环,而不是一问一答。先定义一个核心词——turn(回合):你发一条消息后,模型可能来回调用好几轮工具,直到给出不含工具调用的最终回答。这"一来一回若干轮直到收尾"整体,就是一个 turn。
三层结构:谁负责什么
这个循环在代码里分成清晰的三层,各司其职(三层用 generator 的 yield* 串起来):
① 会话层 QueryEngine
一个会话一个实例。每次你发消息 = 一个"用户回合"。负责:拼 system prompt、把内部消息转成对外 SDK 消息、统计 token/成本、产出最终 result。
② 循环层 query / queryLoop
★ 真正的 agent loop:while(true) 反复"调模型→检测工具→跑工具→拼回消息→再调模型",直到没有工具调用(或触发停止条件)。
③ API 层 queryModel
真正打 Anthropic API,把 SSE 流式事件累积成完整的 assistant 消息,同时把原始流事件透传出去(给 UI 做打字机效果)。
yield*:JS 里能"边算边产出"的函数(async function*)。yield 吐出一个值,调用方能一个个消费。yield* 是"把另一个 generator 产出的东西原样转发出去"。这三层就是层层 yield*:API 层吐出的被循环层消费/再吐,循环层吐出的被会话层消费/转成 SDK 消息再吐给最外层。这套结构让"流式"能一路穿透三层直达界面。循环 8 步走一遍
把一次 turn 拆成 8 步(对应真实代码位置,Day 06 再逐行):
用户消息进入
QueryEngine.submitMessage(prompt)(QueryEngine.ts:217):处理 slash 命令/附件,push 进消息列表。
进入循环层
QueryEngine.ts:688 的 for await (const message of query({...})) 消费循环层的全部产出。
压缩上下文 + 调模型
queryLoop 的 while(true)(query.ts:460)每轮:先瘦身上下文,再 deps.callModel(...) 流式拿模型响应。
检测工具调用
流式过程中,assistant 消息里若有 tool_use 块,就收集起来并置 needsFollowUp=true(query.ts:1090)。
执行工具
若 needsFollowUp,跑 runTools(...)(query.ts:1671):只读工具并发、写工具串行(Day 07 讲)。
结果拼回,进下一轮
messages = 旧消息.concat(assistant消息, 工具结果)(query.ts:2044),continue 回循环顶。
直到不再要工具
某轮 !needsFollowUp(模型只给文字)→ 走停止路径 return {reason:'completed'}(query.ts:1647)。
产出最终结果
回到 QueryEngine,抽取最后一条 assistant 文本,yield 一个 type:'result'(QueryEngine.ts:1194)。
把上面 8 步套到一个具体输入上,像调试器一样单步走一遍——需求是「把 config.json 里的 debug 改成 false」:
| 轮次 | 模型这轮说什么 | 循环怎么反应 | needsFollowUp? |
|---|---|---|---|
| 第 1 轮 | "我要 Read config.json"(含 tool_use) | 跑 Read,把文件内容作为 tool_result 拼回 | ✅ 继续 |
| 第 2 轮 | "看到了,我要 Edit 把 true 改 false"(含 tool_use) | 跑 Edit,把"已改"结果拼回 | ✅ 继续 |
| 第 3 轮 | "已把 debug 改为 false ✅"(纯文字,无 tool_use) | 走停止路径 completed,产出 result | ❌ 停,turn 结束 |
关键:怎么判断"要不要继续"
你可能以为"看模型返回的 stop_reason 是不是 tool_use 就行"。但代码里明确注释说这个不可靠(query.ts:751)。它改用一个更硬的判据:
// query.ts:1090 —— 唯一的"继续循环"信号
const msgToolUseBlocks = content.filter(c => c.type === 'tool_use')
if (msgToolUseBlocks.length > 0) {
toolUseBlocks.push(...msgToolUseBlocks)
needsFollowUp = true // ← 流式过程中只要出现过 tool_use block,就必须再来一轮
}
stop_reason 可能不准或滞后。而"这次回复里到底有没有工具调用块"是确定的事实。用事实(有没有 tool_use block)而非信号(stop_reason 字段)来决定循环——这是一个很典型的"用确定性判据代替不可靠信号"的健壮性设计。stop_reason 字段就行。" 代码里偏偏不信它(query.ts:751 有注释说它不可靠)——而是数"这轮回复里有没有 tool_use 块"这个铁一样的事实。就像判断实习生干没干完,不听他嘴上说"差不多了",而是看他手上还有没有活。👶 小白:模型自己不会说"我说完了"吗?为啥还要程序去数 tool_use 块?
👨🏫 老师:会说,但"说"是自然语言、可能含糊或滞后;而"这次回复里有没有工具调用块"是结构化、确定的事实。程序要的是能 100% 判定的信号,所以用事实(有无 tool_use)而不是措辞(stop_reason)来决定绕不绕圈——更稳。
工具结果怎么回填给模型
这是循环能"转起来"的关键——工具跑完,结果要变成模型下一轮能看到的输入。每个工具产出一条 user 角色、含 tool_result 块的消息(query.ts:1673),回合末尾拼进下一轮:
{ type:"tool_use", id:"tu_01", name:"Read", input:{file_path:"config.json"} }工具跑完,回填一条 user 消息:
{ role:"user", content:[{ type:"tool_result", tool_use_id:"tu_01", content:"{ \"debug\": true }" }] }注意
tool_use_id 必须对上——模型才知道"这是我刚才那次 Read 的回音"。下一轮模型就"看到"文件内容了。// query.ts:2043 —— 三者拼成下一轮输入
const next = {
messages: messagesForQuery.concat(assistantMessages, toolResults),
// 历史 + 模型这轮的回复 + 工具执行结果
}
state = next // continue → 下一轮 callModel 就带着 tool_result 再问模型
tool_result 必须以 user 角色回给模型(表示"这是外部世界对你工具调用的回应")。模型看到自己上一轮说"我要 Read 这个文件"、紧接着一条 user 消息说"这是文件内容:…",就能接着推理下一步。循环的本质就是:不断把"模型的动作"和"世界的反馈"拼进对话,让模型基于最新事实继续。健壮性细节:如果已经告诉模型"我调了工具"却因异常没产出结果,yieldMissingToolResultBlocks(query.ts:149)会为每个悬空的 tool_use 补一条 is_error 的结果——否则 API 会报"tool_use 没有对应 tool_result"的错。
什么时候停:一堆终止原因
循环层返回一个 Terminal 类型(src/query/transitions.ts),穷举了所有停止原因。主要几类:
| 停止原因 | 含义 |
|---|---|
completed | 正常完成:模型不再调工具(最常见) |
max_turns | 达到最大回合数上限 |
prompt_too_long | 上下文太长且压缩也救不回来 |
aborted_streaming / aborted_tools | 用户中断(按了 Esc/Ctrl+C) |
model_error | API 报错 |
stop_hook_prevented / hook_stopped | Stop hook 拦截(Day 14) |
其中最有意思的是 Stop hook 能"逼"模型继续干(query.ts:1557):模型说"我完事了",但如果配置了 Stop hook 且它返回"还没达标",循环会把错误插回消息并 continue——相当于对模型说"不行,接着做"。还有 token budget(query.ts:1598):没花够预算时会插一条 nudge 让模型继续用满。这些"续命"机制 Day 10 细讲。
流式:deltas 怎么变成界面更新
你在终端看到的"逐字蹦出来"的打字机效果,来自 API 层对 SSE 流的处理(claude.ts:2075 的 switch(part.type)):
content_block_delta:文字增量 push 进缓冲、工具入参 JSON 一段段拼(claude.ts:2150)。content_block_stop:一个块结束,拼成完整的 assistant 消息 yield 出去(claude.ts:2270)。- 每处理完一个流事件,还无条件再 yield 一条原始
stream_event(claude.ts:2408)。
QueryEngine.ts:850)。这就是为什么你既能看到字一个个蹦出来,Agent 又能在整块结束后准确判断该不该调工具。今日小结 + 动手
🧠 今天你应该能回答
- 一个 turn 是什么?循环靠什么判据决定继续?(流式中是否出现 tool_use block)
- 三层结构各负责什么?(QueryEngine 会话 / query 循环 / queryModel API)
- 工具结果为什么以 user 消息回填?怎么拼进下一轮?
- 有哪些停止原因?Stop hook 怎么"逼"模型继续?
- 为什么 API 层同时吐"完整消息"和"原始流事件"?
✋ 动手
# 1. 看会话层怎么消费循环层
sed -n '217,260p' src/QueryEngine.ts
sed -n '688,700p' src/QueryEngine.ts
# 2. 看循环层的 while(true) 主体(核心中的核心)
sed -n '460,470p' src/query.ts # 循环开始
sed -n '1079,1103p' src/query.ts # 检测 tool_use
sed -n '2032,2045p' src/query.ts # 拼回下一轮
# 3. 看 API 层流式 switch
sed -n '2075,2090p' src/services/api/claude.ts
# 4. 看所有停止原因
sed -n '1,20p' src/query/transitions.ts