我做了半年的 CI 改造,最明显的一个变化是:团队 Pull Request 的等待时间从“看心情”变成“先过机器人这一关”。不是不相信人工 review,而是人工 review 本身不够稳定,有时候半天不看一次,有时候扫一眼就 Approve。AI PR 门禁接进 GitHub Actions 之后,每次提 PR,机器会先把改动扫一遍,有问题直接亮红灯,红灯不灭 Merge 按钮就会被禁用。这套东西拆开看其实就三步:准备审查执行器、把执行器塞进 Actions、最后配置分支保护把结果挡在合并前。
先说受众,写代码、管仓库、做 CI 的人都能用这套思路。如果只是个人项目,那它帮你省掉“自己审自己”的尴尬;如果是一支团队,那它就是给 review 流程加了一道明确的底线。不用买额外构建机器,不要求大家都切到同一 IDE,只是多一个补齐规则的过程,整个过程能落地,也是我写这篇的主要原因。
1. 为什么要把“AI 审查”变成“合并门禁”—— 我先把三步规划讲清楚
很多团队的现状是:GitHubActions 里已经跑着语法检查、单测、覆盖率,但代码合不合主要由人决定。可惜人的状态波动很大,一个下午开十个会,晚上回来看到几十条新消息,基本就“都绿了,直接 rebase merge”。这时候不是人不想审,是没力气审。AI PR 门禁想解决的,不是替代人,而是先把那些一眼就能看出来的问题,在合并之前拦住,比如用了调试残留、明显缺少异常处理、改动把公共函数签名改了却没人注意到调用方。
真正要落地时,大多数人第一反应是“很复杂”,实际上拆成三步就清晰了。
1.1 三步方案的分工
我习惯把方案拆成三个彼此独立的产物,每个产物解决一个具体问题,组合起来就是一个完整的门禁:第一步是能让机器读懂 diff 并给出结论的“审查执行器”,第二步是让每个 PR 提交都自动跑这个执行器的 Actions 工作流,第三步是让审查结果确实影响合并的分支保护规则。分工如下:
| 步骤 | 核心产物 | 解决的问题 |
|---|---|---|
| 第 1 步 | 审查脚本/执行器 | 把 PR diff 取出来,让大模型判断,并返回结构化结论 |
| 第 2 步 | GitHub Actions 工作流 | 每次 PR 自动触发,把审查结果写回仓库 |
| 第 3 步 | 分支保护规则 | 状态不通过时,禁止合并按钮 |
这样拆有直接好处:哪一步出了问题,就单独改哪一步。脚本想换模型,不用动工作流;想改成只警告不拦截,只需要改保护规则。分界线一清二楚,团队里不同角色各管一段,谁也不用读懂全部代码。
1.2 为什么“门禁”比“提醒”效率高
如果机器人只是在 PR 底下留言“我怀疑这里有 bug,你回去看看”,那它的价值和插件、辅助工具区别不大,大家还是会忽略。门禁的本质区别是状态:留言是看完成,状态是 enforce 完成。一个不通过的状态会让 Merge 按钮变成灰色,强迫提交者低头看一眼原因。
这种设计很贴近我对代码评审的一个理解:绝大多数 PR 问题不是“发现不了”,而是“看见了不当回事”。AI 评论没人看,失败状态一定会有人在合并之前处理。所以我特别强调,第一步的审查结论必须映射到一个明确的“通过/不通过”结果,而不是一段可读可不读的话。后面每一步都需要这个状态做基础。
当然,“通过/不通过”不等于“AI 说好就一定好”,这是两回事。门禁只是设了一条最低线,实际场景里我们会把判断阈值定义得比较保守,后面第 6 节我会专门讲这个平衡。
2. 第 1 步:做一个能被 Actions 调用的 AI 审查执行器
第一步是整个门禁的引擎。如果这步做得草率,后面只是把一个不稳定的东西接到了门禁上。我最早试过直接在 workflow 里写一长串 shell 和 curl,结果很快不可维护;后来还是收敛成一个独立的 Python 脚本,把“取 diff、调模型、回写状态”三件事分开,这样本地也能跑,Actions 里也能跑。
2.1 执行器的组成
先明确脚本要做的四件事:
- 用 GitHub Token 拉取当前 PR 的变更文件列表和 diff。
- 把 diff 整理成能放进提示词的文本,注意长度控制。
- 调用大模型接口,要求它返回结构化 JSON,里面至少包含结论、摘要、问题列表。
- 根据结论,把“通过/不通过”状态回写到 GitHub。
我不建议在脚本里直接用git diff本地命令来拿 diff。原因有两个:一是 Actions checkout 时如果不配置fetch-depth: 0,拿不到完整对比历史;二是脚本要同时兼顾本地调试,调用 GitHub REST API 反而是最稳的路径,只要有 token,就能拿到某个 PR 的完整文件列表和 patch,不依赖 Git 状态。
2.2 完整参考脚本
下面是一个我压缩后仍然能用的示例。原则上不依赖重型框架,只用了requests。真实项目里可以按自己的模型接口稍作调整,但整体结构可以照搬:
#!/usr/bin/env python3 """AI PR 门禁示例:读取 PR diff,调用大模型,回写提交状态。""" import json import os import sys import time import requests PR_NUMBER = os.environ["PR_NUMBER"] REPO = os.environ["REPO"] HEAD_SHA = os.environ["HEAD_SHA"] TOKEN = os.environ["GITHUB_TOKEN"] API_KEY = os.environ["LLM_API_KEY"] API_BASE = os.environ.get("LLM_API_BASE", "https://api.example.com/v1") MODEL_NAME = os.environ.get("MODEL_NAME", "your-model-name") GATE_CONTEXT = "AI Review Gate" HEADERS = { "Authorization": f"token {TOKEN}", "Accept": "application/vnd.github+json", } LLM_HEADERS = { "Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json", } def get_pr_diff(): """拉取 PR 文件列表,裁剪掉过大或没有 patch 的项。""" files_endpoint = ( f"https://api.github.com/repos/{REPO}/pulls/{PR_NUMBER}/files" "?per_page=300" ) resp = requests.get(files_endpoint, headers=HEADERS, timeout=30) resp.raise_for_status() files = resp.json() diff_parts = [] for f in files: filename = f.get("filename", "") patch = f.get("patch") if not patch: continue patch_line_count = len(patch.splitlines()) if patch_line_count > 800: # 防止一个文件过大把上下文塞爆,先跳过,后续可做分片 diff_parts.append(f"文件 {filename} 改动过大,跳过大模型细看") continue diff_parts.append(f"文件: {filename}\n状态: {f.get('status')}\n{patch}") return "\n\n".join(diff_parts) def call_llm(diff_text: str) -> dict: """调用模型接口,要求返回 JSON。""" sys_prompt = ( "你是一个严格的代码评审员。请基于以下 PR diff 输出 JSON: " '{"verdict": "approve" 或 "request_changes", ' '"score": 0-100, ' '"summary": "一句话总结", ' '"issues": [{"file": "...", "line": 数字或 null, ' '"level": "blocker|warning|suggestion", "message": "..."}]}' ) payload = { "model": MODEL_NAME, "messages": [ {"role": "system", "content": sys_prompt}, {"role": "user", "content": diff_text[:30000]}, ], "temperature": 0.2, "max_tokens": 1200, "response_format": {"type": "json_object"}, } # 常见接口路径是 /chat/completions,如果你的服务不是,改这里 resp = requests.post( f"{API_BASE}/chat/completions", headers=LLM_HEADERS, json=payload, timeout=90, ) resp.raise_for_status() raw_content = resp.json()["choices"][0]["message"]["content"] try: return json.loads(raw_content) except json.JSONDecodeError: # 有些接口不会严格遵守 JSON 输出,这里做一次兜底 start = raw_content.find("{") end = raw_content.rfind("}") if start != -1 and end != -1: return json.loads(raw_content[start : end + 1]) raise def post_comment(body: str): """把审查摘要写到 PR 评论区。""" endpoint = f"https://api.github.com/repos/{REPO}/issues/{PR_NUMBER}/comments" requests.post(endpoint, headers=HEADERS, json={"body": body}, timeout=30) def post_gate_status(state: str, description: str): """写入提交状态,分支保护会读取这个状态。""" endpoint = f"https://api.github.com/repos/{REPO}/statuses/{HEAD_SHA}" payload = { "state": state, "context": GATE_CONTEXT, "description": description, } resp = requests.post(endpoint, headers=HEADERS, json=payload, timeout=30) resp.raise_for_status() print(f"[gate] {state}: {description}") def main(): diff_text = get_pr_diff() if not diff_text.strip(): post_gate_status("success", "没有可审查的 diff") return result = None for attempt in range(3): try: result = call_llm(diff_text) break except Exception as exc: print(f"[gate] 调用失败,第 {attempt + 1} 次重试: {exc}") time.sleep(5) if result is None: post_gate_status("failure", "AI 审查执行器多次调用失败,请人工介入") sys.exit(2) verdict = result.get("verdict", "request_changes") issues = result.get("issues", []) blocker_issues = [x for x in issues if x.get("level") == "blocker"] comment_lines = [f"AI 审查结论: {verdict}", result.get("summary", "")] for issue in issues[:20]: comment_lines.append( f"- {issue.get('file')}:{issue.get('line', '-')} " f"[{issue.get('level')}] {issue.get('message')}" ) post_comment("\n\n".join(comment_lines)) if verdict == "approve" and not blocker_issues: post_gate_status("success", "AI 审查通过") else: post_gate_status("failure", "存在需要处理的审查意见") sys.exit(1) if __name__ == "__main__": main()这个脚本写完,别急着进 GitHub Actions,先把变量的概念搞对:GATE_CONTEXT就是后来分支保护规则里要选的那个状态名,名字必须稳定,不要一会儿叫 “AI Gate” 一会儿叫 “AI Review”。API 的地址、模型名建议通过环境变量注入,而不是写死在脚本里,否则换模型要改代码。
脚本里的退出码值得单独说。有人说既然状态已经写成 failure,那脚本直接退出 0 也可以。但我不建议这样做,因为 Actions 运行的日志会显示“成功”,这会掩盖真实问题,导致大家只看得见 PR 红了,找不到是哪个 job 出的错。让脚本在审查不通过时退出 1,等于在 Actions 面板里也留了一条线索。注意上面代码里是sys.exit(1)而不是静默成功。
2.3 先在本地跑通的关键点
在接到 GitHub Actions 之前,我强烈建议先在本地跑一遍完整流程。具体方式是把GITHUB_TOKEN临时设成一个有仓库读权限的 Personal Access Token,再把PR_NUMBER指到一个真实 PR。本地跑有两个好处:一是可以反复改提示词,看模型在不同场景下的输出质量;二是能直接观察 GitHub API 返回的字段,避免等到了 Actions 里才发现字段名不对。
本地测试时重点确认三件事:第一,模型是否稳定输出合法 JSON,如果不是,是否触发了兜底解析;第二,对于你准备拿来做测试的那个 PR,结论是否符合你的直觉;第三,提交状态是否真的出现在 PR 页面的 checks 区域。第三点很容易被忽略,如果状态没出现,后面第 3 步分支保护就找不到这个状态。
我在这个阶段踩过一次坑:模型返回了很多 warning,但verdict还是给 approve,而门禁只看 verdict。后来我把规则改成“只要存在 blocker 级别的问题,无论 verdict 是什么都不通过”,才让结果更可信。这个策略我写进了代码,你也可以按自己的尺度调整。
3. 第 2 步:嵌入 GitHub Actions,让每次 PR 自动触发门禁
脚本就位之后,第二步的工程量其实很小,核心是写一个 workflow YAML,把脚本接到 GitHub Actions 的pull_request事件上去。很多人在这里会犯一个错误:把太多逻辑塞进 YAML,恨不得用 shell 在 workflow 里做所有事情。我的建议正好相反,YAML 只负责触发、传环境变量和调用脚本,逻辑尽量留在脚本里,这样既好测试,也好排错。
3.1 workflow 骨架推荐
下面这个 workflow 我实际跑过,整体结构很干净:
name: ai-pr-gate on: pull_request: types: [opened, synchronize, reopened] branches: - main permissions: contents: read pull-requests: write statuses: write concurrency: group: ai-gate-${{ github.event.pull_request.number }} cancel-in-progress: true jobs: ai-gate: name: AI Review Gate runs-on: ubuntu-latest timeout-minutes: 10 steps: - name: 检出代码 uses: actions/checkout@v4 - name: 配置 Python uses: actions/setup-python@v5 with: python-version: "3.11" - name: 安装依赖 run: pip install requests - name: 执行 AI 审查 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} REPO: ${{ github.repository }} PR_NUMBER: ${{ github.event.pull_request.number }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} LLM_API_KEY: ${{ secrets.LLM_API_KEY }} MODEL_NAME: ${{ vars.MODEL_NAME }} LLM_API_BASE: ${{ vars.LLM_API_BASE }} run: python review_pr.py这里有几点我要特意解释一下。
第一,触发事件只写了pull_request,不是pull_request_target。pull_request_target能在 fork 来的 PR 里使用仓库 secrets,但也因为 checkout 的是 PR 里的代码,容易被恶意利用。除非你非常清楚风险,否则别图省事直接用。外部协作者提的 fork PR,我建议单独单独设流程,或者先让维护者手动触发,不要一股脑套这套门禁。
第二,permissions尽量收紧。这里需要pull-requests: write是为了发评论,需要statuses: write是为了写门禁状态。如果你暂时不想要评论,可以把 pull-requests 改成 read,少给一点权限就少一分风险。
第三,concurrency这段很实用。如果提交者在门禁还没跑完又 push 了新代码,旧任务会被取消,只留下最新的一次审查。这避免了“上一个审查还在,这一个 PR 又触发”导致的混乱,也节省了 API 调用费用。group按 PR 号分组,不同 PR 之间仍然并行,互不阻塞。
3.2 为什么触发链要这样设置
很多人以为只要工作流文件存在,PR 一开就会自动跑,实际上触发条件全部由on控制。我选了opened, synchronize, reopened三个类型,对应的场景是:新建 PR、有新 commit 推上来的同步、被关闭后重新打开。这三个覆盖了绝大多数需要重新审查的时刻。
branches: [main]是个好习惯。如果你的团队同时维护多个主分支,比如 release 分支也需要门禁,就把它们加进去。不加的话,任何分支的 PR 都触发,可能白白消耗 API 额度。
触发后的运行时机也有讲究。GitHub Actions 默认的pull_request工作流会用自动生成的GITHUB_TOKEN访问仓库,这个 token 在普通 PR 场景下的权限范围是可控的,配合前面收紧的permissions,已经够这套流程使用了。
3.3 API 密钥与最小权限
LLM_API_KEY是这套流水线里最敏感的东西。我建议在仓库的 Settings → Secrets and variables → Actions 里把实际密钥放进 Secrets,命名为LLM_API_KEY,不要让它在 YAML 里明文出现。模型名称和接口地址如果希望发给协作者看,可以放在 Repository variables 里,比如MODEL_NAME、LLM_API_BASE,因为它们不是机密。
还要想清楚一个现实问题:运行这套工作流的 GITHUB_TOKEN 属于仓库机器人身份,这个身份可能也有合并权限。万一脚本被恶意 PR 篡改,token 就会被利用。所以我会把脚本完整性也纳入审查范围,比如审查执行器本身变更时触发人工关注。这些细节听起来烦琐,但真正被入侵一次就长教训了。
4. 第 3 步:设置分支保护,把状态检查变成“不可逾越的门”
前两步跑通后,PR 上能看到一个名为AI Review Gate的检查,但它还只是“提醒”。如果开发人员无视红灯,照样可以点 Merge 按钮。第三步就是给仓库上锁:在分支保护规则里把AI Review Gate设成必需的检查,不通过就禁止合并。
4.1 在仓库设置中怎么配置
路径非常直接:进入仓库 Settings → Branches → Add branch protection rule,或者在已存在的规则里编辑。在 Branch name pattern 里填main。然后勾选:
- Require a pull request before merging
- Require status checks to pass before merging
- 在搜索框里输入
AI Review Gate,选中它
选中后,GitHub 会记住这个状态名。以后每次 PR 请求合并,它都会先检查是否存在一个对这个状态的成功记录。如果状态是 failure,或者根本没有这个状态记录,合并按钮都会被禁用。
这里有个容易踩的坑:分支保护里的状态检查,必须跟脚本写入的GATE_CONTEXT名字完全一致。我见过有人 UI 里选了 “AI Review” 而代码里写的是 “AI Review Gate”,结果状态永远对不上,合并按钮一直灰着。排查半天才发现是拼写不一致。配置完成后,建议立刻开一个测试 PR,确认红灯状态确实能阻止合并。
4.2 几个值得斟酌的附加选项
分支保护规则里还有其他选项,我不建议全勾。比如Require branches to be up to date before merging这个选项,理论上更严格,但它要求 PR 分支必须基于最新 main,否则即使门禁过了也不让合并。这个规则在小团队可能引发大量不必要的 rebase,反而增加摩擦。根据团队习惯决定,不一定开。
另一个是Do not allow bypassing the above settings,如果开了,管理员也不能绕过门禁。初期配置时我不建议开,因为你可能需要手动合入一些紧急修复。等门禁稳定运行、团队形成共识后,再考虑把 bypass 关掉,让规则对所有人生效更公平。
如果仓库里有多个长期分支,比如dev,要确认每个分支是否都有自己的保护规则。只保护 main,其他分支照样可以绕过,这不算真正接进门禁。通常做法是先保护 main,跑一段时间再推广到其他主干分支。
4.3 当状态一直无损响应
配置好之后最常见的问题是:PR 没有出现任何新状态,或者状态一直 pending。先去 Actions 页面看工作流有没有真正触发。如果连工作流都没有,多半是on.pull_request的 events 没有匹配,或者 branches 过滤写错了。如果工作流跑了但看不到状态,那要回头看脚本里的HEAD_SHA是不是取到了正确的 commit。
HEAD_SHA是特别容易踩的坑。github.event.pull_request.head.sha是 PR 头分支最新 commit,这个值没问题。如果你图方便写成github.sha,它在部分场景下可能指向合并 commit 而不是 head commit,状态会被写到错误位置。我一开始就吃过这个亏,日志明明显示成功,保护规则却永远找不到状态。排查时先打印 HEAD_SHA 和分支保护里对应 PR 的 commit 对比,基本能定位。
5. 踩坑现场与排查手册
这套系统跑起来不难,真正烦人的是各种偶发问题。我把遇到过的、以及同事遇到的典型问题做成一份速查表,方便你遇到同样情况时快速定位。
| 现象 | 可能原因 | 处理办法 |
|---|---|---|
| AI 审查结果一直失败 | 模型返回了不合法 JSON | 脚本里做兜底解析,并在失败时重试 |
| 工作流没触发 | on 事件类型或分支过滤不对 | 检查on.pull_request.types和 branches |
| 状态一直 pending | 分支保护里状态名和脚本不一致 | 对比GATE_CONTEXT与保护规则名字 |
| fork 来的 PR 无法调用密钥 | 默认 pull_request 事件不暴露 secrets | fork PR 单独用安全流程处理 |
| API 调用超时 | 模型响应慢或网络问题 | 脚本里设 timeout 和重试,不要无限等 |
| diff 太大导致上下文溢出 | 模型上下文有限 | 限制单个文件 patch 行数,或分块审查 |
| 费用增长过快 | 每次 PR 都全量审查 | 过滤纯文档改动、设置并发取消 |
5.1 大模型返回不稳定
这是最常发生的问题。不同模型的输出有时候不理想,尤其在不支持强制 JSON 时,可能出现多出来的文字、Markdown 代码块包裹、或者issues字段缺失。我的处理方式是:提示词要求 JSON 并给字段例子,同时脚本里做字符串截取兜底;如果服务商支持response_format: {"type": "json_object"},就在调用时显式带上去,成功率会高很多。
但要注意,即使模型输出能解析,结论也未必合理。团队刚开始用时,一定要抽看评论内容,核对模型是否有“看到明显 bug 却放行”的情况。如果频繁发生,问题往往出在提示词太宽松,或者 threshold 设置得太低。提示词要明确说明什么算 blocker、什么只是 suggestion,比如“空指针风险、明文密钥、逻辑分支永远为假”应该列为 blocker。
5.2 diff 太长,模型读不完
大 PR 是模型上下文的主要敌人。几百行的小改动随便跑,上万个文件的大规模重构,直接把 diff 塞进提示词会截断,截断后模型很容易得出错误结论。我在脚本里限制了单个文件的 patch 行数上限,超过 800 行的先跳过,至少让审查不会因为一个超大文件而整体失败。
更复杂的场景可以按文件分组多次调用模型,再把每次的结论合并。这会在成本和复杂度上增加不少,我的建议是:先把小 PR 上的门禁跑稳,大 PR 暂时只做“变更概览”而不做逐行判断。等团队真的经常遇到大 PR,再考虑分片调用,否则就是过度设计。
5.3 设置时区的成见和提示词偏见
这里说的“成见”不是算法道德那一类,而是模型对代码风格有非常强的刻板偏好。比如它可能永远要求加注释,对每个二分法都写一句“可读性不高”。如果门禁把 suggestion 当成 failure,团队会被噪声淹没,情绪迅速变差。我们最终把规则变成只有 blocker 才失败,warning 和 suggestion 只作为评论,不去影响合并状态。这套策略直接降低了门禁的误报率,也让团队接受度提高了一个档次。
所以设计门禁的“红线”时,要时刻记住它是一个自动化的规则执行器,不是第二个有完全主观评判力的 reviewer。规则越窄、越具体,越可靠。
5.4 调用频率和成本控制
每次 PR 触发一次 AI 调用,看起来不多,但如果团队每天产生几十个 PR,量立刻上来。流水线的concurrency已经能避免同 PR 的重复调用,额外还能做三件事:一是对纯文档改动直接放行,比如只改了 md 文件就不必调用大模型;二是利用本地缓存,如果 diff 没有变化,直接复用上一次的结论;三是给 workflow 设置timeout-minutes,避免某个卡住的调用一直占着运行任务。
我实际统计过,把这三件事做上之后,成本下降通常非常明显,而且对审查质量几乎没有影响。毕竟文档变更确实不需要模型给出代码结论。
6. 一些“之后你未必在文档里看到”的收获
6.1 先灰度跑,不要第一天就锁死分支
如果你是一个注意团队的稳定性的工程师,我强烈建议先别立刻在分支保护里勾上“必需状态检查”。可以分两个阶段:第一个阶段,把脚本和 workflow 跑起来,但保护规则先不加,让团队看一周 AI 审查评论,感受它的风格和准确度;等模型误报率调整得差不多了,再在分支保护里启用门禁。
我在某团队落地时,前两周一直听到“AI 管太宽”的抱怨。后来我们改了提示词和 blocker 定义,情况才好转。一步到位就算工具本身不差,团队体验也会差,因为大家还没有建立对它的信任。先灰度,再强控,是更稳的路。
6.2 让人工评审和 AI 分工
AI PR 门禁的价值是筛掉“低级问题”和“明显问题”,真正具有架构意义的讨论仍然要留给人工 review。比如公共组件设计、数据库迁移方案、接口协议变化,这些很难靠一个 diff 文本就能准确判断。所以我最后把门禁的定位调整成:“AI 负责兜底,人负责纠偏,门禁负责保证双方都看过。”
有些团队会用 AI 自动 approve 小 PR,只把大 PR 留给人工,我试过之后觉得还算可行,但要注意方式。不应该让 AI 的 approve 完全替代 human 的 approve,至少在要求责任明确的公司里很难。比较合理的做法是:AI 通过只是 merge 的前提条件之一,还必须有一个真实的人在 PR 上 Approve,这个流程既保留了效率,也维持了责任边界。
6.3 长期维护比搭建更重要
门禁上线最难的从来不是第一天接通,而是三个月后是否还有人维护提示词和阈值。模型在变,团队的代码风格也在变,同一个提示词可能过几个月就开始显示陈旧。所以我会在代码仓库里单独建一个目录放review_pr.py和提示词文档,并在 PR 模板里加一个选项:如果本次改动影响了 AI 审查逻辑,强制要求相关同事 review。
这套流程跑顺之后,其实你会重新审视整个代码评审体系。它不神奇,不会让 bug 消失,但能把那些“本应被拦住”的问题提前拦住,然后让真人把精力放在更有价值的地方。从这个角度看,三步接进门禁只是开始,后面真正值钱的,是团队用这套工具沉淀出的那套共识。