
我做了半年的 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: ftoken {TOKEN}, Accept: application/vnd.githubjson, } LLM_HEADERS { Authorization: fBearer {API_KEY}, Content-Type: application/json, } def get_pr_diff(): 拉取 PR 文件列表裁剪掉过大或没有 patch 的项。 files_endpoint ( fhttps://api.github.com/repos/{REPO}/pulls/{PR_NUMBER}/files ?per_page300 ) resp requests.get(files_endpoint, headersHEADERS, timeout30) 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, headersLLM_HEADERS, jsonpayload, timeout90, ) 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 fhttps://api.github.com/repos/{REPO}/issues/{PR_NUMBER}/comments requests.post(endpoint, headersHEADERS, json{body: body}, timeout30) def post_gate_status(state: str, description: str): 写入提交状态分支保护会读取这个状态。 endpoint fhttps://api.github.com/repos/{REPO}/statuses/{HEAD_SHA} payload { state: state, context: GATE_CONTEXT, description: description, } resp requests.post(endpoint, headersHEADERS, jsonpayload, timeout30) 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 [fAI 审查结论: {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/checkoutv4 - name: 配置 Python uses: actions/setup-pythonv5 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 mergingRequire 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 事件不暴露 secretsfork 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 消失但能把那些“本应被拦住”的问题提前拦住然后让真人把精力放在更有价值的地方。从这个角度看三步接进门禁只是开始后面真正值钱的是团队用这套工具沉淀出的那套共识。