大模型在代码审查中的应用:AST解析+LLM的自动化代码质量检测
大模型在代码审查中的应用:AST解析+LLM的自动化代码质量检测
适读人群:Java后端工程师、研发效能团队、技术架构师 | 阅读时长:约22分钟 | 依赖:Spring AI 1.0、JavaParser、GitLab API
开篇故事
我们团队一度每周有将近200个PR(Pull Request)需要Code Review。高峰时期,光Review就占了每位资深工程师1/3的工作时间,而且Review质量参差不齐——一些基础问题(空指针风险、未关闭的流、过于复杂的方法)经常被遗漏,等到上线后才出问题。
我想了一个法子:用AI做第一轮审查,把常见问题自动筛出来,人工Review只需要确认AI的发现,或者关注AI看不到的业务逻辑层面的问题。
但直接把代码扔给LLM让它"找问题"效果很差——LLM会产生大量误报(把正常代码说成有问题),同时漏掉真实的问题(特别是需要理解代码结构才能发现的问题)。
改进方案是:先用AST(抽象语法树)解析对代码做结构化分析,提取关键信息(方法复杂度、依赖关系、潜在风险点),再把这些结构化信息连同代码一起给LLM,LLM只需要针对已知的风险点做语义分析。这种"AST先行、LLM精炼"的组合,把有效发现率从23%提升到了71%,误报率从68%降到了19%。
一、核心问题分析
代码审查AI化面临的核心挑战:
上下文缺失:LLM看到的只是diff,看不到整个项目的依赖关系、接口契约、测试覆盖情况。如果没有足够上下文,很多重要问题无法发现。
误报问题:没有结构化分析辅助时,LLM倾向于对正常代码也提出"建议",导致开发者对AI建议产生疲劳和信任缺失。
评审深度不均:对于简单的getter/setter方法和复杂的并发逻辑,应该用不同的审查深度,但无脑把所有代码发给LLM,成本和质量都不可控。
二、原理深度解析
2.1 AST + LLM代码审查架构
三、完整代码实现
3.1 JavaParser AST分析器
@Service
public class JavaAstAnalyzer {
private static final Logger log = LoggerFactory.getLogger(JavaAstAnalyzer.class);
/**
* 分析Java代码文件,提取结构化信息和潜在风险
*/
public AstAnalysisResult analyze(String javaCode) {
try {
CompilationUnit cu = StaticJavaParser.parse(javaCode);
AstAnalysisResult result = new AstAnalysisResult();
// 1. 提取所有方法信息
cu.findAll(MethodDeclaration.class).forEach(method -> {
MethodMetrics metrics = analyzeMethod(method);
result.addMethodMetrics(metrics);
});
// 2. 检测潜在风险
detectNullPointerRisks(cu, result);
detectResourceLeaks(cu, result);
detectConcurrencyIssues(cu, result);
detectPerformanceIssues(cu, result);
// 3. 提取类级别信息
cu.findAll(ClassOrInterfaceDeclaration.class).forEach(clazz -> {
result.addClassInfo(new ClassInfo(
clazz.getNameAsString(),
clazz.getMethods().size(),
clazz.getFields().size(),
clazz.isInterface()
));
});
return result;
} catch (ParseProblemException e) {
log.warn("Java代码解析失败(可能不是标准Java): {}", e.getMessage());
return AstAnalysisResult.empty();
}
}
private MethodMetrics analyzeMethod(MethodDeclaration method) {
String methodName = method.getNameAsString();
int lineCount = method.getEnd().map(p -> p.line)
.orElse(0) - method.getBegin().map(p -> p.line).orElse(0);
// 计算圈复杂度(McCabe)
int cyclomaticComplexity = calculateCyclomaticComplexity(method);
// 嵌套深度
int maxNestingDepth = calculateMaxNestingDepth(method);
// 参数数量
int paramCount = method.getParameters().size();
return new MethodMetrics(methodName, lineCount,
cyclomaticComplexity, maxNestingDepth, paramCount);
}
/**
* 圈复杂度 = 条件分支数 + 1
*/
private int calculateCyclomaticComplexity(MethodDeclaration method) {
int complexity = 1; // 基础复杂度
// 统计各种分支语句
complexity += method.findAll(IfStmt.class).size();
complexity += method.findAll(ForStmt.class).size();
complexity += method.findAll(ForEachStmt.class).size();
complexity += method.findAll(WhileStmt.class).size();
complexity += method.findAll(DoStmt.class).size();
complexity += method.findAll(SwitchEntry.class).size();
complexity += method.findAll(CatchClause.class).size();
// 三目运算符
complexity += method.findAll(ConditionalExpr.class).size();
// 逻辑运算符(&&、||)
complexity += method.findAll(BinaryExpr.class)
.stream()
.filter(e -> e.getOperator() == BinaryExpr.Operator.AND ||
e.getOperator() == BinaryExpr.Operator.OR)
.count();
return complexity;
}
/**
* 检测空指针风险
*/
private void detectNullPointerRisks(CompilationUnit cu,
AstAnalysisResult result) {
// 检测:方法返回值直接链式调用而不检查null
cu.findAll(MethodCallExpr.class).forEach(call -> {
if (call.getScope().isPresent() &&
call.getScope().get() instanceof MethodCallExpr) {
MethodCallExpr innerCall = (MethodCallExpr) call.getScope().get();
// 内层调用可能返回null,外层直接调用方法
int lineNum = call.getBegin().map(p -> p.line).orElse(-1);
result.addRisk(new CodeRisk(
RiskType.NULL_POINTER,
"链式方法调用可能存在NPE风险: " + call.toString().substring(0,
Math.min(80, call.toString().length())),
lineNum,
RiskSeverity.MEDIUM
));
}
});
// 检测:Map.get()的结果未经null检查直接使用
cu.findAll(MethodCallExpr.class)
.stream()
.filter(call -> call.getNameAsString().equals("get") &&
call.getArguments().size() == 1)
.forEach(call -> {
// 检查父节点是否有null检查
boolean hasNullCheck = call.findAncestor(IfStmt.class)
.map(ifStmt -> ifStmt.getCondition().toString()
.contains("null"))
.orElse(false);
if (!hasNullCheck) {
int lineNum = call.getBegin().map(p -> p.line).orElse(-1);
result.addRisk(new CodeRisk(
RiskType.NULL_POINTER,
"Map.get()返回值未做null检查",
lineNum,
RiskSeverity.LOW
));
}
});
}
/**
* 检测资源泄漏风险
*/
private void detectResourceLeaks(CompilationUnit cu, AstAnalysisResult result) {
// 检测:new FileInputStream/Connection等但没有try-with-resources
List<String> resourceClasses = List.of(
"FileInputStream", "FileOutputStream", "Connection",
"PreparedStatement", "ResultSet", "BufferedReader"
);
cu.findAll(ObjectCreationExpr.class)
.stream()
.filter(expr -> resourceClasses.contains(
expr.getType().getNameAsString()))
.forEach(expr -> {
boolean inTryWithResources = expr.findAncestor(TryStmt.class)
.map(t -> !t.getResources().isEmpty())
.orElse(false);
if (!inTryWithResources) {
int lineNum = expr.getBegin().map(p -> p.line).orElse(-1);
result.addRisk(new CodeRisk(
RiskType.RESOURCE_LEAK,
expr.getType().getNameAsString() +
" 未使用try-with-resources,可能存在资源泄漏",
lineNum,
RiskSeverity.HIGH
));
}
});
}
private void detectConcurrencyIssues(CompilationUnit cu, AstAnalysisResult result) {
// 检测:在非同步方法中访问static可变字段
// 简化实现,实际需要更复杂的数据流分析
cu.findAll(FieldDeclaration.class)
.stream()
.filter(f -> f.isStatic() && !f.isFinal() && !f.isPrivate())
.forEach(f -> {
result.addRisk(new CodeRisk(
RiskType.CONCURRENCY,
"非private的static可变字段: " +
f.getVariables().get(0).getNameAsString() +
",多线程场景需注意线程安全",
f.getBegin().map(p -> p.line).orElse(-1),
RiskSeverity.MEDIUM
));
});
}
private void detectPerformanceIssues(CompilationUnit cu, AstAnalysisResult result) {
// 检测:在循环中进行字符串拼接
cu.findAll(ForStmt.class).forEach(forStmt -> {
forStmt.findAll(BinaryExpr.class)
.stream()
.filter(e -> e.getOperator() == BinaryExpr.Operator.PLUS)
.forEach(e -> {
result.addRisk(new CodeRisk(
RiskType.PERFORMANCE,
"循环中进行字符串+拼接,建议使用StringBuilder",
e.getBegin().map(p -> p.line).orElse(-1),
RiskSeverity.LOW
));
});
});
}
private int calculateMaxNestingDepth(MethodDeclaration method) {
return calculateDepth(method.getBody().orElse(null), 0);
}
private int calculateDepth(Node node, int currentDepth) {
if (node == null) return currentDepth;
int maxDepth = currentDepth;
for (Node child : node.getChildNodes()) {
if (child instanceof IfStmt || child instanceof ForStmt ||
child instanceof WhileStmt || child instanceof ForEachStmt ||
child instanceof SwitchStmt || child instanceof TryStmt) {
maxDepth = Math.max(maxDepth, calculateDepth(child, currentDepth + 1));
} else {
maxDepth = Math.max(maxDepth, calculateDepth(child, currentDepth));
}
}
return maxDepth;
}
}3.2 LLM代码审查服务
@Service
public class LlmCodeReviewService {
private final ChatClient chatClient;
private final JavaAstAnalyzer astAnalyzer;
private static final String REVIEW_PROMPT = """
你是一名资深Java工程师,正在进行代码审查。
【代码变更】
文件:{filename}
变更类型:{change_type}
```java
{code_diff}
```
【AST静态分析结果】
{ast_summary}
【已识别的潜在风险点】
{risk_points}
请重点针对以上风险点进行深入分析,同时关注:
1. 业务逻辑是否正确
2. 异常处理是否完善
3. 性能是否有优化空间
4. 代码可读性和可维护性
输出JSON格式的审查意见:
{
"issues": [
{
"severity": "critical/major/minor/suggestion",
"category": "bug/security/performance/style",
"line": 行号或null,
"description": "问题描述",
"suggestion": "建议的修改方式"
}
],
"overall_score": 0-100,
"summary": "总体评价(一句话)"
}
""";
public LlmCodeReviewService(ChatClient.Builder builder,
JavaAstAnalyzer astAnalyzer) {
this.chatClient = builder.build();
this.astAnalyzer = astAnalyzer;
}
public CodeReviewReport review(CodeChange change) {
// 1. AST分析
AstAnalysisResult astResult = astAnalyzer.analyze(change.getNewContent());
// 2. 构建AST摘要(压缩信息,节省token)
String astSummary = buildAstSummary(astResult);
String riskPoints = buildRiskSummary(astResult.getRisks());
// 3. 根据代码复杂度决定审查深度
ReviewDepth depth = determineReviewDepth(astResult);
if (depth == ReviewDepth.SKIP) {
return CodeReviewReport.skipped("代码变更过于简单,跳过AI审查");
}
// 4. 构建Prompt并调用LLM
String prompt = REVIEW_PROMPT
.replace("{filename}", change.getFilename())
.replace("{change_type}", change.getChangeType().name())
.replace("{code_diff}", truncateDiff(change.getDiff(), 3000))
.replace("{ast_summary}", astSummary)
.replace("{risk_points}", riskPoints);
CodeReviewReport report = chatClient.prompt(prompt)
.options(ChatOptions.builder().temperature(0.1).build())
.call()
.entity(CodeReviewReport.class);
// 5. 合并AST发现的问题(high和critical级别的直接加入报告)
for (CodeRisk risk : astResult.getRisks()) {
if (risk.getSeverity() == RiskSeverity.HIGH) {
report.addIssue(new ReviewIssue(
"critical", "bug", risk.getLineNumber(),
risk.getDescription(), "请修复此安全/可靠性问题"
));
}
}
return report;
}
private String buildAstSummary(AstAnalysisResult result) {
StringBuilder sb = new StringBuilder();
// 找出高复杂度方法
result.getMethodMetrics().stream()
.filter(m -> m.getCyclomaticComplexity() > 10)
.forEach(m -> sb.append(String.format(
"方法%s:圈复杂度%d(较高),嵌套深度%d,%d行\n",
m.getMethodName(), m.getCyclomaticComplexity(),
m.getMaxNestingDepth(), m.getLineCount())));
return sb.length() > 0 ? sb.toString() : "代码复杂度正常";
}
private String buildRiskSummary(List<CodeRisk> risks) {
if (risks.isEmpty()) return "未发现静态分析风险";
return risks.stream()
.map(r -> String.format("行%d [%s-%s]: %s",
r.getLineNumber(), r.getType().name(),
r.getSeverity().name(), r.getDescription()))
.collect(Collectors.joining("\n"));
}
private ReviewDepth determineReviewDepth(AstAnalysisResult result) {
// 只有简单getter/setter变更,跳过
boolean allSimpleMethods = result.getMethodMetrics().stream()
.allMatch(m -> m.getLineCount() <= 5 && m.getCyclomaticComplexity() <= 2);
if (allSimpleMethods && result.getRisks().isEmpty()) {
return ReviewDepth.SKIP;
}
// 有高风险或高复杂度,深度审查
boolean hasHighRisk = result.getRisks().stream()
.anyMatch(r -> r.getSeverity() == RiskSeverity.HIGH);
return hasHighRisk ? ReviewDepth.DEEP : ReviewDepth.NORMAL;
}
private String truncateDiff(String diff, int maxChars) {
return diff.length() <= maxChars ? diff :
diff.substring(0, maxChars) + "\n... (diff已截断,仅分析前部分)";
}
enum ReviewDepth { SKIP, NORMAL, DEEP }
}3.3 GitLab PR集成
@Service
public class GitLabReviewIntegration {
private final RestTemplate restTemplate;
private final LlmCodeReviewService reviewService;
@Value("${gitlab.api-url}")
private String gitlabApiUrl;
@Value("${gitlab.access-token}")
private String accessToken;
/**
* 处理GitLab Webhook事件
*/
@PostMapping("/webhook/gitlab")
public ResponseEntity<Void> handleWebhook(@RequestBody GitLabMrEvent event) {
if ("merge_request".equals(event.getObjectKind()) &&
"open".equals(event.getObjectAttributes().getState())) {
// 异步处理,不阻塞webhook响应
CompletableFuture.runAsync(() -> reviewMergeRequest(event));
}
return ResponseEntity.ok().build();
}
private void reviewMergeRequest(GitLabMrEvent event) {
int projectId = event.getProject().getId();
int mrIid = event.getObjectAttributes().getIid();
// 获取MR的所有变更文件
List<MrChange> changes = getMrChanges(projectId, mrIid);
// 过滤:只审查Java文件
List<CodeReviewReport> reports = changes.stream()
.filter(c -> c.getNewPath().endsWith(".java"))
.filter(c -> !c.isDeleted())
.map(c -> reviewService.review(toCodeChange(c)))
.filter(r -> !r.isSkipped())
.collect(Collectors.toList());
if (reports.isEmpty()) return;
// 在MR中添加审查评论
postReviewComment(projectId, mrIid, buildReviewComment(reports));
}
private String buildReviewComment(List<CodeReviewReport> reports) {
StringBuilder comment = new StringBuilder();
comment.append("## AI代码审查报告\n\n");
int totalIssues = reports.stream()
.mapToInt(r -> r.getIssues().size()).sum();
long criticalCount = reports.stream()
.flatMap(r -> r.getIssues().stream())
.filter(i -> "critical".equals(i.getSeverity())).count();
comment.append(String.format("发现 **%d** 个问题,其中 **%d** 个严重问题\n\n",
totalIssues, criticalCount));
for (CodeReviewReport report : reports) {
if (report.getIssues().isEmpty()) continue;
comment.append("### ").append(report.getFilename()).append("\n\n");
comment.append("整体评分:").append(report.getOverallScore()).append("/100\n\n");
for (ReviewIssue issue : report.getIssues()) {
comment.append(String.format(
"**[%s]** %s: %s\n> 建议:%s\n\n",
issue.getSeverity().toUpperCase(),
issue.getCategory(),
issue.getDescription(),
issue.getSuggestion()
));
}
}
comment.append("\n---\n*由AI代码审查助手自动生成,仅供参考,最终决策由人工审查员确认*");
return comment.toString();
}
private void postReviewComment(int projectId, int mrIid, String comment) {
Map<String, String> body = Map.of("body", comment);
HttpHeaders headers = new HttpHeaders();
headers.set("PRIVATE-TOKEN", accessToken);
headers.setContentType(MediaType.APPLICATION_JSON);
restTemplate.exchange(
gitlabApiUrl + "/projects/" + projectId +
"/merge_requests/" + mrIid + "/notes",
HttpMethod.POST,
new HttpEntity<>(body, headers),
Map.class);
}
}四、效果评估与优化
AI代码审查系统上线6个月的数据(团队20人,累计处理2400个PR):
| 指标 | 上线前 | 上线后 |
|---|---|---|
| 人均Code Review时间(小时/周) | 4.2h | 2.1h |
| AI发现但人工曾遗漏的问题数/月 | - | 平均82个 |
| AI误报率 | - | 19%(AST+LLM组合) |
| 生产环境Bug中可Code Review发现的比例 | 38% | 24%(减少了14%) |
| 开发者满意度 | - | 4.1/5.0 |
误报率19%的背后:纯LLM时是68%误报,加了AST预分析后降到19%。AST让LLM的审查更有针对性,大幅减少了对正常代码的错误评论。
五、踩坑实录
坑1:LLM对大型文件的diff分析质量下降
当一个PR包含1000行变更时,把完整diff发给LLM,输出质量明显下降——意见变得笼统,具体的行号不准确。改进方案:把大diff拆分成多个语义块(按方法/类为单位),分别Review然后合并结果。虽然多了LLM调用次数,但每次调用的质量显著提升。
坑2:AST解析在有语法错误的代码上失败
开发者有时候提交的是"半成品"代码(工作中途提交保存进度),JavaParser在有语法错误时会抛异常,导致整个审查流程中断。改进:对AST解析加了try-catch,解析失败时降级为纯LLM审查,并在报告中注明"AST分析不可用"。
坑3:开发者对AI评论产生"免疫"
刚上线时大家都认真看AI的评论,两个月后,有些开发者开始自动忽略所有AI评论(因为偶尔有误报)。这是"告警疲劳"的经典问题。解决方案:按严重程度分级呈现,只有"critical"级别的问题才用醒目标记,"suggestion"级别折叠默认不展示。同时在每周会议上统计哪些AI建议被采纳了,建立正向反馈机制。
六、总结
AST解析+LLM的代码审查组合,让AI能真正减轻工程师的Review负担,而不是制造噪音。关键是AST承担"找到哪些地方值得重点看",LLM承担"深度语义分析这些重点",两者分工协作,效果远好于单独使用任何一个。
这个方案的价值不只是发现Bug,更重要的是建立了一个持续学习的质量守护机制——每个PR的审查结果都是一次团队知识积累,AI的"重点发现"也在不断告诉团队哪些问题最常见、哪里最容易出错。
