第 4 课 · 一轮变很多轮:循环,和它什么时候停
读这一课前你需要会什么:读过第 1 到 3 课。 你需要知道一轮长什么样:发请求 → 它说要调工具 → 我们执行 → 结果按形状放回去 → 再发一次。 出现的每一个新词都会当场用大白话讲清。讲不清楚的地方就是我的问题,请直接标出来。
这一课结束时你会
- 默写出这个循环的骨架——它只有十几行;
- 说得出「什么时候停」为什么是这整件事的分界线,以及为什么它同时也是最危险的地方;
- 说得出为什么「循环写在哪里」这个选择,选错了以后要重写。
1. 先看第 3 课留下的那个问题
第 3 课那一轮跑完了,它回了一句人话:「北京今天多云,26 度。」
那一轮里模型被调用了两次,而我们的代码写死了「调两次」。
换个问题就不行了。 比如:
北京和上海哪个更暖和?
这次它得查两次天气。 我们的代码怎么知道要调三次模型,而不是两次?
答案是:不知道,也不该由我们知道。 我们要做的是一直问下去,直到它说不用问了。
把「一轮」套进一个「一直做下去」的结构里——这就是这一课。
2. 顶层全景:骨架只有十几行
先给这个词:
循环 = 同一段代码反复跑,直到撞上一个条件才停。
这是所有程序里都有的东西,不是 AI 特有的。AI 特有的是那个条件由谁定。
有一份资料把这件事讲成了整个领域的定性,值得原样记住:
循环人人都写过。agent 的关键,是让模型来控制那个停止条件——由它决定什么时候不再转。 (依据:本库摘录 · learning-langchain)
这句话锋利的地方在它的反面: 如果停止条件是我们写死的(跑三轮就停),那它就退化成一条流水线,不是 agent。
骨架长这样:
消息 = [系统提示, 用户那句话]
一直做下去:
回复 = 问模型(消息, 工具清单)
把回复追加进消息
它这次点工具了吗?
├ 没有 → 这就是最终答案,跳出
└ 有 → 逐个执行,把每个结果按形状追加进消息
回到开头
图说:整个 agent 就是这十几行。
后面五节讲的所有东西,都是挂在「跳出」那一行上的。
有一份资料把这个骨架压进了 196 行,包括工具、状态、上限全部在内 (依据:本库摘录 · openmanus); 另一份只用了约 100 行,而且它的全部状态就是那个只增不改的消息列表 (依据:本库摘录 · mini-swe-agent)。
下限比这还低。 有一份把循环抽象到只剩一句话: 跑当前节点、拿它报的动作名查后继、换过去——agent 循环在这套原语里就是一张带回头路的图 (依据:本库摘录 · pocketflow)。
3. 主走查:「北京和上海哪个更暖和?」
盯住这句话,看循环转了几圈、每圈的岔路口怎么走的。
这一节的数字全是真的 。 下面每个数都来自一次真实跑测,不是编的演示 (依据:本库实验 · 001-agent-loop/baseline-01)。跑的是本课第 6 节那段代码。
先看结果:它只转了 2 圈,不是 3 圈
我第一版讲义在这里写的是「转三圈」——查北京一圈、查上海一圈、给答案一圈。 真跑出来是 2 圈。差别在哪儿,下面走完你就看到了。
| 实测 | |
|---|---|
| 循环转了 | 2 圈 |
| 模型被调用 | 2 次 |
| 工具被执行 | 2 次 |
| 花掉 | 输入 805 / 输出 251 个 token |
| 为什么停 | text_response(end_turn) |
第 1 圈
消息列表现在只有 1 条(系统提示是单独一个字段送的,不占消息列表):
用户:北京和上海哪个更暖和?
问模型, 它回来的东西是这样(真实输出,只省略了编号里的乱码部分):
为什么停:tool_use ← 「我停下来是因为要调工具」
正文: 我来帮您查询北京和上海的实时天气。
调用一: call_00_1Dy… → get_weather{"city": "北京"}
调用二: call_00_7Kx… → get_weather{"city": "上海"}
这一轮花掉:输入 512、输出 121 个 token
这里有三件事和我原来写的不一样,一件比一件要紧:
① 它一次点了两个工具,不是一个。 所以不需要「查完北京再转一圈查上海」——它一开始就知道要查两个,一次全点了。
这正是第 3 课 §5 分歧一说的那个「一批」。 我写第一版讲义时把它想成两轮, 真跑起来才发现它是一轮里的两个调用。 这就是为什么讲义必须等原型跑通再回来改——不跑,你不知道自己在哪儿想错了。
② 正文不是 空的。 它先说了一句「我来帮您查询北京和上海的实时天气」。 这句话就是第 2 课 §5.3 那张草稿纸(第 4.6 节细说)。
③ 输入是 512 个 token,而那句问题本身根本用不了这么多。
我把同一句话分三次发出去量了一遍(依据:本库实验 · 001-agent-loop/token-breakdown):
| 发的是什么 | 输入 token |
|---|---|
| 只有那句问题 | 90 |
| 加上系统提示 | 113 |
| 再加上两个工具的说明 | 512 |
两个工具的说明占了 399 个 token,是那句问题的四倍多。
这就是第 3 课那句「工具定义要占地方、要花钱」的实测值。 而且这个数每一轮都要再付一遍——工具说明在前面,每次请求都得重发。 第 6 课讲的「工具太多要按需披露」,省的就是这 399。
岔路口:它点工具了吗? 点了两个。
串行执行两个,把结果按形状追加回去:
✓ get_weather({"city":"北京"}) ← {"温度":26,"天气":"多云","湿度":45}
✓ get_weather({"city":"上海"}) ← {"温度":29,"天气":"晴","湿度":68}
回到开头。
第 2 圈
把加长后的消息列表整个再发一次。它回:
为什么停:end_turn ← 「说完了」
正文: 根据实时天气数据:……上海更暖和,目前气温 29°C,比北京(26°C)高出 3°C……
调用: (没有)
这一轮花掉:输入 293、输出 130 个 token
岔路口:它点工具了吗? 没点。这就是最终答案,跳出。
第 2 圈的输入只有 293,比第 1 圈的 512 还少。 这不矛盾——第 1 圈那 512 里有一大半是工具说明,而这一次的请求形状不同。 真实的账单不是「每轮翻倍」这么简单,第 6 课细讲。
停一下:刚才发生了什么
| 项 | 实测 |
|---|---|
| 模型被调用 | 2 次 |
| 工具被执行 | 2 次(在同一轮里) |
| 循环转了 | 2 圈,第 2 圈在岔路口跳出 |
| 总花费 | 输入 805 / 输出 251 个 token |
| 我们的代码做了几个判断 | 1 个:它点工具了吗 |
最后一行是重点。整个循环里,我们只判断了一件事。 「要查哪两个城市、查完了没有、够不够回答」这些决定,从头到尾是它做的。
而「它一次点几个工具」这件事,我们也没有决定权。 这一次它点了两个; 换个问法,它可能点一个、也可能点四个。 所以那段执行代码必须写成「逐个跑一遍」, 不能假设只有一个。
4. 拆开看:五件事
4.1 「它点工具了吗」这个判断,能这么简单吗
主走查里的判据只有一句:它这次点工具了吗。
有资料确实就是这么干的: 这轮没要任何工具、也没有积压的用户输入,就结束(依据:本库摘录 · codex)。
但这条判据有一个洞,而且是致命的洞:
它可能没干完就不点工具了。
有一份资料把这件事记得最诚实。它说自己的全部工程量不在「转」, 而在四类打断和一串跑偏纠正怎么插进这个循环; 而且模型没叫工具时不能简单结束——它在这个分支上堆了五道纠正加一道跨回合续跑 (依据:本库摘录 · kun)。
它列的真实情况是:
| 它干了什么 | 我们看到的 |
|---|---|
| 说一句「好的,我这就去改」然后什么也没做 | 没点工具 → 我们判定完成 |
| 改完文件后回一个空响应 | 没点工具 → 我们判定完成 |
| 被输出长度截断,话说到一半 | 没点工具 → 我们判定完成 |
还有一种更隐蔽的,有资料专门给它起了名字:
它确信自己已经完成了任务,但实际上没有。 那份资料的例子是:把 50 个人分配到 30 间房,它只分配了 40 个,却坚称任务已完成。 (依据:本库摘录 · ai-engineering)
所以「它不点工具了」这条判据,准确的说法是: 它是「它认为自己做完了」,不是「它做完了」。
4.2 判定「干完了」的八种做法
这一节把各家的答案摆在一起。 从最省事到最严格排:
| # | 判据 | 代价 | 出处 |
|---|---|---|---|
| 1 | 这轮没点工具 | 上面那个洞 | (依据:本库摘录 · codex) |
| 2 | 抽象成一个只看「已经跑过哪些步」的纯函数,任一为真即停 | 判据本身要人写 | (依据:本库摘录 · vercel-ai-sdk) |
| 3 | 登记一个「完成工具」,它调这个工具就是收工 | 它可能忘了调 | (依据:本库摘录 · openmanus) |
| 4 | 同上,但不硬编码工具名,看工具的一个属性 | 同上 | (依据:本库摘录 · cline) |
| 5 | 忘了调就替它调——纯文字被兜底转成一次收工调用 | 掩盖了「它为什么没调」这个真问题 | (依据:本库摘录 · beeai-framework) |
| 6 | 环境在输出里认出一个哨兵字符串——完成不由它自称 | 要设计一个不会被误触的哨兵 | (依据:本库摘录 · mini-swe-agent) |
| 7 | 真去执行,拿执行结果当裁判,失败原因变成下一轮的输入 | 只适用于可重复、无副作用的动作 | (依据:本库摘录 · db-gpt) |
| 8 | 让另一个模型看着逐条证据签字,签字文件不存在就不准结束 | 每次收工多一次模型调用 | (依据:本库摘录 · webwright) |
第 7 条的做法值得展开,因为它换了一个思路:
别信模型,去执行,用执行结果当裁判。 那份资料的场景是让模型写数据库查询——它不问模型「你写得对吗」, 而是真的连库去跑。查不到数据、报错,都返回「失败 + 具体原因」, 这个原因就变成下一轮的输入。(依据:本库摘录 · db-gpt)
第 8 条更狠:想收工必须先把任务拆成可独立验证的条目、跑一遍留下逐条证据、 再让另一个模型看着证据签字(依据:本库摘录 · webwright)。
我们的原型选哪一种
选第 1 种。 理由:
- 第 3 到第 6 种都在补「它忘了说完成」的洞,而这个洞在短任务里几乎不出现;
- 第 7、8 种要一个外部裁判,而我们第一版的工具是查天气这类只读操作,没有可自动判定的成功信号;
- 第 1 种的洞,用下一节的硬闸兜住就够了。
但要记住这个洞的存在。 任务一变长,它就会浮出来。
4.3 三道硬闸:必须有,而且要分开数
上一节说完了「它说停」。这一节说「我们说停」。
硬闸 = 不管它怎么说,撞上就停的上限。
三道,各管一个维度:
| 闸 | 管什么 | 撞上了怎么办 |
|---|---|---|
| 轮数 | 转了多少圈 | 见下 |
| 时间 | 从开始到现在多久 | 停 |
| 窗口 | 那段文字有多长(按 token 数) | 见下 |
有一份实现三道齐全,而且数值都写得很清楚: 单题超 150 分钟停、可用的模型调用次数 100 次用光停、token 超过 11 万强制收尾 (依据:本库摘录 · tongyi-deepresearch)。
窗口那一道的做法特别值得抄,因为它不是「停」,是「逼它现在交卷」:
不粗暴截断历史,而是改写最后一条消息成一句强指令,再逼它立刻用现有信息给出答案。 这样答案仍然基于完整证据,只是不再允许继续查。 (依据:本库摘录 · tongyi-deepresearch)
而且那个阈值的选法有讲究:定在 11 万而不是窗口上限 12.8 万, 留了约 1.8 万给「最后这次回答」本身要占的地方。
这条要记成一句规矩:任何「快满了就收尾」的阈值,都必须给收尾动作本身留出预算。 不留的话,你会在触发收尾的那一刻超窗。
轮数那一道也有更体面的做法:
- 轮数用光不报错,而是摘掉工具再问最后一次(依据:本库摘录 · semantic-kernel)—— 摘掉工具它就只能用现有信息回答,不会又点一个;
- 预算耗尽时补一次「请总结」(依据:本库摘录 · hermes-agent)。
这条我们实测过,而且值得看数字。
没有它的时候,被闸门掐断的那几道题,用户拿到的是一片空白—— 不是「查不到」,是什么都没有,连一句解释都没有。
加上它之后(同一组题各跑三遍取平均):
| 平均分 | |
|---|---|
| 不做收尾 | 7.3 / 11 |
| 摘掉工具再问一次 | 8.0 / 11 |
(依据:本库实验 · 001-agent-loop/endgame-anthropic-1)
分数只涨了 0.7,但那不是它的全部价值——它治的是「用户拿到空白」, 而 11 道题里只有两三道会撞上闸门。真实产品里撞闸门的比例只会更高。
还有一条容易搞混的:两层重试要分开数。
| 层 | 是什么 | 为 什么要分开 |
|---|---|---|
| 网络层 | 网抖了,同一个请求再发一次 | 这不算「转了一圈」 |
| 循环层 | 带着失败原因重来一轮 | 这才算 |
有一份资料把这两层做成了两个独立的计数器 (依据:本库摘录 · hermes-agent); 另一份也明确区分了「问模型那一步自带三次网络重试」和外面那圈的循环重试 (依据:本库摘录 · db-gpt)。
混成一个计数器,「网抖了三次」就会被记成「跑了三轮」。
4.4 「停了」有十几种,记错一种就会做错决定
这一节是这一课最该抄的一条,而且它的成本只有一个字段。
先看反面教材。 有一份资料诚实地写下了自己的一个缺陷:
它把「步数耗尽」也标成了「完成」。(依据:本库摘录 · fara)
这为什么是个问题? 因为上层拿到「完成」之后会去做完成该做的事—— 展示结果、结束会话、记一笔成功。而实际上那次任务是被闸掐断的。
做得最彻底的一份,把出口做成了十三种,只有一种算正常:
| 类别 | 出口 |
|---|---|
| ✅ 正常(唯一) | 它不要工具了,给了文字 |
| 用户主动 | 循环顶部发现中断标志 / 接口调用期间被打断 |
| 异常 | 预算耗尽 / 跑满上限 / 空转守卫刹车 / 空响应重试用尽 |
| 降级 | 流被打断,把已吐出的部分当答复 / 这轮空,复用上一轮的文字 |
| 失败 | 重试用尽仍无响应 / 本地模型窗口撑不下工具 / 逼近上限时出异常 |
| 说明有漏网路径 | 未知 |
(依据:本库摘录 · hermes-agent)
最后一行是这套设计里最巧的地方:那个字段的初值就是「未知」。 谁写了一个新出口却忘了填,它就会暴露出来,而不是静默地混进某一类。
这条要抄,成本只有一个字符串字段。
其它几份也各自到了同一个结论:
| 出处 | 它的做法 |
|---|---|
| (依据:本库摘录 · acp-agent-client-protocol) | 一轮一定带一个「为什么停了」的枚举收尾,五种取值各有明确语义 |
| (依据:本库摘录 · cherry-studio) | 因为让步而停时,状态记成**「成功」而不是「暂停」**——它是被设计地主动停的 |
| (依据:本库摘录 · agentscope) | 停在半路等人时不发结束事件,前端据此知道这一轮没完、要留着 会话 |
| (依据:本库摘录 · kun) | 输出被截断时专门发一条警告说明「这是被截断了」,而不是当成干净完成 |
还有一条关于日志的判定很实用: 如果最后一条消息是工具结果、而且不是用户主动中断,就把日志级别升为警告 (依据:本库摘录 · hermes-agent)。
理由很直接:「历史以工具结果结尾」= 工具跑完了但没再问模型 = 一定有问题。 这正是「它干到一半就不动了」那个场景的机器可读特征。
4.5 循环该写在哪一层:这是唯一选错要重写的地方
先给这个词:
分层 = 把「跑一轮」和「一直转」拆成两块代码,让它们各自能被单独替换。
为什么这件事值得单独讲一节? 因为前面四节的东西都是「以后可以加」, 只有这一条是「以后要改就得推倒重来」。