AI 代码审查别吞整份 diff:AST 增量筛选与 Token 预算
AI 代码审查最忌讳把整份 diff 原样扔给模型。先用 AST 和确定性规则筛掉格式、导入与无关文件,再把函数签名、变更节点和必要类型控制在可解释的 Token 预算里。
为什么全量 Code Review 会把 CI 拖垮
大部分团队搭 AI Code Review 脚本时,写法非常随意:获取git diff,或者直接读源文件,然后拼一段 "Please review this code" 的 Prompt,发请求给 OpenAI 或本地部署的 Ollama。
这里有三个常见问题。
第一,无效上下文挤爆 Token。代码库里大量无意义的import语句、格式化缩进、接口声明,不需要大模型重新看一遍。把这些全扔进去,会增加无关的调用成本。
第二,长上下文通常会增加排队、预填充和生成时间。具体幅度取决于模型和服务端实现,不能只用固定 Token 阈值判断。
第三,模型输出不应直接决定 CI 成败。结构校验、超时、重试与人工复查通道都应独立于模型输出。
要搞定这事,思路很明确:在前端打包与编译阶段,先用 AST(抽象语法树)做增量差分计算,只把发生实际逻辑变更的函数节点与上下文类型抽出来,配上 Token 预算计算器后再交给大模型。
SWC 增量 AST 解析与 Token 预算流转控制
在 Node.js 环境中,可用 SWC 或现有构建工具解析 TSX。解析耗时受文件大小、机器和缓存影响,应在仓库中基准测试后再决定是否替换工具。
下面是这个增量提取与流式审查控制器的完整实现示例。
import { parseSync, Visitor } from '@swc/core'; import type { Module, FunctionDeclaration, ClassMethod } from '@swc/core'; import crypto from 'node:crypto'; import EventEmitter from 'node:events'; export interface CodeChunk { id: string; name: string; kind: 'function' | 'method' | 'type'; code: string; startLine: number; endLine: number; } export interface ReviewOptions { maxTokensPerChunk: number; timeoutMs: number; apiKey: string; baseUrl: string; } export class ASTDiffExtractor { /** * 从源码中精准提取函数与关键节点 */ public extractChangedChunks(code: string): CodeChunk[] { let ast: Module; try { ast = parseSync(code, { syntax: 'typescript', tsx: true, target: 'es2022', }); } catch (err) { console.error('[AST Parser Error] 无法解析源码,降级为文本行处理', err); return this.fallbackLineSplit(code); } const chunks: CodeChunk[] = []; const lines = code.split('\n'); // 遍历 AST 提取目标函数与方法节点 class ScopeVisitor extends Visitor { visitFunctionDeclaration(n: FunctionDeclaration): FunctionDeclaration { if (n.identifier && n.span) { // SWC 的 span 是源码偏移量,不是行号;示例用偏移量截取源码。 const startOffset = Math.max(0, n.span.start - 1); const endOffset = Math.min(code.length, n.span.end - 1); const funcCode = code.slice(startOffset, endOffset); chunks.push({ id: crypto.createHash('md5').update(funcCode).digest('hex'), name: n.identifier.value, kind: 'function', code: funcCode, startLine: code.slice(0, startOffset).split('\n').length, endLine: code.slice(0, endOffset).split('\n').length, }); } return n; } } const visitor = new ScopeVisitor(); visitor.visitModule(ast); return chunks.length > 0 ? chunks : this.fallbackLineSplit(code); } private fallbackLineSplit(code: string): CodeChunk[] { return [{ id: crypto.createHash('md5').update(code).digest('hex'), name: 'FullModuleFallback', kind: 'function', code: code.slice(0, 4000), // 硬限制截断 startLine: 1, endLine: code.split('\n').length, }]; } } export class AICodeReviewEngine extends EventEmitter { private options: ReviewOptions; constructor(options: ReviewOptions) { super(); this.options = options; } /** * 执行流式审查,带有超速控制与 JSON 格式纠错防线 */ public async processStreamReview(chunk: CodeChunk): Promise<string> { const prompt = `你是一名严格的前端工程专家。请对以下 TypeScript 代码节点进行审查。 仅输出格式化的 JSON,禁止包含 Markdown 标记: { "score": number (0-100), "issues": [{"line": number, "severity": "error"|"warn", "message": string}], "suggestion": string } 代码节点名称: ${chunk.name} 源码: \`\`\`typescript ${chunk.code} \`\`\``; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), this.options.timeoutMs); try { const response = await fetch(`${this.options.baseUrl}/chat/completions`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${this.options.apiKey}`, }, body: JSON.stringify({ model: 'deepseek-coder', messages: [{ role: 'user', content: prompt }], stream: true, temperature: 0.1, }), signal: controller.signal, }); if (!response.ok || !response.body) { throw new Error(`HTTP 异常状态码: ${response.status}`); } const reader = response.body.getReader(); const decoder = new TextDecoder('utf-8'); let rawResult = ''; while (true) { const { done, value } = await reader.read(); if (done) break; const chunkText = decoder.decode(value, { stream: true }); const lines = chunkText.split('\n').filter(l => l.trim().startsWith('data: ')); for (const line of lines) { const jsonStr = line.replace(/^data: /, '').trim(); if (jsonStr === '[DONE]') continue; try { const parsed = JSON.parse(jsonStr); const delta = parsed.choices?.[0]?.delta?.content || ''; rawResult += delta; this.emit('chunk_token', delta); } catch { // 忽略流中的中间解析抖动 } } } clearTimeout(timer); return this.validateAndSanitizeJson(rawResult); } catch (err: unknown) { clearTimeout(timer); const message = err instanceof Error ? err.message : String(err); console.warn(`[Review Pipeline Warnings] 节点 ${chunk.name} 触发熔断兜底: ${message}`); return JSON.stringify({ score: 80, issues: [], suggestion: '流式响应超时或网络异常,已自动跳过深度审查,放行构建。', }); } } /** * 确定性校验器:清理大模型可能混入的 Markdown 标记并校验 JSON 结构 */ private validateAndSanitizeJson(rawText: string): string { const sanitized = rawText .replace(/```json/gi, '') .replace(/```/g, '') .trim(); try { const obj = JSON.parse(sanitized); if (typeof obj.score !== 'number' || !Array.isArray(obj.issues)) { throw new Error('Schema 校验失败:缺少关键字段'); } return JSON.stringify(obj); } catch { // 解析失败时,输出安全格式,绝不卡死 CI 进程 return JSON.stringify({ score: 70, issues: [{ line: 1, severity: 'warn', message: '大模型返回格式非标准 JSON,已强行修复' }], suggestion: sanitized.slice(0, 200), }); } } }上线前如何验证
不要把其他项目的延迟和 Token 数据当作自己的结论。可以在 CI 中记录每次审查的输入 Token、TTFT、总耗时、超时比例和缓存命中率,并按 PR 大小分组比较“全量 diff”和“函数级片段”两种策略。确认审查覆盖率没有明显下降后,再据此设置预算。
这里有几个生产踩坑经验应警惕。
第一,AST 解析不能盲目信任。遇到模板语法诡异或者写了大量宏扩展的代码,语法树可能直接报错崩溃。这时候代码里应带 fallback 机制,退回到常规行数切割,防止脚本抛异常把 CI 搞死。
第二,Token 限制不能光看字数。应使用目标模型对应的 tokenizer 估算输入,并为提示词、输出和重试预留预算。超限时优先拆分并保留函数签名、调用点和相关类型,而不是静默截断关键逻辑。
先用规则缩小输入范围,再让模型处理需要语义判断的部分,通常比单纯扩大上下文更可控。