从0打造编码Agent 阅读 + 实验 ≈ 40 分钟 页内实验 ×2
A1 循环:模型只会吐文字,它怎么改你的文件?什么时候停? 第 3 课

「没有工具调用就停」够不够?

什么时候停
「没有工具调用」有三种来历:模型真写完了、回复被截断了、服务端把调用丢了。本课项目的第一版只认第一种,真实运行里 3 次把「丢了」当成「写完了」;回头重放,只按这一条判停,6 次成功里有 3 次会变失败。生产循环同时看 finish_reason 和消息体,再给截断和丢弃各留一条路——同一批事件交给 8 个循环,判法各不相同。
❓

本课一问:第 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——按说是个优点。

  1. ① 朴素方案
  2. ② 它在哪失败
  3. ③ 改进方案
朴素方案没有工具调用就停:if not tool_calls: break
CoreCoder v0.4.0 · agent.py:没有工具调用就返回 CoreCoder@v0.4.0:corecoder/agent.py:54-65需联网 MIT
54 for _ in range(self.max_rounds):
55 resp = self.llm.chat(
56 messages=self._full_messages(),
57 tools=self._tool_schemas(),
58 on_token=on_token,
59 )
60
61 # no tool calls -> LLM is done, return text
62 if not resp.tool_calls:
63 self.messages.append(resp.message)
64 return resp.content
65
👆 带下划线的行可以点:点哪行,讲哪行。

下面是贯穿任务的一次真实运行(harness v2 · 关闭思考):第 7、8、9 轮都是服务端丢了调用。v1 的谓词会在第 7 轮直接停下。

真实运行 pd-v2-nothink-s1:finish_reason = tool_calls,调用却是空的 请求模型回复工具调用工具结果harness 注入

数据:本课项目 miniagent 的真实运行(src/data/project/agent-traces.json,2026-09-26 导出;模型 Qwen/Qwen3-8B-MLX-4bit,本机 mlx_lm.server)。token 数取自服务端 usage;思考截取约 600 字、正文与工具输出约 900 字(保留头尾)。耗时受本机同时运行的其他任务影响,只能粗看;token 与轮数才可比。

改进:八个循环,同一批事件

生产循环各有各的判法。下面把同一条事件序列交给 8 个循环:一个只信 finish_reason 的朴素谓词(构造的,不是任何一家的代码)、6 个 pin 源码,加上本课项目。三条序列取自真实 trace,其余是构造的边界情况。

同一条事件序列,8 个循环各自怎么判:继续、重发、提醒,还是停? 正在用浏览器移植重算…

真实序列取自本课项目 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 逐条一致 。

OpenCode 的退出条件是这张表里最完整的一种。逐项拆开:

OpenCode 的退出条件(prompt.ts:1111-1116,pin v1.18.32):四项同时成立才停
&& && &&
👆 公式里带下划线的每一项都可以点——点哪项,讲哪项。
四项里,前两项看 finish,第三项看消息体,第四项看有没有新的用户输入:这就是「双重校验」。注意它对「finish 是 tool-calls 却没有 tool part」的处理是原样再请求一轮,没有专门的次数上限(maxSteps 默认 Infinity)——上面表格里 OpenCode 那一行在序列结束时仍在运行。

另外两种补救值得对照着看:

Pi · agent-loop.ts:执行之前先看是不是被截断的 pi@31b513e3:packages/agent/src/agent-loop.ts:192-224需联网 MIT 最新版已变化 · 勘误表 P3
192 // Stream assistant response
193 const message = await streamAssistantResponse(currentContext, config, signal, emit, streamFunction);
194 newMessages.push(message);
195
196 if (message.stopReason === "error" || message.stopReason === "aborted") {
197 await emit({ type: "turn_end", message, toolResults: [] });
198 await emit({ type: "agent_end", messages: newMessages });
199 return;
200 }
201
202 // Check for tool calls
203 const toolCalls = message.content.filter((c) => c.type === "toolCall");
204
205 const toolResults: ToolResultMessage[] = [];
206 hasMoreToolCalls = false;
207 if (toolCalls.length > 0) {
208 // A "length" stop means the output was cut off by the token limit, so
209 // every tool call in the message may carry truncated arguments. Fail
210 // them all instead of executing potentially borked calls.
211 const executedToolBatch =
212 message.stopReason === "length"
213 ? await failToolCallsFromTruncatedMessage(toolCalls, emit)
214 : await executeToolCalls(currentContext, message, config, signal, emit);
215 toolResults.push(...executedToolBatch.messages);
216 hasMoreToolCalls = !executedToolBatch.terminate;
217
218 for (const result of toolResults) {
219 currentContext.messages.push(result);
220 newMessages.push(result);
221 }
222 }
223
224 await emit({ type: "turn_end", message, toolResults });
👆 带下划线的行可以点:点哪行,讲哪行。

停止原因要能数得清

「为什么停」本身也要写下来。各家都把退出原因做成了可枚举的值:

循环退出原因出处
本课项目 miniagentno_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 核对)
Hermespin 上 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 次验收没通过 。退出原因只说明循环为什么停,不说明任务做没做成——那是岔路 B3 的问题。

⚠
常见误解一:「stop_reason / finish_reason 可以直接信。」

它是 provider 对「为什么停止生成」的一个标签,不是对「任务状态」的判断,而且会和消息体不一致:本课项目的真实运行里,tool_calls 配空调用 7 次,length 19 次;OpenCode 的源码注释记录了反方向的情况(带着调用报 stop)。只信它(上表的朴素谓词)会扔掉调用,只信消息体(if not tool_calls)会把截断和丢弃当成写完了。

常见误解二:「turn / 轮 是跨厂商的通用计数单位。」

它是各家自己的记账口径。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」「只看消息体」「两者都看」下的判定是确定的,本页逐格算出 ;具体怎么补救(提醒、重发、判失败)属于设计取舍
③ 实验上呢?本课项目:只按消息体判停,重放下 6 次成功里有 3 次变失败;v2 的提醒在真实运行里一次也没救回来

演进地图:你在这里

模型只会吐文字,怎么动手、什么时候停:每一代都被上一代的某个真数字逼出来
  1. 解决了不需要任何协议:模型本来就会写命令。
    卡在真实基线 T1a:同一个模型、同一个任务跑 8 次,执行抠出来的命令后测试通过 0 / 8;其中 6 次抠出的 GNU 写法 sed -i 在 macOS 上直接报错,改成 BSD 写法再跑也 0 次通过——一次回复只看得见第一处 bug。
  2. 第一代 工具调用协议:模型写结构化的调用,harness 执行后回填 tools 参数 + tool_calls + role: tool
    解决了格式由模型训练与服务端解析器负责,harness 拿到的是 name + JSON 参数;执行结果以 role: tool 回填,模型能看见后果。
    卡在一次调用只能走一步:贯穿任务的真实成功运行,最少也用了 6 轮(读 → 改 → 测出第二处 → 改 → 再测)。协议之下仍然是文字:24 次原生调用的运行里,服务端解析器 7 次把 JSON 不合法的调用整条丢掉。
  3. 朴素 固定调用 3 轮 for _ in range(3)
    解决了能连续走几步了,而且一定会停。
    卡在贯穿任务的 20 次真实运行里,3 轮以内收工的 0 次;只调 3 轮,6 次成功里有 6 次变成失败(重放前 3 轮的 edit 后实跑验收)。
  4. 第二代 循环到模型不再调工具为止 CoreCoder、learn-claude-code s01
    解决了轮数由任务决定,不由写死的数字决定;状态只有一个 messages[],每轮整份重发。
    卡在「没有工具调用」不等于「做完了」:harness v1 有 3 次运行因服务端丢了调用而误停;26 次里模型自己停下 21 次,其中 13 次验收没通过。
  5. 第三代 双重校验 + 截断防护 + 可枚举的退出原因 OpenCode、Pi、Hermes;本课项目 v2 你在这里
    解决了finish_reason 和消息体都看:说要调工具却没给调用、被截断、报了不认识的结束原因,都不当成「做完了」。
    卡在补救也会失败:pd-v2-nothink-s1 连续 3 次发出被丢弃的调用,提醒 2 次无效后以 dropped_tool_call 停;而且它不回答「要不要强制停」。
  6. 第四代 上限与预算:硬上限 / 收尾提示 / 不设上限靠压缩兜底 / 预算加总结调用 CoreCoder 50 · OpenCode steps · Codex 不设 · Hermes 预算
    解决了死循环有了兜底;到上限时可以返回显式原因,或让模型先收尾。
    卡在上限 4:2 次运行都在第 4 轮刚改完第一处时被截断,模型毫不知情;不设上限的 6 次全部自己停下(最长 8 轮)。重放:上限 6 碰巧救回 2 次(截在重复 edit 之前),而最长那次成功运行要到第 19 轮才修好。
  7. 岔路 双层循环:干活时插话、做完后追加 Pi steering / follow-up;Codex 任务层
    解决了用户不必等整次运行结束才能纠偏。
    卡在插话时机本身是设计决策:同一条事件序列里,4 个生产循环把插话送进下一次请求,3 个教学 / 项目循环要等这次运行结束。
  8. 岔路 不用框架的自动多步,自己驱动循环 OpenCode 调 streamText 不传 stopWhen
    解决了压缩检查、提醒注入、权限中断恢复都能插在两次调用之间。
    卡在循环要自己维护:Codex 把它拆成四层(submission / task / turn / stream),教学蓝本只写了其中一层。
  9. 岔路 独立评估器决定能不能停 learn-claude-code s17 · Hermes goals · Codex Stop hook
    解决了「模型说做完了」交给另一个没有工具的模型核对。
    卡在评估器也会错:Hermes 给目标循环设了 20 轮上限(pin 56dc01d9 config_defaults.py:1801),注释写明是防评估器「假阴性」;每轮多一次模型调用。
数字口径:本课项目 miniagent 的真实运行(Qwen3-8B-MLX-4bit,2026-09-26;单一模型、单一任务,只说明现象会发生,不估计频率);「截在第 N 轮」的验收结果是重放那次运行前 N 轮的 edit 后实跑测试得到的;插话一行来自 8 个终止谓词在同一条构造事件序列上的判定(a01-termination.json)。

一句话

📌
「没有工具调用」只是正常退出的一条路,生产循环要双重校验。
🚦

先停在这里:记住三种「没有工具调用」,和「finish 与消息体都要看」。
再深一层:去实验课把 26 次真实运行逐轮翻一遍,找出每一次退出的真实原因。

下一个缺口

停不下来怎么办?要不要给循环设上限? → 第 4 课

上面表格里,OpenCode 在「服务端丢掉了工具调用」这条序列的末尾仍在运行:它会一直原样重发。模型要是一直调工具、就是不收工呢?

你可能会问