
一次工具调用跑到步数上限才停,日志里同一个响应出现了七次。循环体每次都在等一个「完成」信号,而那个信号在那一轮根本不会出现。
循环的终止条件通常写成三种样子:正文非空就算得到答案、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} 步仍然没有收尾`);
改动只有两处:进循环先看终止原因,异常原因直接抛错,不进下一轮;出循环用工具调用作为唯一的分岔依据。跑满上限抛错而不是返回最后一段正文,这一点容易省,省掉之后空转的那次运行会以「返回了空字符串」的形式静默通过。
上限和重试是两件事
跑满上限说明这一轮没有收敛,和网络重试不是一类问题。原地重试只会把钱再花一遍,能做的判断是:被截断就把上限抬高,内容被过滤就改输入,工具一直失败就去看工具那一侧。这三种都不该走同一个重试循环。
模型那一侧用的是写死的桩响应,没有调用任何真实模型接口,所以真实模型在这几种情况下会不会返回这样的字段组合,我没实测。上面这些结论只覆盖「拿到这样的响应时循环会怎么走」这一半,判据本身的正确性不依赖于模型行为,异常轮出现时能停对地方才是重点。