最小 Agent 循环:二十行代码,和三个都会失效的终止条件

Agent 循环与工具调用第 1 / 2 篇

本文的每个数字都是跑出来的。 环境是 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 那个节点的题目,本站刻意留空, 因为我还没写出它的可实跑验证手段。