循环:模型只会吐文字,它怎么改你的文件?什么时候停?
主线定位
- 上游 · A0 编排选择:这个任务交给模型决定每一步。LLM 课最后一课(C12·L8)也把模型交到了这里:它已经会写出一段「调用工具」的文字——然后呢?
- 本章 · A1 循环:谁来执行那段文字、结果怎么送回去、怎么连续走很多步、什么时候停。答案是包在模型外面的一个程序(harness)和它的一个
while。 - 下游 · A2 工具系统:循环转起来了,模型怎么说清「改哪里」?这一章里「上限 6 碰巧救回两次」的那个重复修改,病根就在 A2。
本章问题链
每一课只回答一个问题;上一课的失败,逼出下一课的问题。
| 课 | 这一课的问题 | 朴素方案在哪个真数字上失败 | 一句话 |
|---|---|---|---|
| 第 1 课 | 模型只会吐文字,它怎么「动手」? | 不给工具、正则抠命令:同一个模型 8 次,测试通过 0 次 | 模型只提出调用,harness 负责执行 |
| 第 2 课 | 一次调用不够,怎么让它连续干活? | 固定调 3 轮:20 次真实运行 3 轮内收工 0 次,重放下 6 次成功有 6 次变失败 | agent 的唯一状态是 messages[],每一轮都整份重发 |
| 岔路 | 用户在它干活时插话怎么办? | 等运行结束再读:第 1 轮的插话要等 6 次模型调用之后才被看见 | 插话什么时候被看见,是 harness 定的 |
| 岔路 | 为什么不用框架自带的自动多步? | OpenCode 的 streamText 不传 stopWhen,两次调用之间要做十来件事 | 自己写循环,是为了保住「两次调用之间」这个位置 |
| 第 3 课 | 「没有工具调用就停」够不够? | v1 在 3 次运行里把「服务端丢了调用」当成写完了;重放下 3 次成功会变失败 | 「没有工具调用」只是正常退出的一条路,生产循环要双重校验 |
| 第 4 课 | 要不要给循环设上限?设多少? | 上限 4:2 次运行都截在刚改完第一处时,模型毫不知情 | 上限是在死循环与静默截断之间取舍,没有免费的默认值 |
| 岔路 | 谁来判定「真的做完了」? | 模型自己停下 21 次,验收没过 13 次 | 停不停可以由模型说了算,做没做成要由模型之外的东西来判 |
| 实验课 | 26 次真实运行:循环怎么转、在哪停 | — | trace 让每一次都能逐轮查清 |
演进地图
- 解决了不需要任何协议:模型本来就会写命令。卡在真实基线 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),注释写明是防评估器「假阴性」;每轮多一次模型调用。
贯穿任务:修好 test_parse_date
全课的贯穿任务是项目 04-实践项目/tasks/parse_date:让失败的 test_parse_date 通过,测试文件不许改。dateparse.py 里埋了两处 bug,按顺序暴露:修掉第一处、重跑测试,才看得见第二处——这逼着 agent 走「跑测试 → 改 → 再跑」的多轮循环。
这一章给贯穿任务的 trace 立起骨架:messages[] 每轮怎么长、每轮的 finish_reason、总轮数与停止原因。后面每一章都在这副骨架上加一层(A2 的 edit 裁决、A3 的权限、A5 的上下文……)。本章用到的真实数据:
- miniagent v0.5.0 用本机 Qwen/Qwen3-8B-MLX-4bit 录制的 26 次运行(贯穿任务 20 次),
src/data/project/agent-traces.json; - 无工具基线 T1a:8 次,src/data/project/agent-t1a-notools.json;
- 中途插话与中断 T1b:2 次(插话、中断各一次),src/data/project/agent-t1b-steer.json,岔路 B1;
- 本章自己的派生数据(生成脚本与数据一起放在
src/data/precomputed/):每条消息的 token 归属(项目模型的 tokenizer 与 chat template,129 轮请求与服务端 usage 全部一致)、「截在第 N 轮」的重放验收、8 个终止谓词在 10 条事件序列上的期望判定。
三层交互
| 层 | 本章里的组件 | 在哪一课 |
|---|---|---|
| 概念动画 | 一次工具调用的协议逐步展开(请求体、回复、执行、回填,可切到「模型实际读到的文本」);Pi 两层 while 与两个队列的逐步动画(事件顺序由原文件实跑得到) | 第 1 课、岔路 B1 |
| 算法模拟器(真算法、小数据) | 8 个终止谓词(朴素谓词 + CoreCoder、learn-claude-code、Pi、OpenCode、Codex、Hermes + 本课项目)× 10 条事件序列,逐步揭示、每格链到 pin 源码行;页面加载时 80 组判定与期望值逐格比对 | 第 3 课、第 4 课、岔路 B1、实验课 |
| 真数据 | 无工具基线 T1a;messages[] 逐轮增长(Python Tutor 式,块高 = 真实 token);轮数分布 × 上限(重放验收);26 次运行的逐轮回放与并排对照;折成环的聚合图;中途插话与中断的真实录制 T1b | 第 1、2、4 课、实验课、岔路 B1 |
期望值的来源分三种,组件里每一行都标着:CoreCoder v0.4.0、learn-claude-code s01、Pi 与本课项目是原文件实跑(Python 加载原模块、bun 执行原 TypeScript;假模型按剧本回复,工具只记录不执行);OpenCode 与 Hermes 的判定条件从 pin 源码逐字切出来求值;Codex 是 Rust、本机没有编译器,按源码逻辑复刻。生成脚本:src/data/precomputed/gen_a01_termination.py 与 .ts。
项目推进:里程碑 M1 · 最小循环
对应 04-实践项目/MILESTONES.md 的「A1 · Agent Loop」。代码在 miniagent/loop.py,测试在 tests/test_loop.py;本课项目 v0.5.0 已经实现。代码 AI 来写,决策你来做。
- 要不要
max_rounds,设多少?不设时靠什么兜底?理由必须引用 T1 的轮数分布(第 4 课的方块图),写进DECISIONS.md。项目现在:默认 30、可设 None;另有墙钟上限 1200 秒。 - 回复被截断、又没有工具调用,算不算做完了?项目现在:不算,提醒 2 次,仍截断才以
length停(第 3 课)。 - 服务端说要调工具、却没给调用,怎么办?重发、停、还是换成提示词约定 JSON 自己解析?项目 v2:提醒重发最多 2 次,真实运行里一次也没救回来(第 3 课)。
- 「模型说做完了」和「真的做完了」是不是一回事?项目现在:停止由模型决定,成败由 harness 跑测试验收;要不要把验收结果喂回模型,留到 A9(岔路 B3)。
- system prompt 要不要写死「完成前必须重跑测试」?用 v1 / v2 两组 trace 的数字判断这条规则值不值(岔路 B3)。
验收标准(全部可测;括号里是本章开发时在项目里核对的现状):
.venv/bin/python -m unittest tests.test_loop全过,至少覆盖:无工具调用即停;工具结果带匹配的tool_call_id回填;max_rounds生效;max_rounds=None时能跑过第 50 轮;截断不被当成完成;服务端丢弃的调用会提醒重发且次数有界;模型报错时停并记录(2026-09-26 实跑:章开发时 13 个用例全过;项目批次 2 之后 18 个,全过)。- 设计卡补充的两条,项目批次 2 已补上:①「finish = stop 但消息体里有工具调用」不丢调用:
test_stop_with_a_tool_call_still_executes_the_call(钉住现有行为,没有改代码);② 中断时给没回复的调用回填结果、历史仍然合法(CoreCoder 的做法:agent.py:90-94,:132-146):test_interrupt_during_tool_calls_backfills_the_rest等 3 个中断测试,外加 1 个中途插话测试,并真实录制了一次插话、一次中断(T1b,见岔路 B1)。 - 真跑一次:
.venv/bin/python -m miniagent run --task parse_date --run-id a1-demo --max-rounds 0,生成的 trace 用python -m miniagent validate校验为 OK。 - 与本章页面一致:把你的新运行导出、重跑
gen_a01_termination.py,实验课 TerminationSim 右上角仍为全部一致;本课项目那一行在三条真实序列上的停止原因与提醒轮次与 trace 一致(生成脚本的trace_checks)。
坑与带走
本章拦截的误解| 误解 | 纠正 | 在哪一课 |
|---|---|---|
| 模型执行了命令 / 调用了工具 | 模型只写下调用;执行、出错处理、回填全在 harness | 第 1 课 |
| 每轮只发新增的消息,模型记得前面的 | API 无状态,每轮整份重发;7 轮请求合计是对话长度的 5.1 倍 | 第 2 课 |
| 插话只能等这次做完 | 检查点放在哪一层循环是设计决策;4 个生产循环一轮之内就送进去 | 岔路 B1 |
| 用了框架的 maxSteps 就等于写好了 harness | 它拿走了「两次调用之间」这个位置 | 岔路 B2 |
| stop_reason / finish_reason 可以直接信 | 它和消息体会不一致:空调用配 tool_calls、截断配 length、带调用报 stop | 第 3 课 |
| turn / 轮 是跨厂商的通用计数 | 各家口径不同:Claude SDK 只数工具往返,本课项目提醒重发也算一轮 | 第 3 课 |
| 上限越小越安全 | 截断发生在任务中途、模型不知情;上限 4 把两次运行都截在刚改完第一处 | 第 4 课 |
| 模型说做完了就是做完了 | 自己停下 21 次,验收没过 13 次 | 岔路 B3 |
| 症状 | 后果 | 怎么办 |
|---|---|---|
| 只看 finish_reason 判停 | 带着调用报 stop 时,调用被扔掉 | 同时看消息体里有没有调用(第 3 课) |
| 只看消息体判停 | 服务端丢了调用、回复被截断,都被当成「写完了」 | finish 不是正常收尾就走补救路径,补救有上限、退出写明原因 |
| 截断的回复里照常执行工具调用 | 残缺参数悄悄改错文件 | 整批判失败,让模型重发(Pi 的做法) |
| 中断后 assistant 的 tool_calls 没有对应结果 | 下一次请求被 API 拒绝 | 给没回复的调用回填 [interrupted](CoreCoder agent.py:90-94,:132-146);本课项目区分「执行中被中断」与「未执行」,真实录制见岔路 B1 |
| 硬上限到了静默返回 | 半途而废却像完成了 | 显式返回原因,或先让模型收尾(第 4 课) |
| 把 turn 当通用概念跨实现比较 | 数字不可比 | 先说清各家的计数口径 |
带走这些句子(各课「一句话」):
- 模型只提出调用,harness 负责执行。
- agent 的唯一状态是 messages[],每一轮都整份重发。
- 插话什么时候被模型看见,是 harness 定的:纠偏在下一次请求前插进去,追加等它做完再说。
- 框架的自动多步替你转圈,也就拿走了「两次调用之间」这个位置;生产 harness 自己写循环,就是为了在那里做事。
- 「没有工具调用」只是正常退出的一条路,生产循环要双重校验。
- 上限是在「死循环」与「静默截断」之间取舍,没有免费的默认值。
- 停不停可以由模型说了算,做没做成要由模型之外的东西来判。
深入这一章
按你愿意花的时间选。每条都在 2026-09-26 实际打开核对过;站外链接需联网。
🎮 想更直观 10–30 分钟
🔬 想吃透原理 1–3 小时
📄 想读原文 按需
我替你排除了什么:OpenAI《Unrolling the Codex agent loop》(2026-01-23,官方一手,本想放进「想读原文」;本轮 curl 返回 403、正文无法直读核对,按纪律不收,想看可以自己打开)· Geoffrey Huntley「how to build a coding agent」工作坊(只有 22 秒音频加代码,没有完整录像)· Thorsten Ball 播客《Agents At Work 4》(访谈闲聊,没讲机制;他的文字版已入选)· claude-trace 原 npm 包(维护已转手,功能被 claude-tap 覆盖)· 只画 happy path 的循环示意图与速成视频(终止条件、失败分支才是这一章的本体)· Lilian Weng《LLM Powered Autonomous Agents》(2023,写于原生工具调用普及之前,组件划分偏学术)· 任何「CoreCoder 约 150 行 while 循环」的二手说法(净 117 行,且说的是 v0.4.0 的整个 agent.py)。