
上个月我们团队终于把AI先审、人工再审这套流程正式固化成了一条CI流水线跑在GitHub Actions里。前前后后我们改过三版踩了不少坑最后发现整个接入过程其实就三个关键动作触发工作流、让AI返回结构化结论、把结论变成真正的合并门禁。这篇文章我就把这套方案完整拆开讲适合那些PR评审排队时间太长、想用自动化减轻人工负担又不想把代码质量交给一个黑盒的团队参考。1. 先说清楚AI PR门禁到底要卡什么东西1.1 传统人工评审慢在哪很多团队的PR评审实际上变成了一个排队-排队-等有空的过程。一个PR提交上来之后reviewer要先看一眼标题和描述再打开文件一页一页翻同时还要在本地跑一下看看有没有编译错误。如果代码量稍微大一点一次评审半小时就没了。遇到跨时区协作一个PR等一天都是常态。我并不是说人工评审可以不要而是说人工评审里有一大半工作是机械性的是不是改了不该改的文件、有没有把密钥写进代码、是不是突然删了某个测试、注释里是不是还留着一堆TODO。这些事并不需要人脑的创造力只需要仔细和耐心而这恰恰是AI最擅长的。1.2 门禁不是模型审核是规则引擎加模型结论很多人第一次接触AI PR门禁时会把它理解成让大模型在PR下面发一条评论说哪里有问题。但那样根本不算门禁充其量是自动评论。门禁的本质是决策这条PR到底能不能合入CI必须给出一个明确的通过/不通过信号。AI模型本身是概率生成它不会主动说我检查完了可以合所以中间必须加一层规则引擎。规则引擎负责把模型输出的文本解析成结构化数据再根据预先定义好的判定条件得出一个只有几种取值之一的结论。打个比方AI模型就像一个能力很强的实习生你让他去检查代码他会给你一份报告。而门禁规则就是项目经理拿着报告去比对哪些问题是红线、哪些问题只是提醒最后拍板说过还是不过。1.3 应该设哪些判定维度我们团队在实践中把判定维度分成了四类每一类对应不同的拦截策略不搞一刀切。判定维度典型信号默认门禁策略安全问题硬编码密钥、开放接口、危险函数调用强制拦截代码完整性测试文件被删除、构建配置被改动强制拦截变更范围标题说改文档结果改了十几个源文件提醒并告警遗留问题TODO、临时注释、调试输出残留提醒并告警这四类维度写进提示词里AI才知道你希望它从哪些角度去审查。如果只是让它帮我看看代码有没有问题它往往会给出一些大而空、没法执行的意见比如建议优化代码结构这种话门禁根本用不上。2. 第一步写一个不会漏掉上下文的PR触发工作流2.1 最小可用的workflow文件我们先看一个最精简的GitHub Actions工作流事件触发用pull_request类型监听打开、同步更新和重新打开。name: ai-pr-review on: pull_request: types: [opened, synchronize, reopened] permissions: contents: read pull-requests: write checks: write concurrency: group: ai-review-${{ github.event.pull_request.number }} cancel-in-progress: true jobs: review: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkoutv4 - name: Build diff and metadata id: diff env: PR_NUMBER: ${{ github.event.pull_request.number }} BASE_REF: ${{ github.event.pull_request.base.ref }} HEAD_REF: ${{ github.event.pull_request.head.ref }} run: | git fetch origin refs/heads/${HEAD_REF}:refs/remotes/origin/pr-head git diff origin/${BASE_REF}...refs/remotes/origin/pr-head /tmp/pr.diff echo diff_size$(wc -c /tmp/pr.diff) $GITHUB_OUTPUT - name: Run AI review env: AI_API_KEY: ${{ secrets.AI_API_KEY }} AI_ENDPOINT: ${{ secrets.AI_ENDPOINT }} PR_BODY: ${{ github.event.pull_request.body }} PR_TITLE: ${{ github.event.pull_request.title }} DIFF_PATH: /tmp/pr.diff run: python3 .github/scripts/ai_review.py工作流的关键点有两个。第一permissions里必须给checks: write否则后续创建check-run时没有权限门禁状态写不回去。第二concurrency一定要设置否则一个PR连续推送多个commit时工作流会并发的跑好几次既浪费token又可能在最后结果里把旧commit的审查结论覆盖掉新commit的。2.2 触发条件为什么不能只监听PR打开很多刚接触的人只写types: [opened]这样确实在PR刚创建时跑一次但问题是如果开发者提交PR之后又推了一个修复commitsynchronize事件不会触发工作流不会重新跑。于是门禁结果还停留在上一个commit的状态等于失效了。reopened这个类型也建议加上。我在实际工作中遇到过开发把PR误关了又重新打开如果不开这个check-run的状态就是旧的三天前的结果很容易造成误判。这三个事件组合在一起基本能覆盖PR从创建到合并前的所有状态变化。2.3 要喂给AI的上下文怎么收集工作流本身只是壳真正决定AI审查质量的是喂给它的上下文。我们目前收集三块信息完整diff、PR描述、变更文件列表。diff是核心。但要注意git diff命令的参数顺序会直接影响结果。我们用的是base...head三点表示法它代表从base分支和当前PR分支的共同祖先开始比较到head这样能排除base分支上后来新增的无关提交只显示PR里真正改的内容。如果误用了两点表示法可能会把base分支上刚合并进来的改动也算进PR里导致AI看到的diff很大却又很多不是开发者改的门禁结果自然不准。PR描述和标题是辅助上下文。AI需要知道开发者自己的意图才能判断改动是不是符合描述。我们直接把github.event.pull_request.body塞进环境变量传给脚本脚本再把它放进提示词。文件列表不用单独生成git diff的输出里已经包含了每个文件的变更路径和行号AI可以从diff里解析出来。3. 第二步让AI按固定格式给出门禁结论3.1 接口调用方式脚本直接HTTP还是用现成SDK我们用的方案是脚本直接通过HTTP调用大模型推理接口没有用具体厂商的SDK。这样做的好处是以后如果想换一个模型服务商只需要改环境变量里的AI_ENDPOINT和AI_API_KEY脚本逻辑基本不用动。如果你所在团队的模型服务有完善的SDK用它也完全可以但建议做好抽象不要把某个SDK特有的类型对象直接传到后面的判题函数里。下面这段Python代码就是我们触发模型推理的核心片段import json import os import requests diff_text with open(os.environ[DIFF_PATH], r, encodingutf-8) as f: diff_text f.read() def call_ai_review(diff_snippet, pr_title, pr_body): payload { model: os.environ[AI_MODEL_NAME], messages: [ { role: system, content: SYSTEM_PROMPT }, { role: user, content: fPR标题: {pr_title}\nPR描述: {pr_body}\nDiff内容:\n{diff_snippet} } ], temperature: 0.1 } resp requests.post( os.environ[AI_ENDPOINT], headers{Authorization: fBearer {os.environ[AI_API_KEY]}}, jsonpayload, timeout60 ) resp.raise_for_status() return resp.json()[choices][0][message][content]这里把温度调低到0.1是为了让AI的输出尽量稳定。门禁系统不需要创造性需要的是可重复性。温度太高的话同一个PR跑两次可能给出不同结论这谁受得了。3.2 提示词怎么设计才能让它给出可执行的结果提示词是整个门禁里最容易被低估的部分。我们第一版写得很随意结果AI返回了各种风格迥异的评论有的长篇大论有的只说一句看起来没问题根本没法自动化解析。后来我们改成这样一套系统提示词模板你是一个严格的代码审查助手。你只依据用户提供的diff和PR描述进行审查禁止编造diff中不存在的代码内容。 请检查以下问题领域 1. 安全是否包含密钥、Token、数据库连接串。 2. 完整性是否删除或破坏测试文件、是否修改构建配置但未在PR描述中说明。 3. 一致性是否在代码中出现TODO、调试输出、注释掉的死代码。 4. 变更范围改动文件是否与PR标题描述的目标一致。 如果diff为空或信息不足请将verdict设为skipped。 你必须只输出JSON对象不要输出任何解释性文字。JSON格式如下 { verdict: success | warning | failure | skipped, issues: [ { level: error | warning, file: 文件路径, line: 行号, message: 具体问题描述 } ] }关键点有两个。一是明确告诉AI只依据给定的diff并且禁止编造diff中不存在的代码。模型在没有看到代码时会产生幻觉一旦给了它编造的许可它会一本正经地报出不存在的bug门禁就会变成笑话。二是强制JSON输出格式。只有结构化的输出后面的规则引擎才能稳定解析。如果AI没有输出JSON而是输出了markdown包裹的代码块脚本也要能识别并解析所以我建议在代码里对这种情况做兜底处理。3.3 拿到AI输出后的校验脚本模型返回的内容不能直接当结论用必须经过一层严格校验。我们写过一个小函数def parse_review(text): # 去掉可能的json围栏 cleaned text.strip() if cleaned.startswith(): cleaned cleaned.split(\n, 1)[-1] if cleaned.endswith(): cleaned cleaned[:-3] try: data json.loads(cleaned) except json.JSONDecodeError: print(::warning::AI返回内容不是合法JSON本次门禁降级为warning) return {verdict: warning, issues: []} valid_verdicts {success, warning, failure, skipped} if data.get(verdict) not in valid_verdicts: data[verdict] warning if not isinstance(data.get(issues), list): data[issues] [] return data这个校验函数是在踩过几次坑之后加的。有一次模型可能因为上下文太长输出了一大段正常文字外加一个JSON结果我们第一版脚本直接json.loads抛异常导致整个workflow崩溃门禁状态停在了pendingPR卡住无法合并。后来我们不仅做了围栏清理还把解析失败时的默认行为设成warning而不是success。原因很简单门禁宁可误报也不能放过潜在问题解析失败说明结果不可信这时候放行是不负责任的。4. 第三步把结论变成分支保护规则认可的门禁4.1 用Check Run把状态挂到PR上AI的审查结论就算在脚本里算出来了如果只打一条日志GitHub根本不知道有这个门禁。必须通过GitHub的Checks API创建一个check-run并把这个check-run关联到具体的commit SHA上。import os from datetime import datetime, timezone import requests conclusion_map { success: success, warning: success, # 默认不阻塞仅提醒 failure: failure, skipped: neutral } verdict data[verdict] conclusion conclusion_map.get(verdict, failure) check_payload { name: AI PR Review, head_sha: os.environ[GITHUB_SHA], status: completed, conclusion: conclusion, output: { title: fAI审查结论: {verdict}, summary: f共发现 {len(data.get(issues, []))} 个问题, text: json.dumps(data, ensure_asciiFalse, indent2) } } headers { Authorization: fBearer {os.environ[GITHUB_TOKEN]}, Accept: application/vnd.githubjson } repo os.environ[GITHUB_REPOSITORY] url fhttps://api.github.com/repos/{repo}/check-runs resp requests.post(url, headersheaders, jsoncheck_payload) resp.raise_for_status()这里需要注意GITHUB_SHA是GitHub Actions自动注入的环境变量它指向的是当前运行该workflow的commit。如果用其他方式获取SHA要确保它和PR的最新head commit一致否则check-run会挂在错误的commit上PR还是会显示没有通过检查。GITHUB_TOKEN默认权限是只读的我在第一步的workflow文件里已经申请了checks: write所以这里能正常写。如果忘了配置权限会收到403排查起来比较隐蔽。4.2 结论如何映射到GitHub状态我见过有人直接把AI的failure映射成check-run的failure然后发现PR列表里到处都是红色。这样虽然严格但会造成AI误报一个低危问题开发就要被阻塞。我们的映射逻辑分三档已经在上面代码里默认体现AI判定Check结论门禁行为successsuccessPR可以合并warningsuccess不阻塞合并但是会在PR评论里展示警告failurefailure阻塞合并必须处理skippedneutral表示本次未评估不参与合并判断其中warning映射成success是我经过一段挣扎后定的策略。最初我们把它映射成action_required结果发现一个PR里只要有一个调试日志残留AI就给一个warning然后整个check变黄CI就被卡住。团队里很快就有人反感了。后来我们把必须处理的问题限定为那些真正会导致代码错误、安全风险或被反复修改的问题其他一律只提醒不拦截。这样保护了门禁的权威性也保护了团队的开发体验。4.3 分支保护规则里怎么设置check-run创建好之后还需要去分支保护规则里把它设为必须通过的检查项。在仓库的Settings里找到Branches新建或编辑分支保护规则勾选Require status checks to pass before merging然后搜索刚才创建check-run时填的name也就是AI PR Review把它加入列表。这里有个小坑如果你在创建check-run之后的几分钟内就去搜索可能会搜不到。GitHub的check-run状态会有一定延迟稍等片刻再刷新即可。另外分支保护规则一定要和concurrency配合。如果不设置concurrency两个并发workflow同时结束时后完成的那个check-run会覆盖先完成的那个。如果先完成的判定为failure后完成却判定成功最终门禁会错误放行。在目录保护规则里加上cancel-in-progress: true可以避免这个问题。5. 跑通之后才暴露的几个坑5.1 上下文太大导致AI假装没看到上线第一周我们遇到最头疼的问题是AI偶尔会漏掉diff中间某个文件的明显问题。排查下来是因为PR的diff太大超过了模型上下文窗口的可用长度。我们的解决方案是分片审查。脚本先读diff内容如果总字符数超过阈值比如20000就把diff按文件切分成多个片段每个片段单独调用一次AI然后把所有片段的审查结果合并。def chunk_diff(diff_text, max_chars15000): chunks [] current [] current_size 0 for line in diff_text.split(\n): line_size len(line) 1 if current_size line_size max_chars and current: chunks.append(\n.join(current)) current [line] current_size line_size else: current.append(line) current_size line_size if current: chunks.append(\n.join(current)) return chunks分片后每个片段要带上这是一个PR变更的其中一部分请只审查这个片段的说明合并时要对每个分片的verdict取最严重值而不是简单取第一个。5.2 API超时和限流时的兜底模型服务并不是100%可靠的我们遇到过偶发超时、限流、甚至502。如果门禁因为这个挂了进而是PR无法合并整个团队的开发节奏会受很大影响。我们的兜底策略是每次调用模型前记录开始时间设置60秒超时超时或抛异常时先重试一次如果重试也失败就创建conclusion为neutral的check-run并在输出里写清楚AI服务暂不可用本次门禁未判定。这样PR不会被硬卡住reviewer也会在PR里看到为什么没有AI结论。有人可能会问为什么不直接设为failure强制拦截我试过结果第二天模型服务维护所有PR都合不进去团队差点暴走。门禁工具本身不能成为单点故障neutral是一个合理的中立状态。5.3 开发者会想办法绕过门禁你可能会觉得门禁不就是个check-run吗开发者绕有绕的招。我们发现的主要绕过方式是拆分PR。有人把一个大改动拆成五个小PR每个PR的描述都写fix typo这样每个PR的diff都很小AI审查看起来每个都干净合起来却是巨大变更。这其实是流程问题不是代码问题。我们在提示词里增加了一个变更范围合理性维度如果PR标题或描述是修复拼写但diff涉及超过5个非测试源文件就判定为warning提醒reviewer人工看。同时我们在分支保护规则里要求至少有一个人工approveAI不能单独放行。这样才能堵住明显绕过。另一个细节是check-run的结论可以被有权限的人通过API重新写入。我们遇到过开发者用管理员账号手动重跑或者取消检查。要避免这个需要在分支保护规则里限制只有code owner可以编辑规则并且保证组织的管理员不会随意修改。5.4 token成本与缓存控制跑一次AI审查的token成本在PR频繁的团队里是一笔不小的账。我们做了两个优化一是用concurrency.cancel-in-progress保证一个PR同一时间只有一个workflow在跑旧commit不会产生浪费。二是增加了一个简单的结果缓存。脚本会检查当前head SHA的diff和上一次的diff是否完全一样如果一样就复用上一次的check-run结论。这个可以用GitHub Actions的缓存实现也可以把结果追加写入到PR描述里作为历史记录。实测下来一个活跃仓库的月token消耗能降三成以上。最后我再分享一个个人习惯我会把整套审查提示词放到仓库的.github/ai-review/目录下和规则表一起纳入版本管理。改动提示词不再是一个拍脑袋的操作而是跟着PR走一遍和代码一样的评审流程。这样AI门禁本身也处于受控状态不会某天突然变得过于严格或者过于宽松。我测了一个月这套配置帮我们拦下了约三成低质量问题人工reviewer终于可以把精力集中在架构和逻辑讨论上而不是去逐行找漏掉的TODO。