智能审查别跳过前端的确定性检查
1. 告警群里的反打卡:AI 审查助手把正常组件全踢回去了
接入 LLM 代码审查后,常见问题是:正常的useMemo被误报为风险,模型给出的修复无法通过编译,或在同步逻辑中建议加入无意义的异步处理。
把 LLM 直接接到 Git Hook 并不能替代编译器、类型检查和既有规则。没有明确输入范围与输出约束时,它很容易制造告警噪音,开发者也会逐渐忽略结果。
更合适的定位是:用确定性工具提取和验证代码事实,再让模型补充解释或提出待人工确认的建议。
2. 深入 AST 链路:为什么把整段代码丢给大模型审查必然失效
将整个前端文件作为纯文本直接交给模型,通常会降低审查结果的相关性。
前端代码不是小说。一个现代 React 或 Vue 组件中包含了大量的 import 依赖、类型声明、样式处理以及核心业务逻辑。在上下文窗口有限且注意力机制分布衰减的情况下,将一个 500 行的.tsx文件全量发给 LLM,大模型关注的焦点会被大量的 boilerplate 代码(样板代码)分散。
全量输入通常会带来两类问题:
第一类是上下文干扰。模型同时看到 CSS-in-JS 和复杂 Hook 时,可能关注风格问题而遗漏useEffect依赖等需要工具验证的风险。
第二类是语义信息缺失。模型无法替代 TypeScript 的符号解析;复杂泛型和条件类型的修改必须经类型检查和测试验证。
下面的表格说明两种输入方式应比较的指标。具体数值需要由团队在自身仓库中采集:
| 审查维度 | 全量文本直接投喂 | AST 节点过滤后定向投喂 |
|---|---|---|
| 虚假告警率 (False Positive) | 容易受无关上下文影响 | 应在标注样本上评估 |
| 致命逻辑漏报率 | 不应仅依赖模型判断 | 与静态规则和人工复核对照 |
| 平均 Token 消耗 | 随文件长度增长 | 可按高风险节点控制 |
| 单文件审查耗时 | 受上下文和模型调用影响 | 受节点数量与并发控制影响 |
| 开发者采纳率 | 需通过反馈数据统计 | 需通过反馈数据统计 |
先用样本库测量误报、漏报和采纳情况,再调整输入与规则。
在代码进入 LLM 之前,必须在本地用确定性的编译器工具(如 Babel Parser 或 TypeScript Compiler API)将代码切碎,剔除那些无关紧要的静态声明,只抽取核心的函数体、副作用 Hook、状态变更点以及异步调用链路。
3. 从幻觉到死循环:AI 自动补全在复杂泛型与状态机里的崩塌现场
自动补全适合重复性较高的代码;处理复杂状态机或 TypeScript 高级类型时,生成结果需要逐行审阅。
看一个我们在真实业务中抽取的真实反例。这段代码原本是一个管控多步表单提交的状态机 Hook,开发者试图让 AI 补全状态转移逻辑:
// ❌ 错误做法:盲目信任 AI 补全的无限递归与类型断言 export type MachineState = 'idle' | 'loading' | 'success' | 'error'; export function useFormStateMachine() { const [state, setState] = useState<MachineState>('idle'); // AI 自动补全生成的所谓"智能重试机制" const dispatch = async (action: string) => { if (state === 'loading') { // 🚨 幻觉:AI 自作聪明地加入了无限自旋等待,直接卡死渲染主线程! while (state === 'loading') { await new Promise((resolve) => setTimeout(resolve, 100)); } } try { setState('loading'); // 🚨 幻觉:为了避开 TS 类型检查,AI 直接使用了 any 强制断言 const res = await fakeApiCall(action as any); if ((res as any).ok) { setState('success'); } else { // 🚨 幻觉:死循环重试,未设置最大重试次数与退避窗口 dispatch(action); } } catch (e) { setState('error'); } }; return { state, dispatch }; }上面的代码至少有三处需要修正:
- 在 React 组件内用
while和setTimeout等待状态变化,读取到的可能是旧闭包中的state,逻辑无法按预期退出。 - 用
as any压过类型报错会丢失类型约束,应补齐动作和响应类型。 - 异常分支递归调用
dispatch(action),没有最大次数和退避策略,可能形成重复请求。
审查工具应结合循环、递归、any和副作用等确定性规则,而不是仅根据模型评价决定是否放行。
4. 搭建确定性防护网:用 AST 预清洗与结构化 Schema 约束 AI 审查
为了消除上述反模式,我们重构了团队的 AI 代码审查引擎。
新架构可用 AST 静态分析缩小输入范围,用 JSON Schema 校验 LLM 输出。格式不合法或缺少证据的结果应标记为“需人工复核”,而不是阻断提交。
下面是完整的生产级 Node.js 审查管道实现代码。你可以直接放到自动化 CI 工具链中运行:
import * as parser from '@babel/parser'; import traverse from '@babel/traverse'; import * as t from '@babel/types'; import { z } from 'zod'; // 1. 严格定义 LLM 输出的 JSON Schema,防止模型输出伪文本 const ReviewResultSchema = z.object({ hasIssue: z.boolean(), severity: z.enum(['low', 'medium', 'high', 'critical']), line: z.number().optional(), targetCode: z.string().optional(), reason: z.string().describe('具体的代码隐患说明,禁止使用泛化词汇'), suggestion: z.string().describe('符合 TS 规范的修复代码片段'), }); export type ReviewResult = z.infer<typeof ReviewResultSchema>; export interface CodeSnippet { location: string; code: string; contextType: 'useEffect' | 'customHook' | 'stateMutation'; } // 2. 确定性 AST 节点抽取器:只提取高风险代码段 export function extractRiskSnippets(sourceCode: string): CodeSnippet[] { const snippets: CodeSnippet[] = []; try { const ast = parser.parse(sourceCode, { sourceType: 'module', plugins: ['typescript', 'jsx'], }); traverse(ast, { // 专门提取 React.useEffect CallExpression(path) { const callee = path.node.callee; if ( t.isIdentifier(callee, { name: 'useEffect' }) || (t.isMemberExpression(callee) && t.isIdentifier(callee.property, { name: 'useEffect' })) ) { const loc = path.node.loc; snippets.push({ location: loc ? `L${loc.start.line}-L${loc.end.line}` : 'Unknown', code: sourceCode.slice(path.node.start!, path.node.end!), contextType: 'useEffect', }); } }, }); } catch (err) { console.error('AST 解析失败,回退至确定性安全兜底:', err); } return snippets; } // 3. 带防护网的 AI 审查调用器 export async function reviewCodeWithGuardrails( snippet: CodeSnippet, llmClient: { invoke: (prompt: string) => Promise<string> } ): Promise<ReviewResult | null> { const systemPrompt = `你是一个严谨的 React/TypeScript 代码审查引擎。 必须且只能输出合法的 JSON 格式。 绝对不能使用 any 类型,绝对不能在无网络请求的代码中增加 async/await。 如果不具备明确的逻辑漏洞,必须返回 {"hasIssue": false, "severity": "low", "reason": "无", "suggestion": ""}`; const userPrompt = `审查目标片段类型: ${snippet.contextType} 代码位置: ${snippet.location} 代码内容: \`\`\`typescript ${snippet.code} \`\`\` 请评估是否存在内存泄露、闭包陷阱或死循环风险。`; const MAX_RETRIES = 2; for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { try { const rawResponse = await llmClient.invoke(`${systemPrompt}\n${userPrompt}`); // 提取 JSON 内容,抵御 LLM Markdown 嵌套包覆 const jsonMatch = rawResponse.match(/\{[\s\S]*\}/); if (!jsonMatch) { throw new Error('LLM 输出未包含标准 JSON 结构'); } const parsed = JSON.parse(jsonMatch[0]); // 使用 Zod 校验返回结构 const validatedResult = ReviewResultSchema.parse(parsed); return validatedResult; } catch (error) { console.warn(`第 ${attempt} 次 LLM 审查结果未通过防线校验: ${(error as Error).message}`); if (attempt === MAX_RETRIES) { // 返回空结果,交由人工审查处理 return null; } } } return null; }核心在于边界清晰:
- 不直接把全量代码发送给 API。
- 使用
@babel/parser提取需要审查的节点。 - 使用
Zod校验返回结构;失败后返回空结果或人工审查提示。
5. 落地验证:评估审查结果是否可用
上线前后可在同一批 PR 上比较全量输入与 AST 定向输入,记录 Token 使用量、响应时间、误报率、漏报率和开发者采纳率。误报和漏报要由代码所有者或独立审查者标注,避免只统计模型自评。
编译、类型检查、测试和安全规则仍应是提交门禁;LLM 适合作为补充信号。把输入、输出和降级路径写清楚,审查结果才可复核。