1. 为什么我要自己动手做一个 open-code-review 工具
代码审查这件事,做过团队协作的人都有体会。提了 PR 之后等 reviewer 有空、reviewer 看漏了边界条件、同一个低级问题在三个文件里重复出现——这些场景几乎每天都在发生。市面上的商业代码审查服务不少,但要么按席位收费贵得离谱,要么把代码传到别人的服务器上,公司安全规范直接卡死。我所在的团队就属于后者,私有仓库连外部 CI 都不让接,更别说把 diff 发给第三方 API 了。
于是我开始琢磨:能不能用本地能跑的 LLM,配合 Git 的命令行能力,做一个完全离线的代码审查工具?这就是open-code-review的起点。它的定位很明确——一个跑在终端里的 CLI 工具,读取git diff的输出,把变更内容喂给本地或自建的 LLM Agent,让它按预设规则逐文件、逐 hunk 地给出审查意见,最后汇总成一份可读的报告。整个链路不依赖任何外部服务,代码不出内网。
这篇文章适合三类人看:一是想给自己团队搭一套私有代码审查流水线的工程师;二是正在研究 LLM Agent 怎么和 Git 工作流结合的技术爱好者;三是单纯想搞清楚 CLI 工具怎么调用大模型、prompt 怎么设计、diff 怎么解析的开发者。我会把设计取舍、核心实现、踩过的坑全部摊开讲,代码片段可以直接抄。
先说清楚一个概念区分,因为热词里很多人问agent、LLM、AI 模型到底啥关系。简单讲,LLM(Large Language Model)是底层的大语言模型,比如 DeepSeek、GPT 系列、Claude 系列,它们本质是"输入文本、输出文本"的函数。AI 模型是个更大的范畴,LLM 只是其中一类。而Agent是在 LLM 之上加了一层"感知—决策—行动"的循环:它能调用工具(比如读文件、执行 git 命令、跑测试),根据工具返回的结果决定下一步做什么。open-code-review 里的审查逻辑就是一个轻量 Agent——它不只是把 diff 丢给模型要一段评论,而是会先解析 diff 结构、按文件分组、对每个 hunk 单独提问、再把结果聚合。这个"分而治之"的调度逻辑,就是 Agent 和裸调 API 的区别。
2. 整体架构:CLI 外壳 + Git 解析层 + LLM 调度层
2.1 三层职责划分
我把整个工具拆成三层,每层只干一件事,方便单独测试和替换。
第一层是CLI 外壳,负责参数解析、配置加载、输出格式化。用户敲ocr review --staged或者ocr review main..feature,这一层把命令翻译成内部调用。我选的是 Python 的argparse加rich做终端渲染,没上 Click 是因为 argparse 零依赖、够用,rich 负责把审查结果渲染成带颜色和表格的漂亮输出。
第二层是Git 解析层,负责调用 git 命令拿到 diff,然后把 unified diff 格式解析成结构化的数据结构。这一层是整个工具的地基,也是最容易出 bug 的地方,后面单独展开讲。
第三层是LLM 调度层,负责把结构化的变更内容组装成 prompt,调用模型,解析模型返回,做结果聚合。这一层要处理超长 diff 的分片、并发调用、失败重试、结果去重。
三层之间用简单的 dataclass 传递数据,不引入消息队列或复杂框架。理由很直接:这是个单人维护的工具,过度设计只会增加维护成本。
2.2 为什么不用现成的 diff 解析库
Python 生态里有unidiff、patch-ng这类库,我一开始也用了unidiff,但很快发现两个问题。一是它对二进制文件、重命名、模式变更(file mode change)的处理不够细,而这些恰恰是代码审查里需要特别标注的地方——比如一个文件从 644 变成 755,可能是误操作。二是它把整个 diff 一次性加载进内存,遇到几千行的巨型 diff 会卡。
所以我最后自己写了个流式解析器,逐行扫描 diff 文本,用状态机识别diff --git、index、---、+++、@@、+、-、 这些前缀。核心逻辑大概是这样:
import re from dataclasses import dataclass, field @dataclass class Hunk: old_start: int old_lines: int new_start: int new_lines: int lines: list = field(default_factory=list) @dataclass class FileDiff: old_path: str new_path: str is_new: bool = False is_deleted: bool = False is_renamed: bool = False is_binary: bool = False hunks: list = field(default_factory=list) HUNK_HEADER = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@") def parse_diff(text: str) -> list: files, current, hunk = [], None, None for line in text.splitlines(): if line.startswith("diff --git"): if current: files.append(current) parts = line.split(" b/", 1) current = FileDiff(old_path=parts[0][11:], new_path=parts[1] if len(parts) > 1 else "") hunk = None elif line.startswith("new file mode"): current.is_new = True elif line.startswith("deleted file mode"): current.is_deleted = True elif line.startswith("rename from"): current.is_renamed = True elif line.startswith("Binary files"): current.is_binary = True elif HUNK_HEADER.match(line): m = HUNK_HEADER.match(line) hunk = Hunk(int(m.group(1)), int(m.group(2) or 1), int(m.group(3)), int(m.group(4) or 1)) current.hunks.append(hunk) elif hunk is not None and line[:1] in ("+", "-", " "): hunk.lines.append(line) if current: files.append(current) return files这段代码看着简单,但有几个细节值得说。diff --git a/foo b/foo这行里路径可能带空格,所以用split(" b/", 1)而不是按空格切。@@头里的行数可能省略(比如@@ -1 +1 @@表示各一行),所以正则里用了(?:,(\d+))?并给默认值 1。二进制文件没有 hunk,直接标记跳过。
提示:解析 diff 时一定要处理
\ No newline at end of file这种特殊行,它不属于任何 hunk 的内容,但会影响行号计算。我一开始忽略了它,导致审查意见里的行号全部偏移。
2.3 数据流全景
从用户敲命令到看到报告,数据流是这样的:CLI 解析参数 → 调用git diff拿到原始文本 → 解析器转成FileDiff列表 → 过滤器按规则剔除不需要审查的文件(比如 lock 文件、图片)→ 分片器把大 diff 切成模型能吃的块 → 调度器并发调用 LLM → 结果聚合器合并同一文件的意见 → 渲染器输出。
这个链路里,过滤器和分片器是两个容易被低估的环节。过滤器决定了"审什么",分片器决定了"怎么审得动"。下一节专门讲这两个。
3. Git 变更获取与 diff 分片策略
3.1 拿到正确的 diff 范围
git diff的参数组合非常多,工具要支持几种常见场景。我定义了三个入口:
ocr review --staged:审查暂存区,等价于git diff --cached,适合提交前自查。ocr review --range main..feature:审查两个分支之间的差异,适合 PR 场景。ocr review --commit abc123:审查单个提交,等价于git show abc123。
底层统一走一个函数,根据参数拼 git 命令。这里有个坑:git diff默认会调用外部 diff 工具或受.gitattributes影响,输出格式可能不是标准 unified diff。所以我在命令里强制加了几个参数:
git -c diff.mnemonicprefix=false -c core.quotepath=false \ --no-optional-locks diff --no-color --no-ext-diff \ --unified=3 --find-renames --find-copies main..feature逐个解释。diff.mnemonicprefix=false保证路径前缀是标准的a/和b/,而不是i/、w/这种随场景变化的助记前缀,否则解析器要处理多种前缀。core.quotepath=false让中文路径不被转义成八进制,否则文件名会变成\346\226\207...这种鬼东西。--no-optional-locks避免在只读操作时去抢 index 锁,多人同时跑不会互相阻塞。--no-ext-diff禁用外部 diff 工具。--unified=3固定上下文行数,方便后续按 hunk 处理。--find-renames和--find-copies让 git 识别重命名和复制,这样审查时能知道"这个文件只是改名了,内容没动",避免误报。
注意:如果你的仓库很大,
git diff本身可能就要跑好几秒。我加了个--no-optional-locks之后,在 CI 环境里并发跑多个审查任务时,不再出现 index.lock 冲突导致的失败。
3.2 哪些文件不该送进模型
不是所有变更都值得让 LLM 看。我维护了一个默认忽略列表,同时允许用户在配置文件里覆盖:
| 类别 | 匹配规则 | 忽略理由 |
|---|---|---|
| 锁文件 | *.lock,package-lock.json,poetry.lock | 机器生成,无审查价值,且极长 |
| 压缩产物 | *.min.js,*.min.css,dist/** | 压缩后不可读,浪费 token |
| 二进制 | 图片、字体、.so,.dll | 模型读不了,解析器已标记 |
| 生成代码 | *_pb2.py,*.generated.* | 由工具生成,改也是改生成器 |
| 快照测试 | __snapshots__/**,*.snap | 内容随实现变,审查意义低 |
这个列表不是拍脑袋定的。我统计过团队三个月的 PR,锁文件和 dist 产物占了 diff 总行数的 40% 以上,全部剔除后 token 消耗直接砍半,审查速度提升明显。但要注意,忽略规则不能一刀切——有些团队把dist也纳入版本管理且需要审查,所以必须可配置。
配置用 YAML,长这样:
ignore: - "*.lock" - "package-lock.json" - "dist/**" - "**/*.min.js" max_hunk_lines: 200 max_file_lines: 2000max_hunk_lines和max_file_lines是分片阈值,下面讲。
3.3 大 diff 怎么切才不丢上下文
LLM 有上下文窗口限制,一个几千行的 diff 塞进去要么超限,要么模型注意力被稀释、审查质量下降。所以必须分片。但分片有个矛盾:切得太碎,模型看不到跨函数的调用关系;切得太粗,又超限。
我的策略是按文件优先、按 hunk 兜底。具体规则:
- 单个文件的所有 hunk 加起来不超过
max_file_lines(默认 2000 行),就整个文件作为一个审查单元。 - 超过的话,按 hunk 切,但每个分片尽量包含连续的 hunk,且分片之间保留 3 行重叠上下文。
- 单个 hunk 超过
max_hunk_lines(默认 200 行)的,说明这是个巨型改动,单独成片并标记oversized,在报告里提示人工重点看。
为什么按文件优先?因为代码审查里很多问题是文件级的——比如"这个文件新增了 500 行但没加任何测试""这个模块的 import 顺序乱了"。如果按 hunk 切碎,模型就看不到文件全貌,这类意见就提不出来。只有文件实在太大时才退化成 hunk 级。
分片时还要注意保留 hunk 头里的行号信息,因为最终报告要定位到具体行。我在每个分片的 prompt 里都会带上@@ -old_start,old_lines +new_start,new_lines @@这行,让模型知道它看的是文件的哪一段。
def split_file(fd: FileDiff, max_file_lines: int, max_hunk_lines: int): total = sum(len(h.lines) for h in fd.hunks) if total <= max_file_lines: return [fd] # 整文件一片 chunks, buf, buf_lines = [], [], 0 for h in fd.hunks: hlen = len(h.lines) if hlen > max_hunk_lines: if buf: chunks.append(buf); buf, buf_lines = [], 0 chunks.append([h]) # 巨型 hunk 单独成片 continue if buf_lines + hlen > max_file_lines: chunks.append(buf); buf, buf_lines = [], 0 buf.append(h); buf_lines += hlen if buf: chunks.append(buf) return chunks这段逻辑我调了好几版。最早是简单按行数累加切,结果把一个函数的开头和结尾切到两个片里,模型对着半截函数提了一堆莫名其妙的意见。后来改成"hunk 是不可分割的最小单位",因为一个 hunk 通常对应一处连续改动,切开就失去语义了。再后来加了巨型 hunk 的单独处理,因为确实遇到过自动生成的迁移脚本单 hunk 上千行的情况。
4. 把 diff 变成模型能懂的审查指令
4.1 prompt 结构设计
prompt 设计是这个工具的灵魂。我试过很多版本,最后稳定下来的结构是四段式:角色设定 + 审查规则 + 变更内容 + 输出格式。
角色设定要具体,不能只说"你是一个代码审查员"。我写的是:"你是一位有十年经验的资深工程师,擅长发现并发问题、边界条件、资源泄漏和安全隐患。你的评论要具体、可操作,指出问题所在行,并给出修改建议。" 这样模型输出的意见会明显更聚焦,而不是泛泛地说"代码可以更清晰"。
审查规则是可配置的,默认包含这些维度:
- 正确性:逻辑错误、边界条件、空指针、类型不匹配
- 安全性:注入风险、敏感信息硬编码、权限校验缺失
- 性能:不必要的循环、重复计算、N+1 查询
- 可维护性:命名、重复代码、过长函数、缺失注释
- 测试:新增逻辑是否有对应测试
变更内容部分,我会把文件路径、hunk 头、具体增删行都带上,并且用明确的标记区分新增和删除:
文件: src/auth/login.py 变更类型: 修改 @@ -45,7 +45,9 @@ def verify_token(token): if not token: return None - payload = jwt.decode(token, SECRET) + payload = jwt.decode(token, SECRET, algorithms=["HS256"]) + if payload.get("exp", 0) < time.time(): + raise TokenExpired() return payload输出格式我要求模型返回 JSON,字段固定:file、line、severity(critical/warning/info)、category、message、suggestion。用 JSON 是为了后续能程序化聚合和渲染,而不是让模型自由发挥写一段散文。但这里有个坑——模型经常在 JSON 外面包一层 markdown 代码块,或者加一句"好的,以下是审查结果"。所以解析时要先剥离代码块标记,再用容错的方式提取 JSON。
import json, re def extract_json(text: str): text = text.strip() text = re.sub(r"^```(?:json)?\s*", "", text) text = re.sub(r"\s*```$", "", text) try: return json.loads(text) except json.JSONDecodeError: m = re.search(r"\[.*\]", text, re.DOTALL) if m: return json.loads(m.group(0)) return []提示:让模型输出 JSON 时,一定要在 prompt 里给一个完整的示例,并且明确说"只输出 JSON,不要任何解释文字"。即便如此,仍要写容错解析,因为模型偶尔会"忘记"。
4.2 并发调用与限流
一个 PR 可能有几十个文件,串行调用模型太慢。我用concurrent.futures.ThreadPoolExecutor做并发,默认 4 个 worker。为什么是 4 而不是更多?因为本地跑的模型(比如通过 Ollama 或 vLLM 部署的)通常只有有限的并发能力,开太多反而排队。如果是调用远程 API,可以调到 8 到 16,但要配合限流。
限流我用的是简单的令牌桶,每秒放行 N 个请求。这里踩过一个坑:一开始没做限流,20 个文件同时发出去,远程 API 直接返回 429,然后我的重试逻辑又同时重试,形成雪崩。后来加了指数退避加随机抖动,才稳定下来。
import time, random def call_with_retry(fn, max_retries=3): for i in range(max_retries): try: return fn() except RateLimitError: sleep = (2 ** i) + random.uniform(0, 1) time.sleep(sleep) raise RuntimeError("重试耗尽")指数退避的2 ** i保证每次等待翻倍,随机抖动避免多个线程同时重试撞在一起。这个模式在任何调用外部服务的场景都适用,不只是 LLM。
4.3 结果聚合与去重
并发调用回来的是一个个分片的审查结果,需要合并。合并时有两个问题:同一文件多个分片的意见要按行号排序;不同分片可能对同一行提出重复意见(因为分片有重叠上下文)。
去重策略是按(file, line, category)三元组做键,保留 severity 最高的那条。如果两条意见 message 高度相似(用简单的编辑距离或 Jaccard 相似度判断),也合并。实测下来,重叠上下文导致的重复意见大概占 5% 到 10%,不去重的话报告会很啰嗦。
聚合后按 severity 排序,critical 在前,info 在后。渲染时用 rich 的表格,critical 标红,warning 标黄,info 标蓝。最后给一个统计摘要:本次审查共发现 X 个 critical、Y 个 warning、Z 个 info,涉及 N 个文件。
5. 实测中暴露的问题与修复过程
5.1 模型对删除行的误判
最早版本里,我把 diff 的增删行原样喂给模型,结果模型经常对被删除的代码提意见。比如删掉了一行有 bug 的代码,模型却说"这行代码有风险"。原因是它没理解-前缀的含义。
修复方法是在 prompt 里明确说明 diff 格式,并且在变更内容前加一段说明:"以下 diff 中,以-开头的行是被删除的旧代码,以+开头的行是新增代码,以空格开头的是未改动的上下文。请只对新增代码和上下文提出审查意见,不要对被删除的代码提意见。" 加了这段之后,误判率从大概 15% 降到 2% 以下。
这个坑的本质是:模型不知道你的输入格式约定。任何把结构化数据喂给模型的场景,都要显式说明格式,不能假设模型"应该懂"。
5.2 行号对不上的问题
报告里的行号一开始经常对不上,用户点过去发现是空行或者别的函数。排查后发现两个原因。一是模型返回的行号是相对于 hunk 的偏移,而不是文件绝对行号。二是\ No newline at end of file这类特殊行影响了计数。
修复方案是在 prompt 里明确要求模型返回文件绝对行号,并且在变更内容里把每个 hunk 的起始行号标出来。同时在解析器里正确处理特殊行,不把它们计入行号。为了验证,我写了个测试:构造一个已知行号的 diff,跑一遍审查,断言返回的行号落在预期范围内。这个测试后来成了回归测试的一部分。
5.3 大文件导致的超时
有个 PR 改了一个 3000 行的配置文件,虽然我做了分片,但每个分片仍然很大,模型响应慢,加上并发,整体超时。后来我加了两条规则:一是对配置文件、数据文件这类"低审查价值"的大文件,默认只审查前 500 行并提示"文件过大,仅审查前 500 行";二是给每个 LLM 调用设置独立的超时(默认 60 秒),超时就跳过该分片并在报告里标记"未完成审查"。
这里的原则是:宁可漏审,不可卡死。一个审查工具如果跑十分钟还没结果,用户就不会再用第二次。快速给出 80% 的价值,比慢慢给出 100% 更有用。
5.4 中文注释和字符串的处理
团队代码里有大量中文注释,早期版本模型对中文的处理不稳定,有时把中文注释当成乱码。排查发现是core.quotepath的问题——git 默认会把非 ASCII 路径转义。加上-c core.quotepath=false之后路径正常了,但文件内容里的中文本来就没问题。另外在 prompt 里加一句"代码中可能包含中文注释和字符串,请正常理解",模型的表现会更好。
6. 配置、集成与日常使用心得
6.1 配置文件长什么样
工具的所有行为都通过一个.ocr.yaml配置,放在仓库根目录。完整示例如下:
model: provider: openai_compatible base_url: http://localhost:8000/v1 api_key: ${OCR_API_KEY} name: deepseek-coder temperature: 0.2 max_tokens: 2048 timeout: 60 review: concurrency: 4 max_file_lines: 2000 max_hunk_lines: 200 rules: - correctness - security - performance - maintainability - testing ignore: - "*.lock" - "dist/**" - "**/*.min.js" output: format: table show_info: true fail_on_critical: truetemperature设 0.2 是因为代码审查需要稳定、可复现的输出,太高会每次给出不同意见。fail_on_critical设为 true 时,如果发现 critical 问题,进程退出码非零,可以直接接进 CI 流水线做门禁。
api_key用${OCR_API_KEY}从环境变量读取,避免密钥写进配置文件提交到仓库。这是基本的安全习惯,但确实见过有人把 key 硬编码进去然后推到公开仓库。
6.2 接进 CI 和 Git hooks
最常见的两种集成方式。一是pre-commit hook,提交前自动跑一次审查,发现 critical 就阻止提交:
#!/bin/sh ocr review --staged --fail-on-critical放在.git/hooks/pre-commit并加执行权限即可。但要注意,pre-commit 里跑 LLM 会拖慢提交速度,如果模型响应慢,开发者会烦。我的做法是 pre-commit 只跑快速规则(比如正则匹配敏感信息),完整审查放到 CI。
二是CI 集成,在流水线里加一步:
- name: Code Review run: | pip install open-code-review ocr review --range origin/main..HEAD --format markdown > review.md env: OCR_API_KEY: ${{ secrets.OCR_API_KEY }}输出 markdown 后可以贴到 PR 评论里。这里的关键是模型服务要能被 CI 访问——如果模型跑在本地开发机,CI 就够不着,得部署一个内网可访问的推理服务。
6.3 几个提升效果的小技巧
第一,给模型提供项目背景。在配置里加一个context字段,写清楚项目是干什么的、用了什么框架、有哪些约定。比如"这是一个 Django 项目,所有数据库操作必须走 ORM,禁止裸 SQL"。模型知道这些之后,提的意见会贴合项目规范,而不是泛泛而谈。
第二,定期更新审查规则。团队踩过的坑应该沉淀成规则。比如曾经因为没做参数校验出过线上问题,就把"所有外部输入必须校验"加进规则。规则越贴合团队实际,工具越有用。
第三,不要迷信模型的每一条意见。LLM 会有幻觉,会提出不存在的"问题"。我的做法是把 severity 为 info 的意见默认折叠,只展开 critical 和 warning,减少噪音。审查意见是辅助,最终判断还是人来做。
第四,关注 token 成本。如果用的是按量计费的 API,一个大 PR 可能花掉几块钱。我统计过,剔除锁文件和产物后,平均每个 PR 的审查成本在可接受范围内。但如果团队 PR 特别频繁,建议用本地模型,边际成本几乎为零。
6.4 关于模型选型的实际对比
我试过几种模型跑同一批 diff,感受如下(仅代表个人实测环境):
| 模型类型 | 审查质量 | 速度 | 部署难度 | 适合场景 |
|---|---|---|---|---|
| 本地 7B 代码模型 | 中等,能抓明显问题 | 快 | 低 | 日常快速自查 |
| 本地 32B 代码模型 | 较好,边界条件也能抓 | 中等 | 中 | 团队内网部署 |
| 远程大模型 API | 最好,理解力强 | 取决于网络 | 低 | 对质量要求高的场景 |
选型的核心权衡是质量、速度、隐私三者不可兼得。内网部署牺牲一点质量换隐私和成本;远程 API 换质量但代码要出内网。没有标准答案,看团队约束。
7. 后续可以继续打磨的方向
工具目前能稳定跑,但还有几个我想做的改进。一是增量审查——记住上次审查到哪个 commit,只审新增部分,避免重复劳动。二是意见学习——记录开发者对每条意见的采纳或忽略,用这些反馈微调 prompt,让工具越来越懂团队的偏好。三是多语言规则包——不同语言(Python、Go、Java)的常见问题不一样,做成可插拔的规则包会更专业。
我在实际使用中最大的体会是:代码审查工具的价值不在于替代人,而在于把人从重复劳动里解放出来。格式问题、明显的空指针、忘记加校验这类低级错误,交给工具;架构设计、业务逻辑是否合理这类需要上下文判断的,留给人。分工清楚了,工具才真正有用,而不是变成一个制造噪音的负担。