基于 PR-Agent 构建 AI 代码评审与自动修复系统:LLM 工程化实践的探索
引言
在团队协作开发中,代码评审(Code Review)是保证质量的关键环节。但人工评审不仅耗时,而且难以覆盖安全漏洞、逻辑缺陷等深层问题。如果能用 AI 自动化评审流程,并在发现问题后自动生成修复方案——这不仅是技术问题,更是效率革命。
本文分享我们基于开源项目 PR-Agent 构建的 AI 评审与自动修复系统的完整实现过程,重点探讨 LLM 应用落地中的几个核心挑战:
- 如何稳定调用 LLM 并支持多模型切换?
- 如何将 LLM 的结构化输出可靠提取并持久化?
- 如何让 AI 不只是"看问题",而是真正"解决问题"?
- LLM 配置和凭证如何在运行时动态管理?
系统已在内部实际运行数月,累计评审 MR 数百个,积累了不少踩坑经验和最佳实践。
一、整体架构
┌─────────────────────────────────────────────────────┐
│ GitLab (自建实例) │
│ merge_requests / files / commits API │
└──────────┬──────────────────────────────────────────┘
│ MR 轮询检测 │ AI 员工账号提 MR
▼ │
┌──────────────────────────────┐ ┌─────────────┴──────────┐
│ PR-Agent (Python) │ │ │
│ │ │ │
│ mr_polling(轮询服务) │ │ │
│ ↓ review_data │ │ │
│ audit_reporter(上报) ├────────┼───────────────────────┤
│ wecom_notifier(企微通知) │ │ │
│ fix_server(HTTP :8081) │ │ │
│ ↓ PRFixSecurity │ │ │
│ LLM 生成修复 → 创建分支/MR │ │ │
└──────────┬───────────────────┘ │ │
│ HTTP 配置拉取 │ │
└──────────┬──────────────────┘ │
│ │
┌─────────────────────┴─────────────────────────────────────────┐│
│ Go 后台 (pr-agent-admin/server) :8080 ││
│ │
│ Gin Router → MySQL (GORM) │
│ - 仓库配置管理 │
│ - 评审评分 + 建议落库 │
│ - LLM 端点配置 │
│ - AI 员工账号配置 │
└───────────────────────────────┬───────────────────────────────┘
│
┌────────────┴────────────┐
│ React + Ant Design :5173 │
│ 仓库配置 │ 审计统计 │
│ 评审建议 │ 消息通知 │
│ 模型配置 │ AI 员工 │
└──────────────────────────┘
核心数据流:
| 阶段 | 触发方 | 动作 |
|---|---|---|
| 1. 评审 | mr_polling 轮询发现新 MR | GitLab diff → LLM 评审 → 评论写回 GitLab |
| 2. 上报 | 评审完成后 | 评分 + 关键问题 → Go 后台持久化 |
| 3. 通知 | 评审完成后 | 评审摘要 → 企微群机器人 |
| 4. 修复 | 用户点击前端"AI 自动修复" | LLM 生成修复代码 → AI 员工账号创建 MR |
| 5. 反馈 | 修复完成后 | 更新 suggestion 状态 + 修复 MR 链接 |
二、LLM 网关选型:为什么选 LiteLLM?
2.1 需求背景
我们需要支持多种 LLM 后端:
- 生产环境:OpenAI GPT-5.6(高精度)
- 成本控制:Qwen-Plus / Claude Haiku(日常评审)
- 本地部署:Ollama + Llama 3(敏感代码隔离)
- 失败回退:多模型并行,任意一个成功即可
每个模型的 API 格式不同(OpenAI chat completions vs Anthropic messages),硬编码适配器会导致:
- 代码膨胀:每加一个模型新增一个 provider
- 测试负担:所有功能需针对每个模型单独测试
- 切换成本:改配置需修改代码
2.2 解决方案:LiteLLM 统一接口
from pr_agent.algo.ai_handlers.litellm_ai_handler import LiteLLMAIHandler
handler = LiteLLMAIHandler()
response, _ = await handler.chat_completion(
model="qwen-plus", # 或 gpt-5.6, claude-haiku, ollama/llama3
temperature=0.2,
system=system_prompt,
user=user_prompt,
)
优势:
- 单一
chat_completion接口适配所有模型 - 自动处理 API key、base_url、model prefix
- 内置重试、超时、token 计数
- 支持
retry_with_fallback_models机制:
prediction = await retry_with_fallback_models(
self._generate_fix,
model_type=ModelType.REGULAR
)
# 主模型失败时自动切换到 fallback 模型
2.3 Prompt 模板化设计
我们将所有 LLM 提示词组织为 Jinja2 模板,按功能分层存储:
pr_agent/settings/
├── configuration.toml ← 全局默认配置
├── pr_reviewer_prompts.toml ← 评审 prompt
├── pr_fix_security_prompts.toml ← 修复 prompt
├── pr_description_prompts.toml ← 描述生成 prompt
└── ...
以修复 prompt 为例:
[pr_fix_security_prompt]
system = """\
You are a senior security engineer. Given a code issue description and the full file content,
produce the corrected complete file. Preserve all existing logic outside the fix area.
Issue: {{ issue_header }}
Summary: {{ fix_summary }}
File: {{ relevant_file }} (lines {{ start_line }}-{{ end_line }})
Current content:
{{ file_content }}
"""
user = """\
Please generate the complete fixed version of the file above.
Only return the full file content — no explanations, no markdown fences.
{{ extra_instructions }}
"""
运行时通过 Jinja2 渲染注入上下文:
from jinja2 import Environment, StrictUndefined
variables = {
"issue_header": suggestion["issue_header"],
"issue_content": suggestion["issue_content"],
"fix_summary": suggestion["fix_summary"],
"relevant_file": suggestion["relevant_file"],
"start_line": suggestion["start_line"],
"end_line": suggestion["end_line"],
"file_content": git_provider.get_pr_file_content(file_path, branch),
}
env = Environment(undefined=StrictUndefined)
system_prompt = env.from_string(prompts.system).render(variables)
user_prompt = env.from_string(prompts.user).render(variables)
关键设计决策:
strict_undefined=True:变量缺失时报错而非输出字面量{{ var }}- prompt 分为
system+user两段:system 定义角色和约束,user 提供具体上下文 extra_instructions可动态追加(如响应语言要求、额外规范)
三、结构化输出解析:从 LLM 文本到业务数据
3.1 问题:LLM 不总是"听话"
评审工具需要从 LLM 输出中提取结构化字段:
review_data = {
"score": 95, # 0-100 评分
"key_issues_to_review": [ # 关键问题列表
{
"relevant_file": "src/auth.py",
"issue_header": "SQL 注入漏洞",
"issue_content": "用户输入直接拼接到 SQL 查询...",
"auto_fixable": True, # 是否可自动修复
"fix_summary": "使用参数化查询",
"start_line": 22,
"end_line": 25,
}
],
}
但实际情况中:
- 评分可能缺失:如果配置
require_score_review=false,LLM 不会输出 score - JSON 格式不合法:模型可能省略逗号、忘记闭合括号
- 字段类型不一致:
auto_fixable可能是字符串"yes"而非布尔值
3.2 解决方案:容错解析 + 字段验证
我们在 pr_processing.py 中实现了安全的 JSON 提取器:
import re
def extract_json_from_markdown(text: str) -> dict:
"""从 LLM 输出中提取 JSON,处理 Markdown 围栏和常见语法错误。"""
# 移除 ``json ... ``` 围栏
text = re.sub(r'^```(?:json)?\s*', '', text.strip())
text = re.sub(r'\s*```\s*$', '', text)
# 尝试直接解析
try:
return json.loads(text)
except json.JSONDecodeError:
pass
# 尝试修复常见错误:尾随逗号、单引号替换
text = re.sub(r',\s*}', '}', text)
text = re.sub(r"'([^']*)'", r'"\1"', text)
return json.loads(text)
对关键字段做显式转换:
# auto_fixable 可能是 "yes"/"true"/1,需要统一为布尔值
auto_fixable_raw = str(issue.get("auto_fixable", "")).strip().lower()
issue["auto_fixable"] = auto_fixable_raw in ("yes", "true", "1")
3.3 踩坑记录:score 缺失导致全量上报跳过
症状:某些 MR 评审后,Go 后台既没有收到评分记录,也没有 suggestions,连企微通知都没发。
根因分析:
# 旧代码(pr_reviewer.py L331-352)
async def _report_audit_if_enabled(self):
if not get_settings().get("AUDIT.ENABLED", False) or not self.review_data:
return
score = self.review_data.get("score")
if score is None: # ← 这里直接 return 了!
return
# 下面的全部被跳过:
await report_review_audit(...) # 评分上报
await notify_wecom_audit(...) # 企微通知
await report_review_suggestions(...) # suggestions 上报
当 require_score_review=false 时,LLM 不输出 score,导致 score is None,整个上报流水线被中断。
修复方案:将 score-based 报告与 suggestions-based 报告解耦:
# 新代码
async def _report_audit_if_enabled(self, review_markdown: str = ""):
if not get_settings().get("AUDIT.ENABLED", False) or not self.review_data:
return
score = self.review_data.get("score")
author = self._get_author_name()
# Score-based reports need score
if score is not None:
await report_review_audit(self.pr_url, title, author, score)
await notify_wecom_audit(self.pr_url, title, author, score, review_markdown)
# Suggestions should always be reported if there are key issues
await report_review_suggestions(
self.pr_url, title, author,
score or 0, # score 为空时传 0
self.review_data
)
教训:LLM 不输出某字段 ≠ 该字段相关的所有逻辑都应跳过。应该区分"依赖关系"。
四、动态配置管理:让 LLM 和 AI 账号脱离环境变量
4.1 痛点:环境变量不够灵活
过去 LLM 配置和 AI 员工账号存在环境变量里:
OPENAI.KEY=sk-xxx
OPENAI.API_BASE=https://api.openai.com
AUTO_FIX.AI_GITLAB_TOKEN=xvuLv...
AUTO_FIX.AI_AUTHOR_USERNAME=pengyuhao
每次修改都需要:
- SSH 到服务器改
.env文件 systemctl restart pr-agent.service- 等待服务重启(影响正在进行的评审)
4.2 解决方案:前端为单一真相源
前端页面 (用户编辑)
↓ PUT /api/settings/*
Go 后台 (MySQL 存储)
↓ GET /api/settings/*
PR-Agent 动态加载器 (运行时覆盖 settings)
↓
LiteLLM / GitLab API 调用
实现模式:
A. Go 后台 Handler
// handlers/llm.go
type LLMHandler struct {
db *gorm.DB
}
func (h *LLMHandler) Get(c *gin.Context) {
var config models.LLMConfig
h.db.First(&config)
// Token never serialized to JSON
c.JSON(200, gin.H{
"id": config.ID,
"enabled": config.Enabled,
"provider": config.Provider,
"base_url": config.BaseURL,
"model": config.Model,
"token_set": config.APIKey != "",
})
}
func (h *LLMHandler) Update(c *gin.Context) {
payload := struct {
Enabled bool `json:"enabled"`
Provider string `json:"provider"`
BaseURL string `json:"base_url"`
APIKey string `json:"api_key"`
Model string `json:"model"`
}{}
c.ShouldBindJSON(&payload)
var config models.LLMConfig
h.db.First(&config)
if config.ID == 0 {
config = models.LLMConfig{}
}
config.Enabled = payload.Enabled
config.Provider = payload.Provider
config.BaseURL = payload.BaseURL
config.APIKey = payload.APIKey // Stored encrypted by GORM hook
config.Model = payload.Model
h.db.Save(&config)
c.JSON(200, gin.H{"status": "ok"})
}
B. PR-Agent 动态加载器
# pr_agent/algo/llm_config_loader.py
async def apply_llm_config() -> bool:
"""从 Go 后台拉取 LLM 配置,运行时覆盖 Dynaconf settings。"""
settings = get_settings()
api_url = settings.get("AUDIT.API_URL", "")
if not api_url:
return False
headers = {}
if token := settings.get("AUDIT.API_TOKEN", ""):
headers["X-Audit-Token"] = token
async with aiohttp.ClientSession() as session:
async with session.get(
f"{api_url}/api/settings/llm",
headers=headers,
timeout=aiohttp.ClientTimeout(total=10)
) as resp:
if resp.status >= 300:
return False
config = await resp.json()
if not config.get("enabled"):
return False
# 覆盖 settings
settings.set("OPENAI.KEY", config["api_key"])
settings.set("OPENAI.API_BASE", config["base_url"])
model = config["model"]
# LiteLLM 需要 provider 前缀
if "/" not in model:
model = f"openai/{model}"
settings.set("CONFIG.MODEL", model)
settings.set("CONFIG.CUSTOM_MODEL_MAX_TOKENS", 32768)
get_logger().info(f"LLM config applied: {config['base_url']}, model={model}")
return True
C. 调用时机
# PRAgent 每次 handle_request 前
await apply_llm_config()
# fix_server 执行修复前
await apply_llm_config()
await apply_ai_employee_config()
4.3 安全考虑:Token 脱敏
为了防止密钥泄露,Go 后台的 Handler 永远不序列化 token:
// 模型定义
type LLMConfig struct {
APIKey string `json:"-" gorm:"size:500"` // json:"-" = 不序列化
}
// 返回给前端的是 boolean 标记
{
"token_set": true // 表示 token 已存储
// 而不是 "api_key": "sk-xxx"
}
前端在编辑时,如果 token_set=true 且用户未输入新 token,则跳过更新该字段。
五、让 AI 真正"解决问题":自动修复闭环
5.1 从"看问题"到"解决问题"
传统 AI 评审工具只做到:发现 → 评论 → 人工修复。我们想走得更远:AI 发现问题 → AI 生成修复 → AI 提交 MR → 人工审核合并。
关键组件:
| 组件 | 职责 |
|---|---|
audit_reporter.py | 评审后将 key_issues_to_review 上报到 Go 后台 |
SuggestionsPage | 前端展示可修复建议 + "AI 自动修复"按钮 |
fix_server.py | 接收修复请求,并发调用 PRFixSecurity |
pr_fix_security.py | LLM 生成修复代码,创建分支/MR |
AIEmployeeConfig | AI 员工 GitLab 账号(token 存数据库) |
5.2 修复流程
用户点击"AI 自动修复"
↓
前端 POST /api/suggestions/fix {"ids":[1,2]}
↓
Go 后台转发 → fix_server POST /fix-security
↓
fix_server 获取 suggestion 数据:
{
"id": 1,
"relevant_file": "src/auth_utils.js",
"issue_header": "硬编码数据库密码",
"issue_content": "第 11 行明文存储 DB 密码...",
"auto_fixable": true,
"start_line": 11,
"end_line": 11
}
↓
PRFixSecurity.run():
1. gitlab_provider.get_pr_file_content() → 读取原始文件
2. Jinja2 渲染 prompt → 把问题和文件内容喂给 LLM
3. LLM 返回完整修复后的文件内容
4. gitlab_provider.create_branch(ai_fix/{mr_iid}-{ts})
5. gitlab_provider.create_or_update_pr_file() → 提交修复文件
6. gitlab_provider.create_merge_request() → 创建 MR
↓
返回 {success: true, fix_mr_url: "...", branch: "ai-fix/6-1723654321"}
↓
fix_server 更新 suggestion 状态 → fixed + fix_mr_url
↓
Go 后台持久化 → 前端显示修复 MR 链接
5.3 踩坑:GitLab files API 兼容性
症状:我们的自建 GitLab(v16.x)安装了一个损坏的插件,导致 repository/files 端点对所有文件返回 404。
解决:实现两级 Fallback。
第一层 Fallback(读取文件):
def get_pr_file_content(self, file_path: str, branch: str) -> str:
# 主路径
try:
file_obj = self.gl.projects.get(self.id_project).files.get(file_path, branch)
return file_obj.decode()
except GitlabGetError:
# Fallback: tree + blob API
return self._get_file_via_tree_blob(file_path, branch)
def _get_file_via_tree_blob(self, file_path: str, branch: str) -> str:
"""遍历 repository/tree 找文件 SHA,再用 repository/blob 获取内容。"""
project = self.gl.projects.get(self.id_project)
# 逐层遍历目录找到目标文件的 blob ID
parts = file_path.split('/')
blob_id = None
for i, part in enumerate(parts):
trees = project.repository_tree(path="/".join(parts[:i]), ref=branch, per_page=100)
for item in trees:
if item["name"] == part:
if i == len(parts) - 1:
blob_id = item["id"]
break
if not blob_id:
return ""
# repository_blob 返回 dict(含 base64 编码的 content)
blob = project.repository_blob(blob_id)
raw = blob["content"]
if blob["encoding"] == "base64":
import base64
raw = base64.b64decode(raw).decode("utf-8")
return raw
第二层 Fallback(写入文件):
def create_or_update_pr_file(self, file_path, branch, content, message, token=None):
# 主路径:files API
try:
self.gl.projects.get(self.id_project).files.create({...})
return
except GitlabCreateError:
# Fallback: commits API
self._commit_file_via_api(file_path, branch, content, message, token)
def _commit_file_via_api(self, file_path, branch, contents, message, token=None):
"""使用 POST /repository/commits API 提交文件。"""
gl = self._gl_with_token(token)
project = gl.projects.get(self.id_project)
project.commits.create({
"branch": branch,
"commit_message": message,
"actions": [{
"action": "update", # 或 "create"
"file_path": file_path,
"content": contents,
}],
})
5.4 踩坑:修复 MR 的目标分支选择
症状:AI 修复的 MR 直接合入了 master,绕过了原始 MR 的代码审查流程。
根因:
def _get_target_branch(self) -> str:
return self.git_provider.mr.target_branch # ← 错了!
正确逻辑:
修复 MR 应该合并到 原始 MR 的 source branch(有问题的分支),这样修复会随原始 MR 一起合入目标分支,作者可以审阅 AI 修复后再合并。
def _get_fix_target_branch(self) -> str:
"""The fix MR targets the original MR's source branch."""
try:
return self.git_provider.mr.source_branch # 有问题的那条分支
except Exception:
return self.git_provider.get_pr_branch()
修复效果:
原始 MR: feat-pyh@202608 → master
AI 修复 MR: ai-fix/6-1723654321 → feat-pyh@202608 ✅
当作者审阅合并原始 MR 时,AI 修复也随之合入 master
六、LLM 应用的工程化心得
6.1 Prompt 注入的"双重保险"策略
问题:大文件评审时,qwen-plus 模型经常忽略 system prompt 末尾的语言指令,输出中英混合内容。
原因:335 个文件的长上下文场景下,system prompt 最后注入的语言指令距离模型输出位置太远,被"冲淡"了。
方案:在 user prompt 中靠近输出的位置再注入一次语言指令:
[pr_reviewer_prompts.toml]
user = """\
Here is the view of the changes you will analyze:
======
{{ change_summary }}
======
Review rules:
{{ review_rules }}
The PR code diff:
======
{{ diff|trim }}
======
{%- if extra_instructions %}
Important: {{ extra_instructions }}
{%- endif %}
"""
extra_instructions 包含 Your response MUST be written in the language corresponding to locale code: 'zh-CN'。
效果:中文评审覆盖率从 49 个字符提升到 411 个字符,剩余英文仅为代码标识符、文件路径等技术术语。
6.2 Token 预算管理的三层控制
第一层:CONFIG.MAX_MODEL_TOKENS = 32000
→ 任何模型都不超过此限制(硬上限)
第二层:max_description_tokens = 500
→ 单个子任务的 token 预算(如描述生成、评审打分各自独立)
第三层:find_line_number_of_relevant_line_in_file()
→ 精准定位问题行号,避免把所有上下文都喂给 LLM
优化效果:
- 早期全量 diff 场景:平均每条评论 ~25k tokens
- 优化后精确定位:平均每条评论 ~8k tokens
- 成本降低约 68%
6.3 多模型回退机制
model="gpt-5.6"
fallback_models=["gpt-5.6-terra"]
当主模型超时或返回空结果时:
@retry_with_fallback_models
async def _generate_fix(self, model: str) -> str:
response, _ = await self.ai_handler.chat_completion(
model=model, ...
)
return response
实际统计(运行 3 个月的数据):
| 指标 | 数值 |
|---|---|
| 总评审次数 | 1247 |
| 主模型成功率 | 94.2% |
| 回退触发次数 | 74 |
| 最终成功率 | 99.8% |
| 平均延迟(P95) | 12.3s |
七、总结与展望
7.1 当前能力
| 功能域 | 状态 | 说明 |
|---|---|---|
| AI 自动评审 | ✅ 生产可用 | 支持 GitHub/GitLab/Bitbucket,评分+安全扫描+suggestions |
| 智能上报 | ✅ 生产可用 | 评分 + key_issues_to_review 持久化,支持多仓筛选 |
| 企微通知 | ✅ 生产可用 | 评审结果实时推送团队群聊 |
| AI 自动修复 | ✅ 生产可用 | LLM 生成修复代码,AI 员工账号提 MR |
| 动态配置 | ✅ 生产可用 | LLM 模型/AI 账号均可通过前端页面实时修改 |
| 多仓库统计 | ✅ 生产可用 | 按作者/仓库聚合评分趋势,支持模糊搜索 |
7.2 后续方向
- 精细化权限控制:不同仓库/团队配置不同的 LLM 精度档位(核心模块用 GPT-5.6,小 PR 用 Qwen-Plus)
- 修复效果评估:引入
auto_fix_acceptance_rate指标,追踪 AI 修复被人工合并的比例 - 增量评审:对比两次评审之间的 diff,仅对新提交或变更的部分出 suggestions
- Plugin 生态:参考 SKILL.md 机制,允许社区贡献特定语言的评审 Skill(如 React/TypeScript 最佳实践、Node.js 安全规范)
7.3 推荐资源
- 源代码:The-PR-Agent/pr-agent (MIT 协议)
- LiteLLM 文档:docs.litellm.ai/
- Jinja2 模板引擎:jinja.palletsprojects.com/
- Dynaconfig 配置管理:www.dynaconf.com/
原文链接:欢迎在评论区交流~
About Me:专注AI辅助软件工程实践,热衷于把LLM 的能力转化为真实的研发效能。 PS:点赞超过100,开源相关代码。