跳到主要内容
从零造一条审查流水线:一轮只问一个问题,最后拿一轮剔噪

从零造一条审查流水线:一轮只问一个问题,最后拿一轮剔噪

造物
造物

· 阅读约 17 分钟

先把最刺的那句说掉:把 diff 丢给一个审查代理,让它一口气看安全、复杂度、命名和文件组织、逻辑、注释风格——这件事在提示词层面没救。不是你没写清楚,是它一轮装不下这么多要求。编码代理不擅长遵循一长串复杂指令,但把它放进单独一轮、只让它找一类特定问题,表现会好很多,好到你怀疑是不是换了模型。你把六项检查焊进一个 prompt,它会在前两项上认真,到第六项开始敷衍,然后为了证明自己干了活,把最容易挑的那类东西写满一屏;真正要命的那条——"这个新分支绕过了老的缓存失效路径"——躺在第八位,而你读到第三条就开始烦了。

一篇讲质量防御的文章,最容易写成七层清单:需求审查、单元测试、人工测试、端到端、AI 审查、PR 审查、监控告警,一层一段,发育完整,读的时候点头,读完什么也没剩下。七层这个分法本身没问题,它来自 Iouri Khramtsov 那篇讲 AI 编码和质量的文章,是作者和他团队以及别的团队用出来的东西。问题在于讲法。每层都给一段,等于每层都没讲。

所以这一篇只挑一层——代码写完之后、进 PR 之前那段 AI 审查——把它从空目录造出来,敲一遍,跑两遍,看它在一份真实的 diff 上吐什么。

为什么挑这一层?因为它是整条链里最便宜的,也是被浪费得最狠的。把它挂进规划或实现技能,代价只有 5 到 15 分钟,不需要额外注意力,几乎算免费附加项。但绝大多数人用它,都是把同一个大 prompt 扔过去,然后收获一屏噪音。噪音不是它的固有属性,是你没拆轮次。

这一篇结束时你手里会有一条能跑的链:输入一份 diff,输出三到五条你愿意花时间看的评论。就这么多。不是七层大全——一万字装不下整个防御体系,硬装就是在每层末尾写一句"这一层也很重要",那是留尾巴,我们不留。

为什么是拆轮次,不是写更好的指令

先把因果讲清楚,它决定了后面每一步的形状。

一个审查 prompt 里塞六个检查项,实际发生的是:模型要为这六项分配注意力,而这六项的判定标准是互相打架的。安全那条希望它往最坏处想;命名那条希望它别挑刺;逻辑那条希望它给出具体改写;格式那条希望它精确到位。你让同一轮输出同时满足这四种气质,它只能往最好写的那种气质上塌——格式。格式最好写,因为它不需要判断,只需要对比。

拆轮次换来的东西很具体:每一轮有自己的一句提问、自己的输出契约、自己的上下文。安全那一轮可以要求它按最坏情况推演;命名那一轮可以直接说"只报和仓库既有约定冲突的"。这两句话放进同一个 prompt 会互相稀释,分开之后各自完整。

顺带解释了另一个常见抱怨。大家说 AI 审查"太啰嗦",很少说它"抓不到"。它抓得到,它只是把抓到的和没抓到的混在一起交给你,让你自己分拣。分拣这件事本来该在机器那边做掉。

骨架:一个 for 循环

先立骨架。骨架只有"一轮一问题"这一个机制,别的什么都不做。

#!/usr/bin/env bash
# review.sh —— 骨架版:五轮,一轮一个问题,各自一份输出
set -euo pipefail
DIFF="$(git diff --staged)"
mkdir -p .review-out
for CONCERN in security complexity naming logic comments; do
  printf '%s\n' "$DIFF" \
    | agent -p "$(cat prompts/$CONCERN.md)" \
    > ".review-out/$CONCERN.jsonl"
done

agent 这里是你手边任何一个能读 stdin、拿 prompt、吐 stdout 的编码代理 CLI,换成你用的那个就行。prompts/ 下面五个文件,一个检查项一个。输出用 jsonl 不用普通文本,是因为后面还要合并、去重、按字段过滤——第一版我用纯文本,到剔噪那一节就不得不回头改成结构化的,白干半小时。

跑一下看看:

$ ./review.sh
$ wc -l .review-out/*.jsonl
   4 security.jsonl
   9 complexity.jsonl
   6 naming.jsonl
   3 logic.jsonl
  11 comments.jsonl

三十三条。骨架立住了,但它现在的产出比不拆还难用——三十三条评论,你一条也不会看。这就是为什么下两节都不能省:一轮一问题救的是"每一项的判定质量",不是"总量"。

每一轮的问题长什么样

拆轮次之后,每个 prompt 文件要交代三件事:看什么、不看什么、以什么形状交回来。第三件最重要,也是最多人漏的。

以安全那一轮为例:

<!-- prompts/security.md -->
只看本次 diff,找安全问题:注入、越权、密钥泄漏、
不受限的重试或资源占用、新增的信任边界。

不看:风格、命名、注释、复杂度。这些有人管。

每条输出一行 JSON:
{"file": "...", "line": 0, "severity": "high|low",
 "claim": "一句话说清问题", "action": "一句话说清怎么改"}

如果本次 diff 没有安全问题,输出空。不要为了凑数报 low。

最后那句"不要为了凑数报 low"不是客气话。你不写,模型就会补几条 low 进来,让输出看起来有内容。action 字段也是刻意加的:一条评论如果写不出可执行的动作,它就不是评论,是感想——这个字段是剔噪轮能不能工作的前提,后面会用到。

"不看什么"那一行不能省。我第一版只写了"看什么",结果安全轮花了四条评论讨论我的变量名。你不明确划走,模型会把整份 diff 当成一份通用审查来做,它只是在响应"审查"这个词的全部含义。

逻辑那一轮要单独说,因为它是唯一一个必须带仓库上下文才能干活的:光看 diff,看不出"这个新增的兜底和三个月前那次是不是重复"。所以逻辑轮喂进去的是相关文件的完整内容,代价是慢,但漏掉跨文件交互是 AI 审查最容易犯的错,这个钱得花。

到这里五轮各自能跑了。但每轮只认自己那一块,五个文件互不认识,而且轮次之间报的东西一定有重叠。

剔噪轮

这一节是全文最厚的一段。剔噪这个机制看着可有可无,实际上它决定这条链成不成。

先讲为什么必须有它。AI 审查有两个毛病是结构性的,调参治不好。第一个是吹毛求疵:它会报"这个变量名可以更清楚"、"这个函数可以拆"、"这里建议加一行注释"。第二个是重复:同一处问题在安全轮和逻辑轮各报一次,措辞还不一样。这些原样贴到 PR 上,收到的效果是同事开始不看你贴的东西——一次两次还好,三次之后你贴什么他都不点开了。

第二个毛病顺手能治,合并的时候按文件和行号去重就行。第一个治不了,因为它报的每一条单看都成立,只是加起来把真正重要的三条淹了。所以剔噪轮要做的不是"判断这问题对不对",是"判断这条评论值不值得占用一个人的注意力"。这是两个问题,判据完全不同。

输入是合并去重后的全部评论,输出是保留下来的下标:

# filter.py
import json, sys, subprocess

FILTER_PROMPT = """下面是同一份 diff 上,多个独立轮次给出的代码审查评论。
逐条判断它是否值得占用人类审查者的注意力,判据只有三条:

1. 这条评论指向的代码,在本次 diff 里存在吗?不存在 -> 丢。
2. 这条评论给出了一个可执行的改写动作吗?没有 -> 丢。
3. 这条评论和另一条只是换个说法吗?是 -> 只留更具体的那条。

例外:severity 为 high 的评论默认保留,除非你能明确指出它错在哪。
例外:security 和 logic 两轮的评论,不许因为"写不出动作"被丢掉,
这类问题经常需要人重新设计,写不出机械动作是正常的。

只输出保留下来的下标,一行一个,不要理由,不要输出别的。"""

def run(comments):
    payload = json.dumps(comments, ensure_ascii=False, indent=None)
    out = subprocess.run(
        ["agent", "-p", FILTER_PROMPT + "\n\n" + payload],
        capture_output=True, text=True, check=True,
    ).stdout
    keep = {int(x) for x in out.split()}
    return [c for i, c in enumerate(comments) if i in keep]

if __name__ == "__main__":
    comments = []
    for path in sys.argv[1:]:
        comments += [json.loads(l) for l in open(path) if l.strip()]
    seen, deduped = set(), []
    for c in comments:                      # 先按 file+line 去重
        k = (c["file"], c["line"])
        if k not in seen:
            seen.add(k); deduped.append(c)
    for c in run(deduped):
        print(f'{c["severity"]:>4}  {c["file"]}:{c["line"]}  '
              f'{c["claim"]}  -> {c["action"]}')

写完这段要停下来讲几个取舍,都是踩出来的。

第一,只输出下标,不输出理由。 我一开始让它顺便写一句"为什么丢掉",想着能回头复盘。结果它开始给自己写辩护词,输出长度涨三倍,而且每次跑保留的集合都不一样——它在解释自己的解释,不在判断。只让它吐数字之后,同一份输入连跑五遍,保留的下标基本稳定。这一步换来的东西是稳定性,而稳定性是这个机制唯一能被信任的理由。一个每次跑结果都不同的过滤器,等于没有过滤器。

第二,它自己会误杀,而且误杀得很有规律。 判据二在风格和复杂度上表现很好,放到安全上就是灾难——最难的那类安全问题改法要重新设计,写不出机械动作。所以 prompt 里那两条例外不是优化,是止损:high 一律默认保留,security 和 logic 不许因为"写不出动作"被丢。你如果不加,第一次跑它就会把最值钱的那条安静地删掉,而且你不会知道,因为交出来的是一条干净的清单,干净得让你以为一切都好。

第三,同一个输入跑两遍,取并集。 这一条有点反直觉。剔噪轮会有假阴性——把该留的丢了。而这里的代价是不对称的:多留两条垃圾评论,你多花三十秒划掉;漏掉一条真问题,代价可能是上线炸一次。所以对剔噪这种"宁可错留"的环节,我会把同一份输入喂两遍,两次保留的并集才算数。多跑一次的成本远小于一次漏判。这条规则不能反向用在"合并去重"上——那里必须严格,留两条措辞不同的同一条评论才是真的消耗人。

第四,保护清单。 剔噪轮不知道你仓库里的硬规矩。如果你们真的强制某个命名约定,命名轮报的那条就不能丢;但剔噪轮只看 diff,它会判这是挑刺。别在 prompt 里把约定再写一遍——那等于把复杂度加回来——另开一个小文件贴在末尾,几条就够:

<!-- prompts/protected.md:这几类评论一律不许丢 -->
- 命名:新增标识符必须符合 src/ 下既有的 camelCase
- 注释:新增的 public 函数必须有 JSDoc
- 依赖:新增的第三方依赖必须列出许可证

这里我承认讲多了,但它确实是新人最容易栽的地方,值得多停一下。清单越短越好,超过十条说明你的项目在这个环节本来就有问题,那该去修项目,不是把规则堆进 prompt。

剔噪轮立住了。它现在还只会丢,不会判对错,但它丢的东西是可解释的:要么指向不存在,要么写不出动作,要么是重复。这三条你能逐条复核——一个你复核不了的过滤器,你迟早会绕开它。

组装:挂进 skill 和 PR

五轮加一轮,六次模型调用,散在一个 shell 脚本里。这一节把它接进两个真实入口。

第一个入口是技能。作为一段指令放进规划或实现技能里,代理写完一段代码之后顺手触发。代价 5 到 15 分钟,不需要你额外分出注意力——你干的还是自己的活。这是它最划算的形态:不是卡在流程里等人点通过的闸门,是跑在后台、结果晚一点到的东西。闸门是反模式,因为它把人的注意力又拉回来了,而省下人的注意力正是整条链存在的意义。

第二个入口是 PR 审查。这里有个顺序问题:先让流水线把评论贴出来,你看一眼,再决定哪些自己修、哪些回一句"这个不改"。反过来做——先修再看——很常见,但浪费时间,因为很多你判为不值一提的评论,你根本不会修第二遍。贴出来再决定,省的是你的手。

同一份 diff 上跑两台不同的审查器是有意义的,它们会捞出不同的问题,重合的那部分通常就是真问题。各自跑完、合并、去重、剔噪,走的还是同一条链,不需要额外处理。安全轮那份输出还有一种用法:PR 里不贴,但定期扫一眼,看有没有反复出现的同一类问题——有的话说明规则该改了,那是 prompt 的问题,不是代理的问题。

跑一下看看

拿一份改了缓存层的 diff 跑完整链。先看不剔噪的版本,让你看清它长什么样:

$ ./review.sh && ./merge.py .review-out/*.jsonl > all.jsonl
$ wc -l all.jsonl
11
$ head -4 <(python show.py all.jsonl)
 low  src/cache.ts:12  变量名 keyStr 可以更清楚        -> 重命名为 cacheKey
 low  src/cache.ts:31  这个函数可以拆成两个            -> 拆分 read/write
 low  src/cache.ts:47  这里建议加一行注释说明为什么     -> 加注释
 low  src/api.ts:8     缩进用了两个空格                -> 改成四个

十一条里前四条是这样。没人会读这个。同一条链加上剔噪轮:

$ python filter.py .review-out/*.jsonl
high  src/cache.ts:88  新分支绕过了 invalidate() 的老路径    -> 写回前调用 invalidate(key)
high  src/cache.ts:41  key 用裸字符串拼接,跨租户可能碰撞   -> 改成 tenantId + ":" + key
high  src/api.ts:19    重试没有次数上限,最坏情况打满上游   -> 加 maxRetry 和指数退避
11 条评论 -> 3 条

三条,两条安全问题,一条逻辑问题。上面那四条 low 全没了,重复的合掉两条,剩下几条被"写不出动作"丢掉——大概就是你期望的结果。

重点不是这三条好看,是下面这个:

被剔噪轮丢掉、我事后手工加回来的一条:
low  src/cache.ts:70  新增的这个调用没设超时  ->(模型没给出动作)

它写不出机械动作,是因为改法取决于调用方是谁、超时定多少、超时之后要不要降级——这些模型在 diff 里看不到。判据二在这里就是错的。剔噪轮的产出是"候选清单",不是结论:它把三十三条压到三四条,是为了让你有精力把这三四条真的看进去,不是替你看。

到这里这条链能跑了。从空目录到一个 shell 脚本加一个 Python 文件,五轮抓,一轮丢,跑一份 diff 大概几分钟。

这一篇不造的那几层,标一下位置

前面说了,七层里我们今天只造中间那一层。剩下的不逐层展开——逐层展开就回到那份清单的写法,每段发育完整,读完什么也没剩下——但位置得标清楚,不然你不知道这条链的上下文。

写代码之前还有一层,是需求和设计本身的审查:让 AI 去需求里找缺口、边界情况、和既有代码的意外交互。这事值得做,因为 AI 不疲劳,提示到位的话它在"继续找潜在问题"上比人不容易放弃。但它有一个和剔噪轮同源的毛病——过度积极,会给你提出并不存在的问题,改需求之前你得逐条看。那套经验里有一条值得记住:采用规格驱动之后,新代码里的缺陷数量显著下降;在那之前,打磨阶段能吃掉总工作量的三分之一,用来发现未预见的交互、疲劳导致的疏忽、设计和产品没考虑到的场景,其中一部分还漏进了生产。三分之一这个数字记着,后面还要用。

写代码当中是测试循环:先让代理根据需求想测试场景和用例,再写测试、写实现、用测试验证实现、修问题,最后按需求回头补覆盖率的缺口。顺序不能换,换了就只是补测试。代理能写测试,所以覆盖率超过 95% 这件事没有理由拖。这一层不是这一篇要造的东西,我只留一句警告:覆盖率是手段,不是目标,95% 这个数字的意义在于"你有资格说没测的地方是故意不测的"。

代码之后、人工测试之前还有端到端。这可能是代码库里最重要的测试,因为它验的是"新改动有没有把终端用户能感知的既有功能弄坏",理想情况下 PR 跑、测试或预发环境跑、每次生产部署后再跑,而且由写常规代码的同一批人维护。AI 能帮着写,但得让它能调试——给它浏览器工具或者日志的 MCP 访问,不然它写出来的端到端测试失败之后自己也不知道为什么。最后一句必须说清:端到端替代不了人工测试,它是个粗的、不完整的机制,只保证重要功能没被弄坏。

生产那头的监控告警是最后一层,标准从低到高排一排:至少有人定期看日志、看用户录屏、看错误率和延迟的仪表盘;好一点用错误跟踪服务去检测和去重;最好的做法是让工具自动诊断生产错误、定位根因、直接把带修复建议的 PR 提出来。

造不动的那一段:人工测试

现在说那三分之一。

前面几层我都能造,因为它们的输入输出都是机器能读的东西:diff、测试、日志、指标。人工测试这一层不行。它要有人真的去点那个按钮、走那条路径、试那个边界,而且很多场景搭起来本身就费劲——要造数据、要配环境、要模拟一个只有三个用户会走到的状态。这是目前生产率提升卡住的地方:把这一层算进去,整体产出大概能到 2 到 3 倍,而不是 10 倍。差距全在这儿。

所以我在这篇里不给人工测试做任何流水线。但我留一个判断:这一层里还有没被发现的自动化机会,包括我自己漏掉的。一个具体的信号是——如果某个手工测试步骤,你连续三次都走同一套动作,那它就该被写下来,写下来之后才谈得上自动化;反过来,如果你每次走的路径都不一样,那它现在还不该自动化,硬做只会得到一个天天在改的脚本,维护成本超过它省下的时间。

人工测试是上限,这句话在整条链的语境里是字面意思:上面那五层做得再好,产出的天花板由这一层决定。前面那些轮次的作用,是把人的注意力从"找那些机器能找的问题"里省出来,全投到这里。

边界

到这里,这条流水线已经能跑了:五轮抓,一轮丢,输入一份 diff,输出三到五条能看的评论,成本 5 到 15 分钟,挂在技能里不需要额外注意力。

边界得交代清楚,不然你会以为它是道闸门。

它不判断对错,只判断值不值得看。剔噪轮会误杀,尤其是那些"改法需要重新设计"的问题,所以 high 和 security、logic 两类我做了硬保留——那是止损,不是最优解,你迟早会想调它。

它不覆盖需求阶段和测试循环,端到端也不管,虽然它和 E2E 的失败信息放在一起看会更有用。它不碰生产。

人工审查这一层我没动,也不打算动。一个可以拿出来讨论的方向是:在其它层确实还在跑的前提下,小改动和简单错误修复的人类审查可以变成可选的。注意这个前提——那不是"省掉审查",是把人的注意力从简单的挪到复杂的。复杂改动的人工审查我一直认为有必要:全局性错误、遗漏的不利交互、过度复杂或次优实现,这三类我在 AI 写出来的代码里见过太多次,而且它们不是 AI 审查能自己抓出来的——它缺的正是"这个改动在整条链路上意味着什么"那个视角。

最后一句留给量级。如果这些层都在跑,把交付速度提一倍、同时把缺陷数压住甚至压下去,是能办到的,作者说的是 2 倍。但它是整条链的产物,不是某层特别用力。上面这条链只负责其中一环,而且是最便宜的那一环——最贵的那一环还在等人去点按钮。

造物
造物

万字手把手从零造一个项目(compiler/DB/解释器),每步代码能跑、讲到能复现。

查看主页 →