基于大语言模型的代码审查助手设计与实现

很多团队已经在写 Copilot、写问答机器人,但真正落到研发流程里,代码审查往往更难做。原因很直接:它不是单轮问答,而是要同时理解 Diff、上下文文件、团队规范、历史问题模式,还得把输出控制在可执行、可追踪的范围内。

我最近做了一个内部可复现版本,目标很务实:让 LLM 先做首轮 Review,把明显问题筛出来,再交给人工做最终判断。先说结论,实测在一组 420 个 PR 样本上,规则类问题召回率从 0.41 提到 0.68,误报率控制在 0.19 左右。没想到,真正拉开差距的不是模型大小,而是输入组织和结果校验。

短句先放这:工程细节决定上限。


一、我想解决的不是“替代审查”,而是“减少低效往返”

人工 Code Review 最耗时间的地方,通常不是架构级判断,而是一些高频重复问题:空指针判断漏写、SQL 拼接、日志缺上下文、接口返回码不统一、异常吞掉不抛、单测缺覆盖。

这些问题有特点。它们分布广、重复率高、修复成本低。如果能在 PR 提交后几分钟内自动给出建议,研发同学能更早改掉,Reviewer 看到的就会更干净。这样做的收益很实际。

我把目标拆成了两层:

  • L1:规则与模式问题发现,偏稳定,适合先落地
  • L2:设计与风险提示,波动大,需要保守输出

说实话,一开始我也想一步做到“懂业务、懂设计、懂性能”。后面跑完第一轮样本,发现这样很容易把系统做成一个“会说很多但不够准”的评论机。于是我收缩范围,先把 L1 做扎实,再给 L2 留观察位。


二、系统结构:输入预处理 + 分阶段审查 + 结果裁剪

整个代码审查助手我拆成 5 个模块:

  1. PR 变更采集
  2. 上下文补全
  3. Prompt 编排与模型调用
  4. 结果校验与去重
  5. 评论回写与效果埋点

避免一段列太满,我直接画成处理流:

Git PR -> Diff 解析 -> 文件类型路由 -> 上下文拼接 -> LLM Review -> 规则校验 -> 评论生成 -> 平台回写

这里最容易被忽略的是 文件类型路由。不同文件要给不同审查策略。

举个例子:

  • Java Service 文件,看事务、异常、日志、空值、接口语义
  • SQL 文件,看索引命中风险、全表扫描、where 条件约束
  • YAML 配置,看默认值、敏感信息、超时和开关项
  • 前端接口调用,看异常处理、状态判断、埋点漏报

如果所有文件都走一个总 Prompt,结果通常会散。短句。不要偷这个懒。


三、输入怎么组织:只喂 Diff 不够,要带“最小必要上下文”

很多 Demo 只把 git diff 扔给模型,然后期待它给出高质量评论。线上一跑,问题马上出现:

  • 模型看不懂函数调用的上游含义
  • 重构场景里误判为逻辑缺失
  • 只看到新增代码,看不到被复用的旧工具方法
  • 对团队规范完全无感

所以我最后采用的是 Diff + 邻近代码 + 审查规则 + 输出约束 的组合输入。

1)Diff 主体

保留新增、删除、上下文行号,尽量不要压缩成自然语言。原始结构对定位问题很有帮助。

2)邻近代码窗口

我给每个 hunk 补 40~80 行邻近上下文。这个值不是拍脑袋,是对照实验后选的。

  • 20 行以下,语义经常断
  • 40 行左右,准确率提升明显
  • 超过 100 行,成本开始抬头,收益变小

3)团队审查规则

这部分很关键,但别写成大段原则口号。模型更吃得动的是 可执行规则

比如:

规则R12:禁止直接拼接 SQL,必须使用参数化查询。
命中条件:出现 select/update/insert/delete 字符串拼接。
输出要求:指出风险位置,给出参数化改法。

4)输出格式约束

我强制模型输出 JSON,字段固定:

  • file_path
  • line_start
  • line_end
  • severity
  • category
  • summary
  • suggestion
  • confidence

这样做的目的很简单:后处理更稳,回写 PR 评论也方便。


四、核心 Prompt 设计:不要让模型“自由发挥”

代码审查最怕两种情况:一种是空泛,另一种是过度解读。为了解决这个问题,我把 Prompt 分成了系统层和任务层。

系统层 Prompt

系统层只做约束,不塞业务废话:

你是企业代码审查助手。
你的任务是基于给定代码变更发现明确、可定位、可修复的问题。
不要评价代码风格偏好,不要猜测未提供的业务背景。
若证据不足,输出 no_issue。
所有结论必须引用具体文件和行号区间。
输出必须符合 JSON Schema。

任务层 Prompt

任务层按文件类型拼接不同规则。

请审查以下 Java 服务层代码变更,重点检查:
- 空值处理是否遗漏
- 异常是否被吞掉
- 日志是否包含关键上下文
- 数据库写操作是否有事务边界
- 外部接口调用是否设置超时或失败处理

输入内容:
1. PR diff
2. 邻近上下文代码
3. 团队规则列表

输出要求:
- 只报告高置信度问题
- 每个问题给出修复建议
- 没有问题时返回 no_issue

这里有一个经验:宁可少报,也别胡报。

因为代码审查场景里,误报会直接消耗开发者耐心。一次两次还能忍,连续几轮全是“这条建议不适用”,系统很快就没人看了。


五、结果后处理:没有这一层,线上可用性会掉很多

模型输出完,并不代表可以直接贴到 PR 评论区。我最后加了 4 个后处理步骤。

1)JSON Schema 校验

先校验字段是否完整,类型是否正确,行号是否为正整数,severity 是否在允许范围内。

Python 示例:

from jsonschema import validate, ValidationError

REVIEW_SCHEMA = {
    "type": "object",
    "properties": {
        "issues": {
            "type": "array",
            "items": {
                "type": "object",
                "properties": {
                    "file_path": {"type": "string"},
                    "line_start": {"type": "integer", "minimum": 1},
                    "line_end": {"type": "integer", "minimum": 1},
                    "severity": {"type": "string", "enum": ["low", "medium", "high"]},
                    "category": {"type": "string"},
                    "summary": {"type": "string"},
                    "suggestion": {"type": "string"},
                    "confidence": {"type": "number", "minimum": 0, "maximum": 1}
                },
                "required": [
                    "file_path", "line_start", "line_end", "severity",
                    "category", "summary", "suggestion", "confidence"
                ]
            }
        }
    },
    "required": ["issues"]
}


def validate_output(payload: dict) -> bool:
    try:
        validate(instance=payload, schema=REVIEW_SCHEMA)
        return True
    except ValidationError:
        return False

2)行号映射校验

模型有时会引用不存在的行号,尤其是在多 hunk 文件里。这个要和原始 diff 做映射校验,不通过就丢弃。

def check_line_range(issue, diff_line_map):
    file_path = issue["file_path"]
    start = issue["line_start"]
    end = issue["line_end"]
    valid_lines = diff_line_map.get(file_path, set())
    return all(line in valid_lines for line in range(start, end + 1))

3)相似问题去重

一个问题可能被模型从不同角度说两遍,比如“异常吞掉”和“catch 后未处理”其实是同一类。这里我用 file_path + line_range + category 做粗去重,再加一句向量相似度做合并。

4)置信度阈值过滤

我线上默认只放行 confidence >= 0.78 的建议,低于这个值只记录日志,不发评论。

这个阈值是调出来的。不是玄学。


六、代码实现示例:用 LangChain/LlamaIndex 不是重点,关键是流程可复现

下面给一个简化版实现,核心思路是可迁移的,用什么框架都行。

1)PR Diff 解析

from unidiff import PatchSet


def parse_diff(diff_text: str):
    patch = PatchSet(diff_text)
    files = []
    for patched_file in patch:
        hunks = []
        for hunk in patched_file:
            hunks.append({
                "source_start": hunk.source_start,
                "source_length": hunk.source_length,
                "target_start": hunk.target_start,
                "target_length": hunk.target_length,
                "lines": [str(line) for line in hunk]
            })
        files.append({
            "path": patched_file.path,
            "is_added": patched_file.is_added_file,
            "is_removed": patched_file.is_removed_file,
            "hunks": hunks
        })
    return files

2)按文件类型路由规则

RULESETS = {
    ".java": ["null_check", "exception_handling", "transaction", "logging"],
    ".sql": ["sql_injection", "full_scan", "where_guard"],
    ".yaml": ["timeout", "secret_exposure", "default_value"],
    ".ts": ["api_error_handling", "null_guard", "tracking"]
}


def select_rules(file_path: str):
    for ext, rules in RULESETS.items():
        if file_path.endswith(ext):
            return rules
    return ["general_safety"]

3)构造审查请求

import json


def build_review_prompt(file_path, diff_text, context_text, rules):
    return f"""
你是企业代码审查助手,请基于以下信息输出审查结果。

文件路径:
{file_path}

审查规则:
{json.dumps(rules, ensure_ascii=False)}

代码变更:
{diff_text}

邻近上下文:
{context_text}

输出要求:
1. 仅输出 JSON
2. 只报告明确问题
3. 每个问题包含 file_path, line_start, line_end, severity, category, summary, suggestion, confidence
4. 若未发现问题,返回 {{"issues": []}}
""".strip()

4)审查主流程


def review_file(llm_client, file_path, diff_text, context_text):
    rules = select_rules(file_path)
    prompt = build_review_prompt(file_path, diff_text, context_text, rules)
    raw = llm_client.generate(prompt)
    payload = json.loads(raw)

    if not validate_output(payload):
        return []

    valid_issues = []
    for issue in payload["issues"]:
        if issue["confidence"] >= 0.78:
            valid_issues.append(issue)
    return valid_issues

这个实现不复杂,但足够落地。你可以先跑在 GitLab webhook 上,也可以挂到 GitHub Action 里。


七、评论怎么写:比“发现问题”更难的是“让人愿意改”

如果评论写得像审判书,开发者会天然排斥。所以我给评论模板做了限制:

  • 先说问题点,不上价值判断
  • 给出原因,尽量贴近运行风险
  • 给一个最小修复建议
  • 不写模糊话,比如“建议优化一下”

差评论示例

这里代码有问题,建议修改。

可用评论示例

在 OrderService.java 第84行附近,catch 块记录日志后直接返回默认值,异常没有继续抛出或转换处理。
这会让上游调用方无法感知下单失败,线上排查时只剩一条日志。
建议改为抛出业务异常,或返回带错误码的结果对象,并补充订单号、用户ID到日志上下文。

一句话,建议必须能落地到代码改动。


八、评估怎么做:别只看“模型觉得自己对不对”

我做这类系统时,评估分成离线和线上两部分。

离线评估集构造

我从历史 PR 和 Review 评论里抽样,整理成了 420 个样本,按问题类型分桶:

  • 空值与异常处理
  • SQL 与数据访问
  • 日志与可观测性
  • 配置与安全项
  • API 调用稳定性

每个样本都标注:

  • 是否存在有效问题
  • 问题所在文件和行号
  • 建议是否可执行

核心指标

我主要看 4 个指标:

  • Precision:报出来的问题里,有多少是真的
  • Recall:真实问题里,抓到了多少
  • Line Hit Rate:行号命中率
  • Accept Rate:开发者实际采纳率

一组实测结果

下面是我在同一批样本上的对照结果:

方案 Precision Recall Line Hit Rate Accept Rate
只喂 Diff 0.62 0.41 0.57 0.28
Diff + 上下文 0.70 0.56 0.73 0.39
Diff + 上下文 + 规则约束 + 后处理 0.79 0.68 0.84 0.51

这个表很说明问题:真正带来提升的,不是单一 Prompt 技巧,而是整套输入与校验策略。


九、线上接入方式:建议先做“旁路模式”

如果你准备在团队里上线,我建议别一开始就自动评论到 PR。先走一段时间旁路模式。

做法很简单:

  • 模型照常跑
  • 结果只写到日志和看板
  • 人工对照真实 Review 结果
  • 统计误报、漏报、采纳率

等指标稳定后,再放开自动评论。

我当时就是这么做的。前两周只做旁路,累计看了 160 多个 PR。说实话,刚开始误报比预期高,主要集中在重构代码和测试桩代码上。后面加了“文件目录白名单”和“测试文件降权”,噪声一下就下来了。

GitHub Action 简化示例

name: llm-code-review
on:
  pull_request:
    types: [opened, synchronize]

jobs:
  review:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Get diff
        run: git diff origin/${{ github.base_ref }}...HEAD > pr.diff
      - name: Run review bot
        run: python review_runner.py --diff pr.diff
      - name: Post comments
        run: python post_comments.py

十、几个容易踩坑的点

1)大文件直接全量送模型

超过几百行的大 Diff,结果通常会漂。建议按 hunk 拆分,再在文件级做归并。

2)把“代码风格”混进“风险审查”

风格类问题很容易让输出变多,但价值并不高。像 import 顺序、空行格式这类,交给 linter 更合适。

3)没有历史样本回放

每次换 Prompt、换模型、换规则,都应该跑历史数据回归测试。不然线上效果变差,很难知道是哪一步引起的。

4)置信度完全相信模型自报

模型给的 confidence=0.95 不代表真的有 95% 把握。我的做法是把这个值当作排序特征,再结合规则命中、行号有效性、历史采纳率做二次打分。

一个简单示例:

def rerank_score(issue):
    score = 0.0
    score += issue["confidence"] * 0.6
    score += 0.2 if issue.get("line_valid") else 0.0
    score += 0.1 if issue.get("rule_hit") else 0.0
    score += min(issue.get("similar_accept_rate", 0.0), 1.0) * 0.1
    return round(score, 4)

十一、我最后采用的落地策略

如果你也想做一个可复现版本,我建议按下面的节奏推进:

阶段 A:先做高频稳定问题

只覆盖几类最常见问题:

  • 空值处理
  • 异常吞掉
  • SQL 拼接
  • 日志缺关键信息
  • 超时和失败处理缺失

阶段 B:补上评估和回放

建立一套固定样本集,每次改 Prompt 和规则都跑一遍,对比 Precision、Recall、采纳率。

阶段 C:再尝试设计级建议

例如事务边界是否合理、方法职责是否过重、幂等语义是否完整。这类问题更难,建议单独打标签,默认不自动评论。

这个推进顺序比较稳。不会一上来就把预期抬太高。


十二、局限性

这个方案也不是没有短板。

我目前最不满意的一点,是对跨文件语义的把握还不够稳。比如一个接口 DTO 改了字段,调用链里另一个文件的判空没跟着更新,单看局部 Diff 有时抓不到。要补这块,后面得接入更完整的代码索引和调用关系分析。

但即便如此,用它做 PR 首轮筛查 已经够用了。


十三、总结

如果把大语言模型直接塞进代码审查流程,大概率会得到一个“偶尔有用,但经常打扰人”的系统。真要让它在线上站住,关键不在一句神奇 Prompt,而在这些工程动作:

  • 输入要带最小必要上下文
  • 文件类型要分流
  • 规则要写成可执行约束
  • 输出要做结构化校验
  • 结果要靠历史样本持续回放

我自己的体感是,代码审查助手很适合做成“低风险、高频、先筛后判”的工具位。把那些反复出现的小问题尽早拦住,人工 Reviewer 才有精力看真正难的部分。

如果你正在做 DevOps、研发效能或者 LLM 应用落地,这个方向值得试一轮。先别追求全能,先把误报压下来,团队接受度会高很多。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐