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[通知开发者 / 发布卡点]
这条链路里,真正决定效果的不是“调一个模型接口”,而是三件事:
- 让模型看到干净、完整、重点明确的代码变化。
- 让模型能检索到和当前改动相关的业务经验。
- 让评审结果进入研发流程,而不是停留在一份孤立报告里。
触发机制:用 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,往往会导致误判。比如一行判断条件从 > 改成 >=,单看这一行很难判断风险;如果补上完整函数、入参含义、返回值和调用链,模型才能判断边界条件是否合理。
处理逻辑可以是:
- 根据变更行定位所属函数或类。
- 通过 GitLab API(应用程序编程接口)拉取变更前后的完整文件内容。
- 提取当前函数、相邻辅助函数、类型定义和关键常量。
- 把“新增内容、删除内容、完整上下文”一起交给后续环节。
一个简化的 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 需要同时给模型三类信息:
- 代码变更:过滤、补全上下文后的 Diff。
- 改动意图:模型前置分析得到的语义摘要。
- 业务经验: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 才不只是一个通用代码助手,而是一个能参与业务质量守卫的工程系统。