文章建议在 Agent 开工前记录并提交任务范围,再用确定性脚本筛出需要重点审查的差异块。示例指出,重试修复可能夹带默认配置修改和测试断言弱化,仅看测试通过难以发现范围偏移。
大多数 Agent 编写的 PR 并不难读,难的是界定范围。逐行看,代码通常没什么问题;你看不到的是,Agent 在编写过程中,悄悄给自己划定了怎样的任务范围。于是,你花了四十分钟,还原那些从未写下来的意图,最后因为测试全绿而批准合并。
有一种成本更低的操作顺序:在 Agent 开始运行之前写清范围,把它和任务说明一起提交,再让一个确定性脚本告诉你,哪些变更块值得重点关注。脚本不判断代码是否正确。它告诉你,哪些地方需要认真讨论正确性。
一个典型的 Agent PR 写着:“处理 429 响应,避免无限重试。”diff 却修改了这些文件:
src/http/client.py —— 符合预期,这就是任务要改的地方。
src/config/defaults.py —— 不符合预期。默认尝试次数从 3 改成了 1。
tests/http/test_client.py —— 六条断言被替换成了一条 assert response is not None。
没有哪一行明显有错。问题恰恰就在这里。你需要审查一个从未说明理由的决策,它混在一份大部分变更块都是机械性修改的 diff 里,而 review 界面又赋予所有变更相同的视觉权重。
接下来,reviewer 通常会选择两种糟糕策略中的一种:对每个变更块投入同样的注意力,速度慢,一天看到第三个 PR 就精疲力竭;或者粗略浏览,相信测试,速度快,却也正是断言弱化得以合并的方式。这两种做法,都是在弥补元数据的缺失。
scope.json 放在任务说明旁边,属于同一个 PR,而且要在编写任何代码之前提交。
{
"task": "handle 429 from provider without infinite retry",
"allowed_paths": ["src/http/*", "tests/http/*"],
"forbidden_paths": ["src/config/*", ".github/*", "pyproject.toml"],
"allow_delete": false,
"max_files": 8,
"max_changed_lines": 250,
"human_required_if": ["out_of_scope", "deps", "ci", "skip_added", "assertion_weakened"]
}
三条规则,决定了这个文件是有用的约束,还是摆设:
生成材料包的脚本刻意保持简单:只用标准库,读取 git,不联网,不调用模型。关键就在于确定性。一个每次运行都可能给出不同答案的分类器,无法充当合并门禁。
#!/usr/bin/env python3
"""review_packet.py - classify an agent PR diff against scope.json.
Exit 0 = no human-required signal. Exit 1 = human review required.
Globbing is fnmatch-simple; see limitations below.
"""
import argparse, fnmatch, json, re, subprocess, sys
HUNK = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@ ?(.*)$")
MECHANICAL = re.compile(r"^\s*(?:import\s|from\s+\S+\s+import\s|#|//|/\*|\*|$)")
SKIP_ADDED = re.compile(r"(?:pytest\.mark\.skip|\.skip\(|xit\(|xdescribe\(|t\.Skip\()")
WEAKENED = re.compile(r"(?:assert\s+True|assert\s+[\w.]+\s+is\s+not\s+None|except\s+Exception\s*:\s*pass)")
RISK = {
"deps": ["requirements*.txt", "pyproject.toml", "package.json", "package-lock.json", "go.mod"],
"ci": [".github/workflows/*", ".gitlab-ci.yml", "Dockerfile*", "Makefile"],
"db": ["*/migrations/*", "*.sql"],
"infra": ["terraform/*", "*.tf", "helm/*"],
}
TESTS = ["tests/*", "*/tests/*", "test_*.py", "*_test.go", "*.test.ts", "*.spec.ts"]
def git(*args):
return subprocess.run(("git",) + args, capture_output=True, text=True, check=True).stdout
def matches(path, globs):
return any(fnmatch.fnmatch(path, g) for g in globs)
def files_changed(base):
out = git("diff", "--numstat", "--no-renames", f"{base}...HEAD")
rows = []
for line in out.splitlines():
add, dele, path = line.split("\t")
rows.append({"path": path, "add": int(add or 0), "del": int(dele or 0)})
return rows
def hunks(base, path):
out = git("diff", "--no-color", "-U0", "--no-renames", f"{base}...HEAD", "--", path)
start, header, body = None, "", []
for line in out.splitlines():
m = HUNK.match(line)
if m:
if start is not None:
yield {"line": start, "header": header, "body": body}
start, header, body = int(m.group(1)), m.group(3), []
elif start is not None and line[:1] in "+-" and not line.startswith(("+++", "---")):
body.append(line)
if start is not None:
yield {"line": start, "header": header, "body": body}
def label(path, hunk, contract):
added = [l[1:] for l in hunk["body"] if l.startswith("+")]
text = "\n".join(l[1:] for l in hunk["body"])
if matches(path, contract["forbidden_paths"]):
return "out_of_scope"
for kind, globs in RISK.items():
if matches(path, globs):
return kind
if any(SKIP_ADDED.search(l) for l in added):
return "skip_added"
if WEAKENED.search(text):
return "assertion_weakened"
if not matches(path, contract["allowed_paths"]):
return "out_of_scope"
if all(MECHANICAL.match(l) for l in added):
return "mechanical"
return "semantic"
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--base", default="origin/main")
ap.add_argument("--contract", default="scope.json")
ap.add_argument("--format", choices=["json", "markdown"], default="markdown")
args = ap.parse_args()
contract = json.load(open(args.contract))
rows = files_changed(args.base)
gate = set(contract["human_required_if"])
findings, changed = [], sum(r["add"] + r["del"] for r in rows)
for f in rows:
for h in hunks(args.base, f["path"]):
findings.append({"kind": label(f["path"], h, contract), "path": f["path"],
"line": h["line"], "header": h["header"]})
human = [f for f in findings if f["kind"] in gate]
if len(rows) > contract["max_files"] or changed > contract["max_changed_lines"]:
human.append({"kind": "over_budget", "path": "-", "line": 0,
"header": f"{len(rows)} files, {changed} lines"})
if args.format == "json":
print(json.dumps({"task": contract["task"], "human_required": human,
"all": findings}, indent=2))
else:
print(f"# Review packet - {contract['task']}")
print(f"changed: {len(rows)} files, {changed} lines\n")
print(f"## hum
针对 PR 分支运行,而不是针对工作区运行:
python scripts/review_packet.py --base origin/main --contract scope.json > packet.md
echo "$?" # 1 means: a human has to open this, and the packet says where
429 示例的输出,经过删节:
# Review packet - handle 429 from provider without infinite retry
changed: 4 files, 75 lines
## human required (3)
- [out_of_scope] src/config/defaults.py:12 -retries = 3 +retries = 1
- [assertion_weakened] tests/http/test_client.py:88 def test_retry_budget()
- [skip_added] tests/http/test_client.py:140 def test_gives_up_after_limits()
## mechanical, in scope
- src/http/client.py:3
mechanical 是唯一可以快速放行的标签:允许路径内的 import 调整、仅涉及注释的修改、格式调整。快速浏览,运行测试套件,然后继续。其余内容都要仔细阅读。
范围内的 semantic 变更才是真正需要下功夫的地方:控制流、错误处理、新的默认值、发生变化的数字字面量。对于这些变更块,必须先确认新增测试在基线提交上会失败,才能相信它确实有效。材料包里的任何信号,都不能替代这项检查。
右侧一栏刻意写成了可以照章执行的规则。它的作用是,让你不必在下午六点、开着十一个标签页时,重新推导一遍审查策略。
披露:本文是 MonkeyCode 产品推广活动的一部分。
上面的分类器识别的是变更的外在特征:哪个文件、哪个变更块、哪类风险。它无法解释为什么修改了 src/config/defaults.py。这个任务适合交给语言模型,而且任务范围应该很窄:为每个超出范围的变更块写一句话,解释可能的修改原因,让人能在几秒钟内接受或否定这个解释,而不必重新阅读 diff。
这段说明放进 notes 字段。它绝不能修改 kind,绝不能改变退出码,也绝不能隐藏任何 human required 条目。实际操作时,先运行确定性的材料包脚本,再把材料包和超出范围的变更块单独交给模型。这样,便宜的 token 就用在了那 5% 存在歧义的内容上,而不是生成一份没人看的完整 diff 摘要。
这也是免费模型访问对这套工作流有意义的原因。一个每调用一次都要花钱的门禁,会在有人注意到账单的那一周被关闭;一个能在免费额度内运行的门禁,才会持续运行。运营方表示,当前优惠包括 10M 免费 token,以及一个免费服务器选项。请把它视为某次促销活动的现状,而不是永久 SLA;在让流水线依赖它之前,先核实最新条款。如果你想把材料包端点部署成内部 webhook,而不是笔记本上的脚本,它的载荷很小,只有路径、行数和变更块标题,这恰好适合用一台小型免费服务器提供服务。
有一条硬性约束:在阅读托管端点的数据保留条款之前,不要把私有仓库的 diff 粘贴进去。利用免费访问在本地运行模型调用,可以让 diff 留在你的机器上。这是我会优先选择、也会一直保留为默认值的配置。
这个变更块分类器只做语法层面的判断。整文件重命名、第三方随附代码、生成代码,以及仅重新格式化的提交,都可能被错误分类。如果你的 PR 经常同时包含 git mv 和内容修改,--no-renames 就会把并未真正删除的内容报告为删除。
通配符匹配使用的是 fnmatch,而不是 gitignore。**/migrations/* 不会匹配嵌套路径。如果你的目录树很深,就把 matches() 换成基于 pathspec 的检查。
只有先写契约,这套方法才有效。对已经存在的 diff 补写 scope.json,会把门禁变成事后找理由:所有内容都被声明为范围内,材料包也就客客气气地点头同意。
范围内的错误,工具看不见。src/http/client.py 里的重试循环可以被正确标记为 semantic,同时仍然无限循环。材料包负责引导注意力,不负责提供判断。
如果你没有能针对 PR 基线执行 git 的 CI runner,或者 Agent 已经在直接向受保护分支写入代码,就先别用这个工具。此时缺失的控制措施是分支保护,而不是审查脚本。如果团队里根本没人会读材料包,那么加上它,只是在形式主义上多走几步。
在下一个 Agent 任务开始之前,先写好 scope.json。控制在十行以内。
对最近五个已经合并的 Agent PR 运行材料包。只看 human required 部分。如果里面有未经讨论就被合并的内容,那就是你当前的风险基线。
增加一个 CI job,在退出码为 1 时阻止合并。从第一天起就不要把它设为仅供参考;仅供参考的门禁,最后都会退化成评论。
等确定性部分经过一周使用、获得信任之后,再为有歧义的变更块添加模型说明。
节省下来的审查时间是实实在在的,但更持久的收益是另一件事:每个合并的 Agent PR,现在都附带了一份书面声明,说明它被允许修改哪些内容。下一位 reviewer 不必再猜,审计时也一样。
如果你想完整试一遍,先复制 review_packet.py 和 scope.json,它们不需要账号,也不需要网络。等材料包已经让你看清哪些变更块值得再找一双眼睛检查时,再添加模型说明。
后续你可以考虑屏蔽此人,和/或举报滥用行为。