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

要不要给循环设上限?设多少?

要不要设上限
不设上限,死循环没有兜底;设了上限,截断发生在任务中途、模型毫不知情。本课项目的 20 次真实运行给出了这条取舍的形状:上限 4 把两次运行都截在刚改完第一处的时候;上限 6 碰巧救回两次(截在一次重复修改之前);上限 19 以上才一次也不伤。五家给出五种设计:硬上限、收尾提示、不设上限靠压缩兜底、预算加一次总结、交给评估器。
❓

本课一问:第 3 课的循环停不停由模型的回复决定。模型要是一直调工具、就是不收工呢?要不要给循环设一个轮数上限?设多少?

这一课先不碰:上下文越来越长导致的退出与压缩(A5);子 agent 的预算怎么和父 agent 分账(A7);按钱算的上限(A8)。

从已有出发

第 3 课最后留下的例子:遇到「说要调工具却没给调用」,OpenCode 原样重发,而且没有专门的次数限制。把那张表切到「长任务:连续 55 轮都在调工具」看各家默认值在哪里咬住:

从已有出发 · 累计数据流 点已学环节看它回答什么
每一轮循环都经过
各家的默认上限:连续 55 轮都在调工具时,谁先停 正在用浏览器移植重算…

真实序列取自本课项目 miniagent 的 trace(Qwen3-8B-MLX-4bit,录制于 2026-09-26);构造序列只用来检验谓词,标「构造事件序列 · 真谓词」。每格的判定由浏览器里的 JS 移植现算,并与生成脚本在 pin 源码上跑出的期望值比对(a01-termination.json,2026-09-26)。源码链接需联网。

只有 CoreCoder(第 50 轮)和本课项目(默认 30)被上限停下;另外 6 个(含构造的朴素谓词)一直转到第 56 轮模型自己收工。其中几家不是忘了设上限,是故意不设——下面看为什么。

先自己设计:两个朴素答案

答案 A:不设上限(learn-claude-code s01 的 while True)。模型不收工,循环就不停:烧钱、占着机器,直到有人手动中断。本课项目 6 次不设上限的运行全部自己停下,最长 8 轮 ——但「这次没跑飞」不是兜底。

答案 B:设一个硬上限(CoreCoder max_rounds = 50,到上限返回一句「(reached maximum tool-call rounds)」)。代价是截断:

  1. ① 朴素方案
  2. ② 它在哪失败
  3. ③ 改进方案
朴素方案硬上限:转满 N 轮就停
轮数上限设成 N:哪些真实运行会被截断,验收结果怎么变? 验收通过验收没通过自带上限 4 被截断

数据:本课项目 miniagent 在贯穿任务上的 20 次真实运行(Qwen3-8B-MLX-4bit,2026-09-26);截断后的验收结果由 gen_a01_loop_data.py 重放每次运行前 N 轮的 edit_file 并实跑 python -m unittest -v test_dateparse 得到。只是这一个模型、这一个任务上的 20 次,用来看形状,不能外推成「上限该设多少」的通用答案。

🎯

试三件事:① 点预设 4:两个斜纹方块就是录制时自带上限 4 的运行;点一个,下方的逐轮验收条全是「·」;② 点预设 6:2 个灰方块变成了「由不过变过」——点开它们,第 6 轮验收通过、第 7 轮又没了:第 7 轮模型把同一个修改又发了一遍(A2 讲的 + 1 + 1);③ 点预设 20:只剩最右边那个 21 轮的运行被截断,而它的修复在第 19 轮就落地了,截在 20 验收照样通过。

同一个 harness(v2)、不同上限:左边第 4 轮之后就没有第 5 轮了 请求模型回复工具调用工具结果harness 注入

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

⚠
常见误解:「上限越小越安全。」

小上限防住的是跑飞,换来的是在任务中途被切断:上面左栏第 4 轮,模型刚改完第一处、正准备跑测试,循环就停了,它的回复里没有任何「我还没做完」的迹象,因为它不知道自己被截断了。截断会不会留痕迹,取决于 harness:本课项目在 trace 里写 max_rounds,CoreCoder 返回一句固定的话,而用户看到的可能只是「它停了」。Hermes 在 2026-08 把默认上限改成不限,代码注释给的理由就是「caps caused silent mid-task truncation」 。

改进:五种设计

设计谁到上限 / 预算用完时靠什么兜底源码
硬上限CoreCoder;本课项目(默认 30)直接停,返回固定的话(CoreCoder)或写明 max_rounds(miniagent)上限本身CoreCoder v0.4.0 agent.py:28,:54,:99
软上限 + 收尾提示OpenCode最后一步在消息末尾追加 MAX_STEPS_PROMPT(「已到最大步数,只能用文字总结」),让模型自己收尾;默认 agent.steps ?? Infinity,即不设模型遵守提示prompt.ts:1178-1179,:1281
不设上限,靠压缩兜底Codex;Claude Agent SDK 的 max_turns 默认也不设没有「到上限」这回事源码注释:「as long as compaction works well in getting us way below the token limit, we shouldn’t worry about being in an infinite loop」turn.rs:441
预算 + 一次总结调用Hermes迭代预算用完,先以 budget_exhausted 退出,再发一次去掉工具的请求让模型总结,结果记为 max_iterations_reached父子 agent 各有独立预算(pin:500 / 50)conversation_loop.py:1634,:1660-1669;turn_finalizer.py:95-142
交给评估器learn-claude-code s17;Hermes goals;Codex Stop hook模型想停时,另一个没有工具的模型判断「目标达成了没有」,没达成就让它继续评估器自己也有次数上限见岔路 B3
OpenCode · prompt.ts:最后一步只是多一条提示,工具并没有被拿掉 opencode@v1.18.32:packages/opencode/src/session/prompt.ts:1272-1286需联网 MIT
1272 const result = yield* handle.process({
1273 user: lastUser,
1274 agent,
1275 permission: session.permission,
1276 sessionID,
1277 parentSessionID: session.parentID,
1278 system,
1279 messages: [
1280 ...modelMsgs,
1281 ...(isLastStep ? [{ role: "assistant" as const, content: MAX_STEPS_PROMPT }] : []),
1282 ],
1283 tools,
1284 model,
1285 toolChoice: format.type === "json_schema" ? "required" : undefined,
1286 })
👆 带下划线的行可以点:点哪行,讲哪行。
🔍 为什么 Codex 敢不设上限?

Codex 的循环里,每一轮采样结束都会检查上下文是否接近上限(turn.rs:383-439),接近就先自动压缩(run_auto_compact,:443)再继续。它的判断是:只要压缩能把 token 降下来,循环就不会因为上下文爆掉而失控;至于「模型永远不收工」,交给用户随时中断和 Stop hook。这是一个赌注,不是证明:压缩本身可能失败(A5 会看到压缩失忆的真实例子),所以 Codex 另外有 turn 级的错误出口(:546-555)。

证据三问

问本课的回答
① 它解决什么问题?给「模型不收工」一个兜底,同时尽量不在任务中途静默截断
② 为什么没有标准答案?截断伤不伤,取决于上限落在「最后一次有效修改」之前还是之后,而这个位置每次运行都不同 ;各家选择反映的是对两种风险的不同估计
③ 实验上呢?本课项目 20 次:上限 4 截断 18 次,成功全灭;上限 20 截断 1 次,不伤成功;不设上限的 6 次都自己停下

演进地图:你在这里

模型只会吐文字,怎么动手、什么时候停:每一代都被上一代的某个真数字逼出来
  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)。

一句话

📌
上限是在「死循环」与「静默截断」之间取舍,没有免费的默认值。
🚦

先停在这里:这是里程碑 M1 要你拍板的决策:要不要 max_rounds、设多少、不设时靠什么兜底,理由要引用 T1 的轮数分布(见章首页的项目推进)。
再深一层:岔路 B3:谁来判定「真的做完了」——第五种设计,把「能不能停」交给另一个模型。

下一个缺口

循环转起来了,模型怎么说清「改哪里」? → A2 工具系统

上面那个上限 6 碰巧救回来的例子,病根不在循环:模型把同一个 edit_file 发了两遍,第二遍在已经改好的行上又加了一次 + 1,而唯一匹配检查照样放行。edit 该怎么设计,是 A2 的事。

你可能会问