AI Code Review:用 AST 提取完整上下文的精准审查方案

1,315 阅读7分钟

🔗 GitHub 地址:github.com/streaker303…

Code Review 是保障代码质量的重要环节,但人工审查效率低、容易遗漏问题。大多数的 AI Code Review 工具要么只是简单地把 Diff 扔给 GPT,要么无法结合团队的编码规范。

本文将分享如何用 AST 分析 + AI 模型 构建一个智能代码审查工具,实现通过 Babel/Vue Compiler 提取完整函数上下文、自定义团队编码规范检查、精准到行的评论定位,以及稳定可靠的 CI/CD 集成。

🎯 现有工具的问题

市面上已经有不少 AI Code Review 工具,但试用下来发现两个核心问题:

问题一:看不到完整上下文

传统工具只把 Git Diff 发给 AI,就像让人只看几行代码片段就下结论。举个真实例子:

// Diff 只显示新增了这一行
( , 50) +  return (response.data as any).data;

看起来就是个 any 类型的小问题对吧?但如果你看到完整函数:

function handleResponse(response) {
  const code = response.code;
  
  // 新增的逻辑
  if (code === "200" && "data" in response.data) {
    return (response.data as any).data;  // 第 50 行:返回嵌套的 data
  }
  
  // 原有的逻辑
  if (code == "200" || code == "210") {
    return response.data;  // 第 54 行:直接返回 data
  }
}

问题来了:同一个函数里,code == "200" 的情况下,两个分支返回了完全不同的数据结构!调用方根本不知道该怎么处理返回值。这种 bug 只看 Diff 根本发现不了。

问题二:无法结合团队规范

每个团队都有自己的编码规范,但大多数 AI 工具只能做通用性检查,无法针对性地检查 "我们团队要求所有异步函数必须有错误处理" 这类自定义规则。

基于这些问题,下面开始构建一个真正能理解代码上下文的审查工具。

🛠️ 技术实现

在深入细节之前,先看一下整个系统的工作流程:

graph LR
    A[GitLab MR触发] --> B[配置加载]
    B --> C[获取Git Diff]
    C --> D[AST上下文分析]
    D --> E[AI模型审查]
    E --> F[结果汇总]
    F --> G[报告生成]
    G --> H[发布到MR]
    
    style A fill:#e1f5fe
    style H fill:#e8f5e8
    style E fill:#fff3e0

整个流程分为三个核心阶段:

  1. 数据采集阶段:从 GitLab MR 获取代码变更,解析 Git Diff
  2. 上下文分析阶段:通过 AST 提取完整函数上下文,构建精准的审查依据
  3. AI 审查阶段:调用大模型分析代码,生成结构化的审查报告

下面按照实现流程,详细讲解每个关键环节是如何处理的。

Git Diff 解析:精准的行号映射体系

第一个要解决的问题:如何让 AI 精准评论到具体的代码行?

Git Diff 的原始格式长这样:

diff --git a/src/service.ts b/src/service.ts
@@ -10,7 +10,8 @@ export class Service {
-  async fetchData() {
-    return await api.get('/data');
+  async fetchData(params: any) {
+    const response = await api.get('/data', params);
+    return response.data;
   }

看起来简单,但如果你想实现 "在第 12 行评论这个问题",就会发现一个困境:Diff 里的行号和最终文件的行号是不对应的!

Diff 格式中,- 开头的行只有旧文件行号,+ 开头的行只有新文件行号,普通行同时有两个行号。而 GitLab API 要求你评论时,必须明确指定是 old_line 还是 new_line。如果搞错了,评论就发不出去。

解决思路

核心思路是解析 diff 的每一行,根据 @@ 标记提取起始行号,然后逐行打标:

// 核心逻辑简化示意
function addLineNumbersToDiff(diffText) {
  const hunks = splitHunks(diffText);  // 先分割成多个 hunk
  
  hunks.forEach(hunk => {
    let oldLine = hunk.oldStart;  // 从 @@ 中提取
    let newLine = hunk.newStart;
    
    hunk.lines.forEach(line => {
      if (line.startsWith('-')) {
        // 删除行:只有旧行号
        addLineNumber(`(${oldLine}, )`, line);
        oldLine++;
      } else if (line.startsWith('+')) {
        // 新增行:只有新行号
        addLineNumber(`( , ${newLine})`, line);
        newLine++;
      } else {
        // 上下文行:两个行号都有
        addLineNumber(`(${oldLine}, ${newLine})`, line);
        oldLine++;
        newLine++;
      }
    });
  });
}

实际实现还需要处理文件头、行号对齐等细节,这里只展示核心逻辑。

这样处理后,每一行都带上了精准的行号信息:

(10, 10)   async fetchData() {
(11, )   -  return await api.get('/data');
( , 11) +  async fetchData(params: any) {
( , 12) +    const response = await api.get('/data', params);
( , 13) +    return response.data;
(12, 14)   }

现在 AI 模型可以清楚地知道:

  • 第 11 行被删除了 → 使用 old_line: 11
  • 第 12-13 行是新增的 → 使用 new_line: 12new_line: 13

参考:行号映射的设计思路参考了《前端仔如何在公司搭建 AI Review 系统》这篇文章,具体细节可以阅读原文。


AST 上下文提取:让 AI 看到完整函数

有了精准的行号映射,接下来要解决的是上下文提取问题。这是整个工具的核心功能,也是最复杂的部分。

难点一:避免提取过大的代码块

可以用 Babel 的 traverse API 遍历 AST,找到包含新增行的节点就直接提取。但是容易翻车:

export default class UserService {
  // ... 省略很多代码
  
  async getUserInfo(id) {
    // 只修改了这一行
    const user = await db.query(`SELECT * FROM users WHERE id = ${id}`);
    return user;
  }
  
  // ... 省略其他方法
}

什么问题呢?Babel 遍历到 getUserInfo 方法时,发现包含新增行,就把它提取出来。但同时,外层的 UserService 类也包含这个新增行!

如果不加控制,两个节点都会被提取,而且类的代码量远大于单个方法,会消耗更多Token,代码过多也可能会忽略重点。

解决方案:实现"最小包含块"算法

核心思想:按大小排序,优先选择小代码块,避免提取外层容器。(这部分直接交给 ai 调教就行)

// 核心逻辑(简化)
function selectSmallestSections(sections, addedLines) {
  // 按代码块大小排序(小的优先)
  const sorted = [...sections].sort((a, b) => a.size - b.size);
  
  const selected = [];
  const coveredLines = new Set();
  
  // 贪心选择:优先选小的,避免重复
  for (const section of sorted) {
    // 检查是否有未覆盖的新增行
    const sectionLines = section.added_lines.filter(
      line => !coveredLines.has(line)
    );
    
    if (sectionLines.length > 0) {
      selected.push(section);
      section.added_lines.forEach(line => coveredLines.add(line));
    }
  }
  
  return selected;
}

这样就能优先提取小方法,而不是大类。效果明显,Token 消耗大幅降低。

难点二:支持 Vue 单文件组件

Vue 文件的结构比较特殊:

<template>
  <div>{{ userName }}</div>
</template>

<script setup lang="ts">
const userName = ref('John');
</script>

<style scoped>
.container { color: red; }
</style>

Babel 只能解析 <script> 部分,而且 Vue 3 的 <script setup> 语法和普通 JS 还不一样。

解决方案:先拆分 SFC,再解析 script,最后还原行号

// 核心步骤(简化)
async function extractVueAstContext(filePath, addedLines) {
  // 1. 用 @vue/compiler-sfc 解析 Vue 文件
  const { descriptor } = parseSFC(code, { filename: filePath });
  
  // 2. 处理 script 或 scriptSetup
  const scriptBlock = descriptor.scriptSetup || descriptor.script;
  const scriptStartLine = scriptBlock.loc.start.line;
  
  // 3. 将整个文件的行号映射到 script 内部
  const adjustedLines = Array.from(addedLines)
    .filter(line => line >= scriptStartLine)
    .map(line => line - scriptStartLine + 1);
  
  // 4. 用 Babel 解析 script 内容
  const scriptResult = extractScriptContext(
    scriptBlock.content,
    new Set(adjustedLines),
    scriptStartLine
  );
  
  // 5. 还原真实行号
  scriptResult.sections.forEach(section => {
    section.start_line += scriptStartLine - 1;
    section.end_line += scriptStartLine - 1;
  });
  
  return scriptResult;
}

难点三:防止 AST 解析卡死

大型 Vue 组件的 AST 遍历可能导致 CI 超时,需要做好性能保护:

递归深度控制:有些代码嵌套层级特别深,可能导致栈溢出。通过深度计数器,超过设定的最大层数就主动抛出异常中断遍历。

解析超时保护:大文件的 Babel 解析比较耗时。使用 Promise.race 实现超时机制,设定时间内解析不完就放弃 AST 分析,降级为普通 Diff 审查。

代码块大小限制:即使成功解析出一个很大的函数,把它全部发给 AI 也会浪费 Token。根据字符数和行数进行分级截断:字符数超限直接截断,行数超限只提取新增行周围的上下文。

通过这些保护措施,即使大文件也能较快完成分析,Token 消耗控制在合理范围。


Prompt 工程:让 AI 返回准确结果

有了 Diff 和 AST 上下文,下一步是如何构建 Prompt 让 AI 给出准确的审查结果。这部分需要不断调整优化。

挑战一:返回精确的行号

最开始直接把 Diff 发给 AI,让它返回问题所在的行号。结果发现行号经常对不上:

{
  "type": "new",
  "startLine": 50,  // ❌ 错误!应该是 51
  "description": "..."
}

为什么会错?因为 AI 看到的是 (50, 51) +代码,它不知道应该用第一个数字还是第二个。

解决方案:在 System Prompt 中明确格式规范

在 Prompt 中加入了超详细的说明:

# Diff 格式说明
每一行左侧括号内的两个数字格式为 (旧行号, 新行号):
- 括号后的 + 表示新增行(只有新行号,格式为 ( , 新行号))
- 括号后的 - 表示删除行(只有旧行号,格式为 (旧行号, ))

**重要**:
- 如果评审的是 + 部分的代码,type 必须为 "new",startLine 必须使用新行号(括号中第二个数字)
- 如果评审的是 - 部分的代码,type 必须为 "old",startLine 必须使用旧行号(括号中第一个数字)

示例:
- (50, 51) +代码 → type 应为 "new",startLine 应为 51(不是 50!)
- (50, ) -代码 → type 应为 "old",startLine 应为 50

加了这段说明后,行号准确率明显提升。

挑战二:控制审查力度

一开始,AI 会输出很多主观建议:

  • "建议这里改用函数式编程风格"
  • "变量名可以更语义化一点"
  • "这里可以优化性能"

这些建议没错,但太主观了,而且容易引起争议。我希望 AI 优先检查那些明确违反规范的问题。

解决方案:建立审查优先级体系

在 System Prompt 中明确优先级:

# Issue 判定流程

严格按序执行:

1. 收集候选:仅关注 + 新增行,忽略 - 删除行
2. 规范匹配:只有明确违反某 guideline 才产出 issue,不主观臆测
3. 严重度判定:
   - 高:编译错误/运行时崩溃/安全漏洞/内存泄漏/数据破坏
   - 中:性能问题/类型不安全/缺错误处理/违反框架最佳实践
   - 低:代码风格/命名/可读性建议
4. 去重排序:同 (行号, guideline_id, 描述) 仅保留一条
5. 截断:超过设定数量从末尾丢弃

# 禁止输出
- guideline_id 不在规范集合中
- 无具体行号
- 纯主观建议(无 guideline 支撑)
- 猜测性风险(缺直接证据)
- 删除的代码(- 行)

这样 AI 就会优先检查团队规范,而不是发表主观意见。

挑战三:让 AI 利用 AST 上下文

有了 AST 提取的完整函数,下一步是教会 AI 如何真正利用这些上下文信息。

在 Prompt 中加入了具体的审查方法论:

## 如何利用 AST 进行审查

1. **查看 added_lines**:这些是新增的行号,需要重点审查
2. **阅读 snippet**:这是包含新增代码的完整函数/类,而非仅几行 diff
3. **对比上下文**:将新增代码与 snippet 中的现有代码进行对比,检查:
   ✓ 变量名拼写是否一致(避免 typo 导致的 bug)
   ✓ 如果新增 return,检查所有 return 分支的返回值结构是否一致
   ✓ 如果新增条件分支,检查各分支处理逻辑是否一致
   ✓ 如果调用函数,检查参数名/属性名是否与上下文中的定义匹配

## 实例说明
假设 Diff 显示:
( , 50) +  return (response.data as any).data;
(53, 54)   if (code == "200" || code == "210") {
(54, 55)     return response.data;

如果只看 Diff,可能只注意到 any 类型问题。但查看 AST 的 snippet:
function handleResponse(response) {
  const code = response.code;
  if (code === "200" && "data" in response.data) {
    return (response.data as any).data;  // 第50行(新增)
  }
  if (code == "200" || code == "210") {
    return response.data;                // 第54行(已有)
  }
}

**关键发现**:同一个函数中,两个 return 在处理 code == "200" 时返回了不同的数据结构!

这段 Prompt 教会了 AI 如何利用 AST 上下文发现只看 Diff 发现不了的问题。


结果发布:两种展示模式

AI 审查完成后,需要将结果展示给开发者。根据不同的使用场景,设计了两种发布模式:

模式对比

报告模式(Report)

  • ✅ 统一汇总,方便整体把握问题分布
  • ✅ 支持按严重程度排序,优先修复高危问题
  • ✅ 生成 HTML 表格,可视化效果好
  • ❌ 需要跳转到具体行,多一步操作

行级评论模式(Inline)

  • ✅ 精准定位到代码行,直接点击 "Resolve"
  • ✅ 支持多轮讨论(可以回复评论)
  • ✅ 修复后自动标记为已解决
  • ❌ 问题太多时界面会很乱

报告模式会生成 Markdown 表格,包含统计信息和详细的问题列表。

行级评论模式则需要处理 GitLab API 复杂的参数结构。

示例报告:

image.png

GitLab 的行级评论 API 比较复杂,需要提供完整的位置参数:

// position 参数结构
const position = {
  ...diffRefs,             // 包含 base_sha, start_sha, head_sha
  position_type: 'text',
  old_path: 'src/api.ts',  // 旧文件路径
  new_path: 'src/api.ts',  // 新文件路径
  old_line: null,          // 如果是新增行,这里为 null
  new_line: 45             // 新文件的行号
}

示例效果:

image.png


稳定性保障:让 CI 流程更可靠

在 CI 环境中,任何一个环节出错都可能导致流水线失败。需要做好容错和降级处理。

并发控制与限流

AI 平台对 API 调用通常有速率限制。如果一个 MR 有很多文件,同时发起大量请求,可能会触发限流:

Error: Rate limit exceeded (429)

解决方案:用 p-limit 控制并发数

// 核心逻辑(简化)
const limit = pLimit(config.maxParallel);

const reviewPromises = files.map(({ path, diff }) => 
  limit(async () => {
    const result = await reviewSingleFile(path, diff, config, guidelines, systemPrompt);
    return { path, result };
  })
);

const results = await Promise.all(reviewPromises);

使用 p-limit 库控制并发数(默认为 3),避免触发 API 速率限制。

旧评论清理

每次代码更新后重新审查,都会生成新的评论。如果不清理旧评论,MR 界面会有很多过时的提示:

🤖 AI 代码审查报告 (3小时前)
问题1: xxx

🤖 AI 代码审查报告 (2小时前)  ← 这个已经过时了
问题1: xxx

🤖 AI 代码审查报告 (刚刚)
问题1: xxx (已修复)

解决方案:发布前自动清理旧评论

// 核心逻辑(简化)
async function deletePastComments(identifier) {
  const response = await client.get(`/projects/${projectId}/merge_requests/${mergeRequestIid}/notes`);
  
  const notesToDelete = response.data.filter(note => 
    note.body && note.body.includes(identifier)
  );
  
  for (const note of notesToDelete) {
    try {
      await client.delete(`/projects/${projectId}/merge_requests/${mergeRequestIid}/notes/${note.id}`);
    } catch (deleteError) {
      console.warn('删除评论失败:', deleteError.message);
    }
  }
}

关键点是在删除时用 try-catch 包裹,即使某个删除失败,其他删除也会继续执行。

多层容错机制

从 AST 解析到 AI 调用,每个环节都可能失败。容错策略如下:

AST 解析容错extractAstContext 内部捕获所有错误,失败时返回空的 sections 数组,审查流程继续,只是没有 AST 上下文辅助。

AI 调用容错:外层 try-catch 捕获网络异常、超时等问题,失败时返回 ERROR 状态,但不影响其他文件的审查。

JSON 解析容错:AI 返回的内容可能不符合预期格式,解析失败时同样返回 ERROR 状态,记录错误信息,不抛出异常。

通过这些容错机制,保证即使某个文件的某个环节失败,其他文件仍能正常审查完成,整体流程稳定可靠。

🚀 快速接入指南

想要在自己的项目中使用?配置非常简单,只需要 3 步。

第一步:配置 CI 流水线

在项目根目录创建 .gitlab-ci.yml: 比如指定目标分支,才进行审查

ai_code_review:
  stage: review
  interruptible: true
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME =~ /^(dev_(210|215)|stage)$/'
      when: on_success
  script:
    - rm -rf code-review-js
    - git clone https://gitlab-ci-token:${GITLAB_TOKEN_AI}@your-gitlab.com/tools/code-review-js.git
    - cd code-review-js
    - pnpm install
    - node src/main.js
  allow_failure: true

第二步:设置环境变量

在 GitLab 项目的 Settings → CI/CD → Variables 中添加必要的配置:

GITLAB_TOKEN: glpat-xxxxxxxxxxxx  # GitLab 访问令牌
GITLAB_TOKEN_AI: glpat-xxxxxxxxxxxx  # GitLab AI 项目访问令牌,缺失无法拉取ai库
OPENAI_API_KEY: sk-xxxxxxxxxxxx   # 阿里云百炼 API Key
REVIEW_MODE: inline                # report 或 inline

第三步:自定义编码规范(可选)

如果需要检查团队特定的编码规范,可以在项目根目录创建 coding_guidelines.yaml

guidelines:
  - id: "TS-001"
    category: "类型安全"
    description: "禁止使用 any 类型,应使用具体类型或 unknown"
    severity: "中"
    
  - id: "SEC-001"
    category: "安全性"
    description: "禁止使用 eval() 和 new Function() 执行动态代码"
    severity: "高"

配置完成后,每次提交 MR 就会自动触发审查!🎉

💡 使用建议

这个工具是代码审查的"初筛助手",擅长检查编码规范、常见 bug、类型安全等机械性问题。推荐的使用方式:

  1. AI 初筛:快速过滤明显的规范问题、拼写错误、缺失的错误处理等
  2. 人工精审:重点关注业务逻辑、架构设计、性能优化等高层次问题
  3. 协同讨论:把 AI 发现的问题作为讨论起点,结合业务场景决定是否修改

实际使用时,AI Code Review 的价值在于承担重复性的规范检查,让人类审查者把精力聚焦在更高层次的问题上。需要注意的是,AI 对具体业务逻辑的理解有限,建议在关键业务代码处添加注释说明。另外,Prompt 的调优是落地时最核心的工作,需要根据团队特点不断迭代。对于内网环境,需要确保 CI 能访问 AI 服务 API。大型 MR 的 Token 消耗会随文件数量增加,但纯文本成本相对较低。

希望本文能给你一些启发


项目地址github.com/streaker303…
技术栈:Node.js | Babel AST | Vue Compiler | 阿里云百炼

觉得有帮助的话,给个 Star 呗~~~ ⭐