跳到主要内容

23 · Loop Engineering 循环工程

用 mini-swe-agent、smolagents、Codex 的真实源码讲透循环工程:预算、停止、错误分类、轨迹落盘、验证。

本章任务

要回答的问题

一个能跑的 while 循环,怎样升级成会停止、会恢复、会验证、可重放的工程系统?

读完你能

  • 写出预算、停止、错误、轨迹和验证五个最小组件
  • 搭建一个约 30 行的可运行最小循环
  • 为无限重试、假完成和中断设计故障测试
适合现在读
正在写第一个 Agent,或想脱离框架理解运行时本质的工程师
先修知识
会读基础 Python;理解 Tool Calling
实践产物
一个可运行最小 Loop 与五个故障测试
证据边界
示例展示工程模式;预算、阈值和验证器必须按模型与任务集校准

下面这段代码能演示 Tool Calling,却还不能称为可运行时:

context = [user_message]
while True:
step = llm(context)
context.append(step)
if step.intent == "done":
return step.final_answer
context.append(execute(step))

先不要加框架。给它做三次故障注入:

故障裸循环的表现本章要补的工程部件
模型连续 20 次调用同一个失败工具一直重试,直到人工杀进程或账单耗尽错误分类、连续失败断路器、步数/成本/墙钟预算
模型说 done,但测试仍失败直接返回“已完成”结构化完成动作与外部 Verifier
工具已改文件,进程在结果写回前崩溃重启后不知道是否要重做每步落盘、operation_id、可重放事件与恢复状态

你的第一版 Loop 通过下面五项就够:

  1. 每轮模型调用前检查步数、成本和墙钟预算。
  2. 模型错误可以修,框架错误直接上抛;连续同类错误触发断路器。
  3. “完成”是结构化动作,并且要通过至少一个外部检查。
  4. 每一步无论成功失败都落盘,退出和继续都带原因。
  5. 进程中断后能判断安全重放、跳过已提交动作或进入人工检查。

这五项分别对应预算、停止、错误、轨迹和验证。接下来读真实源码,不是为了抄框架 API,而是看这五件事怎样落到 191 行里。ReAct 提供 Observe/Act 的学术骨架,循环工程负责把它变成可停止、可复盘的系统。

先读一个真实的:mini-swe-agent 的 191 行

Section titled “先读一个真实的:mini-swe-agent 的 191 行”

讲方法之前先看实物。mini-swe-agent(SWE-bench 团队出品)的 agents/default.py 只有 191 行,却是一个在 benchmark 上真实跑分的生产循环。逐块拆(引用自 commit a83fcae):

配置就是循环工程的清单。 AgentConfig 一共 7 个字段,其中 4 个是安全网:

step_limit: int = 0 # 最多跑多少步
cost_limit: float = 3.0 # 花超 3 美元就停
wall_time_limit_seconds: int = 0 # 墙钟时间上限
max_consecutive_format_errors: int = 3 # 连续格式错误 3 次退出

注意 cost_limit 默认是 3.0——美元。预算不只是步数,是钱。步数、成本、墙钟时间三个维度的检查全部放在 query() 的开头,也就是每次调模型之前,超限直接抛 LimitsExceeded / TimeExceeded

退出是一条消息,不是一个异常逃逸。 循环体的判停只有一行:

if self.messages[-1].get("role") == "exit":
break

所有退出路径——正常提交、预算超限、连续格式错误——最终都表现为往消息列表里追加一条 role="exit" 的消息。异常只是运输工具(LimitsExceeded 异常体内装的就是那条 exit 消息)。这个设计的好处:退出原因和退出时的状态天然进了轨迹,事后看 exit_status 字段就知道这一趟是 SubmittedLimitsExceeded 还是 RepeatedFormatError

断路器管的是”连续”。 模型输出格式错误时,错误信息作为消息喂回去让它重试,同时 n_consecutive_format_errors += 1;任何一次干净的步骤就清零。连续 3 次才退出。计数器管的不是”总共错几次”而是”是不是卡死在同一个坑里”。

每一步都落盘,包括失败的那步。 run() 的循环体里有个 finally: self.save(...)——不管这一步是成功、格式错误还是抛了没接住的异常,轨迹文件都先写完再说。崩溃后磁盘上永远有最后一步的完整现场。

191 行里没有一行是多余的。下面的五个部件,就是把这 191 行和其他系统的做法归纳成可迁移的方法。

裸循环在生产环境有四种死法:不会停、一错到底、上下文爆掉、断了回不来。五个部件对着救:

裸循环的四种死法与五个部件的对应关系图
四种死法各有部件来救:判停信号救「不会停」,错误分类救「一错到底」,预算救「爆掉」,落盘救「回不来」,验证器把「完成」的裁决权拿出模型。

1. 停止条件:多信号,不信任何单一来源

Section titled “1. 停止条件:多信号,不信任何单一来源”

模型自己的完成信号不可靠,要用几路信号互相兜底:

  • 显式完成动作。 smolagentsagents.py,commit e3a5b89)强制注册一个 final_answer 工具,模型必须显式调用它才算完成——把”完成”从自由文本变成结构化动作。主循环判停就一行:while not returned_final_answer and self.step_number <= max_steps
  • 完成后再验一道。 smolagents 的 final_answer_checks 是一个回调列表:模型给出最终答案后,逐个跑校验函数,任何一个 assert 失败就抛 AgentError 打回去继续跑。完成的定义权在你手里,不在模型手里。
  • 不信 stop_reason。 Claude Code 的源码注释写明 stop_reason === 'tool_use' 有时是错的,所以它自己数流里的 tool_use block(详见第 02 章)。
  • 硬上限。 maxTurns / step_limit 是最后一道闸,防的是前面全部失灵。

2. 预算:三个维度,加一次优雅收尾

Section titled “2. 预算:三个维度,加一次优雅收尾”

mini-swe-agent 给了预算的完整定义:步数(防打转)、成本(美元计,防钱包爆炸)、墙钟时间(防卡死在慢工具上)。三个检查都在调模型之前执行。

预算耗尽不该是无声中断。smolagents 的 _handle_max_steps_reached 在步数用完后额外调一次 provide_final_answer(task)——强制模型基于现有记忆给出最终答复,并把这一步标记 AgentMaxStepsError 记进轨迹。Hermes 管这叫 grace call。用户拿到的是”做到哪儿了、还差什么”的交代,不是空手而归。

smolagents 还有一个值得单独记的机制:planning_interval。每隔 N 步插入一个独立的规划步——不执行动作,只让模型重新审视任务和进展。长任务里模型会逐渐忘记大目标陷进细节,周期性重规划就是防漂移的闹钟。

三维预算与断路器在一轮循环里的检查顺序
预算在调模型之前查;断路器数的是连续失败。两个退出路径都带 transition_reason 落盘。

3. 错误分类:不是所有错误都该重试

Section titled “3. 错误分类:不是所有错误都该重试”

smolagents 的错误处理分两类,_run_stream 里写得很清楚:

except AgentGenerationError as e:
raise e # 实现层的错,重试没用,直接退出
except AgentError as e:
action_step.error = e # 模型层的错,记录后继续迭代

框架自己的 bug(生成错误)重试一万次也是错,立即上抛;模型的错(格式错、工具参数错)喂回去让它自己修。mini-swe-agent 对后者再加一层断路器:连续 3 次同类错误才退出,中途任何一次成功就清零。

塞回上下文的错误要压缩。12-Factor Agents 的 Factor 9:进上下文的不是原始错误栈,而是”出了什么错、试过什么、还剩什么可选”。更进一步是 Reflexion(Shinn et al. 2023)的做法:让模型对失败做一段文字反思,存进独立的反思缓冲区随每次重试注入——不改权重,只靠语言反馈,HumanEval 从 80% 提到 91% pass@1。重试带着教训,才叫重试。

4. 轨迹落盘:循环的每一步都是可重放的记录

Section titled “4. 轨迹落盘:循环的每一步都是可重放的记录”

mini-swe-agent 每步 finally: save(),轨迹文件里有完整的消息序列、每步成本、退出状态、配置快照(trajectory_format: "mini-swe-agent-1.1"——连格式都有版本号)。Codex 走得更远:整个循环就是事件机,每步事件追加写进 rollout JSONL,重启后从 rollout 重建状态续跑,多个 agent 之间也靠读彼此的 rollout 互相观察。

原则是 12-Factor 的 Factor 12:agent 是无状态 reducer,状态就是事件序列。做到这一点,暂停、恢复、迁移机器都是同一个操作:读日志,重建,继续。

5. 验证器:完成与否由循环外的事实裁决

Section titled “5. 验证器:完成与否由循环外的事实裁决”

模型会装作完成。验证器按”硬度”分层:

  • 硬验证:测试退出码、patch 语法校验、命令白名单。Codex 在 coding 场景串四道,全是机器裁决(我们 clone 的 codex 仓库里,codex-rs/core/apply_patch.rsexec_policy 相关模块就是这些验证器的实现)。
  • 软验证:smolagents 的 final_answer_checks 回调、另一个模型当评审(Anthropic 的 evaluator-optimizer 模式)、Reflexion 式自我反思。

验证器有多硬,自主权就给多大。有测试可跑的场景放心跑几十步;只有软验证的场景把循环收短,让人早介入。

五个部件装回去(结构对照 mini-swe-agent 和 smolagents,可直接对着两个仓库的源码抄细节):

def run(task, step_limit=50, cost_limit=3.0):
log = TrajectoryLog(task.id) # 部件 4
ctx = log.replay() or [task.as_message()]
errors = ConsecutiveErrorCounter(limit=3) # 部件 3 的断路器
while True:
if over_budget(step_limit, cost_limit): # 部件 2:调模型前查
ctx.append(grace_prompt()) # 最后一次总结机会
step = llm(ctx); log.append(step)
if step.calls("final_answer"):
if all(check(step.answer) for check in final_checks): # 部件 5
return exit_message(log, "Submitted", step.answer)
ctx.append(check_feedback()); continue
try:
ctx.append(compact(execute(step)))
errors.reset()
except ModelError as e: # 模型的错:喂回去修
ctx.append(compact_error(e))
if errors.bump(e): return exit_message(log, "RepeatedError")
except FrameworkError: # 自己的错:别让模型背锅
raise
finally:
log.save() # 每步落盘,失败也落
if ctx[-1].role == "exit": break # 部件 1:退出是消息

取舍建议:

  • 先做落盘和停止条件,再做压缩。 前两者是安全网。mini-swe-agent 191 行里没有上下文压缩——短任务不需要,别过早优化。
  • 预算三个维度都设。 只设步数防不住慢工具卡死,只设成本防不住免费死循环。
  • 每个 continue 留下原因。 Claude Code 给每次续跑贴 transition.reason 标签;mini-swe-agent 每个 exit 消息带 exit_status。哪怕只是日志里多一个字段。

把框架的循环当黑盒。 Anthropic 的建议是先用裸 API 写。mini-swe-agent 证明了生产级循环 191 行足够——先读懂一个这么大的,再决定要不要上框架。

用对话历史当恢复机制。 恢复需要事件日志加确定性重放。对话历史缺工具副作用记录(文件改了吗?命令跑过吗?),直接重放会把副作用执行两遍。

把退出做成异常逃逸。 异常一路上抛,退出原因就丢在调用栈里了。学 mini-swe-agent:退出是一条带 exit_status 的消息,异常只负责运输,最终一切进轨迹。

所有错误一律重试。 先分类:实现的错上抛,模型的错喂回去,连续同类错走断路器。不分类的重试系统,最贵的 bug 会重试到预算耗尽。

验证器只在结尾跑一次。 验证嵌在循环里,每次模型声称完成就触发。结尾才发现第 3 步错了,中间几十步全白跑。

把循环工程压成五个不可省略的部件:预算在模型调用前检查;停止由多信号决定;错误先分类再重试;每步成功失败都落盘;完成声明必须经过外部事实。

下一步不要继续加框架功能。把本章骨架跑在一个可验证任务上,并依次触发三种失败:同类工具错误连续三次、模型假完成、工具提交后进程中断。你的报告只回答四个指标:退出原因是否正确、轨迹是否完整、副作用是否重复、Verifier 是否从正确检查点继续。

当这四项都稳定,再考虑压缩、并行和插件化。否则新增能力只会扩大无法解释的状态面。

六个检查问题与三道练习
Q1 · 基础:agent 主循环只有十行,为什么生产环境的循环代码经常上千行?多出来的是什么?

多出来的是循环外那圈兜底,可以按五个部件数:停止条件(多信号判停加完成校验)、预算(步数/成本/墙钟三维加 grace call)、错误处理(分类、压缩、断路器)、轨迹落盘(每步写盘加可重放)、验证器(外部事实裁决完成)。demo 只需要循环本体,生产需要全部五个。

参照物:mini-swe-agent 用 191 行装下了全部五个部件,说明”生产级”不等于”庞大”,等于”部件齐全”。反过来,几千行但缺预算或缺落盘的循环,仍然是个 demo。

源码research/mini-swe-agent/src/minisweagent/agents/default.py(commit a83fcae)。 追问:「五个部件先做哪个?」落盘和停止条件。没有它们,连”循环为什么挂了”都答不出来,其他部件无从调试。

Q2 · 预算设计:只设 max_turns 防不住哪两种事故?完整的预算应该有几个维度?

防不住两种:慢工具卡死(每步都合法但一步卡 10 分钟,步数永远用不完)和贵调用烧钱(步数没超但单步吞了 10MB 文件,费用爆炸)。

完整预算三维:步数(防打转)、成本按美元计(防钱包)、墙钟时间(防卡死)。mini-swe-agent 的 AgentConfig 就是这三维加一个连续错误上限,且三个检查都放在调模型之前执行——先检查再花钱,顺序不能反。

耗尽后还差一步:grace call。强制最后调一次模型总结进展,用户拿到”做到哪儿了、还差什么”,而不是循环无声消失。

源码:mini-swe-agent default.pyquery();smolagents _handle_max_steps_reached追问:「grace call 本身会不会超预算?」会多花一次调用,这是设计内的开销。实现上给 grace turn 禁掉工具派发(只许说话不许干活),把开销限制在一次纯文本调用。

Q3 · 错误处理:工具连续失败,什么时候该重试、什么时候该停?给出一个可实现的判据。

先分类再决定。实现层的错误(框架 bug、生成协议错误)重试没有意义,立即上抛退出;模型层的错误(参数错、格式错)压缩后喂回去让模型自己修。smolagents 的 _run_stream 里这两类是两个 except 分支,处理方向相反。

可实现的判据是连续计数器:同类错误连续 N 次(常用 3)就停,任何一次干净执行就清零。计数器度量的是”是否卡死在同一个坑”,不是”总共错几次”——一个跑了 200 步的长任务错 20 次可能很正常,连续 3 次同样的错才说明模型出不来了。

喂回去的内容要压缩成三样:出了什么错(一句话)、试过什么、还剩什么可选。原始错误栈原样塞回去,模型大概率原样重试。

源码:mini-swe-agent 的 max_consecutive_format_errors;smolagents agents.py 的错误分支;12-Factor Agents Factor 9。 追问:「Reflexion 和普通错误压缩的区别?」Reflexion 让模型对失败写一段文字反思存进独立缓冲区,随每次重试注入——重试携带教训而不只是携带事实,HumanEval 从 80% 提到 91%。

Q4 · 可恢复性:为什么”把对话历史重发一遍”不能当恢复机制?正确做法的关键点是什么?

对话历史缺副作用记录。文件改了吗?命令跑过了吗?patch 提交了吗?消息列表里没有这些执行状态,直接重放会把副作用执行两遍(重复提交同一个 patch 是典型事故)。

正确做法是事件日志加确定性重放:每步作为事件追加写盘(工具调用和结果都是事件),恢复时读日志重建状态,已执行的步骤跳过而不是重跑。原则是 12-Factor 的 Factor 12:agent 是无状态 reducer,状态就是事件序列。

两个实现细节值得抄:mini-swe-agent 在 finally 里落盘(失败的那步也有完整现场);退出本身是一条带 exit_status 的消息而不是异常逃逸(退出原因自动进轨迹)。

源码:mini-swe-agent run()finally: self.save(...);Codex rollout JSONL。 追问:「轨迹文件要版本号吗?」要。mini-swe-agent 写着 trajectory_format: "mini-swe-agent-1.1"——格式一旦变更,旧轨迹还能按旧版本解析。

Q5 · 验证器:为什么说”验证器有多硬,自主权就给多大”?两个方向各举一个例子。

验证器决定”完成”由谁裁决。硬验证器是机器裁决:测试退出码、patch 语法校验、命令白名单,模型没有申辩空间,撒谎立刻被拆穿——所以敢让循环自主跑几十步,错了兜得住。Codex 在 coding 场景串四道硬验证器,就是这个逻辑。

软验证只剩另一个模型当评审或人来看,模型”装作完成”可能混过去。这种场景应该收短循环、降低 max_turns、让人早介入——写调研报告的 agent 给 90 步预算就是在赌运气。

反过来推也成立:想给 agent 更大自主权,先问能不能造出更硬的验证信号(比如给写作任务加事实核查脚本、给数据任务加 schema 校验),而不是直接调大预算。

源码:Codex apply_patch.rs 与 exec_policy 模块;smolagents final_answer_checks追问:「验证器应该在什么时机跑?」每次模型声称完成就触发,不是任务结束跑一次。结尾才发现第 3 步错了,中间几十步全白跑。

Q6 · 开放题:给你半天时间改造一个裸 while 循环的 agent,按什么顺序加部件?为什么?

顺序:落盘 → 停止条件 → 预算 → 错误分类 → 验证器。

先落盘(1 小时):每步 finally 写 JSONL,退出改成带 exit_status 的消息。这是调试其他一切的前提——没有轨迹,后面每个部件出问题都是盲修。

再停止条件(1 小时):注册显式的 final_answer 动作,加 max_turns 硬上限。此时循环至少不会跑飞。

然后预算(30 分钟):补成本和墙钟两维,加 grace call。然后错误分类(1 小时):两类 except 分支加连续计数器。

验证器放最后不是因为不重要,而是因为它最依赖领域(有没有测试可跑?有没有 schema 可校验?),前四个部件是纯工程,闭着眼抄 mini-swe-agent 就行,验证器需要想。

源码:整个顺序可以对照 mini-swe-agent 的 191 行逐块抄。 追问:「上下文压缩什么时候加?」出现长任务再加。mini-swe-agent 全文没有压缩逻辑——短任务加压缩是过早优化,还会引入丢信息的新 bug。

  1. 读源码:打开 research/mini-swe-agent/src/minisweagent/agents/default.py,把 191 行里对应五个部件的代码行各标出来。哪个部件占的行数最少?(提示:有一个部件只用了一行 finally。)
  2. 改代码:给本章骨架代码加第四个预算维度——单步 token 上限(一次工具返回超过 N token 就截断并标记)。想清楚:截断信息放不放进轨迹?
  3. 设计题:你的 agent 是”每周自动整理团队周报”的长跑任务,没有测试可跑。为它设计停止条件和验证器:至少两路判停信号、一个软验证方案,并说明 max_turns 应该设大还是设小。

本章结论基于以下一手材料(代码库已 clone 到本仓库 research/ 目录,行号以对应 commit 为准):

来源版本读了什么
SWE-agent/mini-swe-agenta83fcaesrc/minisweagent/agents/default.py 全文 191 行
huggingface/smolagentse3a5b89src/smolagents/agents.py_run_stream_handle_max_steps_reachedfinal_answer_checksplanning_interval
openai/codexfa1d4c4codex-rs/core/ 循环与 rollout 相关模块
ReActarXiv:2210.03629thought-action-observation 三拍结构
ReflexionarXiv:2303.11366语言反馈式重试,episodic 反思缓冲区
Building Effective AgentsAnthropicaugmented LLM、evaluator-optimizer
12-Factor AgentsHumanLayerFactor 6 / 8 / 9 / 12

四个商业/开源 harness(Codex、Claude Code、OpenClaw、Hermes)的循环对照,见本站第 02 章——那里是逐行对照的实现细节,本章是方法。

循环工程管一个 agent 内部。活儿大到一个循环装不下——拆给多个 agent、部分冻成代码、人要进来审批——就到了 Graph Engineering 的地界。