Coding agent在简单任务上产生2000行diff并反复撤销的设计振荡问题,提示需对AI输出设限。
任务在纸面上看起来微不足道:为一个小型 Python 服务添加一个速率限制器,并更新三个调用点。我把它交给了一个运行在 MonkeyCode 上的 coding agent,这是一个开源项目,提供免费模型访问和免费服务器选项。声明:本文是 MonkeyCode 产品推广的一部分。我做了大多数工程师面对一千万 token 配额时会做的事:给 agent 充足的预算,然后走开。
三小时后,我回来时发现一个本应是四十行改动的任务,diff 足足有两千行。测试套件是绿的,这让失败更难解释,但 agent 的日志讲述了一个更清晰的故事。同一文件被编辑了十四次,每次编辑似乎都在撤销前一次改动然后添加新内容。Agent 在两种设计之间振荡,而我的设置中没有任何东西能注意到这一点。
我的第一个假设是 prompt 模糊,因为任务描述确实为速率限制器应该放在哪里留下了多种解释空间。我用明确的约束条件重写了 prompt,固定了确切的函数名,并添加了一句话要求最小化 diff。第二次运行更快了,但日志显示了相同的振荡模式,这排除了 prompt 作为主要原因。
我的第二个假设是模型质量问题,我准备怪罪免费套餐,直到我查看了证据。Agent 的推理轨迹表明,每次设计变更在局部都是合理的;问题在于 agent 没有理由停止探索。它不断发现边际改进,而每一次改进都使前一次迭代中的一个假设失效,所以文件像钟摆一样来回翻转。
根本原因不是模型,也不是 prompt;而是预算作为一种一等公民约束的缺失。一千万 token 的配额大到足以让 agent 将其视为无限,而我的测试工具没有给 agent 提供除了"完成任务"之外的终止条件。没有停止信号,agent 优化了一个未声明的目标:完美的解决方案,而非正确的解决方案。真正的 bug 在我的工作流中,这是最有用的 bug 类型。
修复方案是一个小型 Python 测试工具,它用对待测试断言的方式对待 token 预算。它限制运行次数、测量 diff,并在 agent 超出限额或振荡时大声失败。我用了大约四十行代码编写它,它可以在任何装有 Python 和 git 的机器上复现。
#!/usr/bin/env python3
"""budget_harness.py - cap an agent run and detect edit oscillation."""
import argparse
import subprocess
import sys
import time
def estimate_tokens(text: str) -> int:
# Heuristic: roughly four characters per token for code and prose.
return len(text) // 4
def edits_per_file(diff: str) -> dict[str, int]:
files: dict[str, int] = {}
current = None
for line in diff.splitlines():
if line.startswith("+++ b/"):
current = line[6:]
files[current] = 0
elif current and line.startswith("+") and not line.startswith("+++"):
files[current] += 1
return files
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--task", required=True)
parser.add_argument("--budget-tokens", type=int, default=150_000)
parser.add_argument("--max-edits-per-file", type=int, default=8)
parser.add_argument("--agent-cmd", required=True)
args = parser.parse_args()
if estimate_tokens(args.task) > args.budget_tokens:
sys.exit("Task itself exceeds the token budget.")
start = time.monotonic()
proc = subprocess.run(args.agent_cmd, shell=True, capture_output=True, text=True)
elapsed = time.monotonic() - start
used = estimate_tokens(proc.stdout + proc.stderr)
print(f"elapsed={elapsed:.1f}s estimated_tokens={used}")
if used > args.budget_tokens:
sys.exit(f"Token budget exceeded: {used} > {args.budget_tokens}")
diff = subprocess.run(["git", "diff"], capture_output=True, text=True).stdout
offenders = {f: n for f, n in edits_per_file(diff).items()
if n > args.max_edits_per_file}
if offenders:
sys.exit(f"Oscillation detected: {offenders}")
print("Budget OK, no oscillation detected.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
这个测试工具通过三个步骤直接对应我观察到的三个症状。首先,它估算 agent 输出的 token 数量,并在运行超出限额时以非零代码退出。这将一个无声的成本问题转化为一个可见的失败。其次,它解析 git diff 并计算每个文件新增的行数,因此一个文件被编辑超过八次就会触发振荡警报。第三,它在每次运行时打印经过时间和估算的 token 数量,这让你知道一个正常任务应该花费多少。
我用十五万 token 上限和每个文件八次编辑限制运行了相同的速率限制器任务,agent 在一次通过中完成,diff 为四十一行。有趣的是第二次运行,我故意将上限设得太低。测试工具退出并给出了清晰的消息,我可以检查部分日志来看 agent 从哪里开始偏离。这种失败模式是你想要的,因为它将一个无界过程转化为一个有界实验。
可复用的经验是:免费配额仍然是预算,而从未被执行的预算根本不是预算。把 token 限制当作测试断言:在运行前定义它们,在运行中强制执行它们,并在违反时大声失败。在 diff 审查中添加振荡检测器,因为反复编辑同一文件的 agent 通常陷入局部搜索循环。
这种方法有真实的局限性,它并非适合每个团队。硬性 token 上限会破坏那些真正需要数十万 token 的大型重构,所以这个测试工具属于每个任务的配置,而不是全局策略。基于字符的 token 估算是一个启发式方法,不是真正的 tokenizer,它只测量可见输出,而不是许多 agent 消耗的隐藏推理 token。没有任何预算工具能修复模型质量;它只是在损害到达你的主分支之前让坏模型的成本变得可见。
如果你想亲自看到振荡模式,在 MonkeyCode 的免费服务器上大约需要一个小时来完成相同的复现。上面的测试工具会在 diff 之前向你展示失败。这个脚本现在存在于我的 dotfiles 中,自那第一次事故以来又捕获了两次失控的 diff。慷慨的 token 配额是一份礼物,但使用礼物的唯一安全方式是确切知道它在哪里结束。