ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

AI PR门禁实践:基于GitHub Actions与大模型的代码审查自动化

AI PR门禁实践:基于GitHub Actions与大模型的代码审查自动化

1. 先搞清楚:传统 CI 卡不住的那些问题

先说个很多团队都遇到过的场景:PR 提交上来,流水线亮绿灯——编译过了、单测过了、覆盖率没跌、Lint 也没报错,看起来一切正常。结果人工 review 的时候发现,整个 PR 只是把某个核心方法的名字改了一半,调用方改了三分之二,剩下一处漏改的地方,刚好不在任何测试覆盖路径上。

这种问题不是静态检查能发现的。它既不是语法错误,也不是类型错误,更不是格式化问题。它属于“代码改动语义不完整”“PR 描述和实际改动不匹配”“错误处理边界被悄悄吞掉”这一类更模糊的缺陷。传统 CI 在查语法、查规范、查运行时行为上非常可靠,但恰恰查不了这种“读起来不对劲”的问题。

这也是 AI PR 门禁的价值空间:让大模型像一个不拿工资、24 小时在线的初级评审员,在任何人工介入之前先读一遍 diff、看一遍 PR 描述、跑一遍基础审查逻辑,输出结构化结论,再根据结论决定是放行、还是阻断合并。

接到 GitHub Actions 里其实不复杂,核心就三件事:触发并拿到 diff、把 diff 喂给模型做审查、把审查结果写回 PR。但我得先泼一盆冷水——这个方案解决的是“评审覆盖面”和“响应速度”的问题,而不是“评审质量”的问题。AI 找不出所有 bug,它只是在人工评审之前帮你把那些一眼就能看出来的问题拦下来,减少无意义的人工往返。

适合用这套方案的团队画像我也说一下:PR 有一定数量但不多到爆炸、代码评审经常排队、希望在合入前多一层自动化过滤、有耐心维护提示词模板。如果你们仓库大部分 PR 就是改个文档或者调个小参数,那 AI 门禁的噪音会大于价值,这个判断在第五部分我会展开讲。

2. 三步闭环:从 PR 到 AI 评审再到门禁

2.1 第一步:确定触发方式与获取 diff

最自然的触发事件是pull_request,而且只需要opened和synchronize两种类型。前者覆盖新开的 PR,后者覆盖后续推新 commit 的场景。不需要监听push,否则一个 PR 多次 push 会出现重复审查。

这一步最常见的问题不是触发错了,而是 diff 拿不完整。actions/checkout默认只拉取当前分支的最新提交,不带完整历史,导致你比较基础分支时拿不到全量变更。解决方式很明确:

- uses: actions/checkout@v4 with: fetch-depth: 0

fetch-depth: 0会把全仓库历史拉下来,确保你有足够的上下文去计算base...head的差异。如果你用 GitHub API 的diff_url接口直接拿 diff,就不存在这个问题,但如果 diff 特别大,HTTP 请求可能会超时,所以我更推荐在本地用命令生成 diff。

拿到代码之后,用一条命令生成精确的变更内容:

git diff origin/${{ github.event.pull_request.base.ref }}...HEAD -- '*.py' '*.js' '*.ts'

后面跟的文件通配符很关键。很多仓库的 PR 会夹杂 lock 文件、生成物、图片资源,无条件喂给模型会白白消耗 token,还会稀释注意力。按我的经验,只对源代码文件做 AI 审查,其他类型的文件交给传统工具去处理,效果最好。

2.2 第二步:调用模型接口并解析结构化输出

这一步是整个方案的核心计算节点。我不会在 Actions 配置里写满流程,而是把审查逻辑写成一个独立的 Python 脚本,workflow 负责调用它。这样脚本可以在本地调试,也可以在 CI 里跑,维护成本低很多。

脚本的输入是 diff 文本和 PR 元信息(标题、描述、变更文件列表),输出是模型返回的 JSON 字符串,解析后再按规则生成 GitHub API 请求。

核心代码大致长这样:

import os import json import urllib.request def call_model(diff_text, pr_title, pr_body): system_prompt = load_prompt() # 从独立文件读取,方便版本管理 payload = { "model": os.getenv("LLM_MODEL", "gpt-4o-mini"), "messages": [ {"role": "system", "content": system_prompt}, {"role": "user", "content": f"PR标题: {pr_title}\nPR描述: {pr_body}\n\n代码变更:\n{diff_text}"} ], "temperature": 0.2, "response_format": {"type": "json_object"} } req = urllib.request.Request( os.getenv("LLM_API_ENDPOINT"), data=json.dumps(payload).encode("utf-8"), headers={ "Content-Type": "application/json", "Authorization": f"Bearer {os.getenv('LLM_API_KEY')}" } ) with urllib.request.urlopen(req, timeout=120) as resp: result = json.loads(resp.read().decode("utf-8")) return result["choices"][0]["message"]["content"]

两处细节值得说明:

一是response_format强制 JSON 输出。没有这个约束,模型偶尔会在结果前面加一段解释性文字,或者在 JSON 外面套一层 markdown 代码块,解析时要花额外功夫去清洗。支持 JSON mode 的模型都建议显式开启。

二是temperature设置为 0.2。评审任务不是创意写作,它需要稳定性和可复现性。实测temperature=0.7以上时,模型经常会为了“显得负责任”硬挑几个小毛病,噪音很大,0.2 左右会收敛很多。

2.3 第三步:将评审结果写回 PR 并设置门禁状态

模型返回 JSON 之后,需要决定怎么展示给开发者,以及怎么影响合并流程。我建议两件事同时做:写一条 review 评论,贴出结构化结论;同时创建或更新一个 check run,作为合并保护里的门禁状态。

写评论文案时要注意一个反直觉的点:不要只列“问题清单”,还要给出“为什么这是问题”和“建议怎么改”。AI 门禁的意义在于降低沟通成本,如果它输出一堆没有解释的编号规则,开发者还得自己去猜,那就白做了。

def build_comment(review_result): sections = [] for item in review_result["findings"]: sections.append( f"**{item['severity']}** | 文件 `{item['file']}` 第 {item['line']} 行\n\n" f"{item['reason']}\n\n建议:{item['suggestion']}" ) return "这是 AI 门禁的自动评审结果,仅供人工评审参考。\n\n" + "\n\n---\n\n".join(sections)

check run 的状态则根据findings里的最高严重级别来决定:

  • 只有info和warning级别,回completed+success。
  • 有critical或blocking级别,回completed+failure,阻断合并。

这里要特别提醒:如果你要阻断合并,必须在 repository 的 branch protection rules 里把对应 job 的 check run 加进去,否则状态写入之后也不会拦住任何人。而 check run 的名字不是在脚本里随便指定的——它要和 workflow 里声明的 job 名称一致。在 Actions 里跑一个python review.py的 job,即使它调用了 GitHub API 创建了一个叫CRITICAL_PR_CHECK的 check run,但你在分支保护规则里也要找到叫这个名字的检查项才能选它。很多人在这一步折腾半天,其实只是名字没对齐。

3. Prompt 设计:门禁质量的胜负手

3.1 角色设定与审查边界

整套方案的成败,90% 取决于 Prompt 写得好不好,代码和 workflow 反而是最不值钱的部分。我的经验是,给模型的 Prompt 必须包含几个明确的信息块:

第一,角色定义。告诉它“你是一名有十年经验的代码评审工程师,擅长发现逻辑缺陷、边界遗漏和安全风险”。这个角色越具体,输出越克制。单纯说“请帮我审查代码”,模型容易走两个极端:要么过度敏感,把任何格式差异都当成问题;要么过度宽松,只输出几句不痛不痒的总结。

第二,任务边界。明确告诉它“只评审本次 diff 中涉及的文件和逻辑”,不要跑出去臆测其他模块。大模型有一个很坑的倾向,就是会顺着上下文自由发挥,脑补出一个不存在的场景然后把“问题”安在你头上。你用上一条提示词约束它,这类假阳性会显著减少。实测下来,明确边界之后误报率能降一半以上。

第三,输出格式。我之前遇到过很多次:模型返回的 JSON 解析失败,导致整个 workflow 失败,而 PR 本身可能根本没有问题。所以在 Prompt 里把输出 schema 直接嵌进去,并且举一个简短的合法示例,非常必要。

完整一点的 Prompt 模板长这样:

你是一名资深的代码评审工程师,擅长在 pull request 的 diff 中发现: 1. 逻辑缺陷:变量误用、条件反了、循环边界错误、潜在空指针。 2. 安全问题:SQL 拼接、敏感信息硬编码、不可信输入未校验。 3. 异常处理问题:吞异常、忽略返回值、缺少兜底分支。 4. 与 PR 描述不一致:实现内容与描述不符、漏改动、改动超出范围。 你只能基于下面的 diff 和 PR 信息进行判断。 不要臆测 diff 之外的代码,不要审查格式问题(那是 Lint 的工作)。 请输出一个 JSON 对象,结构如下: { "summary": "一段不超过100字的整体评审意见", "findings": [ { "severity": "info | warning | critical", "file": "文件路径", "line": 数字行号, "reason": "具体原因描述,必须引用代码片段", "suggestion": "改进建议" } ] } 如果没有问题,findings 返回空列表。

有两点经验我建议直接照抄:一是line必须是数字,不要写字符串,否则你在后面做行级评论锚定的时候会很痛苦;二是severity枚举值必须是那几个固定单词,不要让模型自由发挥,否则解析逻辑很难统一。

3.2 大 diff 的截断策略

另一个在真实仓库里躲不开的问题是 diff 太大。一个大型 PR 可能动几千行代码,直接全量塞给模型,token 成本先不说,上下文窗口就可能直接顶爆。而且我实测下来,当 diff 长度超过模型注意力的舒适区之后,审查质量反而会下降——模型会把开头部分记得很清楚,中间就开始忽略细节。

我的处理方式是把 diff 按文件切开,每个文件单独截断,而不是从全局截到固定长度。单文件超过 200 行核心变更时,只保留diff --git头部、文件路径,以及变更行前后各 20 行上下文。这种启发式截断在工程上是够用的,它保证了模型能看到每个文件的结构性变化,而不是只看前几个文件的完整内容。

还有一个容易踩的坑:不要把整个git diff --stat单独喂给模型,让它去猜哪个文件重要。模型的猜测毫无意义,你要做的是按实际文件变更比例分配 token,而不是按模型兴趣分配。

3.3 用 JSON Schema 兜底解析

即使开了 JSON mode,模型偶尔也会吐出合法 JSON 但结构不对的东西,比如把line写成字符串,或者findings直接缺失。我建议写一个 lenient parser:先尝试直接json.loads,失败后正则提取{...}的最外层括号再解析,还失败就返回一个默认的安全结果——默认结果不应该阻断合并,但要记录日志并标注“本次审查未完成”。

4. 真实落地踩过的坑与对策

4.1 fork PR 场景下的权限问题

如果你的仓库开放社区 PR,也就是开发者从 fork 提交 PR,pull_request事件触发的工作流默认不拥有对原仓库的写权限,也读不到需要保密的组织级 secrets。此时用 GitHub API 写评论会直接收到 403。

这个问题有两种解决思路。一是改用pull_request_target事件,它能拿到 secrets 和写权限,但允许 fork 的 workflow 代码在原仓库上执行,存在恶意代码注入风险——我在第一版实现里就这么干过,后来评估之后果断放弃了这个方案。

更稳妥的做法是保持pull_request,通过 GitHub 的issue_comment事件或者workflow_run机制触发第二个具备权限的工作流,由它来写评论。代价是评论不是实时的,会有一分钟的延迟,但对门禁场景来说完全可接受。

4.2 并发 PR 同时跑的竞争条件

人多的时候,一个仓库可能同时有七八个 PR 在等门禁。如果 workflow 没有并发控制,大量模型请求会同时打出去,账单先不说,GitHub API 的 rate limit 也容易被打爆。

在 workflow 里加一个简单的并发组:

concurrency: group: ai-review-${{ github.event.pull_request.number }} cancel-in-progress: false

这里按 PR 编号隔离并发,而不是全局串行。全局串行会导致一个 PR 审查阻塞后面所有 PR,体验很差。按 PR 编号隔离,又能在同一个 PR 重复推送时避免排队堆积。

如果同一个 PR 连续推送了好多次,更聪明的做法是取消上一次还在跑的审查,因为它的结果已经过时了。把cancel-in-progress改成true就能实现。

4.3 重复评论轰炸

没有幂等保护之前,同一个 PR 每次 push 都会多一条 AI 评论。一个反复修改的 PR,一周下来能攒二十多条评论,开发者的第一反应就是“这破机器人疯了”,然后把它关掉。

解决办法是在脚本里先查一下这个 PR 是否已经有本门禁创建的评论。GitHub API 支持按per_page=100拉取 issue comments,然后检查 body 里是否包含一个独特的 marker,比如AI_REVIEW_MARKER_v2。有的话,就更新那条旧评论,而不是新增一条。这个设计很笨但很好使。

后来我发现 GitHub 官方有actions/github-script可以简化这类调用,但它的逻辑还是同样的——先查旧评论,再决定 update 还是 create。PowerShell 版本和 Python 版本我都写过,Python 最终成为我的首选,因为解析逻辑复杂了之后,脚本比配置 go 在可读性和可测试性上强太多了。

4.4 模型幻觉导致的假阳性

AI 门禁上线第一周,会出现一波“假阳性潮”。模型自信满满地指出一个“空指针风险”,但开发者看一眼就知道那个变量早在上面的代码块里判空过了。这类问题的根因往往是长 diff 让模型丢失了上下文——它看到了var.foo(),却没看到前面几行if (var == null) file://...的保护。

我的对策有两个。一是把 diff 拆成以 50~80 行为粒度的“块”,每块带着前 15 行上下文单独分析,再合并结果。二是让模型站在工程推理的角度,给reason字段做强制约束:“你在给出 critical 级别的问题时必须引用具体代码行,否则请降级为 warning。”这个指令不保证绝对准确,但能让误报的“自信度”下降不少。

说句实在话:AI 门禁被开发者信任需要一个过程。如果你刚刚上线时天天冒出低质量的 critical 结论,后面再想扭转印象就难了。所以我强烈建议第一个月把所有critical级别结果配置成“只警告不阻断”,等人群对它有信心之后,再切回“阻断模式”。

5. 更进一步:模型选型、成本控制与适用边界

5.1 模型选型:不是越强越好

跑 AI 门禁不一定非要上最贵的旗舰模型。我自己实测过,对于“挑错误、挑边界、挑遗漏”这类任务,中等参数量的模型配合精心调过的 Prompt,和旗舰模型相差没有想象中那么大。

如果你只是内部仓库做轻量审查,一个小模型即可;如果你需要处理复杂的跨文件变更、需要模型理解业务上下文,那必须上大参数模型。关键判断依据是 diff 的复杂度和误报的容忍度,而不是“哪个时髦用哪个”。

也要考虑响应时间。旗舰模型做一次完整的大 PR 评审可能要 60 秒以上,开发者会忍不住强行跳过门禁。中档模型通常 15 秒内能返回,体验好很多。落地时可以把两个模型都接上,按 diff 大小做 routing:小 diff 走轻量模型,大 diff 走强模型,成本曲线会非常平滑。

5.2 成本控制:按路径过滤 + 去重触发

成本失控是这类项目最容易翻车的地方。一个团队每天 50 个 PR,每个 PR 3 次 push,每次提交都跑大模型,一个月账单会非常难看。

我做三件事来控制成本:

第一,路径过滤。workflow 里先判断变更文件列表是否包含源码文件,纯文档、纯 lock 文件直接跳过模型调用,返回 success。

第二,内容去重。同一个 PR 在短时间内连续 push diff 非常相似,可以加一个简单的缓存:拿当前 commit sha 和上次审查时的 sha 对比,相同就复用上次的结论,不重新调用模型。

第三,PR 状态前置判断。draft PR不触发门禁,changed_files > 30时只审查关键文件,不要把预算砸在没人细看的巨型 PR 上。

5.3 什么场景不该上 AI 门禁

如果仓库的 PR 本来就很少、人工评审压力不大,这套方案是纯纯的负资产。还有一类场景很特殊:合规审计需要留存完整的评审证据链,AI 门禁生成的内容有可能被审计判定为“非正式评审记录”,这时候就要看流程设计的问题。

另外一个不建议的场景是商业保密等级很高的仓库。即使接的是私有化模型,把完整代码 diff 发给任何外部处理链路,都应该经过合规审批。不要觉得“模型服务商有免责协议”就万事大吉——协议管不了数据泄露的实际风险。

5.4 和自研 Bot 的取舍

如果团队已经有成熟的自研机器人或自建 CI 系统,可能不需要 GitHub Actions 这套。但 Actions 的优势在于它和仓库原生的 PR 流程绑定得极紧,分支保护、合并队列、审核记录全部天然打通,接入成本低到三个 step 就完事。

我的建议是:中小团队、想让门禁“跑起来就行”的,首选 GitHub Actions;大团队、有专门平台工程团队维护的,可以把 AI 评审做成独立服务,通过 webhook 接入——但那就是另一个量级的事,不在本文的“三步”范围里了。

6. 几个想留给后来人的现场经验

最后写几条我这一路调下来最想分享的体感经验,不一定全对,但可以让后来者少走一些弯路。

把 Prompt 当成代码来维护,而不是一段一次性文本。我的 prompt 文件放在仓库里,每一次改动都带 PR、带 review,因为 Prompt 本身的 bug(比如让模型输出了一个不存在的字段)会直接导致门禁空转,而且错误方式非常隐蔽。

不要试图用一次模型调用完成所有维度审查。安全和逻辑放一个通道,性能和风格让传统工具去管。我见过有人试图一个 Prompt 同时挑错、夸代码、给重构建议,结果完全失控,模型在三种角色之间反复横跳,最后出来的东西谁也不满意。拆开做,每条结论都有明确指向。

给开发者留一条申诉和豁免路径。不管门禁多准,总会有误报,开发者需要一个明确的“人工复核按钮”,否则他们会把门禁视为敌人而不是工具。我在门禁评论里放了一个“如果这条结论不成立,请 @ human-reviewer”。这个细节看起来小,但它实际上是建立信任的关键。

审计日志很重要。所有模型审查的记录都保留下来,方便回溯门禁到底拦截了什么、漏掉了什么。别小看这一步,后续想要提升 Prompt 质量,没有历史记录就是瞎子摸象。

说到最后,AI PR 门禁不是让你把人工评审砍掉,而是把人工评审从“反复看低级错误”的体力活里解救出来,让人把时间花在真正需要判断力的地方。它更像一道过滤器——过滤掉 80% 一眼可见的问题,剩下 20% 交给人来拍板。先把这三点接好,跑起来,再慢慢调,这是我认为最务实的路线。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进