上个月我们团队接手了一个遗留项目,12万行 Python 代码,0 个单元测试,PR review 全靠人肉。两个 senior 每天花 3 个小时看 diff,看到第三周已经开始出现「这个 import 看着眼熟但无所谓了」的倦怠——你知道,就是那种「算了先合吧」的心态。
我心想这事不能再靠意志力,得自动化。于是花了一个周末搭了一套 LLM 驱动的自动化代码审查 pipeline,效果超出预期:不仅把 review 时间从人均 3 小时砍到了 30 分钟,还顺手抓出了好几个潜伏了半年的 bug。
这篇文章就是整个搭建过程的完整复盘——从 prompt 怎么设计,到怎么把 LLM 嵌进 GitHub PR workflow,每一步都有可运行的代码。不是那种「用 AI 写代码好厉害」的泛泛之谈,是真正跑在生产环境里的东西。
一、为什么 LLM 做 Code Review 这件事是靠谱的
先说结论:LLM 做 CR 不是替代人,而是做「第一道过滤」。你永远需要的那个 senior 不是被取代了,而是从一个「逐行看代码的苦力」变成了「审核 AI 发现并做高级决策的人」。
LLM 在 CR 场景下的优势非常明确:
- 一致性:人看到第 50 个 PR 会疲劳,LLM 不会。它对第 1 个和第 100 个 PR 用完全相同的标准。
- 覆盖面:人能注意到明显的逻辑错误,但很难同时关注 SQL 注入、XSS、敏感信息泄露、资源泄露、空指针等 20 个维度。LLM 的 check list 可以无限扩展。
- 速度:一个 500 行 diff 的 PR,人看要 20 分钟,LLM 大概 15 秒。
- 新人友好:对 junior 的 PR,LLM 发现的问题比人类 reviewer 多 3-5 倍(这是我们实测的数据),不会因为「不好意思说」而放过明显的坑。
当然也有局限——LLM 不懂你们的业务上下文、有时候会较真一些无关紧要的命名风格问题、对复杂架构决策无能为力。所以我们的定位很清晰:LLM 做初筛加分类,人做决策。
二、核心设计:Prompt 才是真正的护城河
很多人以为「接入 LLM 做 CR」就是写个 prompt 让模型看 diff 然后列问题——这样搞出来的结果基本没法用。问题要么太泛(「建议优化这段代码的性能」),要么是幻觉(「这里可能有 SQL 注入风险」——但实际上那是内部管理后台,根本没外部暴露)。
好的 CR prompt 需要三层结构,缺一不可:
第一层:角色与上下文
你是一位资深 Python/TypeScript 后端工程师,有 10 年以上的代码审查经验。
你正在审查一个 Pull Request,项目背景如下:
- 这是一个面向内部用户的 API 服务
- 使用 FastAPI + PostgreSQL + Redis 技术栈
- 团队编码规范遵循 PEP 8,使用 black + isort 格式化
- 测试覆盖率要求 > 80%
第二层:审查维度与严重等级
这一层是整个 prompt 的精华。很多人直接说「帮我审查代码」,LLM 就会随机输出一些不痛不痒的建议。你必须告诉它什么是重要的、什么不算。
请从以下维度审查代码变更,并为每个发现标注严重等级(🔴严重 / 🟡警告 / 🔵建议):
🔴 严重(必须修复,不修不能合):
- 安全漏洞:注入攻击、XSS、敏感信息泄露(密钥/Token)、权限绕过
- 可能导致数据丢失或损坏的逻辑错误
- 资源泄露:数据库连接未关闭、文件句柄泄露、goroutine 泄露
- 竞态条件或死锁风险
- 空指针 / None 未处理导致的潜在崩溃
🟡 警告(强烈建议修复,block 除非有合理理由):
- 性能问题:N+1 查询、不必要的循环、大对象深拷贝
- 错误处理缺失或不完整(bare except、吞掉异常)
- 类型安全问题:Any 滥用、类型标注与实际不符
- 可能导致生产异常的边界情况
🔵 建议(可选优化,不 block):
- 代码可读性改进(过长的函数、深层嵌套)
- 测试覆盖不足的关键路径
- 非关键的命名不规范
- 缺少必要注释的复杂逻辑
第三层:输出格式约束
请严格按以下 JSON 格式输出审查结果,不要包含任何其他文本或 markdown 标记:
{
"summary": "一句话总结本次变更的核心内容和风险等级",
"findings": [
{
"severity": "critical|warning|suggestion",
"file": "文件相对路径",
"line": "行号范围(如 L42-L58)",
"category": "security|performance|logic|style|testing",
"title": "问题简述(10字以内)",
"description": "详细说明为什么这是问题、可能造成什么影响",
"suggestion": "具体的修复建议,最好包含代码示例"
}
],
"overall_score": 0-10,
"risky_files": ["需要重点人工审查的文件列表"]
}
这个三层结构的关键在于:不是让 LLM 判断什么重要——是你告诉它什么重要。LLM 擅长在给定框架内按规则执行,不擅长自己定义评价标准。
关于 Prompt Engineering 的更多技巧,我之前写过一篇AI 编程工具的 Prompt Engineering 实战指南,涵盖了怎么设计 system prompt、怎么用 few-shot 引导、怎么处理幻觉——如果你想让 Copilot 或 Cursor 第一次就写出对的代码,强烈建议翻翻。
三、完整实现:一个可用的 Python CLI 工具
说完了设计,直接上代码。下面是一个可以直接用的 CLI 工具,核心功能:获取 GitHub PR 的 diff → 发送给 LLM → 解析结构化输出 → 生成 Markdown 格式的 review 报告 → 可选自动贴到 PR 评论区。
整个工具的关键设计决策:
- 强制 JSON 输出:用了 OpenAI 的
response_format: {"type": "json_object"},配合 system prompt 里的 JSON 示例。之前没用这个参数时,有 15% 的调用返回的是 markdown 包裹的 JSON,解析直接炸。 - 低温度:temperature=0.1,代码审查不需要创造性,需要一致性。
- 80K 字符截断:超长 diff 会截断,配合后面的「分层审查」策略处理。
#!/usr/bin/env python3
"""llm-code-review - AI-powered automated code review CLI"""
import json
import os
import sys
import argparse
import subprocess
import requests
class CodeReviewer:
def __init__(self, api_key: str, model: str = "gpt-4o-mini"):
self.api_key = api_key
self.model = model
self.system_prompt = self._build_system_prompt()
def _build_system_prompt(self) -> str:
return """你是一位资深后端工程师,审查代码时按三级分类:
- 🔴严重(critical): 安全漏洞、数据丢失、资源泄露、竞态条件、空指针
- 🟡警告(warning): 性能问题、错误处理缺失、类型安全、边界情况
- 🔵建议(suggestion): 可读性、测试覆盖、命名、注释
对于每个发现,输出前先自问:
「如果这个问题不修,最坏会导致什么后果?」
如果答案是「不会有任何后果」,就不要输出。
请严格输出纯 JSON(不要 markdown 代码块标记)。"""
def _build_user_prompt(self, diff: str, context: str = "") -> str:
return f"""审查以下 Pull Request 的代码变更。
项目背景: {context if context else '标准 Python Web 服务'}
输出 JSON 格式:
{{
"summary": "字符串",
"findings": [
{{
"severity": "critical|warning|suggestion",
"file": "路径",
"line": "行号",
"category": "security|performance|logic|style|testing",
"title": "简述",
"description": "详述",
"suggestion": "修复建议"
}}
],
"overall_score": 0-10,
"risky_files": ["文件列表"]
}}
代码 diff:
{diff[:80000]}
"""
def review(self, diff: str, context: str = "") -> dict:
resp = requests.post(
"https://api.openai.com/v1/chat/completions",
headers={
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
},
json={
"model": self.model,
"messages": [
{"role": "system", "content": self.system_prompt},
{"role": "user",
"content": self._build_user_prompt(diff, context)},
],
"temperature": 0.1,
"response_format": {"type": "json_object"},
},
timeout=120,
)
resp.raise_for_status()
raw = resp.json()["choices"][0]["message"]["content"]
return json.loads(raw)
def format_report(self, data: dict, pr_url: str = "") -> str:
lines = [f"## 🤖 AI Code Review Report"]
if pr_url:
lines.append(f"**PR:** {pr_url}")
lines.append(
f"**Overall Score: {data.get('overall_score', 'N/A')}/10**")
lines.append(f"")
lines.append(f"> {data.get('summary', 'N/A')}")
lines.append(f"")
by_severity = {"critical": [], "warning": [], "suggestion": []}
for f in data.get("findings", []):
sev = f.get("severity", "warning")
by_severity.setdefault(sev, []).append(f)
for sev, emoji in [("critical", "🔴"), ("warning", "🟡"),
("suggestion", "🔵")]:
findings = by_severity.get(sev, [])
if not findings:
continue
lines.append(
f"### {emoji} {sev.upper()} ({len(findings)} issues)")
lines.append("")
for i, f in enumerate(findings, 1):
lines.append(f"**{i}. {f.get('title', 'Untitled')}**")
lines.append(
f"- 📁 `{f.get('file', 'N/A')}` "
f"| 📍 Line {f.get('line', 'N/A')}")
lines.append(
f"- 📂 {f.get('category', 'N/A')}")
lines.append(
f"- {f.get('description', 'N/A')}")
if f.get("suggestion"):
lines.append(
f"- 💡 **建议:** {f.get('suggestion', '')}")
lines.append("")
if data.get("risky_files"):
lines.append("### ⚠️ 需要重点人工审查的文件")
for rf in data["risky_files"]:
lines.append(f"- `{rf}`")
return "\n".join(lines)
def get_pr_diff(pr_url: str) -> str:
"""Get PR diff using gh CLI."""
import re
m = re.match(
r"https://github.com/([^/]+)/([^/]+)/pull/(\d+)", pr_url)
if not m:
raise ValueError(f"Invalid PR URL: {pr_url}")
owner, repo, pr_num = m.groups()
result = subprocess.run(
["gh", "pr", "diff", pr_num, "-R", f"{owner}/{repo}"],
capture_output=True, text=True, timeout=30,
)
if result.returncode != 0:
raise RuntimeError(f"gh pr diff failed: {result.stderr}")
return result.stdout
def main():
parser = argparse.ArgumentParser(
description="AI-powered code review")
parser.add_argument("--pr", help="GitHub PR URL")
parser.add_argument("--diff-file", help="Path to diff file")
parser.add_argument("--context", default="",
help="Project context (200 chars max)")
parser.add_argument("--output", help="Output file")
parser.add_argument("--api-key",
default=os.environ.get("OPENAI_API_KEY"))
parser.add_argument("--model", default="gpt-4o-mini")
parser.add_argument("--comment", action="store_true",
help="Post review as PR comment")
args = parser.parse_args()
if not args.api_key:
print("Error: Set OPENAI_API_KEY or use --api-key",
file=sys.stderr)
sys.exit(1)
if args.pr:
print(f"Fetching diff from {args.pr}...")
diff = get_pr_diff(args.pr)
elif args.diff_file:
with open(args.diff_file) as f:
diff = f.read()
else:
print("Error: --pr or --diff-file required", file=sys.stderr)
sys.exit(1)
print(f"Diff: {len(diff)} chars, reviewing...")
reviewer = CodeReviewer(args.api_key, args.model)
data = reviewer.review(diff, args.context)
report = reviewer.format_report(data, args.pr or "")
if args.output:
with open(args.output, "w") as f:
f.write(report)
print(f"Saved to {args.output}")
else:
print(report)
if args.pr and args.comment:
import re
m = re.match(
r"https://github.com/([^/]+)/([^/]+)/pull/(\d+)", args.pr)
owner, repo, pr_num = m.groups()
subprocess.run(
["gh", "pr", "comment", pr_num,
"-R", f"{owner}/{repo}", "--body", report],
check=True, input=report.encode()
)
print("✓ Posted as PR comment")
if __name__ == "__main__":
main()
四、接入 GitHub Actions:全自动流水线
CLI 工具只是第一步。真正的效率提升在于把它塞进 CI/CD——每次有人开 PR,自动触发审查并评论。人只需要看一眼 AI 的报告然后决定哪些要改、哪些可以忽略。
GitHub Actions Workflow
# .github/workflows/ai-code-review.yml
name: AI Code Review
on:
pull_request:
types: [opened, synchronize, reopened]
permissions:
contents: read
pull-requests: write
jobs:
ai-review:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Generate PR diff
run: |
git diff origin/${{"{{"}} github.base_ref {{"}}"}}...HEAD \
> /tmp/pr.diff
- name: Run AI Code Review
env:
OPENAI_API_KEY: ${{"{{"}} secrets.OPENAI_API_KEY {{"}}"}}
run: |
pip install requests
python scripts/llm_code_review.py \
--diff-file /tmp/pr.diff \
--context "${{"{{"}} github.event.pull_request.body {{"}}"}}" \
--output /tmp/review.md \
--model gpt-4o-mini
- name: Post review as PR comment
uses: thollander/actions-comment-pull-request@v2
with:
filePath: /tmp/review.md
comment_tag: ai-code-review
mode: recreate
这里有几个踩坑后总结的经验:
- 用 gpt-4o-mini 而不是 gpt-4o:Code review 场景下,mini 的准确率差距不到 5%(我们内测了 200 个 PR),但成本只有十分之一。对于每天几十次的调用,这个取舍很划算。
- mode: recreate 是救命选项:每次 push 更新 PR 时,用 recreate 模式覆盖上一次的 AI review 评论。如果不用这个,一个改了 6 次的 PR 评论区会有 6 条 AI review——完全没法读。
- comment_tag 不能省:tag 是 actions-comment-pull-request 用来找到并替换已有评论的标识,不设的话每次都是新评论。
- 加并发限制:如果团队同时开 10 个 PR,每个都调 LLM API,你的 API 账单会很好看。用 concurrency group 控制同时运行的 job 数。
五、效果验证:200 个 PR 的实测数据
空口无凭,以下是我们一个月实际使用中收集的数据(内部项目,200 个 PR,总 diff 约 85000 行):
| 指标 | 纯人工 Review | AI + 人工 | 变化 |
|---|---|---|---|
| 平均 Review 耗时 | 18 分钟 | 4 分钟 | ⬇ 78% |
| 发现的 Bug 数 / PR | 0.8 | 2.1 | ⬆ 163% |
| 安全漏洞检出率 | 12% | 67% | ⬆ 458% |
| 误报率 (false positive) | — | 23% | ⚠ 可接受 |
| Junior PR 问题发现率 | 3.1 / PR | 8.7 / PR | ⬆ 180% |
| 每次 Review 成本 | ~$12(人力) | ~$1.35(API) | ⬇ 89% |
最让我震惊的是安全漏洞检出率从 12% 跳到 67%。这不是说 LLM 多聪明——而是人类 reviewer 在安全审查这件事上真的不靠谱。SQL 注入、硬编码密钥、缺少输入校验——这些问题不是看不出来,是根本就没往那个方向想。LLM 的好处是每次都检查同一个 check list,不会有「算了今天太累不看了」这种人类专属 bug。
23% 的误报率在可接受范围内——大概每 4 个 AI 发现里有 1 个是人类看一眼就会说「这个不用改」的。但这个比例不能再高了,再高就会产生「狼来了」效应,人类 reviewer 会习惯性忽略所有 AI 建议。
六、常见坑与避雷指南
这套东西跑了一个月,踩了不少坑,挑几个最有价值的说:
坑 1:Diff 太长导致截断,审查质量断崖式下降
GPT-4o-mini 的上下文是 128K tokens,但当你塞进去一个 5000 行的 diff 时,模型会倾向于给出非常泛泛的回答——「建议优化代码结构」「考虑添加更多测试」之类的水话。我们的对策是分层审查:先让 LLM 快速扫描所有文件名和变更行数,识别出风险最高的 5 个文件(按变更规模 × 文件重要性加权),然后只对这几个文件做深度审查。
坑 2:LLM 的「不痛不痒」型建议占了一大半
第一次跑,80% 的发现都是「建议把变量名改得更语义化」「这个函数可以考虑提取出来」这类东西。问题出在 prompt 里——没给 LLM 一个「自我审查」的步骤。后来在 system prompt 里加了这句关键指令:「输出前先自问:如果这个问题不修,最坏会导致什么后果?如果答案是不会有任何后果,就不要输出。」——效果立竿见影,噪音从 80% 降到了 30%。
坑 3:PR 评论区被 AI 刷屏
没用 comment_tag + recreate 之前,一个改了 7 次的 PR 评论区有:7 条 AI review + 15 条人类讨论 + 3 条 CI 日志 = 完全无法阅读。解决方案就是前面说的 mode: recreate——不是追加,是覆盖。
坑 4:LLM 不懂项目结构
这是最大的硬伤,也是「人机协同」模式不能被「完全替代」的根本原因。LLM 不知道你的 controller/service/repository 分层、不知道哪些是 legacy 代码碰了就会炸、不知道跨服务的调用关系。我们在 context 参数里传一份项目架构概览(200 字以内)之后,效果提升明显——至少不会建议「把这个 util 函数放到另一个微服务里去」这种离谱操作了。
坑 5:API 成本控制
gpt-4o-mini 每百万 input tokens $0.15、output $0.60。一个中型 PR(2000 行 diff ≈ 5000 input tokens + 1500 output tokens)的成本约 $0.95。如果团队每天 20 个 PR,一个月 $570——不算贵,但如果每个 push 都触发,同一个 PR 改 6 次就是 6 倍。策略:只在 PR 首次创建时触发深度审查,后续 push 用简易模式(只看增量 diff)。
FAQ
Q: 只支持 OpenAI 吗?能不能用开源模型?
代码用的是 OpenAI 兼容 API,只要把 endpoint 换成 Ollama 的 http://localhost:11434/v1、DeepSeek 的 API、或者任何兼容 /v1/chat/completions 的服务就行。我们实际用的是混合方案:安全审查走 OpenAI(模型最强),代码风格和命名检查走本地部署的 Qwen 2.5 72B(零成本,无限调用)。
Q: 什么时候 LLM review 会「帮倒忙」?
三种情况:(1) 复杂架构变更——LLM 看不到全局,建议可能完全方向错误;(2) 性能敏感的 hot path——LLM 擅长逻辑检查但不擅长性能直觉;(3) 业务规则校验——LLM 不知道你的业务约束,可能放过致命的业务逻辑错误。这三种场景必须人工 review,AI 建议只能当参考。
Q: 能不能直接让 AI approve PR?
绝对不能。说个真实案例:AI 给一个 PR 打了 9/10 分,建议 approve。但那个 PR 删除了一个「看起来没用」的配置文件——这个文件是 UAT 环境的数据库连接串,删了之后 UAT 全线瘫痪。人类能凭经验判断「虽然没被 import 但这文件很重要」,LLM 完全做不到。
Q: 和 SonarQube、CodeRabbit 比有什么不同?
SonarQube 走的是静态规则路线(AST 分析加预设规则),能发现确定的模式问题但不懂语义——它知道你的代码圈复杂度太高,但不知道这到底是不是个问题。CodeRabbit 也是 LLM-based,但它是 SaaS,你的代码要上传到第三方。自建方案的优势:(1) 代码不出公司,(2) prompt 100% 可控,(3) 可以和内部工具链深度集成,(4) 便宜——CodeRabbit $12/seat/月,自建方案用 gpt-4o-mini 一个团队每月 $100 以内搞定。
总结
AI 辅助 Code Review 这件事,核心不是「AI 能不能做好 review」——它能,而且在安全检查和一致性维度上做得比大多数人类好。核心问题是你愿不愿意重新设计你的 review 流程来容纳 AI 这个新角色。
如果只是把 diff 丢给 ChatGPT 然后复制粘贴——那是玩具。但如果你愿意花 2 个人天设计 prompt、搭 CI 集成、做数据驱动的持续优化——那它就是实实在在的效率杠杆。我们的团队在这套东西上投入了大约 2 天,换回来的是每个月节省约 40 个小时的 review 时间。
至于 AI 编程工具的选型,我上个月写过一篇2026 年 AI 编程工具深度横向评测——Copilot vs Cursor vs Windsurf vs Cline,从代码补全质量到上下文理解能力做了完整的对比,选工具前可以翻一翻。
另外如果你对性能优化也感兴趣,FastAPI 性能调优实战 记录了我把一个生产接口从 1000 req/s 干到 15000 req/s 的全过程——那种级别的性能瓶颈,目前还是得靠人加 profiling 工具,LLM 帮不上太大忙。AI 辅助 CR 和性能调优是互补的关系,不是替代。
📎 延伸阅读:Python AI Agent 从零构建实战 — 用 50 行 Python 代码实现 Tool Calling Agent,含防死循环、容错解析、上下文压缩等生产级技巧。
⚠️ 免责声明:本文中的代码和方案仅供学习参考。在生产环境中使用 AI 辅助代码审查之前,请评估你的安全合规要求、数据隐私政策以及团队的接受度。AI 生成的审查建议不能替代有经验的工程师的专业判断。文中提到的 API 定价数据以 2026 年 7 月为准,实际价格请以官方最新定价为准。