开源的代码审查工具,我一开始是拒绝的,直到我在一个2000多行的PR里用肉眼找出第137行那个漏判的空指针之后,我决定必须把这件事自动化了。open-code-review 就是基于这个需求折腾出来的项目,定位很明确:做一个命令行优先、可以对接任意模型的开源代码审查工具,不管你是个人开发者扔在本地跑,还是小团队想塞进CI当把关人,它都能直接用。
这篇文章我不打算写成说明文档,那太无聊了。我会从项目设计思路、核心模块怎么拆、实际跑通的流程、接进工作流的姿势,再到我踩过的一堆坑,完整讲一遍。如果你正在研究 AI 辅助 code review,或者想给自己的仓库搭一个自动审查管线,这篇应该能给你省下不少时间。
1. 内容整体设计与思路拆解
1.1 传统 code review 的痛点在哪里
先聊聊我为什么觉得 code review 这件事必须被工具介入。你自己回顾一下,团队里真正高质量的审查通常发生在什么情况下?大概率是两人坐在一块对着屏幕逐行讲。一旦变成异步的 MR 评论,质量就开始滑坡,评论者常常只关注大方向,小问题比如变量命名、边界条件、错误处理遗漏,基本靠漏。
另一个痛点是变更量。我自己经历过一天要过 20 个 PR 的情况,前 5 个还有耐心逐行看,后面 15 个基本就是扫一眼有没有明显语法错误就合了。这种状态下的 review 流程,本质上是一个流程合规仪式,而不是质量保障。
open-code-review 的核心设计诉求就是从这两个痛点出发。它要能自动读 diff、找可疑点、按照固定格式输出审查意见,然后把结果贴到 MR 评论区或者打印到终端。它不替代人,而是帮你把机械性的排查工作先做掉,让人集中精力看那些真正需要讨论的设计问题。
1.2 技术选型背后的一些取舍
这个项目我选型时定了几个原则。第一,做成 CLI 工具而不是 web 服务。原因很简单,CLI 部署成本最低,本地一条命令就能跑,CI 里也就是一个 step 的事,不需要维护一个常驻进程,也没有鉴权、网关这些额外复杂度。
第二,语言选了 Python。不是因为 Python 最好,而是生态里处理代码文本、调用 API、写自动化脚本的工具链最全,代码量也最紧凑。一个仓库审查工具,核心也就是文本解析加 HTTP 请求,Python 在这类场景下的开发效率确实高。
第三,模型层做成可插拔的。当初就是一个很朴素的判断:AI 模型迭代这么快,今天用的模型半年后可能就过时了,如果把模型厂商写死在代码里,项目马上会变得难维护。所以我抽象了一个 LLMProvider 接口,OpenAI、Anthropic、本地 Ollama 都能接,只要实现了 chat 方法就行。
1.3 核心模块怎么拆
整个工程我从一开始就按职责拆成了五个模块,后面跑下来觉得这个划分挺合理的:
| 模块 | 职责 | 关键产出 |
|---|---|---|
| diff_parser | 解析 git diff 文本,按文件拆块 | 文件列表、变更块、行号映射 |
| context_builder | 拉取变更文件的相关上下文 | 符号定义、函数签名、关键引用 |
| rule_engine | 内置和自定义审查规则过滤 | 应该重点查什么、跳过什么 |
| llm_interface | 统一模型调用入口,含降级策略 | 模型原始打分和审查结果 |
| reporter | 汇总结果,输出终端/Markdown/评论 | 最终审查报告 |
模块之间通过标准数据结构交互,具体来说就是 diff 解析完了生成一个ReviewFile列表,里面包含每个文件的变更行和上下文,下游所有模块都消费这个结构,互不耦合。这样如果要加一个针对 Java 的专项检查,只需要在 rule_engine 加规则,其他地方碰都不用碰。
2. 核心细节解析与实操要点
2.1 diff 解析:这步决定了后面所有环节的质量
找一个好的 diff 解析姿势是整个项目里收益最高的一件事。一开始我偷懒用过直接拆diff --git a/xxx b/xxx块的方式,解析出来简单,但很快发现一个致命问题:它拿不到准确的旧行号和新行号映射,而模型评论的时候必须依赖行号才能定位到代码。
后来我老老实实按 unified diff 的规范来。核心逻辑是遍历 hunks,每个 hunk 的头部长这样:@@ -1,4 +1,7 @@,前面的-1,4是旧文件起始行和覆盖行数,后面的+1,7是新文件起始行和覆盖行数。往下逐行解析,遇到空格开头的是上下文行,遇到减号开头的是删除行,加号开头的是新增行。
这里有个容易忽略的点:删除行在新文件里没有对应行号,增行在旧文件里没有对应行号。很多审查工具生成评论时行号对不上,就是因为没处理这个映射关系。我单独维护了一个new_line_to_old_line的 dict,遇到删除行时用上下文行的行号作为兜底锚点,这样模型说“这段逻辑有问题”时,评论能准确贴到附近的代码上。
伪代码大概是这样的:
def parse_hunk(hunk_text): lines = hunk_text.split('\n') old_line, new_line = parse_hunk_header(lines[0]) changes = [] for line in lines[1:]: if line.startswith(' '): old_line += 1 new_line += 1 changes.append({'type': 'context', 'old_line': old_line, 'new_line': new_line, 'content': line[1:]}) elif line.startswith('-'): old_line += 1 changes.append({'type': 'delete', 'old_line': old_line, 'content': line[1:]}) elif line.startswith('+'): new_line += 1 changes.append({'type': 'add', 'new_line': new_line, 'content': line[1:]}) return changes2.2 context_builder:给模型喂足够多的前缀和后缀
只给模型一个孤零零的 diff,效果其实很差。模型只知道你那几行改动,不知道变量从哪里来、函数完整逻辑是什么,很容易给出“这个变量名不够有意义”之类的废话评论。
我的做法是,针对变更文件,在解析出变更行之后,向前向后各取固定行数的完整代码作为上下文。默认是向前 30 行、向后 10 行。为什么前多后少?因为代码里一个符号的声明、定义通常在被引用位置之前,往前多拿点更容易捕捉清楚。
还有一个重要操作:提取当前文件里出现的所有函数名和类名。这个其实可以不用正经的 AST 解析器,用简单的缩进和关键字匹配就够了。比如看到def foo或者def foo,记下名字和行号,拼成一行符号摘要,比如functions: [foo, bar], classes: [Baz]。模型看到这类信息,能更准确地理解这个文件在干什么。
上下文也不是越多越好。模型有 token 上限,塞太多无关代码会稀释注意力,还会增加成本,很实际的问题。所以我算了这么一笔账:每条 diff 增行平均 10 行,加上 30 行上下文,总共 40 行左右,按一行平均 15 个 token 算,单个文件约 600 到 800 token,20 个变更文件的 token 控制在 2 万以内,目前常用的模型都能装下。
2.3 rule_engine:便宜的先查,昂贵的后查
我强烈建议把规则引擎放在模型调用之前,让便宜的确定性检查先跑掉。那些明显的错误,例如硬编码密钥、控制台日志打到了生产代码、import 没删、TODO 注释忘了处理,完全不需要大模型参与,用正则和字符串匹配就可以搞定。
这套设计哲学很重要:AI 审查贵且慢,规则引擎廉价且准。两者组合起来,才能保证整体体验。我内置了一批冷启动规则,比如:
rules: - id: hardcoded-secret pattern: "(?i)(password|secret|api_key)\\s*=\\s*[\"'][^\"']+[\"']" severity: critical - id: console-log-left pattern: "console\\.log|print\\(" severity: warning - id: merge-conflict-marker pattern: "^<<<<<<< |^>>>>>>> " severity: criticalrule_engine 模块会把这些规则按 severity 排序,关键问题先报出来。只有命中可疑模式的文件,才会被送进 LLM,这一下能把每次审查成本砍掉不少。实际跑下来,大约 30% 的变更文件根本不需要调用模型,规则引擎就能给出精确结论。
2.4 llm_interface:统一入口,异常降级
模型调用层我做得比较厚,不只是一个 HTTP 封装。除了常规的请求发送、超时控制,还做了三件有价值的事:
第一,自动重试机制。模型 API 不稳定是常态,5xx 错误或者限流都是家常便饭。我实现了指数退避重试,默认最多重试 3 次,退避基数是 2 秒。
第二,JSON 输出解析。现在主流模型基本都支持强制 JSON 输出,但总有失败的时候。我在提示词里要求模型严格输出固定 schema,并做了容错解析,如果 JSON 解析失败,会尝试从回复里截取 JSON 片段再解析。这一招救了很多次。
第三,降级策略。如果选择了远程模型但是 API key 没配好,可以自动降级到本地 Ollama 模型,只要检测到本地有qwen2.5-coder:7b就拉起来。对于很多中小团队来说,这个降级路径其实是主力路径,因为数据不出内网是硬性要求。
3. 实操过程与核心环节实现
3.1 环境准备与安装
建议 Python 3.10 以上,依赖我只装了requests、pyyaml、pydantic,加上一个rich用来控制台输出。安装方式就是常规的 pip:
pip install open-code-review如果你不想污染全局环境,可以用 pipx 装成独立命令,也可以直接用容器跑:
docker run --rm -v $(pwd):/repo ghcr.io/yourname/open-code-review \ --diff <(git diff origin/main...HEAD)二进制问题不用慌,项目没有做多语言解析器,所以不需要装什么额外的编译链,跑起来很轻。
3.2 配置文件:一份配置,全局生效
在项目根目录放一个.open-code-review.yml,配置结构长这样:
language: zh-CN model: provider: openai # openai / anthropic / ollama name: gpt-4o-mini temperature: 0.2 max_tokens: 2000 rules: severity_limit: warning exclude: - generated/ - vendored/ - "*.lock" context: before: 30 after: 10 include_symbols: true reporter: format: table # table / markdown / json output: stdout # stdout / file模型名称默认用gpt-4o-mini,因为审查这种任务不需要太高智商,但需要稳定、便宜、响应快。温度设到 0.2,基本是让模型做几乎确定性的输出,不要放开想象力。
exclude这里我强调一下:生成代码、lock 文件、vendor 目录一定要排除掉,否则噪音会淹没真正的问题。这些文件通常是机器生成的,审查它们纯属浪费时间。
3.3 一条命令跑起一个标准的增量审查
我的常规姿势是这样的,直接对分支间的 diff 做报告:
git fetch origin open-code-review --diff <(git diff origin/main...HEAD) --output table执行完终端里会打出一张类似下面的表:
| 文件 | 行号 | 严重级别 | 问题描述 |
|---|---|---|---|
| src/api/auth.rs | 42 | critical | 用户输入的 token 直接拼接进 SQL 查询,存在注入风险 |
| src/api/user.rs | 157 | warning | 捕获了异常但没有记录任何日志,排查线上问题会很被动 |
| src/route.rs | 88 | info | 硬编码的魔术数字 86400 建议抽成常量 |
这个输出格式是我打磨最久的部分。早期版本没有严重级别排序,报告乱糟糟的,后来改成按文件和严重级别双重排序,并且支持--severity critical来只看高危项。
命令回顾几个核心参数:
open-code-review --help| 参数 | 说明 | 默认值 |
|---|---|---|
--diff | diff 内容来源,可以是文件名或 stdin | git diff |
--base | 基准分支,自动生成 diff | origin/main |
--rules | 自定义规则文件 | .open-code-review.yml |
--format | 输出格式:table/json/markdown | table |
--severity | 只显示指定级别以上问题 | info |
3.4 结合 review 反馈驱动开发
我自己的团队里已经把它跑成了每天的固定动作。每天早上 10 点,流水线自动把所有合并请求的 diff 拉下来,跑一轮 open-code-review,然后评论到 MR 上。开发者看到机器人的评论,可以先解决那些优先级高的问题,再请求人工 review,人工只关注剩下的设计层面问题。
实际反馈情况是,这种模式的好处不只是发现问题,更重要的是它形成了一个基线:机器先过滤低级问题,人的注意力集中在真正值得讨论的地方,审查效率大幅上升。
4. 业务接入与自动化配置
4.1 接到 GitHub Actions 工作流
这是最常见的接入方式。在.github/workflows/code-review.yml里放一个 workflow:
name: code-review on: pull_request: types: [opened, synchronize] jobs: review: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - uses: actions/setup-python@v5 with: python-version: '3.11' - run: pip install open-code-review - run: | open-code-review \ --base origin/${{ github.event.pull_request.base.ref }} \ --format markdown \ --output report.md - uses: actions/github-script@v7 with: script: | const fs = require('fs'); const body = fs.readFileSync('report.md', 'utf8'); await github.rest.issues.createComment({ issue_number: context.issue.number, owner: context.repo.owner, repo: context.repo.repo, body });fetch-depth: 0必须写,否则 GitHub 默认只检出一个浅克隆,git diff拿不到完整历史,这是这个 workflow 里最容易掉的坑之一。
4.2 GitLab CI 接入
GitLab 的原理一样,只是平台 API 不同。我保留了一个原生 GitLab 评论的 reporter,可以直接调用POST /projects/:id/merge_requests/:iid/notes把报告发上去。
review: stage: test script: - pip install open-code-review - open-code-review --base origin/main --format markdown --output report.md - ./scripts/post_gitlab_comment.sh report.md only: - merge_requests要注意 GitLab CI 里默认clone是完整克隆,一般不会缺历史,但如果你开了GIT_DEPTH变量,同样要设置成0。
4.3 pre-commit 本地快速检查
接入 CI 是远程把关,落地到本地则能更早地拦截问题。在.pre-commit-config.yaml里加一个 hook:
- repo: local hooks: - id: open-code-review name: open-code-review entry: open-code-review --base origin/main --severity critical language: system pass_filenames: false这样每次 commit 前,只在当前分支产生的 diff 上跑一次关键问题扫描,如果发现 critical 级别问题就拦截提交。事实上我个人的习惯是,主分支的合并压力不在提交流,而在 review 那一关,所以 pre-commit 关的再严一点也不过分。
4.4 成本控制和模型选择
我自己跑下来的成本数据供参考:一个 500 行真实变更的 PR,用 gpt-4o-mini 审查一次大约消耗 8000 到 12000 个 token,费用不到几厘钱。如果用 gpt-4-turbo,费用会上涨一个量级,但输出质量提升并不一定匹配收益。从成本角度,我建议默认用 mini 级模型,只在需要深度分析核心模块时再切大模型。
本地模型方案也值得讲一下。用 Ollama 跑qwen2.5-coder:7b或者deepseek-coder类模型,审查效果大概能达到商用大模型的七到八成,胜在零成本、数据不出内网。我在配置里预留了provider: ollama,核心代码逻辑完全不用改,只换一个 base_url 就行。
5. 常见问题与排查技巧实录
5.1 大 PR 超时怎么办
一个几百个文件的超大 PR,模型逐文件分析很容易触发 API 超时或者 CI 任务超时。我迭代出的方案是分块加并发双重优化。
分块就是把文件列表拆成多个小组,每个小组独立调用一次模型。默认每块 5 个文件,块之间用concurrent.futures.ThreadPoolExecutor并发跑,并发度控制在 4。这个参数我试过调大,收益不明显,反而容易触发限流。
我手动在配置里可以这样调:
review: batch_size: 5 max_workers: 4 timeout_seconds: 120还有一个优化是跳过未变更的说明性文件,比如纯文档、配置文件、测试数据,这类文件审查价值低,默认就不送进模型。实测下来跳过说明性文件后,一次超大 PR 的审查时间能压缩 60%。
5.2 误报和噪音太多,怎么抑制
这是个绕不开的话题。AI 审查的误报率天然比规则引擎高,因为它本质是在做“听起来有道理”的预测。我有几个实践心得:
第一,用好 exclusion 配置,先把第三方目录、生成代码排除掉。第二,在系统提示词里明确要求“每个问题必须引用具体的代码行并给出修复建议,否则不要输出”,无效评论会少很多。第三,对关键词类规则采用白名单制,比如不是所有print()都需要报,只有特定路径下的print()才报。
还有一些比较水的评论,比如“这个函数可以再拆小一点”,对于无关紧要的建议,我会在聚合阶段直接丢进低优先级,不展示在默认报告里。插件机制里可以配置suggestion_threshold,低于该阈值的评论默认折叠。
5.3 diff 不完整导致行号错乱
这是早期被吐槽最多的一个 bug。起因是某些场景下拿到的是不完整的 diff,比如从 web UI 复制的 diff 文本缺了尾部几行,或者 CI 里 repo 检出深度不够,导致 git diff 只能看到一部分变更。行号一对不上,评论就毫无意义。
解决办法是要求必须提供merge-base之后的 diff,而不是直接对两个 commit 做 diff。正确命令:
git diff $(git merge-base origin/main HEAD) HEAD我在--diff参数里内置了这个逻辑,如果用户只给了两个 commit,就自动用 merge-base 算出共同祖先再 diff,这样可避免大量“评论贴错行”的问题。
5.4 模型返回的 JSON 偶尔解析失败
我用的模型偶尔会输出残缺的 JSON,尤其是 token 快用完的时候。这里我给提示词里加了一个非常硬的约束:只输出一个 JSON 对象,不要有任何解释字段,不要用 markdown 代码块包裹。另外实现了带容错的解析器,如果第一遍 JSON 解析失败,会用正则把大括号内的部分提取出来再解析,效果还行。
后端实际兜底是,如果解析连续失败三次,就把该文件标记为“未审查”,在报告里显式标出来,而不是静默丢弃。宁可告诉用户没查到,也不能假装查过了,这种事关质量的功能必须诚实。
5.5 避坑速查表
| 症状 | 常见原因 | 解决方案 |
|---|---|---|
| 评论行号不准 | 没有基于 merge-base 生成 diff | 改用git diff $(git merge-base base HEAD) HEAD |
| 模型输出 JSON 解析失败 | 提示词约束不够硬 | 严格提示词 + 正则兜底提取 JSON |
| 审了半天但报告是空的 | 排除规则覆盖了变更文件 | 检查 exclude 路径,临时--no-exclude验证 |
| CI 里 diff 为空 | checkout 深度不够 | 设置fetch-depth: 0 |
| 大量自由发挥式评论 | 温度太高或提示词太开放 | temperature 降到 0.2,提示词里固定审查维度 |
| 成本超预期 | 没跳过生成代码/文档 | 检查 exclude 和 max_review_files 配置 |
我在实际项目里用下来,最大体会是这一类工具的价值不是“找出所有 bug”,而是把 review 的门槛降低、把节奏提上来。它没法替代一个了解业务背景的资深工程师,但它能帮你把那些一眼就能看出问题、但人很容易漏掉的地方自动筛掉。尤其在紧急修复上线、凌晨三点被人拽起来审一个热修 PR 的时候,能少看几个明显问题,整个人都轻松不少。
如果后续要扩展,我建议优先考虑两个方向:一是针对具体语言框架的专项规则库,比如对有上下文的 ORM 写法、事务嵌套做专门检测;二是把历史审查结论沉淀成反馈,微调审查时的权重排序。代码审查这件事,永远值得再自动一点。