把 AI 接到 PR 审查上这件事,我去年就试过一轮。当时最大的感受不是“爽”,而是“乱”:要么是模型评论刷屏没人看,要么是 workflow 里一把梭跑完就散,完全没有起到把关的作用。后来我把整个逻辑重新拆成“拿 diff -> 调模型 -> 卡合并”三个动作,配合 GitHub Actions 的检查机制做成真正的门禁,才勉强算落地。这篇博文就围绕“三步把 AI PR 门禁接进 GitHub Actions”展开,把我踩过的坑、最后沉淀下来的工作流骨架一起分享出来。适合被低质量 PR 折腾得头疼、又不想给团队上重平台的个人开发者和中小团队参考。
1. 为什么要把 AI 审查做成“门禁”
1.1 人工 code review 的几个真实困境
先聊一个老生常谈的问题:人工评审为什么越来越难落地。我观察到的现象很典型。小团队里每个人都背着业务迭代任务,能抽出来仔细看别人代码的时间少得可怜。大一点的项目更惨,PR 一多,reviewer 只能凭感觉扫一遍 diff,扫不出细节问题就成了常态。有些人干脆只看是否冲突、CI 是否绿、有没有明显语法错误,然后顺手点个 Approve。这种“搭便车式评审”坑在事后:上线后出事故,翻代码才发现当初有一行隐蔽的越界访问,或者一个资源没释放。
另一类问题是好 reviewer 稀缺。真正能把并发、安全、边界条件都看明白的人,在每个团队里都是少数。可 PR 数量是按人头平摊的,普通开发者评普通开发者的代码,本质是“同等水平互相确认”,很难产生增量价值。我见过不少团队,评审流于形式半年后,主干的代码质量不升反降。
还有一类痛点发生在“合入之后”。代码提交之前没人发现的问题,往往在上线之后以监控告警的形式出现。到那个时候定位成本、修复成本、线上影响全都上去了。门禁的意义就在于把一部分问题的发现时间点,从“生产环境”提前到“合并之前”。
1.2 门禁和“辅助评论”是两种完全不同的东西
很多人以为 AI 审查就是让模型给 PR 写几条评论,告诉人“这里有隐患”。实话实说,这种辅助评论我试过,效果非常有限。瓶颈不在模型,而在流程:评论挂在 PR 下方,作者不一定会看,reviewer 也不一定会跟进。这本质上还是把压力放在人身上,AI 只是额外制造了噪音。
门禁的逻辑完全不同。它不是在“建议”你做某件事,而是定义了一条门槛:某些问题存在时,PR 不能合并。这个门槛可以配置在保护分支上,AI 的输出只有两个结论——通过或阻断。阻断的时候,合并按钮就是灰的,除非修改代码或者人工强制覆盖。
我总结过三个层次:
- 辅助评论:AI 说,人看着办。适合氛围好、责任心强的成熟团队。
- 自动筛选:AI 只挑 critical 级别的问题发出来,减少噪音,但依然靠人执行。
- 门禁阻断:AI 结论直接联动合并权限,不满足条件就锁死合并。
门禁并不是要替代人审,它的核心价值是先把低级问题、高风险问题扫地出门,把人的注意力留给那些真正需要判断力的设计取舍。三个层次解决的是同一个问题的不同阶段,但门禁对“执行力”的保障是最强的。
1.3 什么样的仓库适合这套方案
先说结论:这套方案最适合仓库更新频率高、提交人水平参差、又希望代码长期保持稳定的项目。个人项目也值得接,因为没人帮你看代码,AI 门禁是最便宜的“第二双眼睛”。
某些场景下不建议上,或者不建议直接上阻断式门禁。比如团队还在磨合期,一天几十个 PR,模型误报率高时会让所有人崩溃。再比如代码库里有大量二进制文件、生成的产物、第三方代码,diff 本身就很失真。还有一种情况是合规敏感的项目,要求评审记录完全可追踪、评审人必须是真实人类,那 AI 只能做辅助,不能碰门禁这层。
我自己的实践是:先把 AI 审查结果作为 informational 的通知跑两周,攒一批真实反馈,确认模型输出和人工评审结论的吻合度,再逐步调高 severity 阈值,最后才把阻断打开。这比一开始就 Opens 阻断要稳得多。
2. 第一步:准备密钥、权限与最小工作流
2.1 拆解“三步”里的第一步到底要准备什么
很多人一上来就写脚本,这是最容易翻车的方式。我建议第一步先把地基打好,搞清楚三样东西:模型服务的调用凭证、GitHub 侧的权限模型、以及一个能跑通的最小 workflow。
模型服务这块不用纠结具体厂商。现在市面上的主流大模型服务商基本都提供 OpenAI 兼容的接口,也就是 POST 一个带Authorization头、内容是messages数组的请求,就能拿到补全结果。你只需要准备三样环境信息:接口地址、API Key、模型名称。保险起见,我会把三个都放进 GitHub 仓库的 Secrets 里。
GitHub 侧的权限要看清楚。GitHub Actions 默认会给工作流一个GITHUB_TOKEN,权限范围由permissions字段控制。如果我想只读代码、又能发 PR 评论、还能创建设检查状态,就需要在 workflow 头部显式声明pull-requests: write和checks: write。这种显式声明比用继承权稳得多,也安全得多。
2.2 配置 Secrets 的完整路径
具体操作不复杂。打开仓库的Settings->Secrets and variables->Actions,点击New repository secret,把下面的值填进去:
| Secret 名称 | 建议填的内容 | 说明 |
|---|---|---|
| AI_API_KEY | 模型服务商分配给你的 Key | 注意别和任何个人令牌混用 |
| AI_BASE_URL | 接口基础地址,例如https://api.xxx.com | 脚本会在后面拼/v1/chat/completions |
| AI_MODEL | 模型名称字符串 | 不同服务商叫法差别很大,我建议在 Secret 里配而不是写死在代码 |
| PR_REVIEW_THRESHOLD | 例如critical | 控制门禁的松紧程度 |
一个值得注意的坑:曾经不小心把 Key 打印到日志里,结果 Actions 直接在日志里把 Secret 打码成***,虽然 GitHub 会自动打码,但那是因为你触发了保护机制。正确做法是永远不要在脚本里 print 环境变量,可以打印“长度是否为 40 位”这类脱敏信息来排查。
2.3 先跑一个最小 workflow 确认链路
配置好 Secrets 之后,先不要一上来就写完整脚本。我习惯先放一个最小的 workflow,确认 Actions 环境和模型服务之间的链路是通的。
name: ai-pr-review on: pull_request: types: [opened, synchronize] permissions: contents: read pull-requests: write checks: write jobs: ai-review: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - uses: actions/setup-python@v5 with: python-version: '3.12' - name: Check secret length env: AI_API_KEY: ${{ secrets.AI_API_KEY }} run: | if [ ${#AI_API_KEY} -lt 20 ]; then echo "secret length looks wrong" exit 1 fi这个空跑本身没有调用任何模型,但它能验证三件事:workflow 能不能正常触发、GITHUB_TOKEN的权限声明是否合法、Secret 有没有正确注入到环境变量里。链路通了再往下写脚本,排查范围会小很多。
3. 第二步:写一个能稳定产出门禁结论的审查脚本
3.1 先想清楚审查范围:全量 diff 还是只盯高风险区域
真正动手写脚本前,最容易忽略的是“审查范围”。我第一次做的时候直接把整个 PR diff 灌给模型,结果遇到大 PR 直接报 token 超限,小 PR 的审查质量也不稳定。后来我学乖了:先取 diff,再剪裁。
从 GitHub 拉 diff 的方式有两种。第一种是用 GitHub CLI:
gh pr diff 123这个命令拿到的就是 PR 的完整 diff 文本,输出格式干净,适合直接丢给模型。在 Actions 里运行时,GH_TOKEN已经通过环境变量暴露给gh了,所以不需要额外认证。
第二种是用 REST API 的compare接口,自己控制 base 和 head。复杂是复杂一点,但可以精确拿到指定 commit 区间。我一般够用就选gh pr diff。
拿到 diff 之后要做什么?先设一个上限,比如超过 4000 行就只截取每个文件的新增部分和改动部分,再按代码行数均分给不同批次调用。另一个常用策略是按文件后缀过滤:只让src/、lib/、app/下面的文件进入 AI,docs/、*.lock、自动生成的*.pb.go直接跳过。这个过滤逻辑也可以放在 workflow 的paths字段里,后面详细讲。
3.2 构造 prompt 的关键细节
模型能不能输出有用的东西,一半取决于 prompt。我的经验是:不给模型写作文的机会,强制它输出结构化内容。
好的 prompt 通常长这样:
你是一名资深代码评审专家,正在 review 一个 GitHub PR。请严格关注: 1. 并发安全与资源释放 2. 注入类安全漏洞 3. 数组越界、空指针、异常吞掉等运行时问题 4. 明显的算法复杂度退化和死循环风险 对每个问题输出 JSON,格式为: {"issues": [{"file": "路径", "line": 行号, "severity": "critical|warning|info", "message": "一句话说明问题"}]} 不要输出 JSON 以外的任何文字。有几个细节值得展开。第一是“限定 JSON 输出”非常关键,它让后续解析变的简单,也降低了模型自由发挥的概率。第二是 severity 枚举要提前定死,门禁逻辑依赖这个字段。第三是给 2-3 个真实项目里的问题例子,让模型知道“认真”和“敷衍”的差别。我在 prompt 里加过一次示例后,输出的 bug 定位准确度高了不少。
还要注意 token 预算。大 PR 塞给模型很贵,也容易碰到上下文上限。我的做法是分段调用:按文件打包 diff,甚至按函数级别拆块,一个文件一个文件地审,最后汇总结果。对改动小于 20 行的文件,很多时候直接跳过。
3.3 调用模型 API 的稳定姿势
代码层面,调用模型 API 很简单,但稳定跑起来不容易。下面是我整理过的调用函数骨架:
import json import os import sys import time import requests API_KEY = os.environ["AI_API_KEY"] BASE_URL = os.environ["AI_BASE_URL"] MODEL = os.environ["AI_MODEL"] def call_model(prompt, max_tokens=2000): url = f"{BASE_URL}/v1/chat/completions" headers = {"Authorization": f"Bearer {API_KEY}"} payload = { "model": MODEL, "messages": [ {"role": "system", "content": "你是严格的代码评审助手,按用户要求输出 JSON。"}, {"role": "user", "content": prompt}, ], "temperature": 0.2, "max_tokens": max_tokens, "response_format": {"type": "json_object"}, } for attempt in range(3): try: resp = requests.post(url, headers=headers, json=payload, timeout=90) resp.raise_for_status() content = resp.json()["choices"][0]["message"]["content"] return json.loads(content) except (requests.exceptions.Timeout, requests.exceptions.HTTPError, json.JSONDecodeError) as exc: print(f"attempt {attempt + 1} failed: {exc}", flush=True) time.sleep(2 ** attempt) return {"issues": []} if __name__ == "__main__": diff_text = sys.stdin.read() result = call_model("以下是 PR diff:\n" + diff_text[:12000]) print(json.dumps(result, ensure_ascii=False, indent=2))三个点值得单独说。
超时设置。模型接口经常会慢,尤其是上下文很大的时候。我设置timeout=90,然后在上游 workflow 层把 job 的timeout-minutes设成 10 分钟。重试按指数退避来,第一次等 2 秒、第二次等 4 秒,重试上限 3 次。如果 3 次都失败,宁可返回空结果也不要让门禁卡死整个流程——这是一个非常重要的兜底策略。
response_format参数。大部分兼容 OpenAI 接口的服务商都支持强制 JSON 输出,强烈建议打开。但要注意:有些服务商支持的response_format只支持{"type": "json_object"},不支持 JSON Schema,别硬上 schema,否则会报 400。
最后是成本控制。max_tokens不要设太大,我一般设 2000,够输出几十条 issues 了。重试次数和单次 token 直接关系到账单,可以在测试阶段先拿一个小 PR 跑通,再逐步放开。
3.4 结果送回 PR 的三种姿势与选择
模型跑完了,审查结果怎么回到 PR?我试过三种方式,差别还挺大。
第一种是发单条 PR 评论。逻辑最简单,gh pr comment 123 --body-file result.json,合并门禁前大家能看见。缺点是评论和代码行没有关联,体验一般。
第二种是发 review 评论,能关联到具体行和文件,gh pr review 123 --comment --body "..."。如果你的审查脚本能给出 file 和 line,这是最直观的形态。
第三种是发布 check run。这种方式最有门禁意义,因为它可以直接产出一个“passed / failed / neutral”的结论,和 GitHub 的合并检查体系天然对齐。下面是一个用 Python 发布 check run 的简化版:
import requests def create_check_run(repo, pr_sha, conclusion, summary): api_url = f"https://api.github.com/repos/{repo}/check-runs" headers = { "Authorization": f"Bearer {os.environ['GH_TOKEN']}", "Accept": "application/vnd.github+json", } payload = { "name": "AI Review Gate", "head_sha": pr_sha, "status": "completed", "conclusion": conclusion, # success / failure / neutral "output": { "title": "AI Review Gate", "summary": summary, }, } requests.post(api_url, headers=headers, json=payload, timeout=30)这个 check run 的名字AI Review Gate很重要,因为后面配分支保护规则时,GitHub 靠这个名字来识别你要求的状态检查,后面细讲。
4. 第三步:让门禁真正卡住合并按钮
4.1 门禁的判定策略
脚本能出 JSON 了,接下来要回答的问题只有一个:什么样的结果算“通过”。
我的策略是给 severity 分级。critical 级的个数大于等于 1,直接阻断;warning 级超过 5 条,也阻断;info 级只是提示,不参与门禁判断。为什么阈值要独立配?因为不同团队的容忍度差异太大了。比如一个框架仓库,可能一条 critical 都不能有;一个内部脚本仓库,也许 critical 先只做通知。
判定函数大概长这样:
issues = result.get("issues", []) threshold_raw = os.environ.get("PR_REVIEW_THRESHOLD", "critical") thresholds = {"critical": 1, "warning": 5, "info": 99} def should_block(issues): critical = [i for i in issues if i["severity"] == "critical"] warning = [i for i in issues if i["severity"] == "warning"] if len(critical) >= thresholds.get("critical", 1): return True if len(warning) >= thresholds.get("warning", 99): return True return False还有一个很重要的设计:模型调用失败时怎么办。我的答案是不能静默通过。如果模型服务整体不可用,脚本应该返回neutral结论,并打印一段“AI reviewer unavailable”的说明。这样既不会阻塞开发,也不会假装自己已经审查过。把“失败”伪装成“通过”,是门禁系统最危险的行为。
4.2 完整 workflow:把三步串起来
单一脚本写好后,Workflow 的编排就顺理成章了。下面是一个可以抄作业的版本:
name: ai-pr-review on: pull_request: types: [opened, synchronize, reopened] concurrency: group: ai-review-${{ github.event.pull_request.number }} cancel-in-progress: true permissions: contents: read pull-requests: write checks: write jobs: ai-review: runs-on: ubuntu-latest timeout-minutes: 10 steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - uses: actions/setup-python@v5 with: python-version: '3.12' - name: Install dependencies run: pip install requests - name: Collect PR diff id: diff env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} PR_NUMBER: ${{ github.event.pull_request.number }} run: | gh pr diff "$PR_NUMBER" > /tmp/diff.txt echo "diff_lines=$(wc -l < /tmp/diff.txt)" >> "$GITHUB_OUTPUT" - name: Run AI review id: review env: AI_API_KEY: ${{ secrets.AI_API_KEY }} AI_BASE_URL: ${{ secrets.AI_BASE_URL }} AI_MODEL: ${{ secrets.AI_MODEL }} PR_REVIEW_THRESHOLD: ${{ secrets.PR_REVIEW_THRESHOLD }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} PR_NUMBER: ${{ github.event.pull_request.number }} PR_SHA: ${{ github.event.pull_request.head.sha }} REPO: ${{ github.repository }} run: | python review.py < /tmp/diff.txt > /tmp/review_result.json python gate.py < /tmp/review_result.json - name: Publish check run if: always() env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} PR_SHA: ${{ github.event.pull_request.head.sha }} REPO: ${{ github.repository }} run: | python publish_check.py几个细节要说明。
concurrency字段。PR 更新很快时,旧的审查可能还没跑完,新的 commit 又推上来了。concurrency配合cancel-in-progress: true,能保证同一时间只有一个审查任务在跑,避免浪费 token 和 runner 时间。
fetch-depth 设为 0。如果你用的是git diff而不是gh pr diff,必须全量拉取历史,否则 base 和 head 不在本地仓库里,diff 会失败。
workflow 里的if: always()很关键。如果Run AI review这一步因为脚本 bug 挂了,你希望 check run 仍然有一个“失败”的结论,而不是直接看不到状态检查。否则分支保护里配的“必须通过”规则会因为找不到 check 而产生混乱。
4.3 分支保护规则:把门禁“焊死”在合并流程上
Workflow 跑得再欢,不配置分支保护,它也只是个“会说话的看门狗”,没有执法权。这一步才是“门禁”真正卡住合并按钮的地方。
进入仓库Settings->Branches->Add branch protection rule。填入你的主干分支名,比如main。然后勾选:
- Require a pull request before merging
- Require status checks to pass before merging
- 在搜索框里输入
ai-review或者你自定义的 check 名称,选中它
这里有个超多的坑:GitHub 识别的状态检查名称,要么是 job 名称(比如ai-review),要么是你创建的 check run 的name字段。如果 workflow 里 job 叫做ai-review,分支保护里就填ai-review;如果你用了自定义 check run 名称AI Review Gate,分支保护里就要填AI Review Gate。实践中经常因为大小写不一致、或者多了个空格,导致规则匹配不上,门禁形同虚设。
注意,状态检查列表里只能看到“已经至少运行过一次”的 check。所以配置分支保护前,最好先手动触发一次 PR,让那个 check 出现在下拉列表里。空列表是配不了状态的。
还有一个细节:Require branches to be up to date before merging建议勾上。这可以避免基于过期分支的 PR 绕过最新的 AI 审查结论。代价是所有 PR 合并前都要先把主干合并进来,流程会严格一点,但很稳妥。
4.4 进阶玩法:按路径过滤,避免每个 PR 都“高射炮打蚊子”
直接对所有 PR 跑全量 AI 审查,代价不小。一旦团队活跃起来,每天几十个 PR,每个 PR 动辄几千 token,账单会很难看。更重要的,模型对无关紧要文件的审查,经常输出“风格建议”类废话,影响门禁的公信力。
我用的方案是在 workflow 触发条件里加路径过滤:
on: pull_request: paths: - 'src/**' - 'lib/**' - '!docs/**' - '!*.md'这样只有触碰核心代码的 PR 才会触发工作流,文档和配置文件自动跳过。做了这个优化后,我的 token 消耗降了大概 40%,而且因为审查内容更集中,模型输出质量也提升了。
另一个进阶玩法是只对“满足 diff 行数下限”的 PR 跑审查。比如改动小于 30 行的 PR,人工扫一眼足够,AI 介入反而慢。这类阈值放在 workflow 的 step 里判断就行:if: steps.diff.outputs.diff_lines > 30。
5. 常见问题与排查技巧实录
5.1 状态检查永远显示 pending,或者分支保护找不到检查
这是我被问得最多的问题。现象是 Actions 跑完了,但 PR 的 Checks 列表里要么看不到这个 check,要么一直停在 pending。
排查思路按顺序来。先确认 workflow 文件里的permissions有没有checks: write。默认GITHUB_TOKEN是没有写 check 权限的,哪怕 job 跑成功了,也不会有 check 记录。然后是名称匹配:分支保护里填的检查名称,必须和 job 名称或 check run 名称完全一致,包括大小写和空格。最后看if: always()有没有加:Run AI review挂了会导致后续 publish 步骤不执行,进而没有 check 结论。
5.2 模型返回的不是合法 JSON
这个问题几乎所有人都会遇到。哪怕你强制了response_format,模型偶尔也会在 JSON 外套一层 Markdown 代码块标记,或者末尾多一个逗号。
我的兜底方案是写一个 tolerant parser:先把内容里的json 和去掉,再用json.loads解析,失败就用正则抽{...}最外层的大括号再解析。还不行的,干脆丢弃本次问题的结果但保留 0 条 issues,这样门禁不会因为解析卡死。宁可少审,不可误杀。
5.3 门禁误伤率太高,团队开始无视它
这是“AI 审查”最尴尬的时刻。模型判了几条 critical,但开发者一看全是误报,久而久之所有人对红叉都麻木了,甚至学会点“管理员 override”。
我的解决办法是双阈值。Python 脚本里,模型的critical并不直接等于门禁的blocking,而是需要一个“置信度”字段。模型输出confidence: high / medium / low,只有 high 且 critical 才阻断。大部分误报集中在 medium 和 low,它们可以作为评论展示,但不影响合并按钮。这两个阈值都单独放进 Secrets,方便按项目调整。
另外,我还会记录 AI 审查历史。用 GitHub Actions 的 cache 或简单用 issue 注释存一份过去 30 天的审查结论,人工对可疑误报做标记。跑两周之后,你对自己选的那家模型服务商的“口味”会有很清楚的认知,阈值就能调得非常精准。
5.4 并发与限流:Actions 里请求外网频繁超时
免费额度或者低档次的模型服务,很容易在并发高的时候限流。PR 一多,workflow 同时发起多个审查请求,结果大量 429。我在脚本里加了两个保护:一是重试指数退避,二是对每个 repo 做简单的串行化concurrency。
还有一个容易忽略的问题:Actions 上的外网请求偶尔会遇到超时或者 TLS 抖动。不要依赖单次请求成功。timeout=90和 3 次重试是底线,预算充足的团队可以设置更短超时、更多重试。
5.5 安全问题:fork 的 PR 千万不要在默认分支执行拉下来的代码
如果你的仓库是公开的,任何陌生人都能来提 PR。pull_request触发的 workflow 默认 token 是只读的,不会携带 secrets,所以比较安全。但有人会把触发事件改成pull_request_target,这个事件会让 workflow 运行在目标分支上下文,并且 secrets 可见。一旦你在这样的 workflow 里 checkout 了 PR 的代码然后执行,就等于让陌生人的代码在你的 secrets 环境中运行。
这属于高风险场景,我的建议是:不要为了“能评论 fork 的 PR”就用pull_request_target。如果确实需要,只允许它在独立 job 里跑纯逻辑,用gh pr diff拿文本,无论如何都不要直接执行 PR 提交的脚本。这个边界一定要划清楚。
5.6 常见问题速查表
| 症状 | 可能原因 | 快速修复 |
|---|---|---|
| 找不到状态检查 | 名称不一致或 check 未发布 | 确认 job 名/check-run 名,并加if: always() |
| 一直 pending | 使用了pull_request_target且权限冲突 | 检查 permissions,改用pull_request |
| 模型总是 429 | 限流或并发打满 | 加并发串行、指数退避 |
| 审查结果全是误报 | prompt 没给示例、阈值太低 | 增加示例、引入 confidence 字段 |
| check 显示 success 但门禁不消失 | 分支保护未匹配 check 名 | 重新配置 require status checks |
| 大 diff 被截断 | 上下文超限 | 按文件拆分调用,汇总结果 |
| 评论发出去但卡不住合并 | 只发评论,没有发布 check/failure | 发布 check run 或让 job 非零退出 |
最后再分享一点个人体会
这整套流程落地之后,给我最大的收益不是“代码变好看了”,而是把“合入前必须有人审”从口号变成了系统行为。以前刚性的合并纪律总是靠人提醒,现在 AI 门禁至少把最容易出问题的临界缺陷拦在了主干之外。
我个人最大的教训是:不要把模型的输出当“真理”。它是我筛选高风险的筛子,不是法官。所以我的脚本里永远保留一个severity参数和阈值开关。团队对某个级别容忍度变了,改一下 Secrets 里的PR_REVIEW_THRESHOLD就能调,不需要动代码。如果你打算在自己的仓库里试,我也建议你从低阈值开始,先让 AI 当一个“唠叨的评论者”,跑两周再逐步收紧。等到它对你的代码库真正“有手感”之后,再下决心把它锻造成门禁。