Day 03 / 共 20 天 · 第 1 周 建立心智

Agent 循环总览

你在输入框敲了句话回车——Agent 内部那个"圈"就开始转了。今天从宏观讲清这个循环怎么转、什么时候停。源码细节留到 Day 06。昨天(Day 02)界面挂起来了,今天讲界面背后那颗"心脏"怎么跳,Day 05 会把它和 UI、工具串成一次完整对话。

📍 你在整门课的位置 · 第 1 周 建立心智
D01 全景地图 D02 启动链 D03 Agent 循环 D04 Ink UI D05 完整旅程
💡 用一个类比先兜住今天(延续 Day 01 的「公司实习生」世界观) Agent 循环 = 你给实习生派了个活,他"想一步 → 动手 → 看结果 → 再想"地反复干:先想"要完成这事我得先读哪个文件"(模型思考)→ 真去读(调工具)→ 看到文件内容(结果回填)→ 再想"下一步改哪儿"……直到他觉得"活干完了"才回来交差(不再要工具 = turn 结束)。他每轮要么说"我还得动次手"(要工具→继续),要么说"搞定了,这是结果"(纯文字→收尾)——这一句就是今天所有内容的钥匙。
L01

一次 turn(回合)是什么

🤔 痛点:为什么不能"一问一答"就完事? 你让实习生"把项目里所有 颜色 改成 color"。他一开始根本不知道有哪些文件、每个文件里在第几行——必须先搜、再读、再改、再复查。你不可能指望他不看现场就一次说全。Agent 也一样:它得反复"动手看现场"才能把事办对,所以需要一个循环,而不是一问一答。

先定义一个核心词——turn(回合):你发一条消息后,模型可能来回调用好几轮工具,直到给出不含工具调用的最终回答。这"一来一回若干轮直到收尾"整体,就是一个 turn。

你输入一条需求 拼请求+上下文+工具 模型流式回文字/工具调用 执行工具读写/跑命令 结果回填喂回消息 ↑ 若模型还想调工具 → 回到"拼请求"再来一轮;若不调了 → 收尾结束
关键直觉:模型每次回复要么是"最终答案"(纯文字),要么是"我要用某个工具"。只要它还在要工具,循环就继续;一旦它只给文字不要工具了,这一 turn 就结束。 这一句话是理解全部的钥匙。
L02

三层结构:谁负责什么

这个循环在代码里分成清晰的三层,各司其职(三层用 generator 的 yield* 串起来):

① 会话层 QueryEngine

src/QueryEngine.ts

一个会话一个实例。每次你发消息 = 一个"用户回合"。负责:拼 system prompt、把内部消息转成对外 SDK 消息、统计 token/成本、产出最终 result。

▼ 消费

② 循环层 query / queryLoop

src/query.ts

★ 真正的 agent loop:while(true) 反复"调模型→检测工具→跑工具→拼回消息→再调模型",直到没有工具调用(或触发停止条件)。

▼ 调用

③ API 层 queryModel

src/services/api/claude.ts

真正打 Anthropic API,把 SSE 流式事件累积成完整的 assistant 消息,同时把原始流事件透传出去(给 UI 做打字机效果)。

generator(生成器)+ yield*:JS 里能"边算边产出"的函数(async function*)。yield 吐出一个值,调用方能一个个消费。yield* 是"把另一个 generator 产出的东西原样转发出去"。这三层就是层层 yield*:API 层吐出的被循环层消费/再吐,循环层吐出的被会话层消费/转成 SDK 消息再吐给最外层。这套结构让"流式"能一路穿透三层直达界面。
L03

循环 8 步走一遍

把一次 turn 拆成 8 步(对应真实代码位置,Day 06 再逐行):

1

用户消息进入

QueryEngine.submitMessage(prompt)QueryEngine.ts:217):处理 slash 命令/附件,push 进消息列表。

2

进入循环层

QueryEngine.ts:688for await (const message of query({...})) 消费循环层的全部产出。

3

压缩上下文 + 调模型

queryLoopwhile(true)query.ts:460)每轮:先瘦身上下文,再 deps.callModel(...) 流式拿模型响应。

4

检测工具调用

流式过程中,assistant 消息里若有 tool_use 块,就收集起来并置 needsFollowUp=truequery.ts:1090)。

5

执行工具

若 needsFollowUp,跑 runTools(...)query.ts:1671):只读工具并发、写工具串行(Day 07 讲)。

6

结果拼回,进下一轮

messages = 旧消息.concat(assistant消息, 工具结果)query.ts:2044),continue 回循环顶。

7

直到不再要工具

某轮 !needsFollowUp(模型只给文字)→ 走停止路径 return {reason:'completed'}query.ts:1647)。

8

产出最终结果

回到 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 结束
Agent 循环 = 实习生"想→动手→看→再想" 🧠 想调模型 callModel ✋ 动手runTools 执行工具 👀 看结果tool_result 回填 ✅ 交差无 tool_use → 停 还要工具 → 再转一圈
图注:只要模型还"要工具"就绕圈继续;某轮只给纯文字,就从"交差"出口离开,turn 结束。
L04

关键:怎么判断"要不要继续"

你可能以为"看模型返回的 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? 流式响应里 stop_reason 可能不准或滞后。而"这次回复里到底有没有工具调用块"是确定的事实。用事实(有没有 tool_use block)而非信号(stop_reason 字段)来决定循环——这是一个很典型的"用确定性判据代替不可靠信号"的健壮性设计。
⚠️ 小白常误以为:"循环是不是继续,看 API 返回的 stop_reason 字段就行。" 代码里偏偏不信它(query.ts:751 有注释说它不可靠)——而是数"这轮回复里有没有 tool_use 块"这个铁一样的事实。就像判断实习生干没干完,不听他嘴上说"差不多了",而是看他手上还有没有活。

👶 小白:模型自己不会说"我说完了"吗?为啥还要程序去数 tool_use 块?

👨‍🏫 老师:会说,但"说"是自然语言、可能含糊或滞后;而"这次回复里有没有工具调用块"是结构化、确定的事实。程序要的是能 100% 判定的信号,所以用事实(有无 tool_use)而不是措辞(stop_reason)来决定绕不绕圈——更稳。

L05

工具结果怎么回填给模型

这是循环能"转起来"的关键——工具跑完,结果要变成模型下一轮能看到的输入。每个工具产出一条 user 角色、含 tool_result 块的消息(query.ts:1673),回合末尾拼进下一轮:

📝 举个例子:一条 tool_result 长什么样 模型上一轮说 { 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 再问模型
为什么工具结果是"user 消息"? 因为在 Anthropic 的消息协议里,tool_result 必须以 user 角色回给模型(表示"这是外部世界对你工具调用的回应")。模型看到自己上一轮说"我要 Read 这个文件"、紧接着一条 user 消息说"这是文件内容:…",就能接着推理下一步。循环的本质就是:不断把"模型的动作"和"世界的反馈"拼进对话,让模型基于最新事实继续。

健壮性细节:如果已经告诉模型"我调了工具"却因异常没产出结果,yieldMissingToolResultBlocksquery.ts:149)会为每个悬空的 tool_use 补一条 is_error 的结果——否则 API 会报"tool_use 没有对应 tool_result"的错。

L06

什么时候停:一堆终止原因

循环层返回一个 Terminal 类型(src/query/transitions.ts),穷举了所有停止原因。主要几类:

停止原因含义
completed正常完成:模型不再调工具(最常见)
max_turns达到最大回合数上限
prompt_too_long上下文太长且压缩也救不回来
aborted_streaming / aborted_tools用户中断(按了 Esc/Ctrl+C)
model_errorAPI 报错
stop_hook_prevented / hook_stoppedStop hook 拦截(Day 14)

其中最有意思的是 Stop hook 能"逼"模型继续干query.ts:1557):模型说"我完事了",但如果配置了 Stop hook 且它返回"还没达标",循环会把错误插回消息并 continue——相当于对模型说"不行,接着做"。还有 token budgetquery.ts:1598):没花够预算时会插一条 nudge 让模型继续用满。这些"续命"机制 Day 10 细讲。

L07

流式:deltas 怎么变成界面更新

你在终端看到的"逐字蹦出来"的打字机效果,来自 API 层对 SSE 流的处理(claude.ts:2075switch(part.type)):

  • content_block_delta:文字增量 push 进缓冲、工具入参 JSON 一段段拼(claude.ts:2150)。
  • content_block_stop:一个块结束,拼成完整的 assistant 消息 yield 出去(claude.ts:2270)。
  • 每处理完一个流事件,还无条件再 yield 一条原始 stream_eventclaude.ts:2408)。
两种产出,两种用途:API 层同时吐出"累积好的完整消息"(给循环层检测工具调用用)和"原始流事件"(给 UI 做打字机效果用)。会话层默认只把完整消息转成 SDK 消息;只有当调用方要"打字机"时才透传原始事件(QueryEngine.ts:850)。这就是为什么你既能看到字一个个蹦出来,Agent 又能在整块结束后准确判断该不该调工具。
SSE = Server-Sent Events,服务器持续往下推的事件流。大模型 API 用它实现"边生成边返回",而不是等全部生成完才一次性返回——这样你不用干等。
L08

今日小结 + 动手

🧠 今天你应该能回答

  • 一个 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
明天预告 · Day 04:循环的"大脑"清楚了,那"脸"呢?Day 04 讲 Ink 终端 UI——用 React 写终端界面是什么体验、REPL 的组件树长什么样、流式消息怎么渲染成你看到的样子。
← Day 02 启动链 Day 04 · Ink 终端 UI 入门 →