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

同一个任务跑了很多次,循环每次怎么转、在哪一轮、因为什么停?

26 次真实运行
本课项目 miniagent 用本机的 Qwen3-8B 真实跑了 26 次(贯穿任务 20 次、备用任务 6 次,harness v1 / v2 各 13 次)。这一课把它们全部摊开:轮数分布与上限、逐轮回放、折成环的聚合图、8 个终止谓词在真实事件上的判定。
🧪

这一课做什么:前面每一课只取一两次运行讲一个点。这一课把本课项目的 26 次真实运行全部摊开(合计 517,684 token;验收通过 9 次;停止原因:no_tool_calls 21 次、max_rounds 2 次、length 2 次、dropped_tool_call 1 次),用四个视图去读它们。所有数字都来自 trace,没有一个是手写的。

从已有出发 · 累计数据流 点已学环节看它回答什么
每一轮循环都经过

实验一:轮数分布与上限

实验 · 贯穿任务 20 次真实运行 × 轮数上限(重放验收)
轮数上限设成 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 次,用来看形状,不能外推成「上限该设多少」的通用答案。

🎯

试三件事:① 找出「上限 N 下由过变不过」降到 0 的最小 N,再找「由不过变过」出现的 N——想想为什么截得早反而救了一次;② 点任一方块,下面的回放会跟到那次运行的第 N 轮(实验二);③ 把 N 拖到最右:所有方块都不再被截断,这就是「不设上限」时这 20 次的真实结果。

实验二:逐轮回放任意一次运行

实验 · 26 次真实运行逐轮回放(Qwen3-8B-MLX-4bit · 2026-09-26)
真实运行逐轮回放 请求模型回复工具调用工具结果harness 注入

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

🎯

试三件事:① 选 pd-promptjson-s1(提示词约定 JSON 的那次):第 5、6、8、9 轮都是写满 4096 token 的截断回复,看 harness 每次追加的紫色提醒;② 选 pd-v2-firstmatch-s1:看「权限裁决」那一行,第 2、6 轮的测试命令被规则拒绝(A3 的伏笔),模型之后怎么绕;③ 选 pd-v2-unlimited-s1,翻到第 7 轮:同一个 edit_file 又发了一遍,验收因此没过(A2 的伏笔)。

实验三:并排对照

同一个 harness、只差一个变量的两次运行,按轮对齐:

只差提示词里一条完成规则:v1(左)在第 5 轮宣布修好,v2(右)接着跑测试 请求模型回复工具调用工具结果harness 注入

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

只差上限:20(左)与 4(右) 请求模型回复工具调用工具结果harness 注入

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

实验四:结构与判定

实验 · 聚合:贯穿任务 20 次运行折成一个环
同一份 trace,两种画法:展开是一次执行,折起来就是那个环

数据:本课项目 miniagent 的真实运行(agent-traces.json,Qwen3-8B-MLX-4bit,2026-09-26)。节点与次数全部由 trace 的 rounds[].tool_calls 现数。

实验 · 8 个终止谓词 × 真实事件序列(浏览器移植,与原实现逐格比对)
同一条事件序列,8 个循环各自怎么判:继续、重发、提醒,还是停? 正在用浏览器移植重算…

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

最后,换一次运行,把 messages[] 的增长再走一遍——这次是一次失败的运行,看它在哪一步走偏:

agent 的唯一状态:messages[] 一步一步长出来 harness 注入用户 / 任务模型回复工具调用工具结果

数据:本课项目 miniagent 的真实运行 pd-v2-unlimited-s1(harness v2 · A1 无轮数上限(seed 1),录制于 2026-09-26;模型 Qwen3-8B-MLX-4bit,本机 mlx_lm.server)。每条消息的 token 用该模型的 tokenizer 与 chat template 逐条归属(a01-loop-data.json),8 轮请求的合计与服务端 usage.prompt_tokens 逐轮一致。左栏代码是 04-实践项目/miniagent/loop.py 在 2026-09-26 17:01 的快照,只显示与循环有关的行。

⚠

别从这些数字读出「这个模型修 bug 的成功率」。单一模型(4bit 量化的 8B)、单一推理服务、一个主任务、每种配置一到三次 。它们能说明的是:截断、丢弃、误停、重复修改这些现象都会在真实运行里发生,而且 trace 足够让你逐轮找到它们。

自己动手

  1. 在 04-实践项目 里多跑几次(或换一个模型):.venv/bin/python -m miniagent run --task parse_date --run-id my-run --max-rounds 0;
  2. 用项目的导出脚本更新 src/data/project/agent-traces.json,再运行 src/data/precomputed/gen_a01_loop_data.py 与 gen_a01_termination.py(需要 bun);
  3. 刷新本页:新运行会出现在下拉框和方块图里;TerminationSim 右上角的一致性检查会重新覆盖全部序列。

演进地图

这一课的四个视图,覆盖了演进图的主线四代。

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

一句话

📌
同一个任务跑很多次,循环转几圈、在哪里停、停得对不对,每次都不一样;trace 让每一次都能逐轮查清。