刚看到“Show HN: Nitpicler”这个项目时第一反应不是羡慕作者的效率而是被那个报价震住了——一个 AI PR Review 方案外部报价居然高达 100 万美元。虽然大厂内部合规、权限、私有化部署、历史代码学习这些需求确实能把成本拉得很高但对绝大多数研发团队来说这个数字显然不是唯一选项。作者的做法也很直接用大模型 API 自己撸了一个工具解决“AI 自动评审 Pull Request”这个具体问题。这件事其实暴露了一个更普遍的行业现状AI Code Review 的“成品方案”很贵但“实现路径”已经被大模型 API 压得很低。与其花大价钱买一套重平台不如先根据自己的仓库规模、语言栈、评审规则做一个轻量级 AI PR Review 工具。本文就从 Nitpicler 这类项目切入拆解 AI 代码评审的核心原理并给出一个可以实际运行的 Python 实现解析 Git diff、调用大模型生成评审意见、输出 Markdown 报告。你可以直接复制代码改成自己的版本也可以把它接入 CI 流程。1. 背景为什么 AI PR Review 会值 100 万美元先聊一个基础问题人工 PR Review 到底贵在哪里。一个中型研发团队假设 20 个后端开发每人每天花 40 分钟看别人的代码一个月就是 260 多个小时。更麻烦的是人工评审的质量不稳定资历浅的开发者容易关注代码风格忽略架构问题评审者在 context switch 之后经常漏掉边界条件同一个错误换个 PR 又出现一遍缺少历史沉淀。大厂的解决方案是把 Code Review 做成一套标准化平台接入代码托管平台、扫描历史缺陷、结合 CI 状态、按语言和目录配置规则、做权限管控。这些定制化开发叠加企业级部署、私有模型微调、长期运维报价到百万级并不奇怪。但这里有一个关键变化大模型对“理解代码变更”这件事的能力已经很强了。把git diff的文本喂给 GPT、Claude、DeepSeek 这类模型它能从“这段逻辑有没有问题”“有没有遗漏异常处理”“是否与现有接口设计冲突”等角度给出评审意见。这意味着AI PR Review 的最小可用版本本质上是一个“diff 解析器 提示词工程 大模型 API 调用”的组合。Nitpicler 这类项目的价值就是把过去需要专业团队才能做的“评审逻辑”压缩成了一个开发者周末就能写出来的工具。它的真正门槛不在模型而在如何把代码变更转换成模型能理解、能高质量回应的文本结构。所以本文不打算分析 Nitpicler 的内部实现也没有必要去逆向而是围绕“自研 AI PR Review”这个主题把核心链路拆开讲清楚。你学完之后不仅能做一个类似的工具还能根据自己的团队规范扩展评审维度。2. AI PR Review 的核心工作原理AI PR Review 表面上是一个“调用大模型”的过程但实际工程里效果好坏几乎完全取决于你如何准备输入。2.1 整体流程拆解一个普通的 AI PR Review 工具通常包含以下五个步骤获取代码变更通过git diff或其他代码托管平台 API 拿到 PR 的变更内容解析变更结构把连续文本拆成“哪个文件、哪些行、新增了什么、删除了什么”构造模型输入把 diff、仓库上下文、评审规则组合成 Prompt调用大模型通过 OpenAI 兼容接口或各云厂商 API 发起请求解析并输出结果把模型返回的内容整理成Markdown或JSON报告方便人工复核。这五个步骤里第 2 步和第 3 步最容易被忽略但它们对评审质量的影响最大。2.2 diff 解析与变更分组git diff默认输出的是统一格式unified format它的结构大致如下diff --git a/src/user_service.py b/src/user_service.py index 83db5f4..e3b20a9 100644 --- a/src/user_service.py b/src/user_service.py -18,7 18,7 def get_user(user_id): if user_id 0: raise ValueError(invalid user_id) - user db.query_one(SELECT * FROM users WHERE id ?, (user_id,)) user db.query_one(SELECT * FROM users WHERE id ?, (user_id,)) if user is None: raise UserNotFoundError(fuser {user_id} not found) return user这段文本包含几种信息变更文件路径src/user_service.py变更区间 -18,7 18,7 表示原文件第 18 行开始的 7 行新文件也是第 18 行开始的 7 行上下文行以空格开头删除行以-开头新增行以开头。直接把整个 diff 文本丢给模型也能运行但效果很差。原因有两个大模型的上下文窗口有限大型 PR 可能几十个文件一次性塞进去会丢失关键信息模型的注意力更偏向“看过的最后一段内容”如果文件顺序混乱评审容易漏掉关键文件。所以工程上建议按文件分组然后对每个文件单独生成评审请求最后汇总结果。这也是 Nitpicler 这类轻量工具最常见的架构选择。2.3 提示词决定评审质量如果你用过通用对话模型做代码评审大概率会遇到两种挫败感模型只夸不评返回“这段代码写得很清晰、符合最佳实践”没有任何建设性意见模型乱提意见挑剔命名、纠结格式甚至把正确代码误判为 bug。这是因为模型默认认为自己在做“通用问答”而不是“代码评审员”。所以 AI PR Review 的提示词必须做三件事定义角色和任务你是资深后端工程师请从正确性、健壮性、安全性和可维护性四个维度评审以下变更定义输出格式每条意见包含严重级别、文件位置、问题描述、修改建议定义边界不要提风格类建议不要臆测未提供的上下文不确定的内容不要强行下结论。后面实战部分会给出一个可以直接用的提示词模板。2.4 规则引擎与阈值控制模型返回的意见不一定都准确。一个成熟工具还需要在后处理阶段做“规则过滤”关键词过滤忽略只涉及注释、空行、格式化变动的 PR严重级别权值只有存在error或warning级别意见时才标记为“需修改”文件范围限制前端样式文件不套用后端的空指针判断规则重复问题合并同一个函数被多行变更触发多次时只报一次。这部分虽然不复杂但决定了工具能否真正被团队接受。一个整天误报的工具最终会被开发者关掉。3. 环境准备与项目结构在开始写代码之前先明确本文示例的运行环境和依赖。操作系统Windows / macOS / Linux 均可本文示例基于命令行编程语言Python 3.10 或更高版本版本管理Git 2.30大模型 API任意提供 OpenAI 兼容接口的服务如 OpenAI、DeepSeek、通义千问、Kimi、本地部署的 vLLM 等Python 依赖requests用于调用 HTTP 接口。如果你用的是其他语言也没关系核心思路完全一致只是 HTTP 调用部分换成对应语言的写法即可。项目结构如下ai-pr-reviewer/ ├── main.py # 入口脚本解析命令行参数 ├── git_diff.py # 获取并解析 git diff ├── prompt.py # 构造评审提示词 ├── llm_client.py # 调用大模型 API ├── report.py # 生成 Markdown 评审报告 ├── .env.example # 环境变量示例 └── requirements.txt # Python 依赖下面我们一步步实现每个模块。为了便于阅读代码会保持“教程式”的简单风格生产环境可以继续扩展。4. 完整实战实现一个轻量级 AI PR Review 工具为了不依赖特定代码托管平台这个工具直接读取本地 Git 仓库的 diff然后输出一份评审建议到终端并把结果保存成review_report.md。这样你可以在任何 Git 项目里直接使用。4.1 获取代码变更先实现git_diff.py负责执行git diff命令并返回文本。考虑到 PR 评审通常关注“未提交的改动”或“两个分支之间的差异”支持两种模式--unstaged查看工作区未暂存的改动--base main查看当前分支相对于main分支的改动。模块代码如下# 文件路径git_diff.py import subprocess import sys def get_git_diff(base: str | None None) - str: 获取 Git 仓库的 diff 文本。 Args: base: 可选目标分支名。如果提供则比较当前分支与 base 的差异 否则只比较工作区未暂存的改动。 Returns: diff 文本字符串。 cmd [git, diff] if base: cmd [git, diff, base ...HEAD] try: result subprocess.run( cmd, capture_outputTrue, textTrue, encodingutf-8, errorsreplace, checkFalse, ) except FileNotFoundError: sys.exit(错误未找到 git 命令请先安装 Git 并确认它在 PATH 中。) if result.returncode ! 0: sys.exit(f执行 git diff 失败\n{result.stderr}) return result.stdout要点说明encodingutf-8是为了避免 Windows 平台中文文件名和内容乱码errorsreplace保证特殊字符不会导致程序崩溃checkFalse让我们能手动处理错误信息不让异常直接中断脚本。然后我们在main.py里做一个简单接入先打印 diff 的前 200 个字符确认模块正常。# 文件路径main.py第一阶段 import git_diff def main() - None: diff_text git_diff.get_git_diff() print(diff_text[:200]) if __name__ __main__: main()运行命令cd your_git_project python main.py如果仓库里有未暂存改动你会看到类似diff --git a/xxx b/xxx的输出。这一步验证通过后继续完善解析逻辑。4.2 解析统一 diff 格式为了让大模型更高效地理解变更我们要把原始 diff 解析成结构化对象。这里实现一个轻量解析器按文件切分并提取每个文件的新增行和删除行。# 文件路径git_diff.py追加内容 from dataclasses import dataclass, field dataclass class FileDiff: 单个文件的变更信息。 file_path: str # 新文件路径 added_lines: list[str] field(default_factorylist) removed_lines: list[str] field(default_factorylist) context_lines: list[str] field(default_factorylist) def parse_diff(diff_text: str) - list[FileDiff]: 将 git diff 的原始文本解析为 FileDiff 列表。 这里只做轻量解析不处理二进制文件、重命名检测等高级场景。 files: list[FileDiff] [] current_file: FileDiff | None None for line in diff_text.splitlines(): if line.startswith(diff --git): # 新文件开始 if current_file: files.append(current_file) # 文件路径通常出现在 b/ 之后例如 b/src/main.py current_file FileDiff(file_pathline.split( b/)[-1].strip()) elif line.startswith() or line.startswith(---): # 不处理文件名行跳过 continue elif line.startswith(): # hunk 头部不需要存储但可以保留 context 开始位置 continue elif line.startswith() and not line.startswith(): if current_file: current_file.added_lines.append(line[1:]) elif line.startswith(-) and not line.startswith(---): if current_file: current_file.removed_lines.append(line[1:]) else: if current_file: current_file.context_lines.append(line[1:]) if current_file: files.append(current_file) return files这个解析器有几处简化没有处理\ No newline at end of file这类特殊标记没有区分 hunk 的上下文边界文件路径直接取diff --git行里b/后面的部分如果改动涉及新文件file_path通常没问题。但这种程度已经够用。实际项目中如果你担心解析不完整也可以直接把原始 diff 交给模型通过提示词让模型忽略噪音。两种方式各有优劣后面常见问题里会展开。4.3 构造评审提示词现在来实现prompt.py。这一步是整个工具质量的关键。一个好的评审提示词需要满足两点让模型明确“这是一个代码评审任务”让模型返回结构化结果方便自动化处理。示例提示词如下# 文件路径prompt.py def build_review_prompt(file_diff, repo_hint: str ) - str: 构造单个文件的评审提示词。 Args: file_diff: FileDiff 对象包含文件路径和变更行。 repo_hint: 可选的仓库说明例如项目语言、业务背景。 Returns: str完整的 user prompt。 lines [] lines.append(请对以下代码变更进行评审。) if repo_hint: lines.append(f仓库背景{repo_hint}) lines.append(评审维度) lines.append(1. 正确性是否存在逻辑错误、边界条件遗漏、并发隐患。) lines.append(2. 健壮性是否有异常处理缺失、资源未释放、空指针风险。) lines.append(3. 安全性是否存在注入、敏感信息泄露、越权访问风险。) lines.append(4. 可维护性是否引入难以理解的复杂逻辑、重复代码、糟糕的接口设计。) lines.append() lines.append(输出要求) lines.append(- 如果发现问题按 JSON 数组返回不要输出额外解释。) lines.append(- 每条意见包含 level、file、line、problem、suggestion 五个字段。) lines.append(- level 只能取 error 或 warning。) lines.append(- 如果没有发现问题返回一个空数组 []。) lines.append() lines.append(变更文件 file_diff.file_path) lines.append() lines.append(删除的代码行) lines.append(\n.join(file_diff.removed_lines) or (无)) lines.append() lines.append(新增的代码行) lines.append(\n.join(file_diff.added_lines) or (无)) if file_diff.context_lines: lines.append() lines.append(相关上下文用于理解新增代码) lines.append(\n.join(file_diff.context_lines[:60])) lines.append() lines.append(请结合以上信息只针对新增或删除的代码给出评审意见。) return \n.join(lines)这里有一个很实用的设计把“删除的代码行”和“新增的代码行”分开列出。因为模型对纯 diff 格式的-/前缀理解能力虽然强但有时会因为 hunk 上下文噪声导致错误判断。单独列出来更清晰。另外输出格式强制使用 JSON 数组方便后面直接解析而不是让模型输出一大段 Markdown。4.4 调用大模型接口接下来是llm_client.py。这里使用 OpenAI 兼容接口因为国内外的很多模型服务都支持这种协议比如 DeepSeek、通义千问、Kimi、本地部署的 vLLM 都兼容。我们只需要通过环境变量配置base_url、api_key和model即可。# 文件路径llm_client.py import json import os import sys import requests def chat_completion( system_prompt: str, user_prompt: str, temperature: float 0.2, ) - str: 调用 OpenAI 兼容的大模型接口。 Args: system_prompt: 系统提示词。 user_prompt: 用户提示词。 temperature: 采样温度。 Returns: 模型返回的文本内容。 base_url os.getenv(LLM_BASE_URL, https://api.openai.com/v1) api_key os.getenv(LLM_API_KEY, ) model os.getenv(LLM_MODEL, gpt-4o-mini) if not api_key: sys.exit(错误未设置 LLM_API_KEY 环境变量。) url f{base_url.rstrip(/)}/chat/completions headers { Authorization: fBearer {api_key}, Content-Type: application/json, } payload { model: model, messages: [ {role: system, content: system_prompt}, {role: user, content: user_prompt}, ], temperature: temperature, # 注意不同模型对流式输出的支持不同这里关闭流式 stream: False, } try: resp requests.post(url, headersheaders, jsonpayload, timeout120) except requests.exceptions.Timeout: sys.exit(错误模型调用超时请检查网络或增大 timeout。) except requests.exceptions.ConnectionError: sys.exit(错误无法连接到模型服务请检查 base_url 和网络设置。) if resp.status_code ! 200: sys.exit(f模型接口返回错误 {resp.status_code}: {resp.text}) data resp.json() try: return data[choices][0][message][content] except (KeyError, IndexError): sys.exit(错误模型响应格式异常未找到 choices[0].message.content。)关键点说明timeout120代码评审任务通常比普通问答耗时更长因为输入文本可能较大temperature0.2降低随机性保证评审结果更稳定通过环境变量配置模型信息避免把密钥硬编码在代码里。4.5 生成 Markdown 评审报告模型返回的是 JSON 字符串我们需要解析它并生成人可读的 Markdown 报告。report.py负责这一层。# 文件路径report.py import json from typing import Any def parse_review_json(raw: str) - list[dict[str, Any]]: 尽量从模型输出中解析 JSON 数组。 模型偶尔会输出 json 包裹的代码块这里做一次清理。 text raw.strip() if text.startswith(): # 去掉代码块围栏 lines text.splitlines() lines [line for line in lines if not line.strip().startswith()] text \n.join(lines) try: data json.loads(text) if isinstance(data, list): return data return [] except json.JSONDecodeError: # 如果模型解析失败至少把原文打印出来方便人工查看 return [{level: warning, problem: 模型输出不是合法 JSON请人工检查, suggestion: raw}] def render_report(results: list[dict[str, Any]]) - str: 把多条评审意见渲染成 Markdown 报告。 if not results: return ## AI 评审结果\n\n未发现问题。 lines [## AI 评审结果, ] for idx, item in enumerate(results, start1): level item.get(level, warning) file item.get(file, 未知文件) line item.get(line, 未知行) problem item.get(problem, ) suggestion item.get(suggestion, ) lines.append(f### {idx}. [{level}] {file}:{line}) lines.append() lines.append(f- **问题描述**{problem}) if suggestion: lines.append(f- **修改建议**{suggestion}) lines.append() return \n.join(lines)报告是给人看的所以这里不只保留 JSON还把它渲染成带标题和列表的 Markdown。4.6 组装主程序现在我们把所有模块串起来实现main.py的完整版本。# 文件路径main.py import argparse import os import git_diff import llm_client import prompt as prompt_module import report as report_module # 默认系统提示词可以在使用时通过 --system-prompt 覆盖 SYSTEM_PROMPT ( 你是一名严谨的高级软件工程师负责代码评审。 你只输出 JSON 数组不要输出任何多余的内容。 如果对某条意见没有把握宁可省略也不要编造。 ) def parse_args() - argparse.Namespace: parser argparse.ArgumentParser(description轻量级 AI PR Review 工具) parser.add_argument(--base, defaultNone, help对比的目标分支名例如 main) parser.add_argument(--repo-hint, default, help仓库背景例如 Python 后端项目) parser.add_argument(--system-prompt, defaultSYSTEM_PROMPT, help系统提示词) parser.add_argument(--output, defaultreview_report.md, help报告输出路径) return parser.parse_args() def main() - None: args parse_args() print(1. 获取 git diff ...) diff_text git_diff.get_git_diff(baseargs.base) if not diff_text.strip(): print(没有检测到变更无需评审。) return print(2. 解析 diff ...) files git_diff.parse_diff(diff_text) if not files: print(没有解析出任何文件变更。) return print(f共解析到 {len(files)} 个变更文件。) all_results [] for file_diff in files: print(f正在评审: {file_diff.file_path}) user_prompt prompt_module.build_review_prompt(file_diff, args.repo_hint) raw_output llm_client.chat_completion( system_promptargs.system_prompt, user_promptuser_prompt, temperature0.2, ) parsed report_module.parse_review_json(raw_output) for item in parsed: item.setdefault(file, file_diff.file_path) item.setdefault(line, N/A) all_results.extend(parsed) print(3. 生成报告 ...) md_report report_module.render_report(all_results) with open(args.output, w, encodingutf-8) as f: f.write(md_report) print(f评审完成报告已写入 {args.output}) print(md_report) if __name__ __main__: main()要求文件与环境变量示例# 文件路径.env.example LLM_BASE_URLhttps://api.openai.com/v1 LLM_API_KEYsk-xxxx LLM_MODELgpt-4o-mini运行命令cd your_git_project export LLM_BASE_URLhttps://api.deepseek.com/v1 export LLM_API_KEYyour_deepseek_key export LLM_MODELdeepseek-chat python ../ai-pr-reviewer/main.py --repo-hint Python 后端项目 --base main注意上面的../ai-pr-reviewer/路径需要根据你实际克隆的目录调整。如果你就在项目根目录运行直接python main.py即可。预期输出会类似1. 获取 git diff ... 2. 解析 diff ... 共解析到 2 个变更文件。 正在评审: src/user_service.py 正在评审: src/order_service.py 3. 生成报告 ... 评审完成报告已写入 review_report.md同时review_report.md里会包含 AI 给出的结构化评审意见。5. 常见问题与排查思路自己写 AI PR Review 工具最常遇到的问题往往不在模型而在工程细节。下面列几个高频场景。问题现象常见原因解决思路git diff没有输出当前分支没有未提交改动或--base分支不存在用git status检查改动确认基础分支名正确模型返回内容无法解析为 JSON提示词约束不够严格或模型上下文太长导致格式漂移在后处理中清理代码块围栏增加 JSON schema 示例降低温度评审意见太笼统没有具体行号diff 解析丢失了行号信息在FileDiff中增加start_line字段或直接把原始 diff 片段传给模型一次调用 token 超限大 PR 的 diff 太长按文件拆分请求增加max_line截断过滤无意义的 lockfile 文件请求耗时太久模型较大、输入太长、网络慢使用更小的模型做第一轮过滤开启流式输出并发请求多个文件局域网环境连不上模型 APIAPI 地址不通或需要代理确认LLM_BASE_URL是否可达尽量使用国内模型服务或内网部署的模型除了表格里的问题还有两个容易踩的坑第一个是“评审对象过宽”。有些开发者生成 diff 时把package-lock.json、go.sum、dist目录也纳入评审范围模型面对几百行的依赖哈希变更基本无法提供有效意见还会浪费 token。好的做法是维护一个忽略列表比如.gitignore风格的规则只评审源码文件。第二个是“让模型一次处理整个 PR”。现实中很多轻量工具的做法是逐文件评审。因为一个大 PR 可能有几十个文件一次性塞给模型会导致上下文超限模型只重点分析前面或后面的文件评审意见的严重级别分布失衡。所以建议在工具里增加一个最大文件数参数比如默认只处理前 10 个变更文件超出部分提示人工评审。6. 最佳实践与工程建议到这里一个能跑的 AI PR Review 工具已经完成了。但如果要在真实项目里长期使用还需要补充一些工程化设计。6.1 提示词版本管理提示词是 AI 评审效果的第一决定因素。不要只在代码里随手改字符串。建议把提示词模板放到独立目录比如prompts/review_system_v1.txt、prompts/review_user_v1.txt并用 Git 管理。每次调整提示词后用同一组历史 PR 做回归测试观察评审意见的变化。同时要意识到大模型版本升级后同一段提示词的表现也会变化。所以最好在配置中记录“模型版本 提示词版本 测试样例数量”的对应关系避免线上效果突然下降时无法定位。6.2 安全与隐私边界把代码 diff 发送给第三方模型接口本质上是数据外传。很多公司的代码仓库包含未公开的业务逻辑甚至密钥因此在接入 AI 评审前必须确认是否允许把代码发送到当前使用的模型服务提供商是否需要对 diff 做脱敏处理比如过滤明显的密码、Token、内网地址是否只在私有化部署的模型环境使用。对于开源项目或个人项目默认没有这类限制但仍然建议在工具里加一个--sanitize参数把高熵字符串、sk-开头的密钥、AKIA开头的访问密钥替换为占位符从源头减少泄露风险。6.3 成本控制AI Code Review 的成本主要由 Token 数量决定。控制成本可以从三方面入手只评审新增和删除的行不把整个文件喂给模型使用diff.renameLimit或过滤工具忽略只改文件名或字段名的 PR先用便宜的小模型做一次前置过滤只有变更复杂度达到阈值时才调用更强的模型做深度评审。阈值可以用“变更行数”“涉及文件数”“是否触碰核心目录”等指标。比如如果变更行数 20跳过评审。 如果文件路径以 tests/ 开头使用快速评审模型。 如果文件路径以 src/core/ 开头使用强模型并附加 20 条上下文。6.4 CI 集成思路本地脚本只是第一步真正高效的方式是把工具接入 CI。以 GitHub Actions 为例大致流程是在 PR 触发时Checkout 代码计算目标分支的 diff调用自建的 AI Review 服务或直接运行脚本把生成的review_report.md作为 PR Comment 发回去。但直接跑脚本在 CI 上有一个问题大模型接口的耗时和失败率比普通测试高。所以生产级建议是单独部署一个 Review API 服务而不是每个 CI Job 都调用一次把评审结果缓存到对象存储按 commit SHA 做幂等设置超时和重试避免一个文件请求失败导致整个 PR 流程中断。6.5 从“工具”到“平台”当工具积累了一定使用量后你会逐渐发现团队最需要的不是“更多 AI 意见”而是“意见的闭环管理”。比如开发者是否接受了某条意见哪些问题的漏网率最高评审意见与代码变更行之间的关联是否准确。这些需求已经超出“AI 调模型”的范畴。如果你负责团队效能建设可以从简单的报告归档做起把每次 PR 的 diff 哈希、评审意见、最终是否修改登记到数据库再用统计视图反哺规则配置。7. 总结与下一步实践从 Nitpicler 的“被报价 100 万美元索性自己写”到本文实现一个不到 300 行的 AI PR Review 脚本中间差的核心并不是模型能力而是对“代码变更理解 提示词组织 工程化封装”这三件事的把握。你现在应该已经掌握AI PR Review 的整体链路git diff获取、diff 解析、提示词构造、模型调用、报告生成如何让模型的输出保持 JSON 结构化方便自动化处理控制评审范围、成本和隐私风险的基本方法从本地脚本走向 CI 集成时需要注意的稳定性问题。下一步的实践方向有几个选择如果你在公司内使用最值得做的是把工具接入现有 CI用真实 PR 跑两到四周人工对比 AI 意见和最终改动统计准确率如果你对模型效果感兴趣可以尝试在同一组 PR 上对比不同模型、不同采样温度的评审结果如果你想做成产品那就需要开始考虑用户隔离、权限、评审规则配置、意见聚类等平台化能力。最好的验证方式是找一个刚合入不久且有后续修复 commit 的 PR用这个工具重新评审一次。如果 AI 能指出当时没有被人工发现的问题说明你的提示词和解析步骤已经基本合格。接下来就是不断用真实案例迭代。