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

一次调用不够,怎么让它连续干活?

让它连续干活
固定调 3 轮?贯穿任务的 20 次真实运行没有一次在 3 轮内收工,重放前 3 轮的修改,6 次成功全部变失败。改成「循环到模型不再调工具为止」:agent 的唯一状态是 messages[],每一轮都把它整份重发——7 轮下来各轮请求合计发出的 token,是对话本身长度的 5 倍多。
❓

本课一问:上一课的协议让模型能动一步:写下一个调用,harness 执行、回填。可修一个 bug 至少要「跑测试 → 读文件 → 改 → 再跑测试」四步,第二处 bug 还藏在第一处后面。一次调用不够,怎么让它连续干活?

这一课先不碰:「不再调工具」是不是可靠的停止信号(第 3 课);要不要设上限、设多少(第 4 课);用户在它干活时插话(岔路 B1);框架自带的多步(岔路 B2)。

从已有出发

第 1 课的结论:模型只写下调用,harness 执行并把结果作为一条 role: tool 消息追加;下一次请求带着它发出去,模型就「看见」了结果。要走下一步,只要再请求一次。

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

贯穿任务还是修 test_parse_date。真实运行里模型要做的事大致是:列目录、读 dateparse.py、改第一处、跑测试(这时第二处才暴露)、改第二处、再跑测试、写总结。

先自己设计:固定调 3 轮

最直接的写法:把「请求 → 执行 → 回填」重复固定的次数,比如 3 次(for _ in range(3))。它一定会停,也能连着走几步。

  1. ① 朴素方案
  2. ② 它在哪失败
  3. ③ 改进方案
朴素方案固定调 3 轮:for _ in range(3)
固定只调 N 轮:20 次真实运行里,几次来得及修好? 验收通过验收没通过自带上限 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 次,用来看形状,不能外推成「上限该设多少」的通用答案。

🎯

试三件事:① 默认「只调 3 轮」:所有方块都在红线右边,全部会被截断;② 把 N 拖到 7:只剩 7 轮以上的运行被截断,「由过变不过」只剩 1 次;③ 点那个最右边的绿方块(21 轮,pd-v2-layered-s1):它要到第 19 轮修好的那一刻,截在更早的任何地方验收都过不了。

改进:循环到模型不再调工具为止

Anthropic 的官方文档把这个循环写成五段:接收(提示词、system prompt、工具定义、此前的对话)→ 评估并回复(模型回一段文字、一个或多个工具调用,或两者都有)→ 执行工具(harness 跑每个调用、收集结果)→ 重复(后两段反复,每转一圈叫一个 turn)→ 返回(模型给出不含工具调用的回复时,循环结束) 。

教学蓝本里,整个循环就是一个 while(或 for)加一个判断。CoreCoder 的最小形态:

CoreCoder v0.4.0 · agent.py 的 chat():整个 agent 就是这一段 CoreCoder@v0.4.0:corecoder/agent.py:49-99需联网 MIT
49 def chat(self, user_input: str, on_token=None, on_tool=None) -> str:
50 """Process one user message. May involve multiple LLM/tool rounds."""
51 self.messages.append({"role": "user", "content": user_input})
52 self.context.maybe_compress(self.messages, self.llm)
53
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
66 # tool calls -> execute (parallel when multiple, like Claude Code's
67 # StreamingToolExecutor which runs independent tools concurrently)
68 self.messages.append(resp.message)
69
70 try:
71 if len(resp.tool_calls) == 1:
72 tc = resp.tool_calls[0]
73 if on_tool:
74 on_tool(tc.name, tc.arguments)
75 result = self._exec_tool(tc)
76 self.messages.append({
77 "role": "tool",
78 "tool_call_id": tc.id,
79 "content": result,
80 })
81 else:
82 # parallel execution for multiple tool calls
83 results = self._exec_tools_parallel(resp.tool_calls, on_tool)
84 for tc, result in zip(resp.tool_calls, results):
85 self.messages.append({
86 "role": "tool",
87 "tool_call_id": tc.id,
88 "content": result,
89 })
90 except KeyboardInterrupt:
91 # Ctrl+C mid-execution would leave the assistant tool_calls
92 # message without replies, poisoning the next request; backfill
93 self._answer_pending_tool_calls(resp.tool_calls)
94 raise
95
96 # compress if tool outputs are big
97 self.context.maybe_compress(self.messages, self.llm)
98
99 return "(reached maximum tool-call rounds)"
👆 带下划线的行可以点:点哪行,讲哪行。

learn-claude-code 的 s01 更短:while True,没有上限,没有 tool_use 块就 return(s01_agent_loop/code.py:87-117)。本课项目 miniagent 的 loop.py 也是同一个形状,下面就用它录下的一次真实运行,一步一步走:

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

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

🎯

试三件事:① 每到「回复」这一步先猜模型会调哪个工具——第 4 轮它会跑测试,第 5 轮修第二处(它是看了第 4 轮的测试输出才知道的);② 看每一次「请求」的括号:括住的永远是从第一条开始的全部,不是新增的那几条;③ 点一个很高的绿色块(第 2 轮 read_file 的结果),看它的原文:这 400 多个 token 之后每一轮都会再发一遍。

同一次运行,换一种画法:

同一份 trace,两种画法:展开是一次执行,折起来就是那个环

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

展开图是「这一次」:模型 7 次、工具 6 次、一个出口。切到「折成环」,同名节点合并、边上标次数,就只剩「模型 ⇄ 工具」一个环——所谓 agent loop,就是这个环。环丢掉了顺序;要知道第 5 轮为什么改第二处,得回到展开图、看第 4 轮的测试输出。

极小真数字:每轮整份重发,账怎么算

模型 API 不记得上一次请求:每次请求都必须自带全部上下文。所以第 n 次请求 = 工具定义 + 此前的全部消息 + 模板补的几个标记,而各轮请求的总和比对话本身长得多:

pd-v2-unlimited-s3 的 7 次请求,合计发出多少 token(服务端 usage)
=
👆 公式里带下划线的每一项都可以点——点哪项,讲哪项。
这次运行模型一共生成了 5,930 个 token(含思考),而最后一条回复写回历史只占 84 个:思考不会进入 messages[],只有正文和调用会。
⚠
常见误解:「每一轮只把新增的那条消息发给模型,模型记得前面的。」

模型 API 是无状态的:请求体里没有「上一轮」的引用,模型能用的只有这一次发来的 token。上面的括号每一轮都从第一条括起。服务端的前缀缓存(prefix cache)能让重复的开头算得更快、更便宜,但它是服务端的计算复用,请求里照样是整份——本课项目的服务端从第 2 轮起报告的缓存命中,中位数占 prompt 的 91%,可 prompt_tokens 一个都没少。

🔍 为什么 miniagent 用 while True 而 CoreCoder 用 for range(50)?

这正是第 4 课的问题。本课项目的 LoopConfig.max_rounds 默认 30、可设为 None(不设上限),里程碑 M1 要你拍板;CoreCoder 默认 50;learn-claude-code 的 s01 与 Claude Agent SDK 的 max_turns 默认都不设上限(官方文档表格:Max turns · Default: No limit)。

证据三问

问本课的回答
① 它解决什么问题?让模型根据上一步的结果决定下一步,轮数由任务决定而不是写死
② 为什么是这样?模型 API 无状态,只能把完整历史随每次请求发出 ;停止交给「模型不再调工具」这个信号,是设计选择
③ 实验上呢?贯穿任务 20 次真实运行,自己停下的从 5 轮到 21 轮;固定 3 轮会让 6 次成功里的 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)。

一句话

📌
agent 的唯一状态是 messages[],每一轮都整份重发。
🚦

先停在这里:记住那道每轮都从第一条括起的括号。
再深一层:两条岔路——用户在它干活时插话怎么办?(循环里还有第二种输入)、为什么不用框架自带的自动多步?(生产 harness 为什么坚持自己写这个 while)。

下一个缺口

那它什么时候停?「没有工具调用就停」够不够? → 第 3 课

这一课的停止条件只有一个:回复里没有工具调用。可真实运行里,有的回复是被截断的、有的是服务端把调用丢了——它们也「没有工具调用」。

你可能会问