本节摘要:主循环的工程含量不在"转起来",在"停得下来、坏了不雪崩"。终止分两大类:正常终止(模型不再请求工具、给出最终答案——注意要防"假完成",模型有时会提前宣布成功)与异常终止。异常终止有四类触发器:限额触发(最大步数、token 预算、成本上限、单步超时)、空转检测(连续 N 步无实质进展,如重复调用同一工具同参数)、错误风暴熔断(连续失败超过阈值即熔断,防止"报错→重试→更怪的错误→更频繁重试"的雪崩)、外部取消(用户 Ctrl-C、上游任务超时)。失败后的最后一课是"优雅放弃":与其静默返回半成品,不如生成一份结构化交代——做到了哪、卡在哪、建议人接手做什么。本节给出熔断器的参考实现。
阅读完本节,你应当能够:
一个没有终止设计的循环,失败模式全在账单和事故报告里(以下均为示意场景):
| 事故 | 循环行为 | 根因 |
|---|---|---|
| 空转烧钱 | 连续 37 次调用 read_file("app.py") |
无空转检测 |
| 错误风暴 | 工具报错→模型换姿势再试→再报错→……步数耗尽 | 无熔断,把"重试"全留给模型自由发挥 |
| 假完成 | 测试还没跑,模型宣布"已修复" | 无完成校验 |
| 僵尸任务 | 卡在一个 30 分钟不返回的工具调用上 | 无单步超时 |
**核心认识:模型不是一个可靠的停机方。**它是概率机器,在"差不多了"和"再试试"之间的选择并不稳定——停机的责任必须由 harness 的确定性代码承担。这就是本节全部内容的动机。
正常终止:模型无 tool_calls 且给出最终答案(3.1 节已实现) │ 防线:完成校验(能验就验——跑测试、查文件存在性),防"假完成" ▼ 异常终止四类触发器 ├─ ① 限额触发:max_steps / token 预算 / 成本上限 / 单步超时 ├─ ② 空转检测:连续 N 步"无实质进展" ├─ ③ 错误风暴熔断:连续 M 次工具失败(或失败率超阈值) └─ ④ 外部取消:用户中断 / 上游超时 / 平台关停信号
① 限额是保底,必须硬编码在循环里(mini_loop.py 已有 max_steps)。单步超时常被遗忘:给每个工具执行加超时(如 120s),否则一个挂死的命令就能拖住整个会话。
② 空转检测的实用判据是"重复签名":把每步的工具调用记为 (工具名, 参数哈希),连续 N 步签名相同或签名集合不再变化,即判空转。注意与合法重试区分——同一签名连续出现 2 次可能是合理重试,5 次就是死循环(阈值示意,按任务调)。
③ 熔断见下节。
④ 外部取消要求循环每拍开头检查取消标志,且工具执行要能被中断——这在终端形态(Ctrl-C)里容易,在常驻形态(第 2.2 节 OpenClaw 类)里要专门设计。
错误风暴的机理:工具失败 → 模型看到错误信息 → 尝试修复性重试 → 由于上下文已被污染(历史里堆满失败记录),决策质量下降 → 更容易失败。每一步失败都在消耗上下文预算与模型判断力,失败是会复利的。
熔断器的参考实现(写法示意,以实际工程为准):
# breaker.py —— 错误风暴熔断器(写法示意,以实际工程为准) class Breaker: def __init__(self, max_consecutive: int = 3, cooldown_steps: int = 2): self.fails = 0 # 连续失败计数 self.max_consecutive = max_consecutive self.cooldown = cooldown_steps def record(self, ok: bool) -> str | None: """每次工具执行后调用;返回 None 表示正常,返回字符串表示熔断动作。""" self.fails = 0 if ok else self.fails + 1 if self.fails >= self.max_consecutive: self.fails = 0 return f"连续失败 {self.max_consecutive} 次,熔断:暂停工具执行 {self.cooldown} 步" return None def tripped(self) -> bool: return self.fails >= self.max_consecutive
熔断后的三级响应(从轻到重):
💡 熔断不是"失败",而是 harness 对模型的元反馈:模型没有天然的"我已经连环撞墙"的自我意识,熔断器替它数着,并在适当时机把"你该停了"作为观察喂回去。这是第 8 章 Hook 反馈回路在循环内部的第一个实例。
假完成:模型说"已修复"但测试没跑、甚至改动根本没写进文件。最小防线是完成校验——任务有客观验收标准(测试、命令输出、文件状态)时,在终止前用确定性代码验一遍;验不过,把证据回填("你说修好了,但 pytest 仍报 3 个失败,输出如下……"),让模型继续。这一个小改动对编码智能体的实际可靠性的提升,常常超过换一个更强的模型(社区经验)。
优雅放弃:所有终止路径(限额、熔断、取消)最终都汇到同一个出口——给用户一份结构化交代,而不是静默半成品:
{ "status": "aborted", "reason": "max_steps_reached", "steps_used": 30, "completed": ["已定位 bug 在 auth.py:42", "已修改一处类型错误"], "blocked_at": "运行 pytest 时连续 3 次超时", "suggest_next": "人工检查 pytest 超时是否与环境有关;改动已保存在工作区" }
一句话原则:放弃也是一种交付。用户拿到"做到哪、卡在哪、建议下一步",半成品的工作就没有白费;拿到一句 "Error: task failed",前面 29 步就全部沉没。
循环的正向与反向路径都齐了。还剩第一拍"感知"里那个被反复推迟的问题:每一步到底给模型看什么?下一节讲上下文组装的时机与策略——循环质量的天花板,就压在这一格上。