AI代码审查与自动修复bug

57 阅读13分钟

基于 PR-Agent 构建 AI 代码评审与自动修复系统:LLM 工程化实践的探索

引言

在团队协作开发中,代码评审(Code Review)是保证质量的关键环节。但人工评审不仅耗时,而且难以覆盖安全漏洞、逻辑缺陷等深层问题。如果能用 AI 自动化评审流程,并在发现问题后自动生成修复方案——这不仅是技术问题,更是效率革命。

本文分享我们基于开源项目 PR-Agent 构建的 AI 评审与自动修复系统的完整实现过程,重点探讨 LLM 应用落地中的几个核心挑战:

  1. 如何稳定调用 LLM 并支持多模型切换?
  2. 如何将 LLM 的结构化输出可靠提取并持久化?
  3. 如何让 AI 不只是"看问题",而是真正"解决问题"?
  4. 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 轮询发现新 MRGitLab 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),硬编码适配器会导致:

  1. 代码膨胀:每加一个模型新增一个 provider
  2. 测试负担:所有功能需针对每个模型单独测试
  3. 切换成本:改配置需修改代码

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,
        }
    ],
}

但实际情况中:

  1. 评分可能缺失:如果配置 require_score_review=false,LLM 不会输出 score
  2. JSON 格式不合法:模型可能省略逗号、忘记闭合括号
  3. 字段类型不一致: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

每次修改都需要:

  1. SSH 到服务器改 .env 文件
  2. systemctl restart pr-agent.service
  3. 等待服务重启(影响正在进行的评审)

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.pyLLM 生成修复代码,创建分支/MR
AIEmployeeConfigAI 员工 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 后续方向

  1. 精细化权限控制:不同仓库/团队配置不同的 LLM 精度档位(核心模块用 GPT-5.6,小 PR 用 Qwen-Plus)
  2. 修复效果评估:引入 auto_fix_acceptance_rate 指标,追踪 AI 修复被人工合并的比例
  3. 增量评审:对比两次评审之间的 diff,仅对新提交或变更的部分出 suggestions
  4. Plugin 生态:参考 SKILL.md 机制,允许社区贡献特定语言的评审 Skill(如 React/TypeScript 最佳实践、Node.js 安全规范)

7.3 推荐资源


原文链接:欢迎在评论区交流~
About Me:专注AI辅助软件工程实践,热衷于把LLM 的能力转化为真实的研发效能。 PS:点赞超过100,开源相关代码。