1. 为什么我要在提交前加一道 AI 审查关卡
每次git commit之前,你是不是也有过这种体验:改完代码,跑通了本地测试,心里觉得“差不多了”,结果 push 上去被同事 review 时,对方一眼就看出变量命名拼错了、异常没捕获、日志打得太随意。这种低级问题被指出来,面子上挂不住不说,来回改还得重新走一遍 CI,时间全耗在沟通上。
我自己的习惯是,在git commit之前加一道自动化的“预审”环节。传统的做法是配 ESLint、Pylint、Prettier 这类静态检查工具,它们能搞定格式和一部分语法问题,但搞不定“这段逻辑是不是写反了”“这个函数是不是可以拆得更清晰”“这个异常处理是不是形同虚设”这类需要理解语义的问题。而大模型恰好擅长这种“读懂意图再给建议”的活儿。
所以我花了一个周末,用 Python 写了一个Mini Reviewer——一个跑在本地、只审查本次准备提交代码的小工具。它的工作流程很朴素:从git diff --cached拿到暂存区的改动,拼成一段结构化的 prompt,丢给大模型 API,拿回审查意见,按文件、按行号整理好输出到终端。整个过程不依赖任何云端代码托管平台的插件,纯本地脚本,想改就改。
这篇文章我会把整个工具的设计思路、核心代码、踩过的坑、以及怎么把它挂到 git hook 上全部讲清楚。适合有一定 Python 基础、日常用 git 做版本管理、想给自己加一道代码质量防线的开发者。哪怕你只是刚学会git add和git commit,跟着走也能跑起来,因为我会把每一步为什么这么做都说明白。
关键词里提到的git commit --amend、git diff、Python、AI、代码诊断这些,都会在正文里自然出现,不堆砌,用到哪讲到哪。
2. Mini Reviewer 的整体设计与技术选型
2.1 为什么不做成 IDE 插件,而是做成命令行脚本
市面上已经有 Copilot、Codeium 这类 IDE 插件能实时给建议,也有 CodeRabbit 这种平台级的 PR 审查工具。我为什么还要自己写一个命令行脚本?原因有三个。
第一,审查时机不同。IDE 插件是在你写代码的时候给提示,那时候代码还没定型,提示多了反而干扰思路。而 Mini Reviewer 是在你git add之后、git commit之前运行,这时候改动已经确定,审查意见更有针对性,也更接近真实 review 的场景。
第二,可控性。平台级工具你没法改它的 prompt,也没法决定它审查哪些文件、忽略哪些规则。自己写的脚本,prompt 想怎么调就怎么调,想让它在发现严重问题时直接阻断提交也行,想让它只给建议不阻断也行。
第三,隐私和成本。代码审查涉及公司业务逻辑,有些团队不愿意把完整 diff 发给第三方平台。自己写脚本可以只发关键片段,或者接本地部署的模型。成本上,一次 commit 的 diff 通常几百到几千 token,用便宜模型跑一次几分钱,比平台订阅划算得多。
所以定位很明确:一个轻量的、可定制的、跑在提交前的本地审查脚本。不追求功能大而全,只解决“提交前快速过一遍”这个具体问题。
2.2 技术栈选择:Python + git 命令 + 大模型 API
技术栈上我没怎么纠结,直接选了 Python。原因很简单:处理文本、调 HTTP 接口、写命令行工具,Python 的生态最顺手,标准库里的subprocess、argparse、json就够用了,不需要额外装一堆依赖。
和 git 的交互,我选择直接调git命令而不是用GitPython这类库。理由是:git diff --cached的输出格式非常稳定,解析起来不复杂;而引入一个库反而增加了一层抽象,出问题时不好排查。用subprocess调原生命令,输出是什么样我一眼就能看到,调试成本最低。
大模型 API 这块,我用的是通用的 chat completions 接口格式,任何兼容这个格式的服务都能接。这样设计的好处是,你想换模型、换服务商,只改一个 base_url 和 model 名字就行,核心逻辑不用动。
整个项目的文件结构大概是这样:
mini_reviewer/ ├── reviewer.py # 主脚本 ├── prompt_template.txt # prompt 模板,方便调整 └── config.json # 配置 API key、模型名、忽略规则就三个文件,没有复杂的包管理,没有虚拟环境强制要求(当然你建一个更好)。这种“单文件为主”的设计,是为了让工具足够轻,复制到任何一台装了 Python 和 git 的机器上都能跑。
2.3 审查范围怎么界定:只审暂存区,不审全量
这是设计里最关键的一个决策:只审查git diff --cached的内容,也就是本次git add进暂存区的改动。
为什么不审全量代码?因为全量代码可能有几万行,token 成本高不说,模型注意力也会被稀释,给出的意见反而泛泛而谈。而暂存区的 diff 通常只有几十到几百行,模型能聚焦在“这次改了什么”上,意见更精准。
为什么不审工作区未暂存的改动?因为那些改动你还没决定要不要提交,审了也没意义。暂存区才是你“准备提交”的那部分,审查它最符合“提交前把关”的定位。
这个决策带来一个使用上的小约束:你得先git add再运行审查。但这其实是个好习惯——先想清楚这次要提交什么,再让 AI 帮你看看有没有问题。
3. 从 git diff 到结构化 prompt 的完整链路
3.1 用 git diff --cached 拿到本次改动
第一步是拿到暂存区的 diff。命令很直接:
git diff --cached --unified=3这里有几个参数值得说明。--cached表示看暂存区和工作区 HEAD 的差异,也就是你git add了但还没 commit 的内容。--unified=3表示每个改动块前后各显示 3 行上下文,这是默认值,但显式写出来更清楚。
为什么是 3 行上下文?因为模型需要看到改动周围的代码才能理解意图。比如你改了一个函数里的某一行,如果只给这一行,模型不知道这个函数是干嘛的;给 3 行上下文,模型能大致判断出这段逻辑的用途。上下文给太多(比如--unified=10)会浪费 token,给太少(--unified=0)模型看不懂,3 行是实践中比较平衡的值。
在 Python 里这样调用:
import subprocess def get_staged_diff(): result = subprocess.run( ["git", "diff", "--cached", "--unified=3"], capture_output=True, text=True, encoding="utf-8" ) if result.returncode != 0: raise RuntimeError(f"git diff 执行失败: {result.stderr}") return result.stdout注意encoding="utf-8"这个参数。在 Windows 上,subprocess默认用系统编码(可能是 GBK),遇到中文注释或特殊字符会乱码甚至报错。显式指定 UTF-8 能避开这个坑,这是我踩过一次才加上的。
3.2 解析 diff:按文件切分,提取改动块
拿到原始 diff 文本后,不能直接丢给模型,因为 diff 里混杂了文件头、索引信息、模式变更等噪音。需要先解析成结构化的数据。
diff 的格式是有规律的。每个文件的改动以diff --git a/xxx b/xxx开头,后面跟着index、---、+++等元信息,然后是若干个以@@开头的改动块(hunk)。每个 hunk 里,+开头是新增行,-开头是删除行,空格开头是上下文。
我写了一个简单的解析器:
import re def parse_diff(diff_text): files = [] current_file = None current_hunk = None for line in diff_text.splitlines(): if line.startswith("diff --git"): # 新文件开始,保存上一个 if current_file: files.append(current_file) # 提取文件名,格式: diff --git a/path b/path match = re.search(r"b/(.+)$", line) current_file = {"path": match.group(1) if match else "unknown", "hunks": []} current_hunk = None elif line.startswith("@@"): # 新改动块开始 current_hunk = {"header": line, "lines": []} if current_file: current_file["hunks"].append(current_hunk) elif current_hunk is not None: current_hunk["lines"].append(line) if current_file: files.append(current_file) return files这个解析器不追求完美处理所有边界情况(比如重命名、二进制文件),够用就行。对于二进制文件,diff 里会显示Binary files differ,这种直接跳过,因为模型也看不懂二进制。
解析完之后,每个文件有路径和若干 hunk,每个 hunk 有头部信息和具体行。这个结构方便后面按文件组织 prompt,也方便把审查意见映射回具体文件和行号。
3.3 拼装 prompt:让模型知道自己在审什么
prompt 的设计直接决定审查质量。我试过好几种写法,最后稳定下来的模板大概是这样:
你是一位经验丰富的代码审查者。请审查以下 git 暂存区的代码改动。 审查重点: 1. 逻辑错误:条件判断是否写反、边界条件是否遗漏、循环是否可能死循环 2. 异常处理:是否有未捕获的异常、异常是否被静默吞掉 3. 命名与可读性:变量函数命名是否清晰、是否有误导性命名 4. 潜在性能问题:是否有明显的低效操作,如循环内查数据库 5. 安全隐患:是否有硬编码密钥、SQL 拼接、路径穿越风险 输出要求: - 按文件组织,每个问题标注文件名和大致行号 - 每个问题说明:问题是什么、为什么是问题、建议怎么改 - 如果某个文件没有问题,简单说一句"未发现明显问题" - 不要纠结纯格式问题(缩进、空格),那些交给格式化工具 以下是代码改动: {DIFF_CONTENT}这个模板有几个设计考量。审查重点列了 5 类,是因为如果不限定范围,模型容易发散,给出一堆无关紧要的建议。明确说不要纠结格式问题,是因为格式应该由 Prettier、Black 这类工具管,让模型管格式是浪费 token。要求标注文件名和行号,是为了让输出能对应回代码,方便你定位。
{DIFF_CONTENT}这里填入解析后的 diff。我倾向于填入原始 diff 文本而不是重新格式化的版本,因为模型对标准 diff 格式很熟悉,+-前缀它能正确理解。
如果 diff 特别大(比如超过 5000 行),需要做截断或分批。我的做法是:如果总行数超过阈值,就按文件拆分,每个文件单独发一次请求,最后合并结果。这样虽然请求次数多了,但每次的审查质量有保证。
3.4 调用模型 API 并处理返回
调用部分用标准库的urllib或者requests都行。为了少装依赖,我用urllib:
import json import urllib.request def call_model(prompt, config): payload = { "model": config["model"], "messages": [ {"role": "user", "content": prompt} ], "temperature": 0.2 } req = urllib.request.Request( config["api_base"] + "/chat/completions", data=json.dumps(payload).encode("utf-8"), headers={ "Content-Type": "application/json", "Authorization": f"Bearer {config['api_key']}" }, method="POST" ) with urllib.request.urlopen(req, timeout=60) as resp: data = json.loads(resp.read().decode("utf-8")) return data["choices"][0]["message"]["content"]temperature设成 0.2,是因为代码审查需要稳定、确定的输出,不需要创造性。设太高模型会给出天马行空的建议,设成 0 又可能过于死板,0.2 是个折中。
超时设 60 秒,是因为大模型处理长 diff 可能需要十几秒到几十秒,设太短会频繁超时。如果网络不稳定,可以加重试逻辑,但简单起见我先没加,超时了手动重跑一次就行。
返回的内容是 Markdown 格式的审查意见,直接打印到终端就能看。如果想更友好,可以用rich库做彩色输出,但那就多一个依赖了,我选择保持简单。
4. 把审查意见落到实处的几个关键处理
4.1 行号映射:让意见能对应回代码
模型返回的意见里会说“第 42 行有问题”,但这个 42 行是 diff 里的行号还是文件里的行号?如果不处理,你拿着这个行号去文件里找,很可能对不上。
diff 的 hunk 头部格式是@@ -旧起始,旧行数 +新起始,新行数 @@。比如@@ -10,5 +10,7 @@表示旧文件从第 10 行开始 5 行,新文件从第 10 行开始 7 行。新增的行在新文件里的行号,可以从这个头部推算出来。
我的做法是在解析 diff 时,给每个 hunk 记录新文件的起始行号,然后遍历 hunk 里的行,遇到+开头或空格开头的行(这些在新文件里存在),行号递增。这样每个+行都能算出它在新文件里的真实行号。
然后在 prompt 里,我不仅给 diff 文本,还在每个 hunk 头部标注“新文件起始行号”,让模型知道行号基准。模型返回意见时,我再用一个简单的后处理,把模型说的行号尽量映射到真实行号。这一步不追求 100% 准确,因为模型本身也可能数错行,但能大幅提升可用性。
4.2 过滤噪音:忽略 lock 文件、生成代码、二进制
不是所有改动都值得审查。package-lock.json、yarn.lock、poetry.lock这类锁文件,改动通常是依赖版本变化,模型看了也给不出有价值的意见,纯属浪费 token。自动生成的代码(比如 protobuf 生成的.pb.go、ORM 生成的迁移文件)同理。
我在配置里加了一个忽略列表:
{ "ignore_patterns": [ "*.lock", "package-lock.json", "yarn.lock", "*.min.js", "*.min.css", "dist/*", "build/*", "*.pb.go", "*_generated.go" ] }解析完 diff 后,用fnmatch匹配文件路径,命中的直接跳过。这个过滤能省下大量 token,也让审查意见更聚焦在真正需要人看的代码上。
还有一个容易忽略的点:纯删除的文件。如果一个文件被整个删掉,diff 里全是-行,模型审查它意义不大(代码都要删了还审什么)。我的处理是,如果某个文件的 hunk 里没有任何+行,就跳过这个文件。
4.3 严重问题阻断提交:退出码的设计
Mini Reviewer 可以只做“建议”,也可以做“把关”。我倾向于后者:如果发现严重问题,直接让脚本返回非零退出码,这样挂在 git hook 上时能阻断提交。
怎么判断“严重”?我让模型在输出时给每个问题标一个级别:[严重]、[警告]、[建议]。然后在脚本里检查返回内容,如果包含[严重],就sys.exit(1)。
def has_critical_issue(review_text): return "[严重]" in review_text # 主流程末尾 if has_critical_issue(review): print("\n发现严重问题,提交已阻断。请修复后重试。") sys.exit(1)这个设计的好处是,日常小改动如果只是命名建议,不阻断,你看着改就行;但如果模型发现了空指针风险、SQL 注入这类严重问题,直接拦住,逼你处理。当然,如果你觉得模型误判太多,可以把阻断逻辑去掉,只做提示。
提示:模型判断“严重”并不总是准确,初期建议先只提示不阻断,跑一段时间观察误报率,再决定要不要开启阻断。
5. 挂到 git hook 上:让审查自动发生
5.1 pre-commit hook 的基本写法
git 的 hook 就是.git/hooks/目录下的可执行脚本。pre-commit在git commit执行时、真正生成 commit 之前运行,如果它返回非零退出码,commit 就会被中止。这正是我们需要的时机。
一个最简单的pre-commit脚本:
#!/bin/sh python /path/to/mini_reviewer/reviewer.py把它保存为.git/hooks/pre-commit,然后加上执行权限:
chmod +x .git/hooks/pre-commit这样每次git commit都会先跑一遍审查。但这里有个问题:.git/hooks/目录不会被提交到仓库,团队其他成员 clone 下来没有这个 hook。如果想让团队共用,需要把 hook 脚本放到仓库里(比如scripts/pre-commit),然后让大家手动软链或复制到.git/hooks/。或者用pre-commit这个框架来管理,但那又是另一个话题了。
5.2 处理 amend 和空提交的情况
git commit --amend是修改上一次提交,它同样会触发pre-commithook。这通常是好事——你 amend 时也想审查改动。但要注意,amend 时git diff --cached拿到的是相对于 HEAD 的差异,逻辑上没问题。
真正需要处理的是空提交。如果你运行git commit但暂存区没有任何改动,git diff --cached输出为空,这时候没必要调模型。加一个判断:
diff_text = get_staged_diff() if not diff_text.strip(): print("暂存区无改动,跳过审查。") sys.exit(0)还有一种情况是合并提交。git merge产生的 commit 有时会触发 hook,但 diff 内容可能很大且是自动合并的,审查意义不大。可以在 hook 里检查.git/MERGE_HEAD是否存在,存在就跳过。
5.3 性能与体验:别让审查拖慢提交
审查要调 API,网络往返加上模型推理,通常要 5 到 30 秒。如果每次 commit 都等这么久,体验会很差。几个优化思路:
第一,只审有实际逻辑改动的文件。前面说的忽略 lock 文件和生成代码,能砍掉大部分无意义的审查。
第二,设置 diff 行数上限。如果暂存区改动超过比如 800 行,提示用户“改动过大,建议拆分提交后再审查”,然后跳过。大 diff 审查质量本来就差,不如不审。
第三,给个跳过开关。有时候你赶时间,就想快速提交。可以约定一个环境变量,比如SKIP_REVIEW=1 git commit -m "xxx",脚本检测到这个变量就跳过审查。这比--no-verify更精细,因为--no-verify会跳过所有 hook。
import os if os.environ.get("SKIP_REVIEW") == "1": print("SKIP_REVIEW 已设置,跳过审查。") sys.exit(0)第四,缓存结果。如果同样的 diff 已经审过(比如你 amend 时没改代码只改了 commit message),可以缓存上次结果。用 diff 内容的哈希做 key,存到本地文件。这个优化我暂时没做,因为实际使用中重复 diff 的情况不多,但思路可以留着。
6. 实测中踩过的坑和调优经验
6.1 模型对 diff 格式的误解
最开始我直接把git diff的原始输出丢给模型,结果发现模型有时会把-开头的删除行当成“代码里有这一行”,然后说“这行有问题”。其实那行已经被删了。
解决办法是在 prompt 里明确说明 diff 格式:
注意:以下内容是 git diff 格式。 - 以 + 开头的行是新增的代码 - 以 - 开头的行是删除的代码 - 以空格开头的行是未改动的上下文 请只审查新增的代码,删除的代码不需要审查。加上这段说明后,模型对删除行的误判基本消失了。这个细节很小,但对审查质量影响很大。
6.2 中文注释导致的编码问题
我的项目里有不少中文注释,在 Windows 上跑的时候,subprocess拿到的 diff 输出经常乱码。排查后发现是编码问题:git 默认可能用系统编码输出,而 Python 按 UTF-8 解码就乱了。
解决方案有两层。第一层是在subprocess.run里显式指定encoding="utf-8"。第二层是确保 git 本身输出 UTF-8,可以设置:
git config --global core.quotepath falsecore.quotepath false让 git 不把非 ASCII 字符转义成\xxx形式,中文路径和中文内容都能正常显示。这个配置建议所有用中文的开发者都加上,不只是为了这个工具。
6.3 模型“过度审查”和“审查不足”的平衡
跑了一段时间后,我发现模型有两种极端。一种是过度审查:把每一行都挑出点毛病,连i += 1都要说“建议改成i = i + 1更清晰”,这种意见纯属噪音。另一种是审查不足:明明有个明显的空指针风险,模型却说“未发现明显问题”。
调优的手段主要是改 prompt。对于过度审查,我在 prompt 里加了“只报告真正影响正确性、可维护性、安全性的问题,不要报告风格偏好”。对于审查不足,我加了“请特别关注边界条件、异常路径、并发场景”。来回调了几轮,才找到一个相对平衡的版本。
这里的心得是:prompt 调优是个迭代过程,没有一劳永逸的模板。你的项目类型、代码风格、团队关注点不同,最优 prompt 也不同。建议把 prompt 单独放在一个文件里,方便随时改、随时试。
6.4 成本控制的几个实操技巧
用 API 是要花钱的,虽然单次几分钱,但一天提交几十次也是成本。几个控制成本的技巧:
用便宜模型做初筛。如果只是查明显的语法和逻辑问题,小模型够用,成本是大模型的几分之一。只有初筛发现可疑点时,再用大模型复核。
限制输出长度。在 API 参数里设max_tokens,比如 1000,防止模型长篇大论。审查意见贵精不贵多。
本地缓存。前面提过,相同 diff 不重复审查。可以用hashlib.md5(diff_text.encode()).hexdigest()做 key,把结果存到~/.mini_reviewer_cache/下。
批量提交。与其改一点就 commit 一次,不如攒一攒,一次提交多个相关改动,一次审查搞定。这也符合“提交应该是逻辑完整的单元”这个 git 最佳实践。
7. 这个工具还能怎么扩展
7.1 接入团队规范:自定义审查规则
每个团队都有自己的代码规范。比如有的团队要求所有公开函数必须有 docstring,有的团队禁止在循环里做 IO。这些规则可以写进 prompt,让模型按团队规范审查。
更进一步,可以把规则做成配置文件:
{ "custom_rules": [ "所有公开函数必须有 docstring", "禁止使用 print 调试,必须用 logging", "数据库查询必须在循环外批量执行" ] }然后在 prompt 里把这些规则拼进去。这样不同项目可以用不同的规则集,灵活性很高。
7.2 输出到文件或 PR 评论
目前审查意见只打印到终端,看完就没了。如果想留存,可以输出到文件,比如review_YYYYMMDD_HHMMSS.md。如果想集成到 PR 流程,可以在 CI 里跑这个脚本,把结果作为 PR 评论发出去。不过那需要额外的 API 调用和权限配置,超出了“本地小工具”的范畴,看需求决定要不要做。
7.3 结合静态分析工具做交叉验证
大模型有幻觉,可能报出不存在的问题。一个增强思路是:把 Pylint、ESLint 的输出也喂给模型,让模型结合静态分析结果做判断。静态分析工具报的通常是确定的问题(比如未使用变量),模型可以在此基础上补充语义层面的分析。两者结合,误报和漏报都能降低。
这个扩展我还没做,因为当前版本已经能满足我的需求。但如果你对审查质量要求更高,这是个值得尝试的方向。
7.4 支持多语言项目
目前的 prompt 是语言无关的,Python、JavaScript、Go 代码都能审。但不同语言的关注点不同,比如 Go 要关注 goroutine 泄漏,Rust 要关注所有权,JavaScript 要关注异步错误处理。可以按文件扩展名选择不同的 prompt 模板,让审查更贴合语言特性。这个改动不大,主要是维护多套 prompt 的工作量。
8. 一些使用上的个人体会
这个工具我用了大概两个月,最大的感受是:它不能替代人工 review,但能过滤掉大量低级问题。以前同事 review 我的代码,经常要指出“这里没判空”“这个变量名拼错了”“异常被吞了”这类问题。现在这些在提交前就被 AI 拦下来了,同事的 review 能聚焦在架构和业务逻辑上,沟通效率高了不少。
另一个体会是:prompt 的质量比模型的选择更重要。我试过用同一个模型,换不同的 prompt,审查质量差异巨大。花时间打磨 prompt,比花钱换更贵的模型划算。
还有一点:不要完全信任 AI 的判断。它说“这里有 bug”,你要自己看一眼确认;它说“没问题”,你也不能完全放心。把它当成一个帮你多看一眼的助手,而不是最终决策者。尤其是涉及业务逻辑正确性的判断,AI 只能基于代码表面,理解不了业务上下文。
最后,这个工具本身也在不断改。每次遇到误报或漏报,我就调一下 prompt 或加一条规则。它不是一个“写完就完事”的项目,而是一个跟着你的开发习惯一起进化的工具。如果你也打算自己做一个,建议从最小可用版本开始,先跑起来,再慢慢加功能,别一上来就追求大而全。