AI 代码审查工具链搭建:用 AST 解析 + LLM 实现自动化 Code Review 的前端工程方案

AI 代码审查工具链搭建:用 AST 解析 + LLM 实现自动化 Code Review 的前端工程方案

团队到了 15 人以上,Code Review 就开始变味了。

不是没人 review,而是 review 变成了"LGTM 流水线"------打开 PR,滚动两屏,留一句 "looks good to me",合并。真正的逻辑问题、潜在的性能隐患、不符合团队规范的写法,全靠运气。

人工 review 的瓶颈不是态度,是带宽。一个资深工程师一天能认真 review 多少个 PR?3 到 5 个,顶天了。剩下的要么排队,要么糊弄。

所以我们开始想:能不能让机器先过一遍,把"明显有问题"的地方标出来,人再去看真正需要判断力的部分?

这就是这篇文章要聊的事------用 AST 解析做结构化分析,用 LLM 做语义级审查,把两者串成一条自动化 Code Review 工具链。


先搞清楚:人工 Review 到底哪里不行?

不是人不行,是人干了太多不该干的活。

一次典型的 Code Review,reviewer 的注意力大概分布在这几个层面:

层面 举例 能否自动化
格式规范 缩进、命名、import 顺序 ESLint/Prettier 已解决
模式违规 组件里直接调 fetch、没用 hooks 封装 AST 可以搞定
逻辑隐患 useEffect 依赖缺失、竞态条件 AST + 规则引擎可以搞定
业务语义 这个字段不该在这里改、这段逻辑和需求不符 需要 LLM
架构决策 该不该拆微服务、该不该用新方案 需要人

ESLint 覆盖了第一层,但第二到第四层基本是裸奔状态。我们要做的,就是把中间这三层自动化掉。


整体架构:两阶段流水线

核心思路一句话:AST 做确定性分析,LLM 做模糊判断

markdown 复制代码
┌─────────────┐     ┌──────────────────┐     ┌─────────────┐
│  Git Diff    │────▶│  AST 结构化分析   │────▶│  规则引擎    │
│  提取变更文件 │     │  提取函数/组件/依赖│     │  输出确定问题 │
└─────────────┘     └──────────────────┘     └──────┬──────┘
                                                     │
                                              ┌──────▼──────┐
                                              │  LLM 语义审查 │
                                              │  上下文 + Diff │
                                              └──────┬──────┘
                                                     │
                                              ┌──────▼──────┐
                                              │  结果聚合     │
                                              │  发 PR Comment│
                                              └─────────────┘

为什么不直接把代码丢给 LLM?后面讲,先看怎么搭。


第一阶段:AST 结构化分析

拿到 Diff,先别急着分析

第一步不是分析代码,是搞清楚改了什么

ts 复制代码
import { execSync } from 'child_process'

function getChangedFiles(baseBranch = 'main'): string[] {
  const output = execSync(
    `git diff --name-only --diff-filter=ACMR ${baseBranch}...HEAD`
  ).toString()

  return output
    .split('\n')
    .filter(f => f.endsWith('.ts') || f.endsWith('.tsx')) // 只关心 TS/TSX
    .filter(Boolean)
}

拿到文件列表后,逐个解析 AST。这里用 @typescript-eslint/typescript-estree,因为它对 TSX 的支持最好,而且输出的 AST 和 ESLint 生态兼容。

从 AST 中提取"审查素材"

我们不是要遍历整棵树,而是提取 reviewer 真正关心的结构信息:

ts 复制代码
import { parse } from '@typescript-eslint/typescript-estree'
import { simpleTraverse } from '@typescript-eslint/typescript-estree'

interface ComponentMeta {
  name: string
  hooks: string[]           // 用了哪些 hooks
  deps: string[]            // import 了什么
  stateCount: number        // 多少个 useState
  effectCount: number       // 多少个 useEffect
  lineCount: number         // 函数体行数
  hasCleanup: boolean[]     // useEffect 是否有清理函数
}

function extractComponentMeta(code: string): ComponentMeta[] {
  const ast = parse(code, { jsx: true, loc: true })
  const components: ComponentMeta[] = []

  simpleTraverse(ast, {
    enter(node) {
      // 找到函数组件(大写开头的函数声明/箭头函数)
      if (
        node.type === 'FunctionDeclaration' &&
        node.id?.name?.[0] === node.id?.name?.[0]?.toUpperCase()
      ) {
        const meta = analyzeComponentBody(node, code)
        components.push(meta)
      }
    },
  })

  return components
}

关键在 analyzeComponentBody 里,我们要识别几个高价值信号:

ts 复制代码
function analyzeComponentBody(node: any, code: string): ComponentMeta {
  const hooks: string[] = []
  let stateCount = 0
  let effectCount = 0
  const hasCleanup: boolean[] = []

  simpleTraverse(node, {
    enter(child) {
      if (
        child.type === 'CallExpression' &&
        child.callee.type === 'Identifier'
      ) {
        const name = child.callee.name

        if (name.startsWith('use')) hooks.push(name)
        if (name === 'useState') stateCount++
        if (name === 'useEffect') {
          effectCount++
          // 检查回调是否返回了清理函数
          const callback = child.arguments[0]
          if (callback?.type === 'ArrowFunctionExpression') {
            const body = callback.body
            // 简化判断:函数体内是否有 return 语句
            const hasReturn = code
              .slice(body.range![0], body.range![1])
              .includes('return')
            hasCleanup.push(hasReturn)
          }
        }
      }
    },
  })

  return {
    name: node.id?.name ?? 'Anonymous',
    hooks,
    deps: [], // 从 import 声明中单独提取
    stateCount,
    effectCount,
    lineCount: node.loc!.end.line - node.loc!.start.line,
    hasCleanup,
  }
}

规则引擎:把经验变成代码

有了结构化信息,规则就好写了。这不是玄学,就是把资深工程师脑子里的"直觉"翻译成条件判断:

ts 复制代码
interface ReviewIssue {
  level: 'error' | 'warning' | 'info'
  message: string
  file: string
  component: string
}

function applyRules(meta: ComponentMeta, file: string): ReviewIssue[] {
  const issues: ReviewIssue[] = []

  // 规则 1:组件超过 200 行,大概率该拆了
  if (meta.lineCount > 200) {
    issues.push({
      level: 'warning',
      message: `组件 ${meta.name} 有 ${meta.lineCount} 行,考虑拆分`,
      file,
      component: meta.name,
    })
  }

  // 规则 2:useState 超过 5 个 → 该用 useReducer 或抽 custom hook
  if (meta.stateCount > 5) {
    issues.push({
      level: 'warning',
      message: `${meta.name} 有 ${meta.stateCount} 个 useState,状态管理可能需要重构`,
      file,
      component: meta.name,
    })
  }

  // 规则 3:useEffect 没有清理函数 → 可能有内存泄漏
  meta.hasCleanup.forEach((has, i) => {
    if (!has) {
      issues.push({
        level: 'info',
        message: `${meta.name} 的第 ${i + 1} 个 useEffect 没有 cleanup,确认是否需要`,
        file,
        component: meta.name,
      })
    }
  })

  return issues
}

这一层的好处是零成本、零延迟、百分百确定性。不调 API,不花钱,跑一遍就是几百毫秒的事。


第二阶段:LLM 语义级审查

AST 能告诉你"这个 useEffect 没有 cleanup",但它没法告诉你"这段逻辑有竞态条件"或者"这个状态更新的时机不对"。

这就是 LLM 上场的地方。

Prompt 工程:别把整个文件丢进去

最常见的错误是把整个文件甚至整个 PR 一股脑扔给 LLM。这样做的问题:

  1. Token 浪费严重------一个 PR 改了 20 个文件,8000 行代码,光 input 就烧掉大量 token
  2. 注意力稀释------LLM 在长上下文里容易"走神",真正的问题反而漏掉
  3. 结果不可控------返回一堆格式/命名建议,全是噪音

正确的做法是只给 LLM 它该看的东西

ts 复制代码
interface LLMReviewContext {
  diff: string              // 只给变更部分,不给整文件
  componentMeta: ComponentMeta  // AST 阶段提取的结构信息
  astIssues: ReviewIssue[]  // 第一阶段已发现的问题(避免重复)
  projectContext: string    // 项目级约定(简短)
}

function buildPrompt(ctx: LLMReviewContext): string {
  return `你是一个资深前端工程师,正在 review 一个 React + TypeScript 项目的 PR。

## 项目约定
${ctx.projectContext}

## 已知问题(AST 分析已发现,不需要重复指出)
${ctx.astIssues.map(i => `- ${i.message}`).join('\n')}

## 组件结构信息
- 组件名:${ctx.componentMeta.name}
- 使用的 Hooks:${ctx.componentMeta.hooks.join(', ')}
- useState 数量:${ctx.componentMeta.stateCount}
- useEffect 数量:${ctx.componentMeta.effectCount}

## 代码变更(Diff)
\`\`\`diff
${ctx.diff}
\`\`\`

请从以下角度审查,只输出有价值的问题,不要指出格式或命名问题:
1. 是否存在竞态条件或时序问题
2. 状态更新逻辑是否正确
3. 是否有潜在的性能问题(不必要的重渲染等)
4. 错误处理是否完整
5. 是否有安全隐患(XSS、注入等)

输出格式:
- [严重程度: high/medium/low] 问题描述
- 涉及代码行
- 建议修改方式`
}

注意看,我们把 AST 阶段的分析结果也传进去了,明确告诉 LLM"这些我已经知道了,别重复说"。这是减少 LLM 输出噪音的关键手段。

调用层:流式 + 超时 + 降级

生产环境不能像 demo 那样裸调 API:

ts 复制代码
async function callLLMReview(
  prompt: string,
  options: { timeout?: number; model?: string } = {}
): Promise<string> {
  const { timeout = 30_000, model = 'claude-sonnet-4-6' } = options
  const controller = new AbortController()
  const timer = setTimeout(() => controller.abort(), timeout)

  try {
    const response = await fetch('https://api.anthropic.com/v1/messages', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'x-api-key': process.env.ANTHROPIC_API_KEY!,
        'anthropic-version': '2023-06-01',
      },
      body: JSON.stringify({
        model,
        max_tokens: 2000,    // review 结果不需要太长
        messages: [{ role: 'user', content: prompt }],
      }),
      signal: controller.signal,
    })

    const data = await response.json()
    return data.content[0].text
  } catch (err: any) {
    if (err.name === 'AbortError') {
      // 超时降级:只返回 AST 分析结果,LLM 部分跳过
      console.warn('LLM review timeout, falling back to AST-only')
      return ''
    }
    throw err
  } finally {
    clearTimeout(timer)
  }
}

超时不是异常,是常态。LLM 接口抖一下太正常了。降级策略必须在 Day 1 就写好,不是等线上出事再补。


结果聚合:发到 PR 评论里

两个阶段的结果合并后,通过 GitHub API 写回 PR:

ts 复制代码
async function postReviewComments(
  prNumber: number,
  issues: ReviewIssue[]
): Promise<void> {
  // 按严重程度排序,error 在前
  const sorted = issues.sort((a, b) => {
    const priority = { error: 0, warning: 1, info: 2 }
    return priority[a.level] - priority[b.level]
  })

  // 限制评论数量,超过 10 条就只保留 error 和 warning
  const filtered = sorted.length > 10
    ? sorted.filter(i => i.level !== 'info')
    : sorted

  const body = filtered
    .map(i => {
      const icon = { error: '🔴', warning: '🟡', info: '🔵' }[i.level]
      return `${icon} **[${i.level.toUpperCase()}]** ${i.message}\n> 📍 \`${i.file}\` - \`${i.component}\``
    })
    .join('\n\n---\n\n')

  await octokit.rest.issues.createComment({
    owner: 'your-org',
    repo: 'your-repo',
    issue_number: prNumber,
    body: `## 🤖 Auto Code Review\n\n${body}\n\n---\n*AST 分析 + LLM 审查 | 如有误报请标记 👎*`,
  })
}

为什么限制评论数量?因为一次性抛 30 条 review 意见,等于没说。 没人会看的。


设计权衡:为什么不直接全用 LLM?

这是被问最多的问题。答案很简单------成本、速度、确定性

维度 纯 LLM AST + LLM
单次 PR 成本 <math xmlns="http://www.w3.org/1998/Math/MathML"> 0.05 0.05 ~ </math>0.05 0.30 <math xmlns="http://www.w3.org/1998/Math/MathML"> 0.01 0.01 ~ </math>0.01 0.08
延迟 15~45 秒 AST < 1秒,LLM 10~30秒
确定性问题检出 可能漏,也可能幻觉 AST 部分 100% 准确
可调试性 黑盒 AST 规则可单步调试

用类比来说:AST 是安检机器,LLM 是安检员。 机器先过一遍,把明确违禁的拦下来;安检员再看机器标记可疑的,做人工判断。你不会让安检员一个一个翻包检查所有人,那队伍排到明年。

还有一个更实际的原因------LLM 会产生幻觉,AST 不会。 当 LLM 告诉你"这里有内存泄漏"的时候,你还得去验证。但 AST 告诉你"这个 useEffect 没有 cleanup",那就是没有,不用验证。


CI 集成:GitHub Actions 实现

yaml 复制代码
# .github/workflows/ai-review.yml
name: AI Code Review
on:
  pull_request:
    types: [opened, synchronize]
    paths:
      - 'src/**/*.ts'
      - 'src/**/*.tsx'

jobs:
  review:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0   # 需要完整 git 历史来算 diff

      - uses: actions/setup-node@v4
        with:
          node-version: 20

      - run: npm ci
      - run: npx ts-node scripts/ai-review.ts
        env:
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          PR_NUMBER: ${{ github.event.pull_request.number }}

有一个细节:fetch-depth: 0。默认 checkout 只拉最新一个 commit,算不了 diff。写到这里我开始怀疑人生------每次都有人忘这个配置然后来问"为什么 git diff 是空的"。


可扩展性:从工具到平台

当这套东西跑稳了之后,自然会有新需求冒出来:

1. 规则可配置化

把 AST 规则从硬编码变成配置文件:

json 复制代码
// .ai-review.json
{
  "rules": {
    "max-component-lines": { "level": "warning", "threshold": 200 },
    "max-useState-count": { "level": "warning", "threshold": 5 },
    "require-effect-cleanup": { "level": "info", "enabled": true }
  },
  "llm": {
    "model": "claude-sonnet-4-6",
    "maxTokens": 2000,
    "timeout": 30000,
    "focusAreas": ["race-conditions", "security", "performance"]
  },
  "ignore": ["**/*.test.ts", "**/*.spec.ts"]
}

2. 误报反馈闭环

在 PR 评论里加 👎 按钮,收集误报数据。积累到一定量后:

  • 调整 AST 规则阈值
  • 优化 LLM prompt(few-shot 加入真实误报案例)
  • 对特定模式建立白名单

3. 团队知识沉淀

把高频 review 意见提炼成团队规范文档,反哺到 AST 规则库。这不是一次性工具,是一个持续进化的系统。


边界与风险:这东西不是万能的

几个踩过的坑,提前说:

LLM 输出格式不稳定。 你让它按固定格式输出,它大部分时候听话,偶尔抽风。解析 LLM 返回结果时,必须做容错处理,不能假设格式永远正确。用 JSON mode 或者 structured output 会好很多,但也不是 100%。

跨文件分析是个深坑。 AST 解析天然是单文件粒度的。如果一个 PR 改了 A 文件的接口定义,又改了 B 文件的调用方,要关联分析就需要额外做依赖图。TypeScript 的 Language Service API 能帮上忙,但复杂度直接起飞。

不要试图替代人工 review。 这套工具是过滤器,不是替代品。架构决策、业务逻辑的合理性、代码的"品味"------这些东西目前还是得靠人。工具能做的是把 reviewer 的精力从"找明显问题"释放到"思考设计决策"上

成本控制。 一个活跃项目一天可能有几十个 PR,每个 PR 可能触发多次 review(每次 push 都触发)。按 $0.08/次算,一个月也是一笔钱。可以考虑:只在目标分支是 main/release 时触发、只分析变更超过一定行数的 PR、加缓存避免重复分析同一个 commit。


总结:这类问题的通用模型

退一步看,这其实是一个"结构化预处理 + 智能判断"的通用模式。

不只是 Code Review,很多场景都是这个套路:

  • 日志分析:正则提取结构 → LLM 判断根因
  • 文档审查:AST/Schema 校验格式 → LLM 检查内容质量
  • 测试生成:AST 提取函数签名 → LLM 生成测试用例

核心原则就一条:能确定性解决的,不要浪费智能;需要判断力的,不要硬编规则。

把确定性的事交给确定性的工具,把模糊的事交给擅长模糊推理的模型。两者的接缝处------也就是"AST 提取出来的结构化信息如何变成 LLM 的上下文"------才是真正考验工程能力的地方。

这不是什么前沿技术,就是把现有的东西用对地方。但往往最难的,就是"用对地方"这四个字。

相关推荐
juejin_cn1 小时前
[转][译] 从零开始构建 OpenClaw — 第五部分(对话压缩)
javascript
willow3 小时前
Promise由浅入深
javascript·promise
董员外3 小时前
LangChain.js 快速上手指南:Tool的使用,给大模型安上了双手
前端·javascript·后端
willow4 小时前
Generator与Iterator
javascript
wuhen_n4 小时前
Pinia状态管理原理:从响应式核心到源码实现
前端·javascript·vue.js
晴殇i4 小时前
CommonJS 与 ES6 模块引入的区别详解
前端·javascript·面试
wuhen_n5 小时前
KeepAlive:组件缓存实现深度解析
前端·javascript·vue.js
wuhen_n5 小时前
Vue Router与响应式系统的集成
前端·javascript·vue.js
FansUnion5 小时前
用 AI 自动生成壁纸标题、描述和 SEO Slug
javascript