AI 审查应像 linter 和测试一样成为 CI 门禁,而不是贴在 PR 下面的聊天评论;门禁级检查需满足概率检查的基本属性要求,文章给出了具体实现路径。
如今,在大多数团队中,代码通往生产环境的路径大致如下:

Linter 和测试很早以前就成为了关卡。没人会质疑它们:它们站在路上,不喜欢什么就不让通过。AI 审查现在也在各处被接入——接入 GitHub、接入 GitLab,作为独立的机器人。但我反复遇到的一种角色是,它恰好只在一个地方出现:pull request 上再多一个评论者。我想讨论一个不同的角色——作为另一道关卡,站在 linter 和测试旁边,而不是站在审查者旁边。同时也讨论一个概率性检查在能够与确定性检查并列进入流水线之前必须具备的特性。
先看整体;文章的其余部分会逐步展开每一步:

预先声明利益冲突:我将在自己的工具上演示这一切。ReviewGate 是一个用于终端、git hooks 和智能体的 reviewgate 二进制程序,外加一个用于 merge 和 pull requests 的机器人。它免费但闭源:没有源码可读,靠捐赠维持,没有账号也不需要注册,你自己提供模型的 API key。这里的"自托管"不等于"开源"——还是早点知道比较好,别等安装完才发现。(我计划以后开源,但不是今天。)
当下的流水线,以及其中的漏洞
拿一个小型 Angular 项目来说:一个内部工单面板,支持经理整天开着从不关标签页。这周的任务是工单搜索——一个搜索框、一个状态筛选、一个新服务。四个文件,大约 140 行代码。Lint 是绿的,构建是绿的,搜索在浏览器里也能工作。客户可以签字验收。
这个项目及其完整历史在 GitHub 上:ReviewGate/service-desk。"审查前"状态是 tag search-unreviewed,下面的每个代码片段都来自那里。
以下是搜索框每次按键时触发的方法(也在每次筛选条件变化时触发),摘自 request-list.component.ts:
private runSearch(): void {
if (!this.query() && this.statusFilter() === 'all') {
this.found.set(null);
return;
}
this.search.search(this.query(), this.statusFilter()).subscribe({
next: (items) => {
const now = new Date();
this.found.set(items);
this.foundAt.set(`${now.getHours()}:${now.getMinutes()}`);
},
error: () => this.found.set([]),
});
}
现在什么能 catch 到这个问题?如果有测试的话——测试会通过:搜索能找到东西。Linter 也不会说什么:subscribe({…}) 这行没有问题,它是一个合法的调用,任何 Angular 项目里都有几百个这样的调用。这里出问题的是别的东西,而且只有读完整个文件及其相邻的模板才能发现:
.pipe(takeUntilDestroyed())。所以作者是知道应该怎么做的。这是一个失误,不是无知;这就是这两个发现在报告里的样子,来自一次真实运行(我只裁剪了布局):
🔴 critical · request-list.component.ts:78 · rule team:no-leaking-subscriptions subscribe() in runSearch() is not tied to the component lifecycle: if the manager leaves the screen before the response arrives, the callback still runs and writes into signals of a destroyed component. Team rule no-leaking-subscriptions requires takeUntilDestroyed() (or async pipe/toSignal) even for finite streams. Judge: confirmed.
🟠 major · request-list.component.ts:85 · rule team:three-states-on-load When the search request fails, error() sets found([]), which the template renders as 'No requests yet.' / 'Found: 0'. The manager cannot tell an error from an empty result, and there is no 'searching…' state. Judge: confirmed.
五个严重级别,同一量表贯穿整个工具——在 findings 中、在 gate 阈值中、也在你自己团队的规则中:
Gate 阈值用同样的文字表述:--fail-on major 的意思是"在 major 及以上级别阻塞"。
注意规则 id 前面的 team: 前缀。这不是"模型懂 Angular"。这是我团队的规则,写在仓库里的一个文件中——而 finding 会显示是哪一条。在终端和 --json 中是 team: 前缀;在机器人的 pull request 评论中,同样的归属会显示为一个 📐 徽章在 id 旁边。你会在关于 pull requests 的章节中看到它。
"Linter 不会 hallucinate"
确实——而且它们看不到 AI 审查能看到的东西。AI 审查确实看得到,但也可能看错。其余一切都建立在这个对称性上,所以让我用同一个 diff 来把它说清楚。里面植入了六条团队规则违规,而引擎自己带来了第七个 finding,没有 team: 前缀。以下是这七个中 linter 能 catch 到哪些、不能 catch 到哪些:
七个中有两个是 linter 的工作,而且 linter 应该做这两条:它是免费的、即时的是确定性的。这就引出了整篇文章的核心观点:
AI 审查属于确定性检查的旁边,而不是取代它们的位置,而且应该在它们之后:在每个步骤上,先跑免费的、确定性的,再跑花钱的、会出错的。
概率性关卡的契约
如果一个检查可能会出错,你就不能让它以和 linter 相同的条款进入流水线。它需要不同的条款,而这些是需求,不是附加条款:去掉其中任何一条,你的"AI gate"就会变成一堵墙或者噪音,团队就有充分的理由把它关掉。以下是这些需求。
规则来自仓库,而不是来自泛泛的"最佳实践"。一个 finding 携带一个规则 id,而这个 id 告诉你抱怨的是谁:你的团队(team:)还是引擎——stack preset 和共享的核心。你和这两者的沟通方式不同。
它通过 exit codes 与流水线对话,而不是通过 prose。
0 —— 阈值未被超出(且 gate 关闭时,总是 0);2 —— 阈值被超出;1 —— 运行本身失败。是的,有些 finding 在这个过程中死掉了。这是一个有意的代价:那些通过了的不是偶然通过的。
开箱即用不阻塞,而把阻塞打开不应该是第一天的事。Gate 在默认策略中是关闭的(severity_gate: off);严格程度是团队明确打开的——通过配置中的阈值或 hook 中的 flag。但首先是在你自己的 pull requests 上做校准,没有阻塞权力:
severity_gate 设为阻塞值。在校准之前打开的 gate 会在第一次误拦截时被关闭——然后再也回不来了。
当它出问题的时候,它会让你通过——而且会说明。没有网络、key 被撤销、模型宕机:这些都不会阻塞 push。
1,"允许unchecked";你可以看到跑了什么、花费多少。在报告下方有一个"🔬 Run diagnostics"块,在配置中加一行就能打开(diagnostics: true):
cost.show)。这不是调试,这是信任:一个无法审查的 gate 不是 gate。
Diffs 和代码既不会被记录也不会被存储。
以下是实际中的样子——同一个 diff 两次运行的 🔬 块,一次快一次完整(在调用列表中 judge 标记为"validator",是同一个东西):
**🔬 Run diagnostics**
**Model context**: diff: 4 files · full files: 10 (12K chars) · environment: ✓ · from team standards (8 rules): 6 of 7 findings
**Calls**:
deepseek-v4-flash — 10.8K→19.6K tokens · 173 s · findings: 7No judging took place: fast mode — a single generator, no judge.
🔬 运行诊断
模型上下文: diff: 4 个文件 · 完整文件: 10 个(12K 字符)· 环境: ✓ · 来自团队规范(8 条规则): 8 条中发现 6 条
调用:
deepseek-v4-flash — 10.8K→12.6K tokens(缓存: 10.8K)· 105 s · findings: 8deepseek-v4-pro — 10.7K→5.6K tokens · 114 s · dropped: 0 · downgraded: 0裁判 确认了每一条 finding(dropped 0,downgraded 0)。
从这两个区块可以得出两个真实的观察。
差异是真实存在的。同一个 diff,同一个生成器——在一次运行中输出了 7 条 findings,另一次是 8 条。"diff 中埋入了什么"的表格只是一个参考点,而非承诺。
裁判剔除的不只是 findings。它还会在一条原本确认的 findings 上剥离建议的修复方案——例如当修复方案因缺少 import 而无法编译时。PR 中的"apply"按钮只会在裁判确认为机械性修复的问题上出现。
我无法给出一个公开的准确率指标。我在这里打印的任何"N% 误报率"都只是某一天某一条 diff 上的数字。我能展示的是产生这些百分比的机制,而这在每次运行前的 🔬 区块中都有。
规则:从笔记到配置
规则是那些你已经达成共识、但到第三个月就忘掉的东西。它们存在于任何当初放置的地方:Confluence、置顶聊天消息、ADR、团队负责人的脑海中;最坏的情况下,存在一个代码旁边的 .md 中。我的是 docs/conventions.md 中的纯文本笔记。无论它们来自哪里,迁入配置的过程是这样的:
reviewgate rules # .reviewgate/config.yml 在 HEAD 未找到 — 使用默认配置 reviewgate init # 创建两个配置:仓库骨架和你个人的配置
init 从 manifest 中检测技术栈(preset:angular 已在文件中)且从不触碰已存在的内容——运行两次它会说 already exists——保持原样不动。规则写入仓库骨架。以下是沙盒中的文件,从八条规则中取三条,其他保持原样(完整内容:.reviewgate/config.yml):
version: 1 language: en preset: angular
severity_gate: off
tests: optional
llm: generators: - model: deepseek-v4-flash # 搜索风格:快速且便宜 judges: - model: deepseek-v4-pro # 在完整文件上裁判:更强更深思熟虑
full_file_context: true # 生成器获取完整文件来定位 findings,而非仅靠 diff environment_context: true # 将技术栈版本注入提示词:Angular 21,zoneless,新的控制流
diagnostics: true
committable_suggestions: true
cost: show: true currency: "$" models: deepseek-v4-flash: { input: 0.44, output: 1.32 } deepseek-v4-pro: { input: 1.32, output: 3.96 }
rules:
id: no-leaking-subscriptions description: "组件销毁时,其中的每一个 subscription 也必须销毁。优先顺序:模板中的 async pipe、toSignal、takeUntilDestroyed()。裸的 subscribe() 且无生命周期管理即是一种泄漏,即使该流看起来是有限的。" severity: critical
id: three-states-on-load description: "数据加载处理三种状态:loading、error、空结果。缺失 error 分支是缺陷而非未完成:用户会盯着一个永远的'Loading…'。" severity: major
id: format-through-pipes description: "日期和电话号码通过 pipe 格式化(DatePipe、sdPhone),而非在组件中拼接字符串。" severity: minor
dont_flag:
ignore:
review_prompt: mode: extend text: | 该面板是内部使用,经理们整个工作日都保持打开而不关闭标签页。 所以除此之外,还要关注: - 长生命周期订阅、定时器和 interval:标签页会存活数天。 - 当 API 返回错误或空列表时屏幕的表现。 - 改变数据的操作:当请求未成功时经理看到的是什么。 不要触碰纯样式和 linter 能捕获的任何内容。
三件事让这个文件值得拥有。
只有 linter 看不到的东西才会进入规则。rules: 上方的注释不是装饰:其余的都是 linter 的工作,linter 应该做那些。
dont_flag 保存深思熟虑后的权衡。缺失测试和内联模板不会被评论。这抑制了整类误报,而非单个 finding。
配置即代码。它存在于仓库中,被提交,随分支进入 review,并与它所描述的协议在同一个 PR 中修改。
你可以不借助模型、不花任何费用来检查它:
$ reviewgate rules Config loaded: preset=angular, rules=8, gate=off Stack preset: angular
Team rules:
Review guideline: 该面板是内部使用,经理们整个工作日都保持打开而不关闭标签页。…
这正是模型将看到的内容:带有 id 和级别的八条规则、技术栈,以及 review_prompt 的文本。
这份列表有第二种用途,作为"这个 finding 来自哪里"的查询表:如果一条规则不在上面,说明该 finding 来自你的团队,而是来自技术栈 preset 或核心,你应该用不同的方式与其争辩。Sonar 和你的 linter 在这幅图中是邻居,而非竞争对手:它们依据规则和指标来拦截,而这个工具拦截的是用自然语言写的协议,按意义来评判。
门禁 1:在 AI 智能体内部,commit 之前
代码不再只用手在编辑器中敲出来了——越来越多的代码是由 AI 智能体(Claude Code、Cursor、Codex 及其同类)编写的。等到 PR 打开时已经太晚了:你累了,想合并。所以阶梯的第一步在智能体的循环内部:在它开始写之前应用相同的规则,在它说"完成"之前进行相同的 review。
它通过粘贴一行内容到智能体聊天中来设置:
run reviewgate help agent-setup and do what it says
这些说明是为智能体写的,不是为人写的,这是有意为之:那里有很多客户端,而智能体比我能猜到的更清楚自己环境的形状。它会检查是否已经配置好了(否则它会在已有配置上再写一个配置,最终检查会用旧配置通过),并且它会把 MCP server 放到其客户端期望的位置——对于 Claude Code,仓库根目录下的 .mcp.json,在项目级别,这样它会随仓库一起旅行:
{ "mcpServers": { "reviewgate": { "command": "reviewgate", "args": ["mcp"] } } }
……并且它会在项目指令文件(CLAUDE.md、AGENTS.md、.cursorrules)中添加两行:
项目标准来自 mcp__reviewgate__get_team_rules — 在写代码之前调用它。 在完成任务之前和 git push 之前,用 mcp__reviewgate__review_changes 工具检查变更。
没有这些代码,许多客户端会延迟加载工具描述,AI 智能体永远不知道这些工具的存在。需要两条:`get_team_rules` 在代码之前调用,`review_changes` 在 "done" 之前调用,参数为 `scope: uncommitted | staged | {base, head}` 和 `mode: full | fast`。其他任何值都会导致工具错误,而不是静默使用默认值。
有两件事我需要直说。
人类通过其客户端的对话框授予调用工具的权限。AI 智能体不会自我授权,即使它知道文件格式。这是一道屏障,不是麻烦。
没有工具调用,就没有审查。AI 智能体如果没有工具访问权限,不会说"我做不到"——它只会自行读取代码并写出自己的意见。形式上这是审查;实质上不是:没有团队规则、没有评判者、没有文本背后的退出码。唯一说明它生效的迹象是调用发生了、规则返回了。检查只需一句话,和设置一样:
call get_team_rules and show me what came back
除了 MCP 工具之外,还有一个地方可以将关卡放在 AI 智能体内部:客户端的 hooks——客户端在某些事件上自行运行的命令。ReviewGate 可以是这样一个命令(`reviewgate review --hook-stdin`),这里有两个有用的事件:
`before git push`——AI 智能体即将发送代码出去,hook 运行审查并拒绝阻塞性发现(,在 Claude Code 中这是 Bash 的 `PreToolUse` 事件);
`before "done"`——AI 智能体认为任务完成了,在它宣布之前运行审查(`Stop` 事件)。
对于 Claude Code,这是 hooks 设置中的一个条目:
{ "hooks": { "PreToolUse": [ { "matcher": "Bash", "hooks": [{ "type": "command", "command": "reviewgate review --hook-stdin" }] } ] } }
审查本身的失败(网络问题、密钥被撤销)不会由 hook 转化为阻塞——它会警告并放行,就像这里其他关卡一样。
以下是真实会话,摘自本文的沙箱:

这段记录中有两点值得停下来关注。
团队规则 `authorization-is-server-side` 使 AI 智能体写了一个服务端检查(开发 stub 中对非管理员返回 403),而不是仅仅满足于一个隐藏按钮。是规则做到了这一点,不是模型的好恶——而规则只是仓库中四行 YAML。
评判者将生成者自己的发现从 major 降级为 minor,并附上了理由:"缺少对数据变更删除的进行中护栏会导致重复提交;真正的 UX 问题,但 severity major 对非 bug 的护栏缺口来说太高了。"然后 AI 智能体修复了它并再次运行审查——第二次运行时评判者又去掉了一个候选,理由是"无实际缺陷"。这个机制是双向运作的:它能发现,也能收回。
还有一个关于这一步骤的实时尾声,发生在我为本文准备沙箱的过程中。其中"Call the customer"按钮是由 AI 智能体编写的:由规则驱动,通过 `get_team_rules`,在 "done" 之前经过 `review_changes`。我把它的成果原样提交了——三个文件 18 行代码。Pre-commit(fast 模式,threshold blocker)留下了一个 minor:`telHref()` 在现有的 sdPhone 管道之外重新实现了电话格式化。提交通过了。然后 pre-push(完整运行,threshold major)将同样的发现提交给评判者,评判者将其去掉了:
❌ format-through-pipes — "telHref 构建 tel: URI(仅数字),而非显示格式化;sdPhone 的人类可读格式不适合,所以 format-through-pipes 为假绑定。"
注意这里发生了什么:fast 模式看到了完整运行看到的同样的问题,而完整运行不仅更严格,也更准确。推送带着干净的关卡通过了。发现和裁定都在沙箱的 `feat/call-button` 分支中,按照它们发生的顺序。
关卡 2 和 3:pre-commit 和 pre-push
以下是可供复制的配置。严格程度逐步递增——离键盘越远,越严格:
**commit 时**,fast 模式,threshold blocker。`--fast` 只调用一次模型,不经过评判者:速度快一倍,成本低三分之二。提交是我的草稿:我希望立即看到发现,但不会阻塞我自己的草稿。
**push 时**,完整运行,包含评判者,threshold major。这是不归路了:代码离开前往远程仓库,在这里我愿意等待裁决。
Hooks 通过 husky 走——`.husky/pre-commit`:
npm run lint || exit 1
if [ "$(git config --get reviewgate.hook)" = "false" ]; then exit 0; fi command -v reviewgate >/dev/null 2>&1 || { echo "reviewgate: not installed — review skipped" >&2; exit 0; }
code=0 reviewgate review --staged --fast --fail-on blocker || code=$? case $code in 0) ;; 2) echo "reviewgate: blocker findings — commit stopped" >&2; exit 1 ;; *) echo "reviewgate: the review did not run (failure) — commit allowed unchecked" >&2 ;; esac
……以及 `.husky/pre-push`:
#(网络、密钥):警告并放行——损坏的审查者不能让工作卡住。
if [ "$(git config --get reviewgate.hook)" = "false" ]; then exit 0; fi command -v reviewgate >/dev/null 2>&1 || { echo "reviewgate: not installed — review skipped" >&2; exit 0; fi
code=0 reviewgate review --base origin/main --fail-on major || code=$? case $code in 0) ;; 2) echo "reviewgate: major findings — push blocked" >&2; exit 1 ;; *) echo "reviewgate: the review did not run (failure) — push allowed unchecked" >&2 ;; esac
`--base origin/main` 是关键:它从共享的起点运行完整审查,而不是从你自己的分支起点运行。审查的是你正在推送的东西,而不是你相对于你自己分支的位置。
这是三个关卡的全部内容:pre-commit(fast,草稿阶段)→ pre-push(full,关口)→ PR(可选,完整 + 评判者,在 CI 中运行)。每一步都在更严格的上下文中重新审查同样的代码,直到它达到标准。