一个修复 flaky test 的 Agent 在测试失败后无限制重试,29 个循环后账单暴涨、代码树噪声堆积、问题仍未解决。作者复盘时间线,指出根因是缺少硬性停止机制——人类会因厌倦而停下,Agent 只管继续消耗 token。
你在凌晨 01:14 收到告警,仪表盘显示一个编码 Agent 停不下来了。这个任务本来只是修一个不稳定的测试,然后开一个小小的 PR。可追踪记录看起来像一个仓鼠轮:同样的工具调用、同样的错误、同样的道歉,然后又来一遍。等你终于 kill 掉这个进程时,供应商的账单已经涨到了一个让你无法忽视的数字。
这篇是一次事故复盘,不是关于"模型到底能不能写代码"的感性讨论。你会看到完整的时间线、致因分析,以及一个真正可以落地的持久护栏。这个失败在纸面上看起来很普通——这正是它不断出现在原本谨慎的代码库里的原因。如果你的团队在交付使用工具的 Agent,你一定已经遇到过这类事故的某个"表亲"了。
Agent 收到一个 pytest 失败输出后,被允许调用 run_tests、edit_file 和 git_commit,且没有硬性停止机制。每次重试单独看都合理——就像一个开发者在反复点击同一个红掉的测试。区别在于人会腻,而循环只会烧更多 token。二十九个周期之后,调用树越来越嘈杂,测试依然失败,只有计费表清清楚楚地在走。
你应该把这种模式当作一次事故来对待,因为它的爆炸半径远不止一个失败的任务。钱是最显而易见的成本,但同样代价不菲的还有一条脏分支,以及一个把值班人员训练成"看到 Agent 任务就无视"的告警系统。如果 runner 一直用另一次重试吞掉同一个异常,日志救不了你。持久的修复不是更严厉的提示词,而是一个在进展停滞时能够 fail closed 的监督者。
下面列出的时间是一次重建的演练,不代表某个具体公司或可量化的生产账单。但你写真实事故时应该用同样的结构:时钟时间、保险丝、以及使这类故障不再可能发生的改动。自己发布内部文章时用你自己的追踪记录;这里的数字只是为了让代码有具体的东西可以对照。
00:02,调度器启动了针对 test_invoice_total 这个已知 flaky 测试的夜间 Agent 任务。
00:04,Agent 读取了文件,重写了一个断言,然后运行了测试套件——结果因为一个无关的 fixture 泄漏而失败。
00:07,它把这次新失败当成了继续编辑的理由,工具策略也同意了。没有人为这个任务配置 per-job 保险丝,所以 runner 把每一次额外的调用都当作仍在策略范围内。
从 00:07 到 01:11,循环产生了十七次文件编辑和二十九次测试运行,全部发生在同一个 checkout 里。有几次编辑在振荡:一个舍入改动先进去、又出来、再进去,这次带了一条不同的注释。
01:14,预算告警终于触发了——因为供应商集成的月额度上限,不是 per-run 上限。
01:16,你发送了 SIGTERM,Agent 在起草一条为"前一次提交"道歉的提交信息时死掉了。
整个序列里没有任何一步需要前沿模型以某种戏剧性的方式作恶。它只需要:缺失的边界、一个黏性的工作目录,以及一个把"动了"当成"有进展"的 retry 策略。这些都是工程fault,完全可以用一个回归测试来锁定。
第一个因素是一个隐藏在看似友好的系统提示词里的无界工具预算。你告诉模型"一直跑到测试通过为止"——听起来像授权,实际上是一张空白支票。
第二个因素是用相同的参数反复重试同一个工具,因为测试 harness 没有对调用做 hash。
第三个因素是共享状态——每次编辑都修改了分支的唯一副本,污染了后续的推理。
第四个因素藏在你信赖的账单设计里,你以为它会在月底 catch 问题。你有一个月度账户限额,但没有 per-run 保险丝——就像大厅里有烟雾报警器,但厨房里没有。
第五个因素是沉默——日志存在,但没有人因为反复出现完全相同的工具调用而分页。如果你曾经以一种着迷而不是恐惧的眼神围观一个卡住的 CI 任务,你就知道这件事是如何保持隐形的。
这些因素没有一个是稀有的,这正是它们该出现在事故复盘里、而不是一篇思考文章里的原因。你可以不等更聪明的模型就先改 harness,这周就应该做。下一场事故不会等某篇研究论文重新定义什么才叫 Agent。
下面的 supervisor 是一个提案,不是说某个知名公司发生了生产故障。你可以把它放在任何产生工具名、参数字符串和 token 估算值的 agent runner 旁边。它追踪 token 使用量、相同调用的重复次数和编辑振荡,然后在 orchestrator 必须吞掉的 typed error 上 raise。把 orchestrator 保持无聊:如果出现了 LoopIncident,任务就失败、分支保持未推送状态、人类来读追踪记录。
# proposed supervisor: loop_guard.py
from __future__ import annotations
from collections import Counter
from dataclasses import dataclass, field
from hashlib import sha256
from typing import Callable
class LoopIncident(RuntimeError):
"""Raised when the agent is moving without making durable progress."""
@dataclass
class LoopGuard:
max_tokens: int = 80_000
max_steps: int = 12
max_identical_calls: int = 2
max_oscillations: int = 2
tokens_used: int = 0
steps: int = 0
call_counts: Counter[str] = field(default_factory=Counter)
file_versions: dict[str, list[str]] = field(default_factory=dict)
def charge(self, tokens: int) -> None:
self.tokens_used += tokens
if self.tokens_used > self.max_tokens:
raise LoopIncident(
f"token fuse blown at {self.tokens_used}/{self.max_tokens}"
)
def observe_call(self, name: str, arguments: str) -> None:
self.steps += 1
if self.steps > self.max_steps:
raise LoopIncident(f"step fuse blown at {self.steps} steps")
fingerprint = sha256(f"{name}:{arguments}".encode()).hexdigest()
self.call_counts[fingerprint] += 1
if self.call_counts[fingerprint] > self.max_identical_calls:
raise LoopIncident(f"identical call {name} repeated past policy")
def observe_edit(self, path: str, contents: str) -> None:
digest = sha256(contents.encode()).hexdigest()
history = self.file_versions.setdefault(path, [])
if history and digest == history[-1]:
return
if digest in history:
repeats = history.count(digest) + 1
if repeats > self.max_oscillations:
raise LoopIncident(f"{path} oscillated {repeats} times")
history.append(digest)
def run_agent(step: Callable[[], tuple[str, str, int]], guard: LoopGuard) -> None:
"""Drive one proposed agent loop until success or a fail-closed incident."""
while True:
tool_name, tool_args, token_cost = step()
if tool_name == "stop":
return
guard.charge(token_cost)
guard.observe_call(tool_name, tool_args)
你应该注意到,这个 guard 并没有试图判断模型今晚是否聪明。它只判断这次运行是否仍然是一个有可见预算的bounded job。这和你对 CI minutes 和数据库连接池已经应用的直觉是同一个东西。如果你不会给一个 cron 脚本无限信用卡,你也不应该给一个 tool-calling loop 无限信用卡。
没有失败测试的事故复盘,是你在下一个 sprint 之后就会忘记的故事。下面的测试重建了振荡编辑和相同工具调用,且它们预期会抛出 LoopIncident。把它们当作提案示例,直到你在自己的代码树里跑过它们。它们不会模拟供应商故障;它们只是锁住保险丝策略,使其不会漂移。
# proposed: tests/test_loop_guard.py
import pytest
from loop_guard import LoopGuard, LoopIncident, run_agent
def test_identical_test_runs_fail_closed():
guard = LoopGuard(max_identical_calls=2, max_steps=10, max_tokens=10_000)
calls = [("run_tests", "pytest tests/test_invoice_total.py", 900)] * 4
calls.append(("stop", "", 0))
iterator = iter(calls)
def step():
return next(iterator)
with pytest.raises(LoopIncident, match="identical call"):
run_agent(step, guard)
def test_file_oscillation_fail_closed():
guard = LoopGuard(max_oscillations=2)
path = "billing.py"
guard.observe_edit(path, "return round(x, 2)")
guard.observe_edit(path, "return round(x, 4)")
guard.observe_edit(path, "return round(x, 2)")
guard.observe_edit(path, "return round(x, 4)")
with pytest.raises(LoopIncident, match="oscillated"):
guard.observe_edit(path, "return round(x, 2)")
def test_token_fuse_is_per_job_not_monthly():
guard = LoopGuard(max_tokens=2_000)
guard.charge(1_500)
with pytest.raises(LoopIncident, match="token fuse"):
guard.charge(600)
像普通单元测试一样跑它,让事故变成一个回归测试,而不是 campfire folklore。
python -m pytest tests/test_loop_guard.py -q
如果测试套件全绿,你只是证明了 Agent 不是智慧的,只是证明了 harness 会更早停止挖坑。这个 postmortem 在模型开始空转时愿意做出的承诺就这一个。把同样的限制接进夜间命令,这样 replay 就不可能跑过你刚加的那个测试。
# proposed replay flags; fill values from your own traces
export AGENT_MAX_TOKENS=80000
export AGENT_MAX_STEPS=12
python -m nightly_agent --branch replay/invoice-flake --dry-run
你仍然需要一个地方来 replay 原始任务,因为单元测试无法说明 prompt drift 或工具延迟。复制夜间 payload,让 runner 指向一个非生产分支,保持和你打算交付的同样的 LoopGuard 限制。Replay 的目的不是欣赏一个更便宜的模型;而是看着保险丝在记录的 step 上熔断。在事故 ticket 里捕获 step 索引、工具 hash 和 token 估算值,这样 replay 就有了一个目标。
当你想在不消耗生产 key 的情况下做 replay 时,你需要一个 scratch model 端点加一台 scratch 机器。披露:本文是 MonkeyCode 产品推广的一部分。MonkeyCode 提供免费模型访问和一个可用作 scratch box 的免费服务器选项。用它来exercise这个 guard,直到付费凭证回到调度任务。如果你走这条路,从你自己的追踪记录里取预算数字,并把生产密钥留在 scratch 主机之外。
免费的 scratch lane 不会改变这个 postmortem,也不应该变成第二个生产区域。它只是让这个昂贵的教训在你仍然对工具调用做 hash、仍然不吞掉 LoopIncident 的时候更便宜地重复。把 scratch 主机想象成消防演练场——同样的保险丝必须在那里被主动烧断。
这个方法救不了错误的规格说明、中毒的 fixture,或者无法确定性运行的测试套件。如果测试闪烁,Agent 会不断发现新的失败,而 guard 只是更早停止挖坑。这个结果比空白支票好,但仍然不是可以交付的绿灯构建。如果你需要合同级 uptime、数据主权或经过审计的模型溯源,免费的共享端点不是正确的复现场地。
你不应该用这个模式把一个失控循环藏进更大的配额里。把 max_tokens 调大直到任务跑完,是当初星期二晚上发生这件事的源头。不要把你客户的源代码 paste 到你无法控制的 scratch 服务器上。编辑 payload、构建 fixture、用仍然触发相同保险丝的合成文件 replay 这个循环。
本周更广泛的讨论一直在问:模型是否已经在日常工作中超越了大多数程序员。这个问题噪音很大,而且当仪表盘在 01:14 还在转的时候它帮不了你。更安静的问题是:你的编排层在模型、测试或工具 schema 开始空转时,是否 fail closed。你已经知道如何为 web workers 和 queue consumers 回答这个问题,所以把同样的箍套在 Agent 上。
guard 进了 CI 之后,把这次事故按时间、保险丝和添加了 LoopIncident 的 commit 写下来。未来的你会感谢现在的你给了一条时间线,而不是一张红色 token 图的截图。下一个 Agent 仍然会过度自信,而你的工作是确保它同时也不可能超预算。