IT加油站

Opus 5 生成代码审查实践:如何应对智能体的范围蔓延

22浏览 1天前 软件教程 MA122920

原文:https://dev.to/reporails/opus-5-how-to-review-generated-code-4g8l(作者 @cleverhoods)

于是,又是普通的一个工作日。你让 Opus 5 修一个日期解析器的小问题:它处理带时区后缀的日期时出错了,改一下格式字符串就行。二十秒后,智能体报告完成。你打开差异对比,发现改动不止一行。它涉及十一个文件。它重写了解析器,重命名了三个模块之外的一个辅助函数,整理了你从没提过的导入块,还加了一层没人要求的缓存。所有测试都通过了(实际上还多了 11 个测试)。检查通过,大家都挺高兴,除了你,因为你现在得决定要不要为了本应是一行代码的修改,去读四百行代码来审批。

这篇配对文章的前半部分 解释了为什么这个决定现在如此困难。Opus 5 让编写代码几乎零成本,把所有成本都转移到了审查上;而那种直觉性的补救方法——用第二个模型去检查第一个模型——只是在一次次猜测中重建了这个问题。那篇文章止步于诊断。而这篇是另一半:当十一个文件的意外发生时,你用什么来替代第二个模型进行拦截,而不是仅仅依赖你在下午六点的注意力。

Anthropic 的文档将这种范围蔓延描述为预期行为。其官方的 Opus 5 指南 明确指出:该模型“也可能扩展任务范围,添加未要求的步骤,或自行判断任务应该包含什么”。一个能力更强、运行时间更长、能完成更多工作的模型,同样也会完成更多你没要求的工作。要可靠地捕获这类问题,需要三层控制,它们就是标准的纵深防御层次:将模型引导离错误,审查它的产出,并在这两者之下设置一个确定性的门控。其中两层能阻止错误发生,区别仅在于你能信任它们的程度——引导是通过调整概率起作用,而门控则是直接拒绝;审查则是你用来捕获从这两层中漏网之鱼的方法。下文将为智能体(agent)逐一命名每个层次,按实施成本而非运行顺序排列,但这种分层结构本身是标准的。

📌 三个层次横亘在智能体的十一个文件变更与主分支之间,从左至右:一个引导层,将任务约束在指定路径集合内;一个审查层,由人工运行变更并检查其测试;一个执行层,拒绝任何超出范围集合的写操作。其中两个层次(引导和门控)将变更限制在范围内,中间的审查层负责捕获漏网之鱼。(图,点击查看)

第一层:引导与删除指令

当智能体行为越界时,本能反应是写一条规则让它自我检查。在 CLAUDE.md 里加上 完成前请验证你的工作,然后继续。但在 Opus 5 上,这句话只是白白烧掉 token,毫无用处。Opus 5 的官方文档对此直言不讳:"Claude Opus 5 会自行验证其工作,无需额外指示。如果你的提示词中包含显式验证指令……请删除它们,"因为"这类指令会导致过度验证",而删除它们"能减少浪费的 token,且质量无损"。模型本身就会进行复核。你的指令只是让它在重复检查时调用更多 token,而这种自我检查根本发现不了你真正遇到的问题。模型验证自身工作,只能确认那十一个文件符合模型的意图。它无法知道自己本该只生成一个文件。

引导层真正需要包含的是范围约束,而非验证指令。Anthropic 给出了一个起始模板:

交付所请求的内容,保持在预期的范围内。自行进行常规判断,仅当对请求的不同理解会导致实质性不同的工作时才需确认。如果认为请求有误或存在更好的方法,请用一句话说明,然后按原请求继续任务,而不是悄悄地缩小、扩大或改变任务范围。完成整个任务,但要避免执行明显超出请求范围的操作。


这段文字为模型指明了正确方向,其最后一行已经构成了约束:它指出了三种具体的失败模式(缩小、扩大、改变),并规定了正确的做法。但另外两行依赖于模型需要自行解释的词汇,而一条需要模型解释的规则,就是一条可以被它搁置的规则。"保持在预期的范围内"要求模型根据一个未被明确记录、无法被检查的意图来衡量其修改。"仅当对请求的不同理解会导致实质性不同的工作时才需确认"中的"实质性"一词由模型定义,因此阈值是浮动的。"避免执行明显超出请求范围的操作"则让"明显"成为读者的判断,而非模型可以用来衡量操作的一条准则。

不如给模型一个可以用来检查每一次修改的具体目标,首先从只有你能提供的东西开始:本次任务被允许修改的文件路径集合。

在修改任何文件之前,先将本次任务的范围写成一个命名的路径集合。对于这个日期解析器修复任务,集合是 `src/dates/` 及其测试文件;你自己写出来,并放在显眼处。

只修改命名集合内的文件。仅当修改不运行就无法工作时,才触碰集合外的路径,并在修改前说明。

做出满足请求的最小改动:不加缓存层,不重命名辅助函数,不进行重构,不引入新的抽象,即使这些改动会改善代码。

仅当请求可能指向不同文件时,才在开始前确认,并完成集合中所有需要更改的文件。

如果更大的修改有帮助,请用一句话说明,但只完成被请求的那一个。


现在,每一行都指向一个具体的锚点——命名的路径集合。一条基于你写下的具体名称的规则,能随着上下文增长而持续生效;而一条抽象的规则则会逐渐被忽略。这就是过滤器:一条无法引用命名路径集合的规则,就没有存在的资格。这也就解释了为什么上面那个版本中的模糊确认和可能以后再说的托词被删掉了。这个模板的核心就是指向那个集合;你需要把真实的路径写在开头来让它生效。

这个相同的命名路径集合,正是第三层构建硬性门控的基础。因此,引导和执行守护的是同一条防线:一条要求模型待在界内,另一条则在模型越界时拒绝写入。

📌 两块规则并排展示。左侧是一块引导规则,其措辞需要模型自行解读,高亮了 at the scope intended, materially 和 clearly 这些模型可以权衡和搁置的短语。右侧是一块基于命名路径集合(src, dates)构建的规则,模型可以用它来检查每一次修改。(图,点击查看)

这就是引导:它提高了下一次代码差异保持在请求范围内的概率。但它不能保证概率为一,因为上下文表面上的所有内容都是这个概率过程的输入,是一条模型可以自由权衡、并在它觉得加缓存层是个好主意的那一刻选择搁置的指令。引导是第一层,因为它的成本最低,并且能处理常见情况。对于常规修改,依赖它;对于那些你承担不起出错风险的修改,则需要超越它。

第二层:流程,以及审查实际执行的内容

当有人真的打开代码差异(diff)时,快速浏览就是之前文章指出的那种失败模式:在几分钟内凭直觉批准一个 300 行的差异(LGTM速通)。本能的回答是“仔细读”,但这在差异超出你脑容量时就失效了,而这恰恰是范围扩张代理(scope-expanding agent)产出的差异。

一个能扩展的审查是一项分拣工作。审查者检查的部分内容有明确的答案,可以通过规则解决:测试套件是否通过、类型系统是否健全、变更是否触及了从未在范围内(scope)的路径、是否有密钥被提交到差异中。其余部分则需要基于变更所在世界的模型来判断,而这只有人类才能做到。这个过程是将第一堆工作交给无需你参与就能运行的东西——也就是第三层——而将你自己的阅读时间投入到第二堆中。

📌 A diff to review splits into two piles. One pile holds questions with a definite answer, does the suite pass, do the types hold, did it touch an out-of-scope path, did a secret land, routed to the machine. The other holds questions that need a model of the world, was the caching layer worth adding, does the rename help or hurt, was it the right fix, routed to you.(图,点击查看)

当机器可检查的部分被分派出去后,剩下的就是真正的审查,而两个实践承载了大部分工作。两者都不轻信差异本身。

  • 通过执行来审查。 运行代理编写的代码,针对你最初关心的用例。一个差异读起来可能很合理,但运行后行为可能不同,因此代理添加的缓存层在其被实际使用前只是一个断言。一个执行审查者——无论是人还是运行变更的 CI 作业——测试的是它实际做了什么。
  • 用变异来探测测试。 测试套件全绿(green suite)告诉你测试通过了,但它没有告诉你如果代码是错的测试是否会失败。改动代码应该关注的一个点,比如还原一个守卫、反转一个比较、删除一个分支,然后确认一个测试会变红。如果没有任何测试变红,那么无论代码怎么写你的测试都会通过,而一个范围扩张代理留下的正是这种情况:新代码、新测试、全绿,却没有一个测试能察觉新代码是否出错。这就是手动执行的变异测试(mutation testing);像 `mutmut``cosmic-ray``Stryker` 这样的工具可以自动化这一探测,而实践者正是在代理同时编写了代码和为其担保的测试时才会采用这种方法。

📌 Two terminals. On the left, one comparison in the code is flipped by hand so the code is now wrong on purpose. On the right, the test suite is run and still reports eight passed, zero failed. No test went red, so nothing in the suite would notice the code being wrong.(图,点击查看)

在一个特定角色中,模型可以对剩余部分进行第一轮审查。Anthropic 关于 Opus 5 作为审查者的说明值得结合前文提到的“模型审查模型”警告来阅读:该模型“以高精度和高召回率审查代码”,其指导原则是“要求它报告所有内容,然后在另一个步骤中进行过滤”。将其用作快速的第一读者,以低成本发现候选问题,但有一条规则:审查者不能是作者。模型评估自己的差异,会带有导致该差异产生的盲点,这与你不能通过重读来审查自己的代码是同一个原因。使用同一个模型的一个新实例可以清除它之前过于亲近的上下文,但无法清除模型本身固有的盲点,因此这一环节最强大的版本是使用另一个模型或一个人。将新的读者指向变更,自己来过滤其发现,因为决定哪些发现重要,以及范围(scope)本身是否正确,是需要判断力的工作,第二个模型无法代劳。

第三层:执行,那个直接拒绝的门控

直觉是把审查放在最后,作为合并前的最后一道关卡。这个直觉本身就是问题。审查者,无论是傍晚六点匆匆浏览的人,还是一个给模型打分的模型,恰恰是你不能信以为最终仲裁者的对象,因此确定性的门控应该置于审查之下,而不是让审查凌驾于门控之上。

引导能改变概率;而审查读取的是引导所放行的内容。第三层自动运行所有可机器验证的问题,因此无需任何人重复执行。以这种方式运行的规则不会对差异(diff)形成主观判断,无论变更增长到多大,它都会对第一百次变更给出与第一次相同的判定。

这一层的大部分执行机制可能你已经拥有,并且目前可能作为建议而非底线在运行。测试套件、类型检查器、代码检查器、密钥扫描器:在CI中将它们设为必需检查项,并在合并前启用分支保护,这样失败的检查会直接拒绝合并,而不是留下一个标记,被疲惫的审查者放行。然后,添加智能体自身故障模式所要求的检查项,其中就包括作用域守卫和变异门控。这就是外部执行机制,它在变更被写入之后、在拉取请求阶段运行。

这一层有一项CI无法为你提供的能力,因为CI只能在变更被写入后看到它:一个在智能体循环内部触发的门控,在写入实际落地之前。这正是解决作用域膨胀问题的那一环。

针对那些触碰了未被要求文件的智能体,其门控是一个爆炸半径守卫:拒绝向任何不在本次任务作用域限定的路径集合内写入数据。Claude Code在工具调用执行前触发一个PreToolUse钩子,退出码为2会阻断该调用并将你的消息返回给模型(参见钩子文档)。将以下脚本保存为.claude/hooks/guard-blast-radius.sh并使其可执行:

#!/usr/bin/env bash
# guard-blast-radius.sh: 拒绝在本次任务作用域限定文件之外的写入/编辑/多文件编辑。
# 作为Claude Code的PreToolUse钩子运行。退出码2表示阻断。
# 需要jq。
payload=$(cat)   # 待处理的调用以JSON格式通过stdin传入
path=$(jq -r '.tool_input.file_path // ""' <<<"$payload")
cwd=$(jq -r '.cwd // ""' <<<"$payload")

# Claude Code传递的file_path相对于cwd,但无论如何都去掉cwd前缀,
# 使允许列表无论路径是相对还是绝对都适用。
rel="${path#"$cwd"/}"

# 拒绝 .. 遍历,或防止类似 src/dates/../../etc/passwd 的路径绕过前缀匹配。
case "$rel" in *..*) echo "blocked: '$path' 使用了 .. 路径遍历。" >&2; exit 2;; esac

# 本次任务的爆炸半径:写入操作被允许触碰的唯一路径集。
# 将其保存在可按任务编辑的位置,或从文件中读取。
allowed='^(src/dates/|tests/dates/)'

[ -z "$path" ] && exit 0                      # 此调用中没有路径,无需守卫
if ! grep -qE "$allowed" <<<"$rel"; then
    echo "blocked: '$path' 超出了本次任务的爆炸半径 ($allowed)。\
如果变更确实需要此文件,请有意地、而非随意地扩大作用域。" >&2
    exit 2   # 退出码2 == 硬阻断;stderr中的消息会返回给模型
fi
exit 0


.claude/settings.json中将其配置给写入工具:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Write|Edit|MultiEdit",
        "hooks": [
          { "type": "command", "command": ".claude/hooks/guard-blast-radius.sh" }
        ]
      }
    ]
  }
}


现在,一个被要求修复日期解析器的智能体试图重命名三个模块之外的一个辅助函数。转换在写入发生前触发,路径未能通过允许列表,写入操作从未落地。模型在同一轮交互中看到拒绝信息,它必须要么保持在作用域内,要么明确说出需要扩大作用域——这正是你希望在十一个文件被修改之前、而不是之后发生的对话。这个门控无视差异内容及其大小。它只检查一条路径规则,无论是在第一千次调用还是第一次调用时,都执行相同的检查。

脚本中有一行代码体现了要点:allowed集合由你来编写,绝不能由模型来定义。一个诱人的捷径是让智能体自己定义其爆炸半径,并让钩子去读取它,但一个模型可以编辑的边界不是门控,它只是另一条可以被忽略的指令。这个集合必须来自一个了解本次任务被允许触碰哪些内容的人类。

📌 一条时间线。早期,在写入尝试阶段,一个循环内守卫阻止了对任务作用域路径之外文件的写入,写入从未落地。更晚些时候,当整个变更被写入并且拉取请求打开后,一个CI作用域检查在同一个规则上失败,此时十一个文件已经改变。(图,点击查看)

这个门控有两个诚实的限制。它仅匹配文件编辑工具:WriteEditMultiEdit;一个通过shell输出(如sed -i>重定向)来写入的智能体会绕过它,除非你给Bash匹配器设置同样的规则或拒绝来自shell的写入。并且,它守卫的是变更的落点,而非变更的内容:它阻止接触的那个多余文件是低成本的失败,而在你确实要求它修改的文件内部写错了一行,那是第二层的工作,绝非门控的职责。

建议性与阻断性

门控有两种设置,区别在于是否可以协商。将 exit 2 替换为打印一行并 exit 0,你就有了一个建议层:它告诉你代理越界了,并相信你会注意到。保留 exit 2,你就有了一个模型无法绕过的底线。在你还正在学习自己的影响范围时,建议性是诚实的默认选择,因为一个设置过于宽泛的门控会拒绝正常工作,而误阻断是工作无法进行直到有人修复门控。先运行建议性,观察它一周内会阻断什么,一旦你相信它只拒绝你预期的内容,就将该规则升级为硬阻断。一个只警告的第三层控制是真实工作;升级为一个拒绝的控制是你在证据充分后做出的单独决策。

三层都无法做到的事情

要精确界定边界,因为这很容易被夸大。门控根据你编写的规则检查差异,它没有现实模型来核对。是否值得添加缓存层、重命名是否让下一次更改更轻松或让代码库变差、那一行修复是否真的是你实际遇到的 bug 的正确修复:每一种情况都需要一个关于变更所在世界、产品、使用者以及整个事物走向的模型。这三层都没有。引导可以要求,流程可以呈现候选,但判断本身仍取决于你。

📌 两个面板。左边,三层所解决的问题,每个都有明确的答案:变更保持在范围内、格式正确且无秘密内容、测试套件在代码出错时仍失败。右边,三层都无法解决的问题:是否值得添加缓存层、重命名是有帮助还是有害、它是否是正确的修复。这些仍取决于你。(图,点击查看)

执行层很少是一个钩子。它是一组你为你自己的影响范围、必须遵守的约束、变更不应跨越的路径而设计的钩子,与编写你已经为代码库保留的架构规则是同样的举措,更下一层。你是在提前一次性决定,哪些代理的选择由你来做,哪些它可以自己做。

读一次规则,门控其余

三层:你编写的引导、你运行的流程、你拥有的门控。我们在自己的代理上运行所有三层,原因和你一样:代理现在足够快,以至于一天结束时没有单个读者能成为其输出与主分支之间的障碍。

你使用 Opus 5 来多写少决定。指向第二个模型到第一个感觉像是进步,但悄悄地将决定交回给猜测。真正减少你注意力的方式是将机器可检查的部分交给机器:范围、格式、禁止路径、不再有效的测试。剩下的唯一问题是三层都无法回答的:变更是否值得进行。这就是十一文件差异仍然需要的审查,而且它一直都是重点。这些层不会缩小那个审查;它们清除周围的噪音,所以判断是你桌面上剩下的,而不是被埋在四百行代码下。

原文:https://dev.to/reporails/opus-5-how-to-review-generated-code-4g8l(作者 @cleverhoods)

#AI代码审查 #Opus 5 #智能体测试 #代码审查流程 #Anthropic Claude