芥末
发布于 2026-02-27 / 0 阅读
0
0

用 RAG 和 GitLab Webhook 构建业务级 AI Code Review 系统

Code Review(代码评审)在核心业务项目里承担的是质量门禁角色。它不只是看变量名是否规范、函数是否过长,更重要的是发现那些“看起来能跑,但上线后可能出事”的业务风险。

人工评审的问题也很现实:

  • 合并请求里的 Diff 很长,评审者很难逐行看完。
  • 过去出现过类似事故,但复盘文档散落在知识库里,新人不知道,老人也不一定记得。
  • 通用 AI 只能按代码表面给建议,不理解项目里的业务规则、历史事故和工程约束。

业务级 AI Code Review 的核心目标不是替代人工评审,而是把团队已有经验注入评审流程,让模型在看代码时能参考历史复盘、最佳实践和项目文档。

可以把它理解成一个带记忆的评审助手:

flowchart LR
    A[开发者 push 代码或发起 MR] --> B[GitLab Webhook 触发]
    B --> C[审核服务拉取 Diff]
    C --> D[Diff 预处理与切片]
    D --> E[LLM 生成改动语义摘要]
    E --> F[Embedding 向量化]
    F --> G[(向量知识库)]
    G --> H[召回历史事故和业务规则]
    H --> I[组装 Prompt]
    I --> J[LLM 深度 Review]
    J --> K[生成报告或 MR 评论]
    K --> L[通知开发者 / 发布卡点]

这条链路里,真正决定效果的不是“调一个模型接口”,而是三件事:

  1. 让模型看到干净、完整、重点明确的代码变化。
  2. 让模型能检索到和当前改动相关的业务经验。
  3. 让评审结果进入研发流程,而不是停留在一份孤立报告里。

触发机制:用 Webhook 降低接入成本

AI Code Review 最好不要要求开发者额外安装插件,也不要改变原来的提交习惯。对使用 GitLab 的团队来说,比较自然的方式是使用 GitLab Webhook。

常见触发点有两个:

触发事件适合场景说明
Push events分支发生代码提交可以在开发分支更新时提前审查
Merge request events发起或更新 MR更适合作为合入主干前的质量门禁

在 GitLab 项目或组织级配置 Webhook 时,只需要把审核服务的回调地址填进去,并勾选对应事件。开发者照常提交代码,审核系统在服务端自动接收事件。

典型事件流如下:

sequenceDiagram
    participant Dev as 开发者
    participant GitLab as GitLab
    participant Review as AI Review 服务
    participant LLM as 大语言模型
    participant DB as 向量知识库

    Dev->>GitLab: git push / 更新 MR
    GitLab->>Review: 发送 Webhook 事件
    Review->>GitLab: 拉取 MR 信息和 Diff
    Review->>Review: 过滤、切片、补全上下文
    Review->>LLM: 生成改动语义摘要
    Review->>DB: 根据摘要召回相关知识
    DB-->>Review: 返回事故复盘和规则片段
    Review->>LLM: 发起深度 Review
    LLM-->>Review: 返回风险点和修复建议
    Review->>GitLab: 写入 MR 评论或发送报告

除了 GitLab Webhook,也可以接入构建平台的 Hook。比如某些业务分支只有在构建时才需要做强校验,就可以在 CI/CD(持续集成/持续交付)流水线里主动触发评审任务。

Diff 预处理:不要把原始 Diff 直接丢给模型

Webhook 触发后,审核服务拿到的通常是一段原始 Diff。它包含大量 +-、上下文行、文件路径和非逻辑变更。

直接把原始 Diff 投给 LLM(大语言模型)会有几个问题:

  • .lock、图片、样式文件会消耗大量 token,但对业务风险判断帮助很小。
  • 单个 MR 改动过大时,模型上下文窗口不够用。
  • 只看几行新增删除,模型可能看不到完整函数、调用关系和业务语义。
  • 格式化改动会干扰模型判断,让模型把注意力放错地方。

更合理的做法是先构建一条 Diff 处理流水线:

flowchart TD
    A[原始 Diff] --> B[文件级过滤]
    B --> C[删除无效变更]
    C --> D[按文件切片]
    D --> E{单文件是否过大}
    E -- 是 --> F[按行数或 token 二次切片]
    E -- 否 --> G[保留当前文件块]
    F --> H[补全函数/类上下文]
    G --> H
    H --> I[识别逻辑改动]
    I --> J[生成可审查 Chunk]

文件级过滤

很多文件不适合进入 AI Review。比如锁文件、静态资源、样式文件,通常不承载核心业务逻辑,可以直接过滤。

常见过滤规则:

文件类型处理方式原因
.lock跳过或只做依赖风险扫描内容大,重复信息多
.png / .jpg / .svg跳过二进制或资源文件,不适合文本模型评审
.css / .scss / .less默认跳过多数场景不涉及业务逻辑
.json按路径决定配置文件可能重要,但普通数据文件噪声较多
删除文件通常跳过代码逻辑审查已删除内容不一定需要模型逐行分析

过滤规则不能一刀切。比如 package.json、路由配置、权限配置、灰度开关配置都可能影响业务行为,需要按项目路径和文件名设置白名单。

补全逻辑上下文

只给模型几行 Diff,往往会导致误判。比如一行判断条件从 > 改成 >=,单看这一行很难判断风险;如果补上完整函数、入参含义、返回值和调用链,模型才能判断边界条件是否合理。

处理逻辑可以是:

  1. 根据变更行定位所属函数或类。
  2. 通过 GitLab API(应用程序编程接口)拉取变更前后的完整文件内容。
  3. 提取当前函数、相邻辅助函数、类型定义和关键常量。
  4. 把“新增内容、删除内容、完整上下文”一起交给后续环节。

一个简化的 TypeScript 处理器可以这样写:

type ChangeStatus = "added" | "modified" | "deleted" | "renamed";

interface Change {
  filePath: string;
  status: ChangeStatus;
  additions: number;
  deletions: number;
  diff: string;
}

interface Chunk {
  id: string;
  files: Change[];
  estimatedTokens: number;
  averageWeight: number;
}

class DiffProcessor {
  private readonly ignoredExts = new Set([
    ".lock",
    ".png",
    ".jpg",
    ".jpeg",
    ".gif",
    ".css",
    ".scss",
    ".less",
  ]);

  private readonly coreDirs = [
    "src/core",
    "src/services",
    "src/domain",
    "packages/business",
  ];

  private readonly maxTokensPerChunk = 30_000;

  processChanges(changes: Change[]): Chunk[] {
    const filtered = changes
      .filter((change) => !this.shouldIgnore(change))
      .sort((a, b) => this.calculateWeight(b) - this.calculateWeight(a));

    return this.groupIntoChunks(filtered);
  }

  private shouldIgnore(change: Change): boolean {
    if (change.status === "deleted") return true;

    const ext = this.getExt(change.filePath);
    if (this.ignoredExts.has(ext)) return true;

    return false;
  }

  private calculateWeight(change: Change): number {
    let weight = 1;

    if (this.coreDirs.some((dir) => change.filePath.startsWith(dir))) {
      weight += 5;
    }

    if (change.filePath.includes("permission")) {
      weight += 3;
    }

    if (change.filePath.includes("payment") || change.filePath.includes("order")) {
      weight += 3;
    }

    return weight;
  }

  private groupIntoChunks(changes: Change[]): Chunk[] {
    const chunks: Chunk[] = [];
    let current: Change[] = [];
    let currentTokens = 0;

    for (const change of changes) {
      const tokens = this.estimateTokens(change.diff);

      if (current.length > 0 && currentTokens + tokens > this.maxTokensPerChunk) {
        chunks.push(this.createChunk(current));
        current = [];
        currentTokens = 0;
      }

      current.push(change);
      currentTokens += tokens;
    }

    if (current.length > 0) {
      chunks.push(this.createChunk(current));
    }

    return chunks;
  }

  private createChunk(files: Change[]): Chunk {
    const totalTokens = files.reduce(
      (sum, file) => sum + this.estimateTokens(file.diff),
      0,
    );

    const totalWeight = files.reduce(
      (sum, file) => sum + this.calculateWeight(file),
      0,
    );

    return {
      id: crypto.randomUUID(),
      files,
      estimatedTokens: totalTokens,
      averageWeight: totalWeight / files.length,
    };
  }

  private estimateTokens(text: string): number {
    return Math.ceil(text.length / 3);
  }

  private getExt(filePath: string): string {
    const index = filePath.lastIndexOf(".");
    return index >= 0 ? filePath.slice(index) : "";
  }
}

这段代码只展示核心思路:过滤低价值文件、给关键路径加权、按 token 预算分块。真实系统还需要结合 AST(抽象语法树)解析、GitLab 文件接口、项目配置中心和异常重试机制。

语义重塑:先让模型说明“代码想做什么”

业务级 Review 不应该直接从“请检查这段代码有没有 Bug”开始。更稳妥的方式是让模型先做一次语义重塑,也就是回答一个问题:这次改动到底在做什么?

比如模型先输出这样的结构:

{
  "summary": "订单取消逻辑新增了超时判断,超过配置时间后不允许用户主动取消订单。",
  "changedModules": ["order-cancel", "order-status"],
  "businessActions": ["取消订单", "状态流转", "超时校验"],
  "riskKeywords": ["边界时间", "状态一致性", "并发取消"],
  "files": [
    "src/services/order/cancel.ts",
    "src/domain/order/status.ts"
  ]
}

这个中间结果非常重要。它不是最终评审意见,而是后续 RAG 检索的查询条件。

如果直接拿原始 Diff 去做向量检索,召回结果容易被变量名、代码格式和局部实现干扰。把 Diff 转成“业务动作 + 风险关键词 + 模块信息”后,再去知识库里检索,命中历史经验的概率会更高。

RAG:把历史事故和业务文档变成可召回经验

RAG(Retrieval-Augmented Generation,检索增强生成)适合解决“模型不知道团队内部知识”的问题。它的基本思路是:模型回答问题前,先从知识库中检索相关上下文,再把这些上下文连同问题一起交给模型。

在 AI Code Review 场景里,知识库可以包含这些材料:

知识来源示例内容对 Review 的价值
事故复盘某次订单状态错乱、重复扣费、权限绕过识别类似风险模式
通用工具库说明请求封装、错误码规范、日志规范判断是否误用基础能力
业务最佳实践订单状态机、优惠券核销规则、派单规则检查业务约束是否被破坏
项目技术文档模块边界、调用链路、缓存策略理解代码所在上下文
安全规范鉴权、数据脱敏、防重放发现安全风险

知识库数字化

知识库不是简单把文档丢进数据库。文档需要切片、向量化、入库,并且保留元信息。

flowchart LR
    A[事故复盘 / 技术文档 / 最佳实践] --> B[清洗无关内容]
    B --> C[按标题和语义切 Chunk]
    C --> D[提取元信息]
    D --> E[Embedding Model 向量化]
    E --> F[(向量数据库)]

每个知识片段至少应包含:

{
  "id": "incident-2025-order-cancel-001",
  "title": "订单取消状态并发更新导致状态回滚",
  "content": "事故原因、触发条件、修复方式、预防规则...",
  "module": "order",
  "tags": ["订单取消", "状态机", "并发"],
  "severity": "high",
  "sourceType": "incident-review"
}

Embedding Model(嵌入模型)会把文本转换成向量。在线检索时,系统把“代码改动语义摘要”也转成向量,然后在向量空间中计算相似度。

常见相似度计算方式是余弦相似度:

cosine_similarity(A, B) = (A · B) / (||A|| * ||B||)

相似度越高,表示当前代码改动与某个知识片段在语义上越接近。

在线召回流程

flowchart TD
    A[代码改动语义摘要] --> B[生成查询向量]
    B --> C[(向量数据库)]
    C --> D[召回 Top K 知识片段]
    D --> E{相似度是否达标}
    E -- 否 --> F[丢弃低相关片段]
    E -- 是 --> G[进入 Prompt 上下文]
    G --> H[LLM 结合代码和知识进行 Review]

为了减少误报,不能把所有召回内容都塞进 Prompt。常用策略有三种:

策略作用
相似度阈值过滤明显不相关的知识片段,比如低于 0.85 的结果
Top K 限制只取最相关的 2~5 条,避免上下文膨胀
二次重排用更强的模型或规则判断召回片段是否真的和当前改动有关

RAG 的价值不在于让模型“知道更多”,而是让模型在评审时引用正确的内部经验。召回内容质量越高,最终建议越接近真实业务风险。

组装 Prompt:把 Diff、语义摘要和历史经验合成审查上下文

最终 Review 需要同时给模型三类信息:

  1. 代码变更:过滤、补全上下文后的 Diff。
  2. 改动意图:模型前置分析得到的语义摘要。
  3. 业务经验:RAG 召回的事故复盘、规范或技术文档。

Prompt 可以按固定结构组织,避免模型输出发散:

你是一个负责核心业务系统 Code Review 的工程助手。

审查目标:
- 找出可能导致线上故障、数据错误、权限问题、并发问题、兼容性问题的代码变更。
- 结合历史事故和业务规则判断风险。
- 不要对无关代码风格提出建议。
- 如果没有明确风险,返回“未发现高风险问题”。

代码改动摘要:
{{change_summary}}

相关历史经验:
{{retrieved_knowledge}}

代码 Diff 和上下文:
{{diff_context}}

输出要求:
- 按文件和行号指出风险点。
- 说明风险触发条件。
- 说明为什么与历史经验相关。
- 给出可执行的修复建议。
- 标注风险级别:high / medium / low。

为了方便系统消费,模型最好输出结构化 JSON,而不是一段自由文本:

{
  "issues": [
    {
      "riskLevel": "high",
      "file": "src/services/order/cancel.ts",
      "line": 128,
      "title": "取消订单缺少状态幂等校验",
      "reason": "当前逻辑在并发请求下可能重复执行取消流程,和历史事故“订单状态并发更新导致状态回滚”的风险模式相似。",
      "suggestion": "在更新状态时增加当前状态条件,并对重复取消请求返回幂等结果。",
      "relatedKnowledgeIds": ["incident-2025-order-cancel-001"]
    }
  ]
}

结构化输出有两个好处:一是前端可以直接渲染报告,二是后续可以把高风险问题同步成 MR 行内评论或流水线门禁。

长 Diff 处理:分块、并发和报告聚合

大型 MR 经常超过模型上下文窗口。即使模型支持很长上下文,也不代表应该一次性塞进去。更稳的方式是分块处理,再聚合结果。

分块时要注意两个原则:

  • 核心业务文件优先处理,测试快照、样式、静态资源靠后或跳过。
  • 每个 Chunk 要尽量保持语义完整,不要把同一个函数或同一组相关文件拆得太散。

处理流程可以设计成这样:

flowchart LR
    A[变更文件列表] --> B[过滤和加权]
    B --> C[按 token 预算分 Chunk]
    C --> D[并发调用 Review API]
    D --> E[缓存每个 Chunk 的结果]
    E --> F{所有 Chunk 完成?}
    F -- 否 --> E
    F -- 是 --> G[聚合报告]
    G --> H[推送通知或写入 MR]

并发处理可以缩短等待时间:

class ReviewService {
  async processMergeRequest(params: {
    projectId: number;
    mergeRequestId: number;
    changes: Change[];
  }) {
    const processor = new DiffProcessor();
    const chunks = processor.processChanges(params.changes);

    const reportId = this.createReportId(params.projectId, params.mergeRequestId);

    await Promise.all(
      chunks.map((chunk, index) =>
        this.reviewChunk({
          reportId,
          chunkIndex: index,
          chunksLen: chunks.length,
          chunk,
        }),
      ),
    );

    return this.tryAggregateReport(reportId, chunks.length);
  }

  private async reviewChunk(params: {
    reportId: string;
    chunkIndex: number;
    chunksLen: number;
    chunk: Chunk;
  }) {
    const result = await this.callReviewModel(params.chunk);
    await reportStore.saveChunkResult(params.reportId, params.chunkIndex, result);
  }

  private createReportId(projectId: number, mergeRequestId: number): string {
    return `${projectId}:${mergeRequestId}:${Date.now()}`;
  }

  private async callReviewModel(chunk: Chunk) {
    // 调用 LLM、RAG 检索和最终 Review
  }

  private async tryAggregateReport(reportId: string, chunksLen: number) {
    const report = await reportStore.get(reportId);

    if (!report || report.chunks.length !== chunksLen) {
      return null;
    }

    return aggregateReviewReport(report.chunks);
  }
}

报告聚合不能简单拼接。不同 Chunk 可能会发现同一个问题,也可能有风险等级冲突。聚合阶段需要做去重和排序:

聚合动作处理方式
问题去重按文件、行号、风险标题和知识来源合并
风险排序high 优先,其次 medium,再到 low
关联知识合并同一问题引用多个事故复盘时合并展示
摘要生成再调用一次模型,生成 MR 级别风险摘要
门禁判断根据风险等级决定是否阻断合入或发布

一个简单的完成度判断函数可以这样写:

interface StorageReport {
  [reportId: string]: {
    chunks: Array<{
      chunkIndex: number;
      issues: unknown[];
    }>;
  };
}

function isAllChunksDone(
  reportStore: StorageReport,
  reportId: string,
  chunksLen: number,
): boolean {
  const actual = reportStore[reportId]?.chunks.length ?? 0;
  return actual === chunksLen;
}

生产环境还要处理 Webhook 重复投递、模型调用超时、部分 Chunk 失败重试、报告过期清理等问题。

模型选型:用固定用例集持续评估

模型没有永久最优解。Code Review 对模型的要求也和聊天、代码生成不完全一样,它更看重风险识别、上下文理解和低误报。

可以准备一组固定评测用例:

用例类型检查目标
历史事故复现 Diff是否能识别真实风险
安全漏洞 Diff是否能发现鉴权、越权、注入等问题
无风险重构 Diff是否能避免过度告警
大型 MR Diff是否能抓住关键文件
配置变更 Diff是否能理解配置对业务的影响

评估指标也要贴近使用场景:

指标含义
命中率真实风险是否被发现
误报率无关建议占比是否过高
可修复性建议是否能直接指导改代码
解释质量是否说清触发条件和业务影响
延迟从 MR 更新到报告生成需要多久
成本单次 Review 消耗的模型费用和 token 数

Prompt、模型版本、知识库版本都应该可追踪。否则评审效果变差时,很难判断问题来自模型升级、Prompt 改动,还是知识库召回质量下降。

反馈闭环:让误报和采纳行为反哺系统

业务级 AI Review 不能只单向输出建议。开发者对建议的处理结果很重要:

  • 标记为有效,并按建议修复。
  • 标记为误报,并说明原因。
  • 暂不处理,但记录风险接受原因。
  • 修改代码后再次触发 Review。

这些反馈可以进入两个环节:

flowchart TD
    A[AI Review 建议] --> B{开发者处理}
    B -- 采纳 --> C[记录有效案例]
    B -- 误报 --> D[记录误报原因]
    B -- 忽略 --> E[记录风险接受]
    C --> F[优化评测集]
    D --> G[调整召回阈值和 Prompt]
    E --> H[完善业务规则]
    F --> I[下一轮模型评估]
    G --> I
    H --> I

误报反馈尤其关键。RAG 召回到“似是而非”的历史事故后,模型很容易基于错误上下文推导出错误建议。开发者标记误报后,系统可以降低类似知识片段的召回权重,或要求二次重排模型更严格地判断相关性。

适合和不适合的场景

AI Code Review 并不适合所有项目。它更适合知识密度高、历史经验有沉淀、代码合入流程规范的团队。

场景是否适合原因
核心业务系统适合历史事故和业务规则能显著提升审查价值
中台服务适合模块多、调用链复杂,人工容易漏看
多团队共用代码库适合统一评审标准,减少经验差异
小型脚本项目不太适合接入成本可能高于收益
缺少复盘和文档的项目效果有限RAG 没有高质量知识可召回
强实时本地开发检查需要 IDE 集成Webhook 模式反馈不够即时
只想检查格式规范不必上复杂方案ESLint、静态扫描工具更直接

静态扫描、单元测试和 AI Review 也不是替代关系。更合理的分工是:

工具擅长发现的问题
ESLint / Stylelint代码风格、低级语法问题
TypeScript 类型检查类型不一致、接口误用
单元测试明确输入输出下的行为回归
安全扫描依赖漏洞、常见安全问题
AI Code Review业务语义、历史事故相似模式、复杂上下文风险

常见坑和优化方向

RAG 噪音会放大模型幻觉

RAG 不是召回越多越好。无关知识被塞进 Prompt 后,模型可能强行把当前代码和历史事故联系起来,输出看似合理但实际无关的建议。

可用的控制手段包括:

  • 设置相似度阈值,例如低于 0.85 的知识片段不进入最终 Prompt。
  • 对召回结果做二次重排,过滤只在关键词上相似、业务上无关的内容。
  • 在 Prompt 中要求模型说明“当前改动与历史案例的相似点和差异点”。
  • 对误报知识片段降权,减少同类误召回。

修复建议要尽量靠近代码

只生成报告会增加开发者处理成本。更好的方式是把建议写回 MR 评论,并尽量定位到文件和行号。

进一步可以让模型输出补丁格式:

- if (order.status !== OrderStatus.CANCELED) {
-   await cancelOrder(order.id);
- }

+ if (order.status === OrderStatus.CREATED) {
+   await cancelOrder(order.id);
+ } else if (order.status === OrderStatus.CANCELED) {
+   return order;
+ } else {
+   throw new BusinessError("当前订单状态不允许取消");
+ }

不过自动生成修复代码要谨慎。模型给出的代码可能无法直接编译,也可能破坏项目约定。更稳的方式是提供多个修复方案,并让开发者选择后再应用。

IDE 和本地工具能缩短反馈链路

Webhook 模式适合 MR 阶段的质量门禁,但反馈发生在提交之后。对于复杂问题,如果能在 IDE(集成开发环境)里提前提示,开发体验会更顺。

一种集成方式是通过 Cursor CLI(命令行接口)或本地脚本拉取审核结果,把风险提示展示到编辑器侧边栏,并支持一键跳转到对应文件。

发布平台可以使用风险等级做卡点

当 AI Review 结果稳定后,可以和发布平台联动:

风险等级流水线策略
high阻断合入或发布,必须处理
medium允许合入,但需要负责人确认
low仅作为提示,不阻断流程
no issue正常通过

发布卡点不能只依赖模型结论。高风险阻断最好结合规则引擎、人工确认和可追踪豁免机制,避免误报影响正常发布。

把团队经验变成评审上下文

业务级 AI Code Review 的关键不在模型会不会写代码,而在它能不能带着业务上下文看代码。

一套可落地的系统通常由这些部分组成:

  • GitLab Webhook 或 CI/CD Hook 负责触发。
  • Diff 预处理负责过滤噪声、补全上下文、控制 token。
  • LLM 负责提炼改动意图和生成评审意见。
  • RAG 负责召回历史事故、业务规则和技术文档。
  • 报告系统负责展示、通知、评论和门禁。
  • 反馈闭环负责降低误报,让知识库和 Prompt 持续变准。

当历史踩坑、复盘经验和项目规则都能被检索并注入代码评审流程时,AI 才不只是一个通用代码助手,而是一个能参与业务质量守卫的工程系统。


评论