作者让 AI 编程 agent 在修复 bug 时顺手做变量重命名、代码格式化等额外改动,通过为每个任务声明允许修改的文件和代码量上限,并在修改前拦截越界操作来解决。
我的自主编程 Agent 总会把小任务变成庞大的 diff:让我修一个 bug,它顺便把一个辅助函数改名了、把一个文件重新格式化了、还做了个"顺手清理"——没人要求它做这些。我用 Diff Budget 解决了这个问题:每个任务声明它可以修改哪些路径以及允许的变更规模,一个 hook 在编辑发生前就挡住越界改动,还有一个"停车场"把 Agent 的顺便想法变成后续任务而非突如其来的变更。以下是设计方案、代码实现以及运行它得到的 5 条经验教训。
我运行的是一套完全自主的实现系统:一个编排模块向多个并行实现 Agent 分发任务,它们在我处理其他事情(经常是我睡觉的时候 😴)时处理待办队列。
这些 Agent 完成任务的水平不差。这从来不是问题。问题是它们在任务之外做的那些事。
一个典型例子:任务说"修复分页辅助函数里的 off-by-one"。Agent 修好了。然后,因为它已经在那个文件里了,它就:
把两个变量"为了清晰"重命名了
把一个 callback 转成了 async/await
注意到相邻模块里有类似模式于是"对齐"了一下
更新了一个配置文件,因为 linter 报了某个无关的问题
这些改动单独看都站得住脚。但加在一起,就把一个五行的修复变成了涉及十几个文件的 diff,而这在三个方面造成了损害:
Review 成本暴涨。我一分钟就能核实一个五行修复。但一个十二文件的 diff,真正要修的内容埋在中间某处,需要投入真正的注意力,而注意力是我的系统唯一无法再生的资源。
并行 Agent 互相冲突。当多个 Agent 在同一个仓库工作时,它们未经请求的改动恰好是那些会重叠的部分。两个 Agent 从不会因为被分配的任务而冲突。它们争夺的是双方都决定要"整理一下"的共享工具文件。
回滚变得混乱。如果修复错了,我想撤销这个修复。我不想同时撤销一个后续任务已经依赖了的无关重构。
我的第一反应是显而易见的:在指令里加一行。"只改任务需要的。不要重构无关代码。"
有一点帮助,但远远不够。Agent 并不觉得自己做的清理是"无关的"。从任务内部看,把一个容易混淆的变量重命名,感觉就像是在出色地完成工作。我用一句话让一个模型去抵抗它认为是美德的东西。这是一场注定失败的战斗。
指令描述的是意图。如果你需要保证,你需要的是机制。
所以我不再试图说服 Agent,而是在它周围建起了篱笆。
设计方案有三部分:每个任务声明的预算、在编辑落地前强制执行路径限制的 hook,以及检查最终 diff 强制执行规模限制的检查点。再加一个逃生舱——后来证明正是这个部分让其他一切都运转起来了。
flowchart TD
A[Orchestrator creates task] --> B[Task spec with scope + budget]
B --> C[Implementation agent starts]
C --> D{Edit inside allowed paths?}
D -- yes --> E[Edit applied]
D -- no --> F[Blocked: agent told to use parking lot]
F --> G[Parking lot note written]
E --> H{Final diff within budget?}
H -- yes --> I[Commit + hand to review]
H -- no --> J[Returned to agent: trim or request extension]
G --> K[Orchestrator files follow-up tasks]
编排模块创建任务时,spec 现在携带一个 scope 块。这故意做得很无聊:
{
"id": "task-0412",
"goal": "Fix off-by-one in pagination helper",
"scope": {
"allowed_paths": ["src/pagination/**", "tests/pagination/**"],
"max_files": 4,
"max_changed_lines": 80
}
}
三个数字加一个 glob 列表。规划步骤在写任务时根据任务类型设置它们:bug 修复给紧预算,功能开发给宽松一些的,重构本身是一个独立的任务类型自带显式的大预算。最后这点很重要。我不反对重构。我反对的是把重构偷偷塞进别的任务里。
Claude Code(我用的是 v2.1.x)支持在工具调用前运行的 hook。PreToolUse hook 以 JSON 格式在 stdin 接收工具调用,如果它以退出码 2 退出,该调用被阻止,hook 写入 stderr 的内容会被反馈给模型。
这个反馈通道就是整个诀窍。Agent 不是撞上一堵墙就停了。它被告知为什么、应该怎么做。
#!/usr/bin/env python3
"""PreToolUse hook: block edits outside the current task's allowed paths."""
import fnmatch
import json
import os
import sys
call = json.load(sys.stdin)
if call.get("tool_name") not in ("Edit", "Write"):
sys.exit(0)
with open(os.environ["TASK_SPEC"]) as f:
scope = json.load(f)["scope"]
root = call.get("cwd", os.getcwd())
target = os.path.relpath(call["tool_input"]["file_path"], root)
if any(fnmatch.fnmatch(target, pattern) for pattern in scope["allowed_paths"]):
sys.exit(0)
print(
f"BLOCKED: {target} is outside this task's scope "
f"({', '.join(scope['allowed_paths'])}).\n"
"If this change is required to finish the task, stop and request a "
"scope extension with a one-line reason.\n"
"If it is an improvement you noticed, append it to the parking lot "
"file and keep going.",
file=sys.stderr,
)
sys.exit(2)
在设置文件里注册它只需要几行:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [{ "type": "command", "command": "python3 hooks/scope_guard.py" }]
}
]
}
}
一个诚实的警告:这个 hook 只覆盖文件编辑工具。有 shell 访问权限的 Agent 仍然可以通过 shell 命令修改文件,所以 hook 是一个护栏而非安全边界。这就是为什么下一个检查点存在。
路径限制阻止了 Agent 到处乱窜。但它不能阻止 Agent 重写允许目录里的所有内容。所以在任务的工作被提交前,一个小脚本把实际 diff 与预算进行比较:
#!/usr/bin/env python3
"""Compare the working tree diff against the task's budget."""
import fnmatch
import json
import subprocess
import sys
scope = json.load(open(sys.argv[1]))["scope"]
out = subprocess.run(
["git", "diff", "--numstat", "HEAD"],
capture_output=True, text=True, check=True,
).stdout
files, lines, outside = 0, 0, []
for row in out.strip().splitlines():
added, deleted, path = row.split("\t")
files += 1
if added != "-": # binary files report "-"
lines += int(added) + int(deleted)
if not any(fnmatch.fnmatch(path, p) for p in scope["allowed_paths"]):
outside.append(path)
problems = []
if outside:
problems.append(f"out-of-scope files: {', '.join(outside)}")
if files > scope["max_files"]:
problems.append(f"{files} files changed (budget {scope['max_files']})")
if lines > scope["max_changed_lines"]:
problems.append(f"{lines} lines changed (budget {scope['max_changed_lines']})")
if problems:
print("OVER BUDGET: " + "; ".join(problems))
sys.exit(1)
print(f"within budget: {files} files, {lines} lines")
因为这个脚本读取的是 git 的真实 diff,所以无论文件是如何被修改的都能捕捉到,包括 hook 看不到的 shell 命令路径。
当检查失败时,任务不会被丢弃。输出返回给 Agent,让它二选一:把 diff 缩减到任务需要的规模,或者申请延期。
我的第一版只有篱笆,它有一个糟糕的副作用:Agent 会注意到 scope 外的真正问题,被阻止,然后这个观察就消失了。有些观察是有价值的。一个真正坏掉的相邻模块是我想要知道的。
所以每个任务都有一个停车场:一个普通的 Markdown 文件,Agent 把它注意到但不被允许修改的东西追加进去。
## Parking lot: task-0412
- src/search/cursor.py has the same off-by-one pattern as the pagination
helper. Likely the same bug. Suggested task: bug fix, tight budget.
- Variable names in src/pagination/window.py are misleading (`end` is
inclusive). Suggested task: refactor, low priority.
当任务完成后,编排器读取停车场,把每个条目作为候选任务归档到待办队列中,带着它自己的 scope 和 budget。清理仍然会发生。只是作为它自己的小而可审查、可回滚的变更发生了。
这比篱笆更大地改变了 Agent 的行为。一旦有了一个合法的出口来放它的想法,它就不再试图偷偷塞进去了。
有时候预算就是不够。修复确实需要改两格目录外的共享类型定义。为此,Agent 可以申请延期:它停下来,写一行话解释它需要哪个路径以及为什么,然后由编排器决定。
停留在同一个包内的小延期自动批准。任何涉及共享区域或高流量区域的延期需要等我决定。实际上大多数请求都是合理的,而那些不合理的通常说明任务一开始就没指定好——这也是有用的信息。
范围蔓延是美德放错了地方。Agent 在做整理时并不是在调皮。它在做的是一个认真负责的工程师会做的事。把这当作不服从来处理会让你写出更严厉的指令。把这当作方向错误努力来处理会让你建一个停车场。
用解释来阻止,不要静默。仅仅说"拒绝"的 hook 会让 Agent 换种方式再试。同样的路径、不同的方式。一个说"这超出 scope 了,这里有两个选项"的 hook 几乎每次都能得到合理的下一步行动。错误信息本身就是一个 prompt。像写 prompt 一样写它。
给 Agent 一个合法的出口。每个约束都需要回答"好吧,那我就把这个怎么办?"没有停车场的时候,我是在扔掉真正的发现。有了它,约束就变成了一条路由规则而不是口套。
预算应该按任务类型设定,而不是凭感觉。一个单一的全局限制对某些人总是错的:对 bug 修复太松,对功能开发太紧。三个或四个有不同默认值任务类型几乎覆盖了我需要的一切。
小 diff 是系统的特性,不是奢侈品。Review 速度、并行 Agent 不冲突、干净的回滚:这些都依赖变更要小且单一目的。如果你让 Agent 无人值守运行,diff 规模是要首先保护的东西。
更聪明的预算。现在预算来自任务类型的静态默认值。我想让规划器根据任务实际引用的文件来估算它们。
追踪延期请求。如果代码库的某个区域不断触发延期,那是那里的模块边界有问题的信号。我希望能自动浮现出来。
停车场去重。不同的 Agent 有时会注意到同一个问题。在它们变成任务前合并这些条目已在计划中。
如果你的编程 Agent 给你的 diff 总是任务的三倍大,别写更严厉的指令。声明一个预算,用 hook 强制执行,检查真实 diff,再给 Agent 一个停车场来放它的好想法。用上面两个脚本在一下午就能搭出第一版。