Skip to content

PR Review Agent 的设计

引言 ​

最近写一个 PR Review Agent。写之前想得挺简单:监听 GitHub PR,丢给 agent 看一下,然后把 review 发回去。

真写起来才发现,最麻烦的不是让模型看代码。模型当然会看,也会说得很像那么回事。麻烦的是它会把不确定的东西说得很肯定,比如编一个行号,或者把一个只能算提醒的问题写成 blocker。

所以这个插件的设计核心不是“让 AI 自动审代码”。我现在越来越讨厌这种大词。它更像是一个很啰嗦的 worker:把 PR 拉下来,让模型提候选意见,然后自己检查一遍,能发的再发,不能发的收进 summary。

模型负责提意见。插件负责不信它。

一个插件 ​

这个东西是外部 s5r 插件。Astrcodey 启动它的时候,实际跑的是:

bash
astrcode-pr-review-agent s5r

启动以后它会注册一个 /pr-review-agent 命令和一个 status tool。真正干活的是后台 poll loop。配置开了 webhook 的话,它还会起一个本地 HTTP receiver。

也就是说,它更像一个被 Astrcodey 托管的小服务,而不是页面上的一个按钮。

目前有两个入口:

  • PR 评论里提到配置的账号,比如 @whatevertogo review it
  • 配置仓库里新开的 PR

自动 review 新 PR 这件事我加了一个保护:第一次启用的时候,只 baseline 已经打开的 PR,不立刻全部跑一遍。不然一个仓库几十个 open PR,插件刚上线就开始刷屏,那画面有点丢人。

队列先行 ​

GitHub 事件进来以后,不会马上开始 review。它会先进入本地状态:

text
GitHub event / polling
        |
        v
state queue
        |
        v
review worker

状态都写在 ~/.astrcode/pr-review-agent/state.json。这里面有处理过的 comment、见过的 PR、webhook delivery、正在排队的事件、PR session、review memory。

听起来土,但很好用。

webhook 和 polling 可能同时来,不能一起改 state。插件会抢 run.lock,抢到了就处理,抢不到的 webhook 事件先写到 spool 文件,下一轮 poll 再导入。没有 Redis,没有数据库,也没有额外 daemon。一个本地 JSON 文件加一个锁,够了。

我挺喜欢这种设计。它不酷,但失败模式很清楚。

为什么一个 PR 一个 session ​

每个 PR 都会绑定一个 Astrcode session。

第一次有人触发 review,插件创建 session。之后同一个 PR 再有人评论,比如“重点看看并发那块”,插件继续把内容塞进同一个 session。

这里的原因很现实。PR review 通常不是一次性的。第一次 review 可能发现几个问题,作者改完以后又 push,一会儿又有人让你看测试。每次都新建 session,模型就失忆了,只能重新读一遍上下文。

session 复用也不是绝对安全。旧 session 可能上下文太脏,或者 Astrcodey 服务重启以后状态不完整。所以代码里做了兜底:复用失败就重建 session,再跑一次。

review 不要一次跑完 ​

一开始我也想过,直接把整个 PR 拼成一个 prompt,让模型一次输出所有 finding。

后来放弃了。大 PR 会爆上下文,小 PR 也会因为信息太杂导致模型乱抓重点。一次性 review 看起来简单,结果很不可控。

现在默认走 coverage-first:

text
orientation pass
file shard pass 1
file shard pass 2
...
global pass
merge outputs
final report

orientation pass 先看 PR 元信息、仓库记忆和文件清单。它不急着评论,先建立一个大概判断。

file shard pass 按文件分片。每一片都要求模型返回 files_reviewed,插件用这个判断哪些文件真的被看过。模型如果漏看了某个文件,coverage 里会留下记录。

global pass 再看跨文件风险。比如状态迁移、API contract、并发边界,这些东西只看一个文件经常看不出来。

这个流程没有很神秘,就是把“一次性看完”拆成几次比较可控的检查。

强制 JSON ​

这应该是整个插件里最重要的决定。

模型不能直接写 GitHub 评论。它只能返回 JSON,大概是这些字段:

text
confirmed_findings
advisory_findings
observations
verification
residual_risk
summary

真正能发 inline comment 的只有 finding。每个 finding 还要过校验:

  • severity 是不是 P0/P1/P2/P3
  • confidence 能不能归一化
  • path、side、line 有没有
  • 这个 line 是不是 GitHub diff 里能评论的位置
  • 有没有重复
  • 数量有没有超过 max_inline_comments

GitHub inline comment 对位置很挑。模型说“第 42 行有问题”不够,42 行必须真的在 PR diff 的 commentable line 里。否则 API 直接拒绝。

插件会尝试找附近的 RIGHT 行,但不会硬造一个位置。宁愿少发一条,也不要在错误位置上装作很懂。

提示词怎么写 ​

这个插件的 prompt 不是从零写的。它的底子其实来自我平时用的 reviewnow skill。

reviewnow 做的事情很朴素:先确定 review 范围,再读 diff 和项目上下文,能跑验证就跑验证,然后从几个角度挑真实问题。它不鼓励泛泛而谈,也不鼓励“这里可以更优雅”这种没证据的建议。一个 finding 至少要说清楚几件事:文件行号、问题是什么、影响是什么、证据在哪里、怎么修。

我把这套东西搬进插件时,删掉了很多不适合自动化的部分。人工 review 可以写一段自然语言,可以临时判断哪些话该说、哪些话不该说。插件不行。插件需要稳定格式,需要能被 Rust 校验,需要能发到 GitHub 的具体位置。

所以目录里最后变成了几份 prompt:

text
pr-review-bot.md
orientation-review.md
file-review.md
global-review.md
aggregate-review.md

pr-review-bot.md 是底色。它告诉模型:你不是聊天助手,你是 whatevertogo 的替身 reviewer。你要像 maintainer 一样看 PR,优先找这次 diff 引入或暴露出来的问题,不要写泛泛建议。

这里保留了 reviewnow 里最重要的几条:

  • 先看真实 diff,不要凭感觉 review。
  • 有证据再报问题,低置信度的放 observation。
  • 从 Correctness、Security、Reliability/Performance、Tests/API Contract 几个角度看。
  • severity 按影响分,不要因为问题属于测试或设计就自动降成 P3。
  • 每个 finding 都要有 evidence、project context、impact、fix。

我故意没有把 Architecture 单独列出来。插件里的 global pass 会看跨文件和架构风险,但最后还是要落到具体影响上。否则模型很容易开始写“架构上建议进一步抽象”这种没法处理的废话。

剩下几份 prompt 按阶段来。orientation 先看 PR 元信息、仓库记忆、checks 和文件清单;file pass 只看当前 shard;global pass 再回头找跨文件问题;aggregate 写的是合并阶段的规矩:去重,保留最高 severity,不允许凭空创造新 finding。最后 Rust 还会再合并和校验一遍。

我后来发现,prompt 里最有用的不是“请认真 review”。这种话基本没用。真正有用的是限制:

  • 只能返回 JSON
  • 不要调用 GitHub API
  • 不要写最终评论
  • finding 必须落在给出的 RIGHT / LEFT 行上
  • 看过的文件要写进 files_reviewed
  • 不要为了显得完整而写“看起来没问题”这种废话

这些限制很硬,读起来也不优雅。但 prompt 如果太优雅,模型就会开始自由发挥。自由发挥在聊天里挺可爱,在自动 review 里就容易变事故。

还有一个细节:prompt 会带上 repo memory、PR memory、路径级说明和 deterministic checks。模型不是只看 diff,它会知道这个仓库以前踩过什么坑,当前 PR 有没有失败的 checks,哪些文件没有 patch、不能 inline comment。

我不追求 prompt 写得像一篇漂亮文章。它更像一份任务单:你只能看这些材料,只能输出这种格式,只能在这些位置提问题。剩下的交给 Rust 校验。

发评论要克制 ​

默认最多发 12 条 inline comments。P3 通常只进 summary,除非触发评论明显是在要 nitpick。

这不是为了显得保守,是为了不烦人。

自动 review 很容易变成噪音。它只要连续几次刷出一堆低价值评论,大家就会开始忽略它。我的想法是,inline comment 应该留给真正需要作者停下来看的问题。其他东西放 summary,读的人自己决定要不要管。

发布时分两层:

第一层用 GitHub Pull Request Review API 发 inline comments。

第二层发 final summary,里面写这次 review 的 session、触发来源、head SHA、inline 数量、unplaced findings、验证信息和剩余风险。

如果 GitHub 拒绝 inline payload,插件不会静默失败。它会把这些 finding 转成 unplaced finding,写进 summary。难看一点,但至少人能看到。

memory 不只是给模型看的 ​

插件有 Markdown memory:

text
memory/repos/<owner>__<repo>/index.md
memory/repos/<owner>__<repo>/pr-<number>.md

repo index 记仓库层面的 review 历史。PR memory 记单个 PR 的历史。prompt 会带上这些内容,让模型知道之前说过什么。

还有一份结构化 memory 在 state.json 里。这里会保存已经发布过的 finding fingerprint。

这个比 Markdown memory 更硬。模型下次又提同一个问题,插件会发现 fingerprint 已经存在,然后把它打成 already posted。否则同一个 PR 每 push 一次,agent 就重复提醒一遍,谁受得了。

webhook 和 polling ​

webhook 很快,但我不想只靠 webhook。

webhook 路径大概是:

text
GitHub webhook -> HMAC 校验 -> delivery 去重 -> event queue

polling 做的事情更多:

  • 检查 gh 是否登录
  • 搜索提到配置账号的 open PR
  • 拉取配置仓库的 open PR
  • 做新 PR 自动 review 的 baseline
  • 清理旧 worktree 和旧 session
  • 恢复卡住的 running review

webhook 像门铃,polling 像巡逻。门铃响了最好,但没响也不能假装什么都没发生。

prompt 编进二进制 ​

review 用的几个 prompt 在 prompts/ 目录里,然后通过 include_str! 编进 binary。

我不想让这个插件还依赖某个本地 skill 目录。插件安装以后,最好只依赖这些东西:插件系统、gh、Astrcodey、GitHub。路径依赖越少,部署时越少出现“我这里明明可以”的问题。

这也符合我对内置插件的理解:能靠插件包自己解决的,就不要去摸项目里的其他东西。

结尾 ​

这个 PR Review Agent 最让我在意的一点,是它没有把最后一道门交给模型。

模型可以说“这里可能有问题”。插件要问:行号对吗?严重性够吗?以前发过吗?会不会刷屏?GitHub 接不接受这个位置?

这些问题不适合交给模型自己回答。它太会圆了。

所以最后还是那句话:模型负责提意见,插件负责不信它。自动化 review 要是真的想进工程流程,至少得先学会这一点。