AI代码审查Agent进阶:Spring AI核心组件架构拆解与踩坑实录

34 阅读6分钟

前言

我是宋哥,6年Java后端,正在转型AI Agent开发。

Week1我从零搭了一个AI代码审查助手code-review-agent,5个版本迭代:Day1调通ChatAPI,Day2接入代码解析,Day3加上对话记忆,Day4实现ReAct推理,Day5才是完整版——ReAct + 自我反思 + 迭代约束

今天花一整天消化这三个核心组件的架构和工作原理,写出来跟大家交流。

⚠️ 本文基于JDK 21 + Spring AI 1.1.1 & Spring AI Alibaba 1.1.0.0,实战验证过。

一、ChatMemory:分层架构下的对话记忆管理

1.1 为什么需要 ChatMemory?

Day3我遇到一个坑:直接调ChatAPI时,每次对话都是独立的,LLM根本不记得上下文。翻了Spring AI文档才发现,它提供了ChatMemory接口。

核心区别

  • ChatMemory:有策略的上下文窗口,控制输入给LLM的消息范围(短期记忆)
  • ChatHistory:完整的消息记录,用于长期归档(长期存储)

java

public interface ChatMemory {
    void add(String conversationId, List<ChatMessage> messages);
    List<ChatMessage> get(String conversationId, int capacityIndex);
    void clear(String conversationId);
}

1.2 分层架构:策略层与持久化层分离

Spring AI做了很好的分层设计:

plaintext

┌─────────────────────────────────────────────┐
│            ChatMemory (策略层)               │
│   MessageWindowChatMemory (滑动窗口)         │
│   - retain(20) 保留最近20条消息              │
│   - 超出容量自动截断最旧消息                  │
└─────────────────────────────────────────────┘
                    │
                    ▼
┌─────────────────────────────────────────────┐
│        ChatMemoryRepository (持久化层)       │
│  InMemoryChat... │ JdbcChatMemoryRepository  │
│  - 开发/测试环境  │ - 生产环境推荐            │
└─────────────────────────────────────────────┘

1.3 实战配置

java

@Configuration
public class ChatMemoryConfig {
    
    @Bean
    public ChatMemory chatMemory() {
        // 滑动窗口:保留最近20条
        return MessageWindowChatMemory.builder()
                .maxMessageCount(20)
                .build();
    }
    
    @Bean
    public ChatMemoryRepository chatMemoryRepository() {
        // 生产环境换 JdbcChatMemoryRepository
        return new InMemoryChatMemoryRepository();
    }
}

使用示例:

java

@Service
public class CodeReviewService {
    private final ChatMemory chatMemory;
    private final ChatClient chatClient;
    
    public String review(String conversationId, String code) {
        // 追加用户消息
        chatMemory.add(conversationId, List.of(
            UserMessage.of("请审查:" + code)
        ));
        
        // 获取历史(受滑动窗口控制)
        List<ChatMessage> history = chatMemory.get(conversationId, 20);
        
        // 调用 LLM
        String response = chatClient.prompt()
                .messages(history)
                .options(ChatOptions.builder().model("qwen-plus").build())
                .call()
                .content();
        
        // 追加 AI 响应
        chatMemory.add(conversationId, List.of(
            AssistantMessage.of(response)
        ));
        
        return response;
    }
}

1.4 生产环境注意

我的立场:不要在生产环境用InMemoryChatMemoryRepository,多实例部署时每个实例的记忆是隔离的,用户换实例就会丢上下文。

java

// 生产环境:JDBC 存储,支持多实例共享
@Bean
public ChatMemoryRepository chatMemoryRepository(DataSource dataSource) {
    return new JdbcChatMemoryRepository(dataSource);
}

二、ReactAgent:基于 StateGraph 的 ReAct 循环实现

2.1 为什么选 ReactAgent?

Day4我调研了LangChain4j和Spring AI Alibaba两个方案,最终选ReactAgent,原因是它基于StateGraph实现,更容易嵌入现有架构

表格

特性

LangChain4j

Spring AI Alibaba ReactAgent

架构风格

链式 (Chain)

图式 (StateGraph)

Spring集成

需要适配

原生支持

可嵌入性

独立运行

asNode() 可转为子图节点

2.2 核心架构:StateGraph 图状态机

ReactAgent将ReAct循环建模为状态图:

plaintext

┌──────────────────────────────────────────────┐
│                  StateGraph                   │
│  ┌─────────────┐    ┌────────────────────┐  │
│  │AgentLlmNode │───▶│    AgentToolNode   │  │
│  │  (推理节点)  │    │     (工具执行)      │  │
│  └─────────────┘    └────────────────────┘  │
│         │                      │             │
│         └──────── Loop ────────┘             │
│                                               │
│  OverAllState: { messages: [...], ... }      │
│  终止条件: LLM响应不包含tool_call请求         │
└──────────────────────────────────────────────┘

2.3 实战代码

java

@Configuration
public class ReactAgentConfig {
    
    @Bean
    public ReactAgent codeReviewAgent(
            ToolCallbackProvider toolCallbackProvider,
            ChatMemory chatMemory) {
        return ReactAgent.builder("代码审查Agent")
                .model("qwen-plus")
                .tools(toolCallbackProvider)
                .chatMemory(chatMemory)
                .stateful(true)  // 状态保存
                .build();
    }
}

2.4 自定义工具节点

java

@Tool(name = "parseCode", description = "解析代码AST,提取函数和类结构")
public String parseCode(@ToolParam("待审查代码") String code) {
    return parser.extractStructure(code);
}

@Tool(name = "checkSecurity", description = "安全漏洞扫描")
public String checkSecurity(@ToolParam("代码AST") String ast) {
    return securityScanner.scan(ast);
}

@Tool(name = "generateReport", description = "生成审查报告")
public String generateReport(@ToolParam("审查结果") String result) {
    return reportGenerator.format(result);
}

2.5 Hook 机制

Spring AI提供了四种HookPosition,我用AFTER_AGENT做了日志记录:

java

@Component
public class AgentLoggerHook implements ReactAgentHook {
    
    @Override
    public void afterAgent(AgentContext context, OverAllState state) {
        log.info("Agent执行完成,消息数: {}", state.getMessages().size());
    }
    
    @Override
    public HookPosition getPosition() {
        return HookPosition.AFTER_AGENT;
    }
}

2.6 终止条件

ReAct循环的退出逻辑:

java

while (true) {
    Response llmResponse = callLlm(state);
    
    if (!llmResponse.hasToolCalls()) {
        // LLM认为任务完成,退出
        return llmResponse;
    }
    
    // 执行工具,更新状态
    state = executeTools(llmResponse.getToolCalls());
}

踩坑记录:Day4我遇到LLM死循环问题,根本原因是工具描述不够清晰。后来在@Tooldescription里加上了明确的输入输出示例,问题解决。

三、ReflectiveReviewer:迭代式自我反思执行器

3.1 为什么需要自我反思?

代码审查质量不稳定——LLM有时漏检漏洞,有时格式混乱。我加了ReflectiveReviewer,让Agent主动审视输出,不达标就重来。

核心思想来自论文

Reflexion: Language Agents with Self-Reflection

3.2 迭代循环

plaintext

┌────────────────────────────────────────────┐
│             ReflectiveReviewer             │
│                                            │
│  审查1 → 反思1 → 改进 + 审查2 → ...        │
│                    │                       │
│                    ▼                       │
│  ┌──────────────────────────────────────┐  │
│  │        终止条件 (31)               │  │
│  │  1. 质量达标                         │  │
│  │  2. 结果收敛 (Jaccard ≥ 0.85)       │  │
│  │  3. 达到最大迭代次数 (5轮)           │  │
│  └──────────────────────────────────────┘  │
└────────────────────────────────────────────┘

3.3 核心实现

java

@Service
@Slf4j
public class ReflectiveReviewer {
    
    private static final int MAX_ITERATIONS = 5;
    private static final double CONVERGENCE_THRESHOLD = 0.85;
    
    private final ReactAgent agent;
    private final ReviewQualityChecker qualityChecker;
    
    public ReviewResult iterativeReview(String code) {
        String prompt = buildInitialPrompt(code);
        ReviewResult bestResult = null;
        List<String> previousIssues = new ArrayList<>();
        
        for (int i = 0; i < MAX_ITERATIONS; i++) {
            // 1. 调用Agent执行审查
            String response = agent.execute(prompt);
            ReviewResult current = parseResult(response);
            
            // 2. 质量检查
            if (qualityChecker.isAcceptable(current)) {
                log.info("第 {} 轮审查通过", i + 1);
                return current;
            }
            
            // 3. 收敛检查
            if (bestResult != null) {
                double similarity = calculateSimilarity(bestResult, current);
                if (similarity >= CONVERGENCE_THRESHOLD) {
                    return bestResult;
                }
            }
            
            // 4. 反思并生成改进prompt
            prompt = buildReflectionPrompt(current, previousIssues);
            bestResult = current;
            previousIssues.addAll(extractIssues(current));
        }
        
        return bestResult;
    }
    
    // Jaccard相似度计算
    private double calculateSimilarity(ReviewResult r1, ReviewResult r2) {
        Set<String> issues1 = r1.getIssues().stream()
                .map(i -> i.getTitle() + "|" + i.getDescription())
                .collect(Collectors.toSet());
        Set<String> issues2 = r2.getIssues().stream()
                .map(i -> i.getTitle() + "|" + i.getDescription())
                .collect(Collectors.toSet());
        
        Set<String> intersection = new HashSet<>(issues1);
        intersection.retainAll(issues2);
        
        Set<String> union = new HashSet<>(issues1);
        union.addAll(issues2);
        
        return union.isEmpty() ? 1.0 : (double) intersection.size() / union.size();
    }
    
    private String buildReflectionPrompt(ReviewResult result, List<String> previousIssues) {
        StringBuilder prompt = new StringBuilder();
        prompt.append("请反思以下审查结果并改进:\n\n");
        prompt.append("上次问题:\n");
        result.getIssues().forEach(i -> 
            prompt.append("- ").append(i.getTitle()).append(": ")
                  .append(i.getDescription()).append("\n"));
        
        if (!previousIssues.isEmpty()) {
            prompt.append("\n历史遗漏:\n");
            previousIssues.forEach(i -> prompt.append("- ").append(i).append("\n"));
        }
        
        prompt.append("\n请重新审查,确保识别所有问题、提供可操作建议、评估质量分数");
        return prompt.toString();
    }
}

3.4 三个终止条件的权衡

表格

条件

优点

缺点

质量达标

精确控制输出

需要精心设计规则,可能被"聪明"的LLM绕过

结果收敛

自动化判断

相似度高不等于质量高

最大迭代5轮

兜底保护

经验值,需根据实际调整

3.5 改进空间

回顾这段代码,我发现三个可以优化的地方:

1. Jaccard应替换为语义相似度

java

// 当前:基于文本的Jaccard
// 更好的方案:Embedding计算语义相似度
@Autowired
private EmbeddingModel embeddingModel;

private double semanticSimilarity(ReviewResult r1, ReviewResult r2) {
    String text1 = serializeIssues(r1.getIssues());
    Embedding emb1 = embeddingModel.embed(text1);
    Embedding emb2 = embeddingModel.embed(serializeIssues(r2.getIssues()));
    return cosineSimilarity(emb1, emb2);
}

2. 反思Prompt应动态填充缺失维度

当前是固定模板,更智能的做法是根据检查结果动态生成针对性反思指令

java

private String buildSmartReflectionPrompt(ReviewResult result, Set<String> missingDimensions) {
    StringBuilder prompt = new StringBuilder("请重新审查,注意以下改进点:\n");
    
    if (missingDimensions.contains("issues")) {
        prompt.append("- 上次遗漏了部分问题,请更仔细检查\n");
    }
    if (missingDimensions.contains("score")) {
        prompt.append("- 请重新评估代码质量分数\n");
    }
    // ...
    return prompt.toString();
}

3. previousIssues应融入Agent Prompt

我发现previousIssues提取了但没传给Agent。应该作为System Prompt的一部分:

java

String systemPrompt = """
    你是一个严谨的代码审查助手。
    请注意历史审查中曾被遗漏的问题类型,避免重蹈覆辙:
    %s
    """.formatted(String.join("\n", previousIssues));
agent.setSystemPrompt(systemPrompt);

四、架构串联:三个组件如何协作

plaintext

┌─────────────────────────────────────────────────────┐
│                    code-review-agent                │
│                                                     │
│  ┌───────────────────────────────────────────────┐ │
│  │              ReflectiveReviewer               │ │
│  │         (迭代控制器: 最多5轮审查+反思)         │ │
│  └───────────────────────────────────────────────┘ │
│                        │                            │
│                        ▼                            │
│  ┌───────────────────────────────────────────────┐ │
│  │                    ReactAgent                 │ │
│  │  AgentLlmNode ──▶ AgentToolNode               │ │
│  │     (推理)        parseCode/checkSecurity      │ │
│  └───────────────────────────────────────────────┘ │
│                        │                            │
│                        ▼                            │
│  ┌───────────────────────────────────────────────┐ │
│  │                  ChatMemory                    │ │
│  │   MessageWindowChatMemory (20条)               │ │
│  │   └─ ChatMemoryRepository (JDBC生产)           │ │
│  └───────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘

数据流向

  1. 用户请求 → ReflectiveReviewer 开始迭代
  2. 每轮调用 ReactAgentAgentLlmNode 推理
  3. AgentToolNode 执行工具(代码解析、安全扫描、报告生成)
  4. 工具结果通过 OverAllState 回传给推理节点
  5. ChatMemory 维护对话上下文
  6. 迭代结束 → 输出最终审查报告

五、实际效果展示

结语

回顾这5天的演进,我最大的感受是:Spring AI生态在快速成熟,但文档和最佳实践仍然稀缺。很多设计理念(比如Hook机制、asNode嵌入)需要读源码才能理解。