最小 Agent 循环:二十行代码,和三个都会失效的终止条件
本文的每个数字都是跑出来的。 环境是 Ollama
0.33.2+qwen2.5:7b-instruct-q4_K_M(本地,离线,不需要 key), 验证代码在仓库experiments/agent-loop/(run-loop.mjs的 A/B/C/B2 段)。 ⚠️ 用本地 7B 是为了测机制。凡是形如「N/30」的数只对这个模型这个任务成立, 但「哪种终止条件会怎么失效」是控制流的性质,与模型多聪明无关。
Agent 循环的骨架短到可以背下来:问模型 → 它要调工具就调 → 把结果塞回去 → 再问。 真正花时间的不是写它,是决定什么时候停。
一、循环本体
export async function agentLoop({ model, messages, tools, exec, maxRounds, stopper, hardStop }) {
const trace = [];
for (let round = 1; ; round++) {
if (maxRounds !== null && round > maxRounds) return { stop: 'cap', rounds: round - 1, trace };
if (round > hardStop) return { stop: 'hard-stop', rounds: round - 1, trace };
const msg = await chat({ model, messages, tools, temperature });
messages.push(msg);
const byModel = stopper.afterModel(msg);
if (byModel) return { stop: byModel, rounds: round, trace, answer: msg.content };
if (!msg.tool_calls?.length) { // 不调工具也没触发终止 → 空转
messages.push({ role: 'user', content: '继续。任务完成了就说 TASK_DONE。' });
continue;
}
for (const c of msg.tool_calls) {
const out = await exec(c, trace);
trace.push({ round, name: c.function.name, args: c.function.arguments, out });
messages.push({ role: 'tool', tool_call_id: c.id, content: out });
}
const byTools = stopper.afterTools(trace);
if (byTools) return { stop: byTools, rounds: round, trace };
}
}
两个地方值得指出来:
stopper是参数,不是写死的if。 三种终止条件是本文第三节的被测对象, 写死了就没法比较。maxRounds和hardStop是两个东西。 前者是被测的保护,传null就是“没有上限”; 后者是实验自己的安全阀。⭐ 不给安全阀的话,「测死循环」这件事自己会挂在死循环里 —— 与判题沙箱那篇的负例同一条原则: 即使被测的保护完全失效,最坏后果也要有界。
任务是一个内存里的小目录:list_files() 只返回文件名不返回行数,
file_lines(name) 返回行数。问「哪个文件行数最多,它有多少行」。
不给行数是有意的 —— 给了就一次调用答完,那不是多步任务。
二、轮数分布,以及两条必须分开的判定
跑 30 次(上限 12 轮,终止条件用最朴素的「没有工具调用就停」):
轮数分布(中位数 3,最少 2,最多 5)
2 轮 █████████ 9
3 轮 ██████████████████ 18
4 轮 ██ 2
5 轮 █ 1
理想路径正好是 3 轮:列目录 → 并行量 3 个文件 → 回答。18 次走的是这条。
(一轮里可以有多个 tool_calls,所以量 3 个文件不需要 3 轮。)
那 9 次两轮的呢?两轮意味着它没走完 —— 于是要看第二条判定:
| 判定 | 结果 |
|---|---|
答案正确(文本里同时含 data.csv 和 37) |
16/30 |
| 过程完整(真的量过全部 3 个文件) | 15/30 |
| 🚨 答案对但过程不全 | 1/30 |
⭐ 这两条判定要分开报。 只看答案的话,
「从文件名猜出 data.csv 听着像最大的那个、一个文件都没量」会被记成成功。
这一轮里它真的发生了 1 次。
1/30 不多,但它说明这个判定有能力分辨这件事 —— 而只报“成功率 16/30”的评测,连有没有这个现象都看不见。 这和沙箱负例那篇里 「失败的原因必须是你想测的那一个」是同一条。
三、三种终止条件,各有各的失效方式
noToolCall—— 模型不再调工具就算完事。最朴素。marker—— 模型自己说出TASK_DONE才算完事。toolSignal—— 由工具层判定必要数据是否已拿到,与模型说什么无关。
各跑 10 次:
| 终止条件 | 上限 | 误停 | 不停 | 撞安全阀 | 答案正确 |
|---|---|---|---|---|---|
noToolCall |
12 | 4 | 0 | 0 | 8 |
noToolCall |
无 | 6 | 0 | 0 | 5 |
marker |
12 | 2 | 0 | 0 | 8 |
marker |
无 | 3 | 0 | 0 | 9 |
toolSignal |
12 | 0 | 1 | 0 | 1 |
toolSignal |
无 | 0 | 3 | 3 | 2 |
(误停 = 循环自己认为结束了,但必要数据没拿全;不停 = 靠上限或安全阀拦下来的。
⚠️ N=10,这些数抖动 ±1~2 —— 同样的格子另一轮跑出 noToolCall/12 是误停 3。
能看的是列之间的差,不是单个格子的值。)
noToolCall误停最多(4/10、6/10):模型一停手,循环就认为完事, 而它可能只量了一个文件。toolSignal误停恒为 0 —— 按定义如此,它要的数据没齐就不让停。 代价是 1~3/10 不停:模型不干活,信号就永远不触发。
但 toolSignal 那两行的「答案正确」只有 1/10 和 2/10。看到这个数我准备写
「工具信号虽然不误停但效果最差」。那是错的。
四、1/10 → 9/10:那个数是我的实现缺陷
第一版的工具信号,在「数据齐了」那一刻就 return 掉了整个循环。
于是模型根本没机会说出答案 —— 判定去找答案文本,什么都找不到。
加一次收尾调用(数据齐了之后再问一次,这一次不给工具,否则它可能又去调):
if (finalTurn && res.stop === 'tool-signal') {
messages.push({ role: 'user', content: '数据已经齐了,直接回答问题。' });
const msg = await chat({ model: MODEL, messages, tools: undefined, temperature: TEMP });
res.answer = msg.content;
}
| 答案正确(上限 12) | |
|---|---|
toolSignal |
1/10 |
toolSignal+final |
9/10 |
⭐ 「停止调工具」和「结束任务」是两件事。 把它们当成一件, 代价是 1/10 对 9/10 —— 而修之前,表格看起来只是在说“这个终止条件效果差”。 它不报错、不崩、不缺数据,就是那一列数字小一点。
修完之后 toolSignal+final 是三者里唯一同时做到
「误停 0」和「答案正确 9/10」的。
五、判据:没有上限时,6/40 次跑到了 40 轮
stub 给这一篇定的判据是:
轮数上限不是保险,是必需项。要给出「没有上限时死循环发生的频率」。
上面那张表的「无上限」各行加起来:6/40 撞上了 40 轮的安全阀。 判据成立 —— 没有上限,这 6 次会一直转下去。
但这里有个更有意思的问题:那 6 次为什么会转不停? 在可完成的任务上,模型通常自己就收手了。所以我换了一个任务重跑 —— 问一个不存在的文件有多少行,其余全不动:
| 终止条件 | 撞安全阀(40 轮) | 自己停下 | 停下时轮数中位数 |
|---|---|---|---|
noToolCall |
0 | 10 | 2 |
marker |
0 | 10 | 2 |
toolSignal |
9 | 1 | 11 |
我在写这一段之前的预测是:marker 会永不停止 ——
因为它把终止信号绑在了「模型说完成」上,而任务失败时模型不会说完成。
预测错了。10/10 都自己停了,中位数 2 轮。
模型认为「确定这个文件不存在」也算完成任务,照样说 TASK_DONE。
⇒ 「完成」在模型那里不等于「成功」,而我把这两个词当成了一个。
真正转不停的是 toolSignal,9/10。原因要说清楚:这一段故意沿用了
第二节那个完成判据(“量过全部 3 个文件”),而现在问的是 config.yaml ——
工具信号用的是一个和当前任务不匹配的写死判据。
这不是 bug,正是要测的东西:
⭐ 工具信号把「什么算完成」硬编码进了代码,任务一变它就可能永不触发。
它和 marker 的弱点互为镜像 —— 一个信任模型的判断,一个信任写死的判断,
各有各的失效方式。而轮数上限对两者都有效,因为它不依赖“什么算完成”这个概念。
这就是判据那句话的真正含义:上限不是“万一出错的保险”, 它是唯一一个不需要知道任务是什么就能生效的保护。
六、一个会让第二节整张表失去意义的量具坑
上面全程 temperature = 0.7,不是 0。这不是随手选的。
temperature=0 是贪心解码。同一个 prompt 跑 5 次:
轮数:中位数 5,最少 5,最多 5
轮数分布:
5 轮 █████ 5
五次全同,分布塌成一根柱子,而脚本一切正常、表格照样打印。 「轮数分布」在 T=0 下不含任何信息 —— 它是同一次结果的 5 个副本。
📌 这一条是单独量过的,没有从上一组实验搬。 那边验的是「输出文本逐字节相同」,这边要的是「轮数也塌缩」—— 文本相同蕴含轮数相同,但我不想靠推理链拿结论,那条实验的样本量和任务都不一样。
本文没有回答的三个问题:
一是 前沿模型的轮数分布。第二节那 16/30 是本地 7B 的成绩, 更强的模型会把这个数推高,也可能把
noToolCall的误停率压到 0 —— 那样第三节的比较就没有区分度了。这一节最值得在你自己的模型上重跑, 脚本换个LAB_MODEL就行。二是 并行工具调用的取舍。这里一轮里可以有多个
tool_calls, 而“并行量 3 个文件”和“分 3 轮量”在成本上差很多。本文只记录了轮数, 没有比较这两种形态的差异。三是 上下文增长。每一轮都往
messages里追加,长任务会撞上窗口 —— 而那正是context-compaction那个节点的题目,本站刻意留空, 因为我还没写出它的可实跑验证手段。