侧边栏壁纸
博主头像
一笑痕

人生若只如初见,
是可喜亦或者是可悲?

  • 累计撰写 141 篇文章
  • 累计收到 7 条评论

循环的终止判据写错,Agent 会把同一个响应跑满上限

2026-9-28 / 0 评论 / 9 阅读

循环的终止判据写错,Agent 会把同一个响应跑满上限

一次工具调用跑到步数上限才停,日志里同一个响应出现了七次。循环体每次都在等一个「完成」信号,而那个信号在那一轮根本不会出现。

循环的终止条件通常写成三种样子:正文非空就算得到答案、finish_reason 是 stop 才算结束、响应里没有工具调用就算结束。把这三段条件放进同一个循环,喂四段桩序列,步数和实际执行的工具都能对上。

== 1. 每个场景 × 三种判据 ==
  场景                    按 content 判        按 finish_reason 判   按 tool_calls 判
  工具→工具→回答               3 步  工具[read_file count_lines]        3 步  工具[read_file count_lines]        3 步  工具[read_file count_lines]
  带话的工具调用轮               1 步  工具[read_file]                    2 步  工具[read_file]                    2 步  工具[read_file]
  被 max_tokens 截断        8 步 (撞上限)  工具[]                       8 步 (撞上限)  工具[]                       1 步  工具[]
  只说话不调工具                1 步  工具[]                             1 步  工具[]                             1 步  工具[]

表里最右一列是那一轮结束后已经执行的工具。三种判据在前两个场景上完全一致,分岔出现在后两个场景上。

正常序列上三种判据看不出区别

第一段桩序列是标准形状:两轮工具调用,每轮 content 分别是 null 和空串,第三轮 finish_reason 是 stop 并带回答案。三种判据都是 3 步,工具也都执行了 read_file 和 count_lines。

这也是这类 bug 难查的原因:写完之后拿一个正常的对话试一遍,三种写法结果一样,全都通过。分岔要在异常轮上才出现。

工程上的连带影响是重试会放大它。空转的那 7 次请求都算在账单里,上下文也随每一轮增长,一轮空转的日志看上去和一次正常的多次工具调用几乎一样,只有把「同一轮响应出现了几次」拿出来数才分得清。判断依据是响应内容和上一轮完全相同,接口调用次数却涨了。生产环境里更常见的表现是任务卡住、费用上涨,而不是抛错。

带话的工具调用轮:正文非空不等于活干完了

有些模型会在一轮里同时给正文和工具调用,正文写的是「我先打开文件看看」,工具调用才是要做的事。

== 4. 带话的工具调用轮:正文非空但活没干 ==
  第一轮 content="我先打开文件看看",同时带着 1 个工具调用
  按 content 判:1 步收工,执行的工具 [read_file]

按正文非空判断的那一版在第 1 步就收工了。工具确实执行了,但结果没有机会回填给模型,这一轮的工具调用等于白做,用户看到的是「我先打开文件看看」这句话。按 finish_reason 和按工具调用长度判断的两版都跑满 2 步,第二轮的答案里才有工具返回的内容。

max_tokens 截断那一轮:两版空转,一版提前收工

最容易空转的是被长度截断的那一轮:content 为 null,tool_calls 是空数组,finish_reason 是 length。

== 3. 把「只有工具调用、没有正文」当成继续,是最常见的写法 ==
  截断那一轮:content=null finish_reason=length tool_calls=0
  按 content 判:8 步,等于把同一个桩响应反复问了 7 次
  按 tool_calls 判:1 步就收工,但收工的理由是空回答

按正文判断的版本在这一轮永远等不到非空正文,按 finish_reason 判断的版本也等不到 stop,两者都把同一个响应反复问了 7 次,直到撞上步数上限。按工具调用长度判断的版本 1 步就收工,收工理由是「没有工具调用」,等于把一次空回答当成了最终答案。三种写法都有代价:前两种多花 7 次请求,后一种把截断当成功。

正确的组合是把两件事分开:工具调用决定要不要继续跑,finish_reason 决定这次是怎么结束的。

const MAX_STEPS = 8;
for (let step = 1; step <= MAX_STEPS; step++) {
  const res = await model(messages);
  const calls = res.tool_calls ?? [];

  if (res.finish_reason === 'length') throw new Error('输出被长度截断,重试时提高上限');
  if (res.finish_reason === 'content_filter') throw new Error('内容被过滤,不要原地重试');

  messages.push(res);
  if (calls.length === 0) return res.content ?? '';   // 真的没有活要干了

  for (const c of calls) {
    messages.push({ role: 'tool', tool_call_id: c.id, content: await run(c) });
  }
}
throw new Error(`跑满 ${MAX_STEPS} 步仍然没有收尾`);

改动只有两处:进循环先看终止原因,异常原因直接抛错,不进下一轮;出循环用工具调用作为唯一的分岔依据。跑满上限抛错而不是返回最后一段正文,这一点容易省,省掉之后空转的那次运行会以「返回了空字符串」的形式静默通过。

上限和重试是两件事

跑满上限说明这一轮没有收敛,和网络重试不是一类问题。原地重试只会把钱再花一遍,能做的判断是:被截断就把上限抬高,内容被过滤就改输入,工具一直失败就去看工具那一侧。这三种都不该走同一个重试循环。

模型那一侧用的是写死的桩响应,没有调用任何真实模型接口,所以真实模型在这几种情况下会不会返回这样的字段组合,我没实测。上面这些结论只覆盖「拿到这样的响应时循环会怎么走」这一半,判据本身的正确性不依赖于模型行为,异常轮出现时能停对地方才是重点。

    🤞 分享