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。这样做的问题:
- Token 浪费严重------一个 PR 改了 20 个文件,8000 行代码,光 input 就烧掉大量 token
- 注意力稀释------LLM 在长上下文里容易"走神",真正的问题反而漏掉
- 结果不可控------返回一堆格式/命名建议,全是噪音
正确的做法是只给 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 的上下文"------才是真正考验工程能力的地方。
这不是什么前沿技术,就是把现有的东西用对地方。但往往最难的,就是"用对地方"这四个字。