Claude Code 团队 10 月 15 日在 claude.dev 讲了他们怎么审 Agent 写的代码:人工批准的人数按改动风险定,低风险 0 人,中风险 1 人,高风险 2 人,机器人的批准不算人。我用 Spring Boot 4.1.1 把这套规则写成了一个 CI 关卡,读样例仓库 5 个分支的真实 git 数据做判定。0/1/2 人分级、机器人批准不计、旧提交上的批准作废、三轮封顶都跑到了;风险判定用的是路径规则,没有调用模型 API,也还没接 GitHub。

博客里的规则

原文作者是 Addy Osmani、Morgan Westlee Lunt 和 Qing Wang(原文)。PR 数量和回滚率这些背景数字原文里都有,这里只摘和实现有关的规则。

每个 PR 先过 Claude 审查和安全审查,未关闭的发现会挡住合并,然后才轮到人工批准。低风险指文档、文案、内部功能、开关后面的新增改动,自动审查和安全检查通过后不需要人批。中风险是普通产品改动,以及缓存、自动更新、系统提示词这类敏感区域,要 1 个人。高风险是风险最高的代码,加上一小批永远要两个人的路径,要 2 个人。

配套规则还有几条。级别由改动决定,不看作者是谁。所有者列为敏感的路径和任何 CI workflow 文件,永远要一个人。只有仓库信任名单上的作者能走轻量路径,其他人需要另一个人批。Agent 的批准填不了需要人的名额。Agent 写的 PR,指挥 Agent 的那个人算一个必需的审查人。批准只对一个具体提交有效,系统担保不了的新提交落地后要重新批,比如 Agent 的推送或手工解决冲突的合并。

审查轮数也有上限。博客提到有个 CLI 的 PR 一夜之间被来回审、改了五十多轮,作者才叫停。他们对审查者反复标记的内容做了 git blame,约 59% 是第一轮审查时就已存在的代码。现在满三轮后,审查者会让作者退一步,重新考虑方案。

定级别时,博客用的是分类器:一个分类器读改动、定级别,第二个分类器判断这次改动在级别之外是否还要多一个人。我的关卡没有调用模型,级别来自 application.yml 里的路径规则,第二个分类器也没有对应实现。

规则写进 application.yml

项目是 Spring Boot 4.1.1、JDK 21,只依赖 spring-boot-starter,跑完一次判定就退出。规则全部放在配置里,用 @ConfigurationProperties 绑定到一个 record:

spring:
  main:
    banner-mode: off
logging:
  level:
    root: WARN
gate:
  base: main
  max-rounds: 3
  big-diff-lines: 400
  trusted-authors:
    - alice@example.com
    - bob@example.com
  agent-authors:
    - claude[bot]@users.noreply.github.com
  always-human:
    - .github/workflows/**
    - "**/security/**"
  high:
    - "**/billing/**"
    - src/main/resources/db/migration/**
  medium:
    - "**/cache/**"
    - src/main/java/**
  low:
    - docs/**
    - "*.md"
    - "**/*.md"
    - src/main/resources/messages*.properties

always-human 对应博客里“永远要人”的路径。trusted-authors 是信任名单。agent-authors 用来认出 Agent 提交,这类提交的真正负责人从 Prompted-by trailer 里取。没出现在任何列表里的文件按中风险算,对应博客“先从严,有证据再放宽”的建议。计费和迁移脚本归高风险、缓存归中风险,是我参照博客举的例子给样例仓库配的,换成自己的仓库要重新列。

分类逻辑取所有文件里最高的级别:

    Result classify(List<FileChange> changes) {
        Tier tier = Tier.LOW;
        boolean alwaysHuman = false;
        List<String> reasons = new ArrayList<>();
        int lines = 0;
        for (FileChange c : changes) {
            lines += c.added() + c.deleted();
            Tier t = tierOf(c.path());
            boolean listed = matches(props.high(), c.path()) || matches(props.medium(), c.path())
                    || matches(props.low(), c.path());
            reasons.add(c.path() + " -> " + t + (listed ? "" : " (not listed, default MEDIUM)"));
            if (t.compareTo(tier) > 0) {
                tier = t;
            }
            if (matches(props.alwaysHuman(), c.path())) {
                alwaysHuman = true;
                reasons.add(c.path() + " is on the always-human list");
            }
        }
        if (lines > props.bigDiffLines() && tier == Tier.LOW) {
            tier = Tier.MEDIUM;
            reasons.add(lines + " changed lines > " + props.bigDiffLines() + ", raised to MEDIUM");
        }
        return new Result(tier, reasons, alwaysHuman);
    }

    private Tier tierOf(String path) {
        if (matches(props.high(), path)) {
            return Tier.HIGH;
        }
        if (matches(props.low(), path)) {
            return Tier.LOW;
        }
        return Tier.MEDIUM;
    }

改动行数超过 400 时,低风险会被提到中风险。这条阈值是我们自己的理解,博客里没有这个数,这次的 5 个样例也都没触发。

关卡本身

关卡读的是真实的 git 数据。作者取最后一个提交,Agent 提交再换成 Prompted-by 里的人:

        String head = git.run("rev-parse", "--short", branch);
        String author = git.run("log", "-1", "--format=%ae", branch);
        String owner = author;
        if (props.agentAuthors().contains(author)) {
            String prompted = git.run("log", "-1", "--format=%(trailers:key=Prompted-by,valueonly)", branch);
            owner = prompted.isBlank() ? author : prompted;
        }
        boolean trusted = props.trustedAuthors().contains(owner);

需要几个人由级别、always-human 和信任名单一起决定。信任名单怎么落地是我们自己的理解:博客只说名单外的作者需要另一个人批,我实现成 owner 不在名单上时至少要 1 个人工批准,且 owner 本人的批准不算。名单上的作者批自己的改动是算数的,博客的目标里也写了,那唯一一个人可以就是作者本人。

        int required = switch (result.tier()) {
            case LOW -> 0;
            case MEDIUM -> 1;
            case HIGH -> 2;
        };
        if (result.alwaysHuman()) {
            required = Math.max(required, 1);
        }
        boolean needsOther = !trusted;
        if (needsOther) {
            required = Math.max(required, 1);
        }
        out.add("tier=" + result.tier() + " required_human_approvals=" + required
                + (needsOther ? " (owner not trusted, owner's own approval does not count)" : ""));

每条批准要过三道检查。机器人的批准直接忽略。给在旧提交上的批准忽略。owner 不在信任名单时,他自己的批准也忽略:

                Approval a = new Approval(f[0], f[1], f[2].equals("bot"));
                String verdict;
                if (a.bot()) {
                    verdict = "ignored: bot approval never fills a human slot";
                } else if (!head.startsWith(a.sha()) && !a.sha().startsWith(head)) {
                    verdict = "ignored: given on " + a.sha() + ", head is " + head;
                } else if (needsOther && a.who().equals(owner)) {
                    verdict = "ignored: owner is not trusted";
                } else {
                    counted.add(a.who());
                    verdict = "counted";
                }

轮数超过上限时,不管批准够不够,直接让作者停下:

        if (rounds > props.maxRounds()) {
            out.add("review_rounds=" + rounds + " > " + props.maxRounds()
                    + ": STOP, ask the author to step back and rethink the approach");
            return new Decision(false, 3, out);
        }
        boolean pass = counted.size() >= required;
        out.add("human_approvals=" + counted.size() + "/" + required + " -> " + (pass ? "PASS" : "BLOCK"));

退出码 0 表示通过,2 表示批准不够,3 表示轮数超限。把它配成分支保护里的必需检查,非 0 就合不进去。批准记录我用一个文本文件模拟,每行是提交、批准人、human 或 bot。接 GitHub 时要换成 PR reviews 接口的数据。

重新批准这一条,我的做法比博客正文严。博客正文说的是 Agent 推送、手工解决冲突这类系统担保不了的新提交才需要重新批。我的关卡不区分提交来源,head 一变,旧批准全部作废,效果接近 GitHub 分支保护里的 Dismiss stale pull request approvals when new commits are pushed。博客末尾给读者的落地清单里,写的也是新提交时撤销旧批准。

5 个分支的实际结果

样例仓库有一个 main 和 5 个分支。docs-typo 是 alice 改文档。cache-ttl 是 Agent 改缓存配置,由 alice 指挥。billing-rounding 是 bob 改计费代码,并加了一个数据库迁移脚本。ci-cache 是外部贡献者 eve 改 CI workflow,提交了两次。order-refactor 是 Agent 替 bob 重构订单服务,已经审到第 4 轮。

#!/usr/bin/env bash
cd "$(dirname "$0")"
gate() { java -jar target/review-gate-0.0.1.jar --repo=sample-repo "$@"; echo "exit=$?"; echo; }
gate --branch=docs-typo
gate --branch=cache-ttl --approvals=approvals/cache-ttl.txt
gate --branch=billing-rounding --approvals=approvals/billing-rounding.txt
gate --branch=ci-cache --approvals=approvals/ci-cache.txt
gate --branch=order-refactor --approvals=approvals/order-refactor.txt --rounds=4

输出原样贴在这里:

branch=docs-typo head=b4eae85 author=alice@example.com owner=alice@example.com trusted=true
  docs/getting-started.md -> LOW
tier=LOW required_human_approvals=0
human_approvals=0/0 -> PASS
exit=0

branch=cache-ttl head=4876606 author=claude[bot]@users.noreply.github.com owner=alice@example.com trusted=true
  src/main/java/com/shop/config/cache/CacheConfig.java -> MEDIUM
tier=MEDIUM required_human_approvals=1
  approval alice@example.com@4876606 counted
human_approvals=1/1 -> PASS
exit=0

branch=billing-rounding head=05771b3 author=bob@example.com owner=bob@example.com trusted=true
  src/main/java/com/shop/billing/Invoice.java -> HIGH
  src/main/resources/db/migration/V2__currency.sql -> HIGH
tier=HIGH required_human_approvals=2
  approval bob@example.com@05771b3 counted
  approval claude-review[bot]@05771b3 ignored: bot approval never fills a human slot
human_approvals=1/2 -> BLOCK
exit=2

branch=ci-cache head=86fd506 author=eve@contrib.dev owner=eve@contrib.dev trusted=false
  .github/workflows/ci.yml -> MEDIUM (not listed, default MEDIUM)
  .github/workflows/ci.yml is on the always-human list
tier=MEDIUM required_human_approvals=1 (owner not trusted, owner's own approval does not count)
  approval alice@example.com@e965344 ignored: given on e965344, head is 86fd506
  approval eve@contrib.dev@86fd506 ignored: owner is not trusted
human_approvals=0/1 -> BLOCK
exit=2

branch=order-refactor head=a7d8e8a author=claude[bot]@users.noreply.github.com owner=bob@example.com trusted=true
  src/main/java/com/shop/order/OrderService.java -> MEDIUM
tier=MEDIUM required_human_approvals=1
  approval bob@example.com@a7d8e8a counted
review_rounds=4 > 3: STOP, ask the author to step back and rethink the approach
exit=3

第 3 个分支是 bob 自己批了,审查机器人也批了。但高风险要两个人,机器人那票不算,所以是 1/2,挡住。第 4 个分支有两处:alice 的批准给在 eve 的第一个提交 e965344 上,eve 之后又推了 86fd506,那票作废;eve 不在信任名单里,她自己批的也不算。.github/workflows/ci.yml 在中风险列表里没有,日志里能看到它按默认值算成了中风险,同时命中了 always-human。第 5 个分支批准是够的,但已经是第 4 轮,关卡直接要求停下重来。

第 2 个分支的提交作者是 claude[bot],关卡从 trailer 里取出 alice 作为负责人。alice 的批准就是那唯一一个人工名额,这正是博客里“谁提示的改动谁负责”的意思。之前那篇讲 Kubemend 只开 GitOps PR 的文章里,Agent 的产出同样要落到一个 PR 上由人合并,这套关卡可以直接挡在那类 PR 前面。

博客里另外两件事没法在一台机器上复现。一是他们把引发过事故的历史 PR 做成评测集,每天拿审查模型跑一遍。二是回滚率和事故率被当作护栏:数字上升,就说明省下的人工时间只是挪到了别处。博客也承认,他们还没有在这些指标变化时自动收紧批准要求的熔断机制。

— 感谢阅读 —
广告

一起交流

分享你的思考,让讨论更进一步。