跳到主要内容

第 4 课 · 一轮变很多轮:循环,和它什么时候停

读这一课前你需要会什么:读过第 1 到 3 课。 你需要知道一轮长什么样:发请求 → 它说要调工具 → 我们执行 → 结果按形状放回去 → 再发一次。 出现的每一个新词都会当场用大白话讲清。讲不清楚的地方就是我的问题,请直接标出来。

这一课结束时你会

  1. 默写出这个循环的骨架——它只有十几行;
  2. 说得出「什么时候停」为什么是这整件事的分界线,以及为什么它同时也是最危险的地方;
  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 种。 理由:

  1. 第 3 到第 6 种都在补「它忘了说完成」的洞,而这个洞在短任务里几乎不出现;
  2. 第 7、8 种要一个外部裁判,而我们第一版的工具是查天气这类只读操作,没有可自动判定的成功信号;
  3. 第 1 种的洞,用下一节的硬闸兜住就够了。

但要记住这个洞的存在。 任务一变长,它就会浮出来。

4.3 三道硬闸:必须有,而且要分开数

上一节说完了「它说停」。这一节说「我们说停」。

硬闸 = 不管它怎么说,撞上就停的上限。

三道,各管一个维度:

管什么撞上了怎么办
轮数转了多少圈见下
时间从开始到现在多久
窗口那段文字有多长(按 token 数)见下

有一份实现三道齐全,而且数值都写得很清楚: 单题超 150 分钟停、可用的模型调用次数 100 次用光停、token 超过 11 万强制收尾 (依据:本库摘录 · tongyi-deepresearch)。

窗口那一道的做法特别值得抄,因为它不是「停」,是「逼它现在交卷」:

不粗暴截断历史,而是改写最后一条消息成一句强指令,再逼它立刻用现有信息给出答案。 这样答案仍然基于完整证据,只是不再允许继续查。 (依据:本库摘录 · tongyi-deepresearch)

而且那个阈值的选法有讲究:定在 11 万而不是窗口上限 12.8 万, 留了约 1.8 万给「最后这次回答」本身要占的地方。

这条要记成一句规矩:任何「快满了就收尾」的阈值,都必须给收尾动作本身留出预算。 不留的话,你会在触发收尾的那一刻超窗。

轮数那一道也有更体面的做法:

这条我们实测过,而且值得看数字。

没有它的时候,被闸门掐断的那几道题,用户拿到的是一片空白—— 不是「查不到」,是什么都没有,连一句解释都没有。

加上它之后(同一组题各跑三遍取平均):

平均分
不做收尾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 循环该写在哪一层:这是唯一选错要重写的地方

先给这个词:

分层 = 把「跑一轮」和「一直转」拆成两块代码,让它们各自能被单独替换。

为什么这件事值得单独讲一节? 因为前面四节的东西都是「以后可以加」, 只有这一条是「以后要改就得推倒重来」。

各家怎么放的

做法状态在哪关掉能接着跑吗出处
循环自己拿着,全部塞进一个函数循环里不能(依据:本库摘录 · openmanus)
循环本身无状态,每轮由外层新建一个外层不能(依据:本库摘录 · cline)
循环刻意不拥有会话、传输、权限界面、压缩,全靠回调借用上一层不能(依据:本库摘录 · kimi-code)
决策做成一个只读状态的函数,返回「推理 / 行动 / 收工」三选一写进消息里(依据:本库摘录 · agentscope)
大脑只返回可序列化的指令,引擎只负责执行一份可存盘的状态能,而且能换执行环境(依据:本库摘录 · lobehub)
一台完全不做输入输出、可以整个存盘的状态机拥有全部决策状态机里(依据:本库摘录 · rig)
历史在服务端,客户端只发增量服务端能,换台机器也行(依据:本库摘录 · letta-code)
核心里根本没有转圈的循环,转圈的职责交给入口入口层不能(依据:本库摘录 · qwen-code)
只留一条只能追加的事件日志当唯一事实,消息列表是从它算出来的事件日志(依据:本库摘录 · deepseek-harness)
双层循环:内层管「还有工具要跑」,外层管「本该停了但有人排队追问」分层不能(依据:本库摘录 · pi)
一批一批跑、每批末尾一道同步栅栏,用版本号同时解决「跑谁 / 到哪 / 崩了从哪续」版本号 + 存档点(依据:本库摘录 · langgraph)

三条能带走的判断

① 「跑一轮」应该是一个不做任何多余事的函数。

最彻底的一份把它列成了一张带理由的表——循环不拥有会话、不拥有传输、不拥有权限界面、 不拥有压缩的执行(依据:本库摘录 · kimi-code)。 它的说法:循环只提供「现在可以安全地改历史」的时机,不决定压什么。

② 「转圈」这件事可以不在核心里。

有一份的做法最反直觉:核心跑完一轮就返回,「把结果回灌再问一次」的职责交给入口层。 理由很实在——界面要在工具跑到一半时显示进度、要等用户点确认、要允许中途插话, 这些都是界面的事;把循环留在界面层,核心就不必知道有没有人在看屏幕 (依据:本库摘录 · qwen-code)。

③ 想让它「关掉再开还能接着跑」,状态就必须在函数外面。

上表里能做到这一点的几家,共同点是状态被显式地放进了一个能存盘的东西里—— 消息、指令、状态机、事件日志、服务端。 做不到的几家,共同点是状态散在函数的局部变量里。

我们的原型怎么定

核心是一个纯函数「跑一轮」,转圈写在外面。

理由:这是六个决定里唯一一个「以后改要推倒重来」的。 其余五个(并发、压缩、审批、 方言、服务端)全都是「以后再加」。

判断(无锚): 第一版不必真做到「能存盘续跑」——那要求把所有状态塞进消息里, 是额外的约束。但「跑一轮」和「转圈」必须先分开,因为分层这件事以后加不了。 如果错,会错在: 如果第一版从头到尾只跑一个固定任务、跑完就丢, 那分层是纯负担,一个二十行的循环更快看到结果。判据是:你打不打算给它接界面。


4.6 第 2 课说的那个「先想一想」,在这段代码里是哪一行

第 2 课讲思维链时说过:这个循环每一轮的第一件事是它先想一想。 但你回头看主走查,找不到那一步。 这一节还这笔账。

答案是:那一步存在,但它不是我们写的代码,是它自己写的一段字。

回看主走查第 1 圈,它回来的东西里有一个字段是空的:

为什么停:要调工具
正文: (空) ← 这里
调用: call_001 → get_weather{"city": "北京"}

如果我们在系统提示里要求它「调工具前先说一句你为什么要调」,那个「正文」就不空了:

正文: 要比较两个城市,得先分别查到两边的温度。先查北京。
调用: call_001 → get_weather{"city": "北京"}

这段字就是第 2 课那张草稿纸。 它被追加进消息列表,下一圈它自己看得见。

各家把这段字放在哪儿,做法不同:

做法出处
专门给它一对标签,想的内容写在标签之间(依据:本库摘录 · tongyi-deepresearch)
标签是加进词表的真 token,不是普通字符串(依据:本库摘录 · deepanalyze)
想的内容对用户可见、对模型不可见——省下这段的重发成本(依据:本库摘录 · goose)

最后那一行是个真实的取舍:

留着扔掉
下一圈它看得见自己的思路省钱、省地方
但每一圈都要重发一遍但它下一圈得重新想一次

我们的原型什么都不做:那段字自然留在消息列表里。 短任务里它值那点钱;等对话长到要省地方了,第 6 课那套办法才用得上。


5. 各家的分歧:两处

分歧一:「一轮」到底指多大一段

这个词各家指的不是同一个东西,读资料时很容易被绕进去。

有一份把三层拆得最清楚(依据:本库摘录 · qwen-code):

说法指什么边界在哪
一次往返一次请求 + 一条回复唯一和网络请求一一对应的那层
一个回合一次往返 + 它引出的工具执行工具跑完、结果回填为止
一次交互用户敲一句话 → 它彻底停下可能包含几十次往返

另一份分成三层的叫法不同——回合 / 步 / 批次,而且各对应一个函数边界; 多出来的「批次」那一层,正是「一次模型调用点了好几个工具」的那一层 (依据:本库摘录 · kimi-code)。

我们这门课里的「一轮」,指的是上表第二行:一次往返 + 它引出的工具执行。 主走查里那三圈,就是三个「一轮」。

顺带一个坑:有一份实现用「历史比开始时长了多少条」来数轮数 (依据:本库摘录 · ai-agent-kai-fa-shi-zhan)。 这会失真——一轮如果点了三个工具,历史一次就多四条,等于一轮顶四轮。

分歧二:计划要不要先做出来再执行

我们这个循环是「每一步只决定下一步」。还有一条路是「先出完整计划,验过再执行」。

那条路的动机很具体:

如果它生成了一个一千步的计划,最终却做不成呢?没人盯着的话, 它可能花上几小时执行,浪费大量时间和调用成本,最后你才发现毫无进展。 (依据:本库摘录 · ai-engineering)

验计划的办法也很朴素:计划里有它没权限用的动作就否掉、步数超过阈值就否掉, 或者让另一个模型评一评。

有一份资料把规划按「什么时候定计划」分成三种(依据:本库摘录 · building-applications-with-ai-agents):

种类什么时候定
一次定完生成候选序列、评估、选最优
环境变了就改动态调整
分阶段定,拿到上一步结果再定下一步这就是我们选的那种

它给第三种的适用条件正好对上我们的场景:开始时对任务或环境没有完整认识。

那两条验计划的规则,在我们这条路上等价于: 「这一步要调的工具存在吗」和「已经走了多少步」——也就是 §4.2 的查表和 §4.3 的轮数闸。 换句话说:先验证再执行这件事,在逐步模式下等于「每一步都过一遍闸」。


6. 动手:那个循环,以及三个亲手改一行的实验

完整的能跑的代码在 prototypes/agent-product/src/loop.mjs 这一节只摘出与本课对应的那几段,不另写一份——理由见第 3 课 §6 那段说明。

骨架:和 §2 那张图一一对应

while (turn < maxTurns) {
turn += 1
if (Date.now() > deadline) { stopReason = `wall_clock_exceeded(…)`; break } // ① 时间闸

const r = await provider.runOneTurn({, messages, tools: toolSchemas }) // 问模型
messages.push(...provider.assistantMessages(r.assistantContent)) // 它说的话放回去

if (r.toolUses.length === 0) { // ② 岔路口:它点工具了吗
stopReason = `text_response(${r.stopReason})`
finalText = r.text
break // 没点 → 这就是最终答案
}

for (const call of r.toolUses) {} // 点了 → 逐个执行(§5 分歧一:不做并发)
messages.push(...provider.toolResultMessages(results)) // ③ 结果先回填
if (整轮全被熔断) { stopReason = "all_tools_circuit_broken"; break } // ④ 再判断
}
if (stopReason === "unknown" && turn >= maxTurns) stopReason = `max_turns_reached(…)` // ⑤ 轮数闸

注意 ③ 和 ④ 的顺序。 原来这两段是反的,而那是个 bug——详见 §7 最后一条。

跑起来

cd prototypes/agent-product
node src/run.mjs "北京和上海哪个更暖和?"

你会看到 §3 主走查那两圈,数字和讲义里的一致。

三个亲手改一行的实验

#改哪儿你会看到它验证的是
1loop.mjsmaxTurns = 8 改成 1它只查了一个城市就被掐断,而「为什么停」变成 max_turns_reached(1)§4.4——异常停有自己的名字,没混进正常停
2world.mjs 里把上海那条天气数据删掉它拿到「查不到」,然后自己想办法第 3 课 §4.4——错误喂回去比抛异常有用
3tools.mjs 里加第四个工具,但一行循环代码都不改它自己会用这就是这门课要的效果:加工具不动循环

第 3 条是这个课题一开始就定下的验收标准。

第四个实验:换一家厂商,看循环要不要改

node src/run.mjs "北京和上海哪个更暖和?" --provider openai
node src/run.mjs "北京和上海哪个更暖和?" --provider responses

三条完全不同的接口,同一段循环代码。

这不是我事后美化的说法,是实测: 加第二家时循环那层改了 4 行(全是「把写死的换成传进来的」), 加第三家时改了 1 行,而「还要不要再来一轮」「什么时候熔断」这些判断,一个字没动 (依据:本库实验 · 001-agent-loop/baseline-openai-1)。

那 1 行还不是格式的锅,是我接口设计得不够好:我假设「它一轮说的话」是一条消息, 而第三家一轮会吐好几块。

能带走的:抽象一个接口时,单数复数要按最复杂的那家定。 按最简单的定,加第三家时就得回头改所有实现。


7. 可带走的

  1. agent 的骨架就是十几行:问模型 → 它点工具就执行、回填、再来一圈 → 不点就是答案。
  2. 这一行最重要的一句定性:唯一真正由它决定的,是「还要不要再来一轮」。 停止条件写死了,它就退化成流水线,不是 agent。
  3. 「它不点工具了」这条判据的准确说法是「它认为自己做完了」,不是「它做完了」。 有资料专门给这种情况起了名字:它确信自己完成了,实际没有。
  4. 判定「干完了」一共八种做法,从「不点工具就停」到「让另一个模型看着证据签字」。 短任务用第一种就够,长任务这个洞会浮出来。
  5. 三道硬闸必须有:轮数、时间、窗口。 窗口那道的阈值要给「最后这次回答」留出预算——不留就会在触发的那一刻超窗。
  6. 轮数用光不必报错——摘掉工具再问最后一次,或者补一句「请总结」,比直接掐断体面。
  7. 两层重试要分开数:网络抖了不算转了一圈。
  8. 「停了」有十几种。给每个出口一个名字,而且那个字段的初值要是「未知」—— 谁忘了填就会暴露出来。成本只有一个字段。
  9. 一条免费的健康检查:历史以工具结果结尾 = 工具跑完了没再问模型 = 一定有问题。
  10. 「跑一轮」和「转圈」要分成两块代码。 这是六个决定里唯一一个选错要推倒重来的; 其余五个都是「以后再加」。 实测:换三家完全不同的接口,循环那层一共改了 5 行,判断逻辑 0 行 (依据:本库实验 · 001-agent-loop/baseline-openai-1)。
  11. 想「关掉再开接着跑」,状态就得显式放进能存盘的东西里,不能散在局部变量里。
  12. 结果要先回填,再判断跳不跳出——顺序不能反。 这一条是我们自己踩出来的,下面单说。

最后一条:一个不报错的 bug,以及它为什么等到很久以后才炸

这段代码最早写成这样(顺序是反的):

if (整轮全被熔断) { stopReason = "all_tools_circuit_broken"; break } // 先跳出
messages.push(...provider.toolResultMessages(results)) // 再回填 ← 到不了这一行

熔断跳出时,那一轮的结果没被回填。于是历史里留下一条「有调用、没结果」的孤儿。

这正是第 3 课 §4.5 那条七份资料共同强调的不变量,而我在自己的代码里踩了。

它为什么一直没被发现: 熔断之后循环就结束了,再也不发请求,那份坏历史没人用。 程序不报错,测试也过。

它是什么时候炸的: 后来我加了一个功能——被掐断时摘掉工具再问一次,给用户一句人话。 那一次请求要用那份历史,接口当场拒收:

tool_use ids were found without tool_result blocks

能带走的:「历史坏了」这类 bug 不会当场报错。 它要等到有人真去用那份历史时才炸,而那可能是几个月以后、另一个人加功能的时候。 所以「每走完一步,历史都是合法的」应该是一条不变量,不是一个整洁习惯。


8. 下一课,以及我需要你的反馈

第 4 课的循环能跑了,但它很脆。

第 5 课讲它会怎么跑偏,以及一条各家意见完全相反的分岔:

它犯了错,那个错该不该让它看见?

一派说:写进历史,让它自己改——这是第 3 课那条共识。 另一派说:把这一轮整个抹掉,当没发生过——因为有些错误让它看见了,它会学坏。

两派都有生产系统在用。


读完请标出三种地方:

标什么我会怎么改
哪一段读不下去那一段拆成更小的台阶重写
哪个词没解释清楚换一种解释法,或者干脆不用那个词
哪里嫌啰嗦删掉