「没有工具调用就停」够不够?
本课一问:第 2 课的循环只有一个出口:回复里没有工具调用,就当模型写完了、停下。这个判断够不够?
这一课先不碰:模型一直调工具、就是不停,要不要设上限(第 4 课);「模型觉得做完了」和「真的做完了」是不是一回事(岔路 B3);上下文满了导致的退出(A5)。
从已有出发
每一轮,模型的回复都带着一个 finish_reason(Anthropic 叫 stop_reason),说明它为什么停止生成:stop(写完了)、tool_calls(要调工具)、length(撞上输出上限被截断)……第 2 课的循环没看它,只看消息体里有没有调用。
先自己设计:if not tool_calls: break
CoreCoder、learn-claude-code 的 s01、本课项目的第一版都是这么写的:没有工具调用 → 返回。它简单,而且不信任 finish_reason——按说是个优点。
- ① 朴素方案
- ② 它在哪失败
- ③ 改进方案
下面是贯穿任务的一次真实运行(harness v2 · 关闭思考):第 7、8、9 轮都是服务端丢了调用。v1 的谓词会在第 7 轮直接停下。
数据:本课项目 miniagent 的真实运行(src/data/project/agent-traces.json,2026-09-26 导出;模型 Qwen/Qwen3-8B-MLX-4bit,本机 mlx_lm.server)。token 数取自服务端 usage;思考截取约 600 字、正文与工具输出约 900 字(保留头尾)。耗时受本机同时运行的其他任务影响,只能粗看;token 与轮数才可比。
「没有工具调用」至少有三种来历,只有一种是「写完了」:
| 回复里没有调用,而且… | 其实是 | 真实运行里 |
|---|---|---|
finish_reason = stop | 模型写完了(至少它这么认为) | 常态 |
finish_reason = tool_calls | 模型要调工具,调用被服务端解析器丢了 | 7 次 |
finish_reason = length | 撞上输出上限,话没说完 | 19 次 |
所以要两边都看:消息体里有调用就执行(哪怕 finish 说 stop);消息体里没有调用,还要看 finish 是不是正常收尾,不正常就走各自的补救路径(提醒重发、请它接着写),补救有次数上限,用完了再以一个写明原因的退出码停下。
finish_reason = tool_calls 却一个调用也没给(参数 JSON 不合法被丢弃),v1 在其中 3 次运行里把它当成「写完了」停下;另有 19 次回复写满输出上限被截断、正文为空。回头重放贯穿任务的 20 次运行:只按「没有工具调用就停」,有 5 次会比实际更早停下,原本通过的 6 次里 3 次变成没通过(pd-promptjson-s1 第 5 轮 length;pd-v2-layered-s1 第 4 轮 length;pd-v2-promptjson-s1 第 3 轮 length)。 改进:八个循环,同一批事件
生产循环各有各的判法。下面把同一条事件序列交给 8 个循环:一个只信 finish_reason 的朴素谓词(构造的,不是任何一家的代码)、6 个 pin 源码,加上本课项目。三条序列取自真实 trace,其余是构造的边界情况。
真实序列取自本课项目 miniagent 的 trace(Qwen3-8B-MLX-4bit,录制于 2026-09-26);构造序列只用来检验谓词,标「构造事件序列 · 真谓词」。每格的判定由浏览器里的 JS 移植现算,并与生成脚本在 pin 源码上跑出的期望值比对(a01-termination.json,2026-09-26)。源码链接需联网。
试三件事:① 在「服务端丢掉了工具调用」上一步步走到第 7 列:先猜每一行会怎么做——停下的有 4 台,把它当「写完了」;OpenCode 原样重发;Hermes 和本课项目加一条提醒;② 切到「报 stop,消息里却有工具调用」:只信 finish_reason 的朴素谓词把调用扔掉了,其余 7 台都照常执行——看消息体,正是 if not tool_calls 的优点;③ 切到「finish 报 unknown」:同一件事,OpenCode 继续、Pi 当成错误退出、其余当成写完了。点任一格,看它依据的是哪几行源码。
右上角的「一致」是页面加载时现算的:浏览器里的 8 个谓词移植,对 10 条序列 × 8 个循环共 80 组逐格比对期望值。期望值的来源分三种,每一行都标着:CoreCoder、learn-claude-code、Pi 与本课项目是原文件实跑(假模型按剧本回复、工具只记录不执行);OpenCode 与 Hermes 的判定条件是从 pin 源码逐字切出来求值;Codex 是 Rust,本机没有编译器,是按源码逻辑复刻。本课项目那一行在三条真实序列上的停止原因与提醒轮次,和录制时的 trace 逐条一致 证据等级 · 实验验证 由对比实验得出的经验结论,只在出处写明的模型、数据和规模下成立。换了条件可能不再成立,引用时要带上实验设置。 本条gen_a01_termination.py 的 trace_checks:real-normal / real-dropped / real-length 三条均一致(停止原因与提醒轮次)。 出处待补 。
OpenCode 的退出条件是这张表里最完整的一种。逐项拆开:
另外两种补救值得对照着看:
- Pi 的截断防护:回复因
length停止、消息里又有工具调用时,整批调用一个都不执行,每个都回一条「参数可能被截断,请重发」的错误(agent-loop.ts:208-213→:381-406)。因为流式解析器会把残缺的 JSON 补全成「能解析、但悄悄少了一截」的参数,执行它比不执行更危险。但它只管工具调用:截断的回复里没有调用时,Pi 照样停下。 - Hermes 的丢弃恢复:
finish_reason = tool_calls却没有调用时,追加一句「你说要调工具,却没给出调用」再请求,最多连续 3 次(conversation_loop.py:7385-7414);代码注释说这是在 GitHub Copilot 上的 Claude 模型身上观察到的。本课项目 v2 的做法几乎一样(最多 2 次),真实运行 pd-v2-nothink-s1 里提醒 2 次仍无效——补救也会失败,所以最后要以dropped_tool_call这个明确的原因停下,而不是假装写完了。
停止原因要能数得清
「为什么停」本身也要写下来。各家都把退出原因做成了可枚举的值:
| 循环 | 退出原因 | 出处 |
|---|---|---|
| 本课项目 miniagent | no_tool_calls · max_rounds · wall_clock · length · dropped_tool_call · model_error,共 6 种 | 04-实践项目/miniagent/loop.py |
| Claude Agent SDK | 结果子类型 success · error_max_turns · error_max_budget_usd · error_during_execution · error_max_structured_output_retries;另带 stop_reason(end_turn / max_tokens / refusal…) | 官方 agent-loop 文档(2026-09-26 核对) |
| Hermes | pin 上 conversation_loop.py 里 15 种 _turn_exit_reason,turn_finalizer.py 另有 max_iterations_reached;最新版增到 25 种 | pin 56dc01d9;勘误表 H1 |
| Codex | 没有枚举字段:turn 以 break / Err 结束(5 个 break、2 个 Err),错误作为事件发给用户 | turn.rs:278-558 |
26 次真实运行里,模型自己停下(no_tool_calls)的 21 次中有 13 次验收没通过 证据等级 · 实验验证 由对比实验得出的经验结论,只在出处写明的模型、数据和规模下成立。换了条件可能不再成立,引用时要带上实验设置。 本条项目实测:harness 在循环结束后自己跑测试,并检查测试文件没被改过;模型的最后一句话不算。 出处待补 。退出原因只说明循环为什么停,不说明任务做没做成——那是岔路 B3 的问题。
它是 provider 对「为什么停止生成」的一个标签,不是对「任务状态」的判断,而且会和消息体不一致:本课项目的真实运行里,tool_calls 配空调用 7 次,length 19 次;OpenCode 的源码注释记录了反方向的情况(带着调用报 stop)。只信它(上表的朴素谓词)会扔掉调用,只信消息体(if not tool_calls)会把截断和丢弃当成写完了。
它是各家自己的记账口径。Claude Agent SDK 的 turn 是「一次带工具调用的往返」,max_turns 只数这种往返;本课项目的「轮」是模型调用的次数,提醒重发也算一轮;Hermes 数的是 API 调用次数。同一次真实运行 pd-v2-cap20-s1:miniagent 记 11 轮,其中带工具调用的只有 8 轮。跨实现比较「用了几轮」之前,先对齐口径。
🔍 Codex 为什么不看 finish_reason?
Codex 只对接 OpenAI 的 Responses API,那里没有 finish_reason 这个字段:一次回复由若干「输出项」组成,有函数调用输出项就要继续(stream_events_utils.rs:325 置 needs_follow_up),服务端还可以给一个 end_turn 标志(turn.rs:2542-2544)。撞上输出上限时服务端发的是 response.incomplete 事件,Codex 把它当成可重试的流错误,整次采样重来(responses.rs:426-435,默认最多重试 5 次)——所以表格里 Codex 那一行遇到截断是「原样重发」,而不是「当成写完了」。
证据三问
| 问 | 本课的回答 |
|---|---|
| ① 它解决什么问题? | 把「模型不再调工具」从唯一的停止信号,拆成「正常写完」与几种要补救的异常,并让每次退出都有可枚举的原因 |
| ② 为什么可能有效? | finish_reason 和消息体是两个独立的信号,任一方单独看都会漏:同一组事件在「只看 finish」「只看消息体」「两者都看」下的判定是确定的,本页逐格算出 证据等级 · 数学可证明 在写明的前提下可以严格推导出来,不依赖实验。只要前提成立,换数据、换规模仍然成立。 本条算法性质:给定事件序列与谓词,判定唯一确定;8 个谓词的移植与期望值逐格一致(见组件右上角)。 出处待补 ;具体怎么补救(提醒、重发、判失败)属于设计取舍 证据等级 · 有理论动机 有数学或机制上的理由说明它可能有效,但没有证明它一定更好。最后是否采用,仍然要看实验。 本条源码直证各家做法;为什么这样设计是依据代码注释与结构的归纳。 出处待补 |
| ③ 实验上呢? | 本课项目:只按消息体判停,重放下 6 次成功里有 3 次变失败;v2 的提醒在真实运行里一次也没救回来 证据等级 · 实验验证 由对比实验得出的经验结论,只在出处写明的模型、数据和规模下成立。换了条件可能不再成立,引用时要带上实验设置。 本条项目实测:单一模型(Qwen3-8B 4bit)与单一推理服务;丢弃与截断的频率与模型、服务强相关,只能说明它们会发生。 出处待补 |
演进地图:你在这里
- 解决了不需要任何协议:模型本来就会写命令。卡在真实基线 T1a:同一个模型、同一个任务跑 8 次,执行抠出来的命令后测试通过 0 / 8;其中 6 次抠出的 GNU 写法
sed -i在 macOS 上直接报错,改成 BSD 写法再跑也 0 次通过——一次回复只看得见第一处 bug。 - 解决了格式由模型训练与服务端解析器负责,harness 拿到的是 name + JSON 参数;执行结果以
role: tool回填,模型能看见后果。卡在一次调用只能走一步:贯穿任务的真实成功运行,最少也用了 6 轮(读 → 改 → 测出第二处 → 改 → 再测)。协议之下仍然是文字:24 次原生调用的运行里,服务端解析器 7 次把 JSON 不合法的调用整条丢掉。 - 解决了能连续走几步了,而且一定会停。卡在贯穿任务的 20 次真实运行里,3 轮以内收工的 0 次;只调 3 轮,6 次成功里有 6 次变成失败(重放前 3 轮的 edit 后实跑验收)。
- 解决了轮数由任务决定,不由写死的数字决定;状态只有一个 messages[],每轮整份重发。卡在「没有工具调用」不等于「做完了」:harness v1 有 3 次运行因服务端丢了调用而误停;26 次里模型自己停下 21 次,其中 13 次验收没通过。
- 解决了finish_reason 和消息体都看:说要调工具却没给调用、被截断、报了不认识的结束原因,都不当成「做完了」。卡在补救也会失败:pd-v2-nothink-s1 连续 3 次发出被丢弃的调用,提醒 2 次无效后以
dropped_tool_call停;而且它不回答「要不要强制停」。 - 解决了死循环有了兜底;到上限时可以返回显式原因,或让模型先收尾。卡在上限 4:2 次运行都在第 4 轮刚改完第一处时被截断,模型毫不知情;不设上限的 6 次全部自己停下(最长 8 轮)。重放:上限 6 碰巧救回 2 次(截在重复 edit 之前),而最长那次成功运行要到第 19 轮才修好。
- 解决了用户不必等整次运行结束才能纠偏。卡在插话时机本身是设计决策:同一条事件序列里,4 个生产循环把插话送进下一次请求,3 个教学 / 项目循环要等这次运行结束。
- 解决了压缩检查、提醒注入、权限中断恢复都能插在两次调用之间。卡在循环要自己维护:Codex 把它拆成四层(submission / task / turn / stream),教学蓝本只写了其中一层。
- 解决了「模型说做完了」交给另一个没有工具的模型核对。卡在评估器也会错:Hermes 给目标循环设了 20 轮上限(pin 56dc01d9 config_defaults.py:1801),注释写明是防评估器「假阴性」;每轮多一次模型调用。
一句话
先停在这里:记住三种「没有工具调用」,和「finish 与消息体都要看」。
再深一层:去实验课把 26 次真实运行逐轮翻一遍,找出每一次退出的真实原因。
下一个缺口
停不下来怎么办?要不要给循环设上限? → 第 4 课
上面表格里,OpenCode 在「服务端丢掉了工具调用」这条序列的末尾仍在运行:它会一直原样重发。模型要是一直调工具、就是不收工呢?
你可能会问
- 「模型说『做完了』,就算做完了吗?」 不算。本课项目的判定是 harness 自己跑测试;把这个判断交给另一个模型,是岔路 B3:谁来判定「真的做完了」的问题。
- 「提醒重发的次数该设多少?」 本课项目是 2 次,Hermes 是 3 次,都是作者选的数,没有找到对照实验。真实运行里提醒一次也没救回来(同一个模型、同一种非法转义反复出现),这时换一种调用方式(提示词约定 JSON,能拿到原文、告诉模型具体错在哪)可能比多提醒几次更有用,见 A8。
- 「Pi 最新版还是这样判吗?」 结构变了:
shouldStopAfterTurn已删,改成finishTurn返回 end / continue(勘误表 P3)。本页的 Pi 一律指 pin 31b513e3。