Java + Spring AI 实战:从零构建智能 Agent 系统

Java + Spring AI 实战:从零构建智能 Agent 系统

一、项目背景

在大语言模型(LLM)飞速发展的今天,智能 Agent 已成为 AI 应用的重要形态。一个完整的智能 Agent 需要具备四大核心能力:

  1. 决策能力:理解用户意图并选择正确的处理路径
  2. 知识能力:拥有领域知识,回答专业问题
  3. 执行能力:调用工具完成实际任务
  4. 安全能力:敏感操作需人工审批方可执行

Sagent 正是基于这四大能力构建的智能 Agent 示例项目。它基于 Spring AI 2.0 框架,实现了完整的消息路由、知识库检索和工具调用能力,是学习和理解智能 Agent 架构的优秀参考。

1.1 为什么选择 Spring AI 2.0

Spring AI 2.0 是 Spring 官方推出的 AI 应用开发框架,相比其他框架具有以下优势:

  • 与 Spring 生态无缝集成:天然支持 Spring Boot、Spring Cloud 等生态
  • 标准化的工具调用机制:内置 ToolCallingAdvisor 实现自动工具调用循环
  • 灵活的记忆管理:支持多种会话记忆策略
  • 多模型支持:一键切换不同的 LLM 提供商

1.2 项目目标

Sagent 项目旨在展示如何构建一个生产级智能 Agent,具体目标包括:

  • 实现完整的消息分类和路由机制
  • 演示 RAG 知识库检索的最佳实践
  • 展示工具调用循环的实现方式
  • 展示敏感操作的审批安全机制
  • 提供可复用的技能和工具抽象
  • 构建友好的前端交互界面

二、技术栈介绍

Sagent 采用了现代化的技术栈,确保项目的稳定性和可扩展性。

2.1 核心技术

技术 版本 用途
JDK 21 项目运行环境,支持虚拟线程等新特性
Spring Boot 4.1.0 应用框架,提供便捷的依赖管理和自动配置
Spring AI 2.0.0 AI 应用开发框架,核心依赖
OpenRouter - LLM 接入平台,提供多模型支持

2.2 机器学习组件

技术 用途
Transformers 本地运行 ONNX Embedding 模型
SimpleVectorStore 内存向量库,存储和检索文档向量
all-MiniLM-L6-v2 轻量级 Embedding 模型(ONNX 格式)

2.3 数据存储

技术 用途
H2 内存数据库,存储产品数据供查询测试
System Temporary Directory 文件存储,保存下载的网页、图片和压缩包

2.4 前端技术

技术 用途
Vue 2 前端框架,构建聊天界面
Element UI UI 组件库,提供美观的界面组件

2.5 MCP 协议

MCP(Model Context Protocol)是一种标准化的工具调用协议,用于连接 LLM 和外部工具。Sagent 通过 MCP 协议调用外部服务,实现计算器、天气查询、股票查询等功能。


三、消息分类:Agent 的大脑决策

消息分类是 Agent 的入口决策层,决定用户请求应该走哪条处理路径。

3.1 分类体系设计

我们设计了六种消息类型,按优先级从高到低排列:

类型 场景 特点
SKILL 企业固定技能 单次调用单一工具,不进入循环
GSKILL 通用技能 多轮工具调用循环
ASKILL 审批技能 敏感操作需人工审批后执行
RAG 知识库查询 本地向量检索 + LLM 回答
MCP 外部服务 通过 MCP 协议调用外部工具
CHAT 普通聊天 兜底的通用对话

这种优先级设计确保了精确匹配的任务不会被通用逻辑"吞没"。

3.2 分类器实现

核心逻辑在 MessageClassifier.java

@Tool(description = "根据用户消息内容进行分类")
public RouteDecision classify(String message) {
    String prompt = """
        你是一个消息分类器,根据以下规则对用户消息进行分类:
        
        SKILL(最高优先级):组合技能(单次调用),不含数据库查询
        关键词:网页下载、截图、文件下载、压缩打包
        
        RAG:知识库查询
        关键词:Sagent、项目说明、路由规则、知识库、文档、手册
        
        GSKILL:通用技能(循环调用)
        关键词:数据库、产品查询、时间、闹钟
        
        ASKILL:审批技能
        关键词:删除产品、修改价格、修改库存等敏感数据库操作
        
        MCP:外部服务
        关键词:计算器、天气、股票、系统信息、回显
        
        CHAT:其他通用对话
        
        请严格按照优先级判断,返回格式:{"type": "SKILL", "reason": "xxx"}
        """;
    
    // 调用 LLM 获取分类结果
    String response = chatClient.prompt(prompt + "\n用户消息:" + message)
                                .call()
                                .content();
    
    return parseRouteDecision(response);
}

3.3 关键设计要点

分类器不使用会话记忆:分类器使用独立的 ChatClient,不注入 MessageChatMemoryAdvisor,避免把分类决策写入正式聊天记录。

// MessageClassifier 中的 ChatClient
private final ChatClient classifierClient;

// 通过构造函数注入,不包含 memoryAdvisor
public MessageClassifier(ChatModel chatModel) {
    this.classifierClient = ChatClient.builder(chatModel).build();
}

四、RAG 原理:让 LLM 拥有领域知识

RAG(Retrieval-Augmented Generation)是让大模型回答特定领域问题的核心技术。

4.1 RAG 完整流程

用户提问 → 文本向量化 → 向量检索 → LLM 重排序 → 生成回答

4.2 文本向量化

使用本地 ONNX 模型进行 Embedding:

// RagHandler.java
private final EmbeddingModel embeddingModel;

public String generateAnswer(String query, List<Document> documents) {
    // 1. 将查询文本向量化
    Embedding queryEmbedding = embeddingModel.embed(query);
    
    // 2. 在向量库中检索相似文档
    List<Document> retrievedDocs = vectorStore.similaritySearch(queryEmbedding, 5);
    ...
}

4.3 混合检索策略

我们实现了关键词检索 + 向量检索的混合策略:

// VectorKnowledgeRetriever.java
public List<Document> hybridSearch(String query) {
    // 1. 关键词检索
    List<Document> keywordResults = keywordSearch(query);
    
    // 2. 向量检索
    List<Document> vectorResults = vectorSearch(query);
    
    // 3. 去重合并
    Set<Document> combined = new LinkedHashSet<>();
    combined.addAll(vectorResults);
    combined.addAll(keywordResults);
    
    return new ArrayList<>(combined);
}

4.4 LLM 重排序

检索结果可能包含噪声,我们使用 LLM 进行智能重排序:

// RagHandler.java
private List<Document> llmRerank(String query, List<Document> documents) {
    // 使用独立的 rerankClient,避免会话记忆干扰
    String prompt = """
        根据与查询的相关性对以下文档进行排序,返回排序后的索引(逗号分隔):
        查询:%s
        文档:%s
        """;
    
    String response = rerankClient.prompt(String.format(prompt, query, documents))
                                  .call()
                                  .content();
    
    return reorderDocuments(documents, parseIndices(response));
}

关键坑点rerankClient 必须独立创建,不能复用带 MessageChatMemoryAdvisorchatClient,否则会报 conversationId cannot be null 错误。

4.5 最终回答生成

将检索到的上下文注入 LLM 提示词:

private String generateAnswer(String query, List<Document> documents) {
    String context = documents.stream()
        .map(d -> d.getContent())
        .collect(Collectors.joining("\n---\n"));
    
    String prompt = """
        基于以下上下文回答用户问题:
        
        %s
        
        用户问题:%s
        
        如果上下文没有相关信息,请直接回答,不要编造。
        """;
    
    return chatClient.prompt(String.format(prompt, context, query))
                     .call()
                     .content();
}

五、工具调用循环:让 Agent 具备执行能力

工具调用是 Agent 从"聊天机器人"进化为"智能助手"的关键。

5.1 Spring AI 2.0 的工具调用机制

Spring AI 2.0 将工具调用循环从 ChatModel 内部抽取为 ToolCallingAdvisor 递归顾问:

注入工具定义 → 调用 LLM → LLM 返回工具调用请求 → 执行工具 → 回填结果 → 再次调用 LLM → 循环

5.2 工具注册方式

通过 @Tool 注解定义工具,然后通过 .tools() 注册到 ChatClient

// DataBaseSkill.java - GSKILL 示例
@Component
public class DataBaseSkill implements GSkill {
    
    @Tool(description = "查询产品数量")
    public String getProductCount() {
        return "产品总数:" + productRepository.count();
    }
    
    @Tool(description = "查询指定价格范围内的产品")
    public String searchProductsByPrice(
        @ToolParam(description = "最低价格") double minPrice,
        @ToolParam(description = "最高价格") double maxPrice
    ) {
        List<Product> products = productRepository.findByPriceBetween(minPrice, maxPrice);
        return formatProducts(products);
    }
}

5.3 工具调用配置

// SkillHandler.java
public HandlerResult handle(String message, String conversationId) {
    ChatClient chatClient = ChatClient.builder(chatModel)
        .defaultSystem("找到一个最合适的工具即可调用,不要调用多个工具")
        .build();
    
    // 注册工具
    chatClient = chatClient.tools(webPageTool, compressionTool);
    
    // 调用工具并获取结果
    String result = chatClient.prompt(message)
        .call()
        .content();
    
    return HandlerResult.success(result);
}

5.4 SKILL vs GSKILL vs ASKILL 的区别

特性 SKILL(企业固定技能) GSKILL(通用技能) ASKILL(审批技能)
调用模式 单次调用,不进入循环 多轮工具调用循环 敏感操作需人工审批
LLM 角色 工具选择器 工具选择器 + 任务规划者 工具选择器
适用场景 明确的单一步骤任务 需要多步骤协作的复杂任务 删除、修改等敏感操作
示例 截图、下载网页 数据库查询、多步计算 删除产品、修改价格

5.5 循环停止条件

ToolCallingAdvisor 的循环在以下情况停止:

  1. LLM 返回不含工具调用的响应 — 这是正常结束
  2. 达到最大循环次数 — 防止无限循环
  3. 工具执行失败 — 返回错误信息

六、ASKILL 审批机制:让人在回路中

对于删除产品、修改价格等敏感操作,直接让 LLM 执行存在安全风险。Sagent 引入了 ASKILL 审批技能,通过"人在回路中"(Human-in-the-Loop)的审批机制来保障安全。

6.1 核心设计理念

LLM 调用敏感工具时,不直接执行,而是创建一条审批记录等待人工确认:

LLM调用deleteProduct → @Approval AOP拦截 → 创建PENDING记录 → 返回等待审批
用户批准 → ApprovalBypass(ThreadLocal)绕过 → ToolRegistry反射调用原方法 → 真正执行

6.2 组件职责

组件 职责
@Approval(enable=true) 标注需要审批的 Tool 方法
ApprovalAspect AOP 切面,拦截 @Approval 方法,自动创建审批记录
ApprovalService 审批记录 CRUD(创建 PENDING、批准、拒绝)
ToolRegistry 扫描所有 ASkill Bean 的 @Tool 方法,建立工具名到方法的映射
ApprovalBypass ThreadLocal 标志,审批面板执行时跳过 AOP 拦截
ApprovalContext ThreadLocal 上下文,传递会话 ID 和用户 ID

6.3 ASkill 接口设计

// ASkill.java - 审批技能接口
public interface ASkill {
    String getName();
    String getDescription();
}
// ApprovalSqlSkill.java - 审批 SQL 技能实现
@Component
public class ApprovalSqlSkill implements ASkill {
    
    // 查询方法:无需审批,直接放行
    @Tool(description = "查询当前会话的所有审批记录")
    public String getMyApprovals() { ... }
    
    @Tool(description = "根据审批编号查询单个审批状态")
    public String checkApprovalById(String id) { ... }
    
    // 写入方法:带 @Approval,需要审批
    @Tool(description = "删除指定ID的产品")
    @Approval(enable = true)
    public String deleteProduct(Long id) { ... }
    
    @Tool(description = "修改产品价格")
    @Approval(enable = true)
    public String updateProductPrice(Long id, Double newPrice) { ... }
    
    @Tool(description = "修改产品库存")
    @Approval(enable = true)
    public String updateProductStock(Long id, Integer newStock) { ... }
}

6.4 审批流程详解

第一步:LLM 调用,AOP 拦截

// ApprovalAspect.java
@Around("@annotation(approval) && execution(* com.example.sagent.agent.skills.ASkill+.*(..))")
public Object checkApproval(ProceedingJoinPoint joinPoint, Approval approval) {
    if (ApprovalBypass.isEnabled()) {
        return joinPoint.proceed(); // 审批面板直调,跳过拦截
    }
    // 创建 PENDING 记录并返回
    String recordId = approvalService.createPending(
        ApprovalContext.getConversationId(),
        joinPoint.getSignature().getName(),
        joinPoint.getArgs()
    );
    return "PENDING:" + recordId + " 操作已提交审批,请在审批面板中确认";
}

第二步:用户在审批面板批准

// ApprovalController.java
@PostMapping("/approvals/{id}/approve")
public String approve(@PathVariable String id) {
    ApprovalRecord record = approvalService.findById(id);
    
    // 启用绕过标志,直接执行业务逻辑
    ApprovalBypass.enable();
    try {
        String result = toolRegistry.invokeTool(record.methodName(), record.args());
        approvalService.approve(id);
        return result;
    } finally {
        ApprovalBypass.clear();
    }
}

6.5 为什么这么设计

这个方案引入了 AOP + ThreadLocal + ToolRegistry 三块基础设施,看似复杂,但这是追求通用性必须付出的代价:

  • AOP:统一拦截所有 @Approval 方法,新增加敏感操作只需加注解,无需改业务代码
  • ThreadLocal:区分"LLM 调用"和"审批面板执行"两个来源,同一条链路两个角色无缝切换
  • ToolRegistry:反射扫描 ASkill Bean 的 @Tool 方法,审批通过后自动唤起,不依赖具体类名

去掉任何一个都会牺牲通用性。简化方案(比如审批面板直接调 Service 层 SQL)虽然代码少,但每新增一个敏感操作就需要加 case,不具备复用性。


七、完整架构

flowchart TD U["用户"] --> C["ChatController"] C --> AS["AgentService"] AS --> MC["MessageClassifier"] MC --> D{"分类结果"} D -->|"SKILL"| SH["SkillHandler"] SH --> WT["WebPageDownloadSkill"] D -->|"GSKILL"| GSH["GSkillHandler"] GSH --> DB["DataBaseSkill"] GSH --> AK["AlarmSkill"] D -->|"ASKILL"| AH["ASkillHandler"] AH --> APS["ApprovalSqlSkill"] APS --> APV["ApprovalService<br/>(创建PENDING)"] APV --> UI["审批面板<br/>批准/拒绝"] UI --> TR["ToolRegistry<br/>(重新唤起)"] D -->|"RAG"| RH["RagHandler"] RH --> VS["VectorStore"] RH --> EM["EmbeddingModel"] D -->|"MCP"| MH["McpHandler"] MH --> MS["MCP Server"] D -->|"CHAT"| CH["ChatHandler"] WT --> FC["FileController"] SH --> MEM["ChatMemory"] AH --> MEM RH --> MEM GSH --> MEM MH --> MEM CH --> MEM MEM --> C FC --> U

八、关键技术总结

8.1 内存隔离

  • 分类器内存:独立的 ChatClient,不使用记忆
  • 处理器内存:共享 MessageChatMemoryAdvisor,支持多轮对话
  • 重排序内存:独立的 rerankClient,避免会话 ID 干扰

8.2 安全约束

  • 文件操作限制在系统临时目录
  • 工具调用限制单次调用(SKILL)或受控循环(GSKILL)
  • 敏感操作需人工审批(ASKILL)
  • 路径遍历攻击防护

8.3 审批安全机制

  • AOP 拦截@Approval(enable=true) 统一拦截所有敏感 Tool 方法
  • ThreadLocal 隔离ApprovalBypass 区分 LLM 调用和审批面板执行
  • 反射唤起ToolRegistry 审批通过后自动唤起原始方法
  • 会话隔离ApprovalContext 传递会话 ID,每个会话的审批记录独立

8.4 延迟初始化

MCP 客户端采用延迟初始化,首次请求时才建立连接,避免启动依赖。


九、快速上手

# 克隆项目
git clone https://github.com/hdwang123/sagent.git

# 设置环境变量
export OPENROUTER_API_KEY="你的Key"

# 启动项目
cd agentdemo
mvn spring-boot:run

# 访问聊天页面
http://localhost:8080/chat.html

十、结语

智能 Agent 的核心在于决策(消息分类)、知识(RAG)、执行(工具调用)和安全(审批机制)四者的有机结合。Sagent 项目展示了如何基于 Spring AI 2.0 构建一个架构清晰、功能完整的智能 Agent 系统。

如果你正在构建自己的 Agent 系统,希望本文能给你带来启发。欢迎在 GitHub 上 Star 和 Fork 项目,一起交流学习!


项目地址
https://github.com/hdwang123/sagent
https://gitee.com/hdwang123/sagent

posted @ 2026-07-19 10:45  追极  阅读(159)  评论(0)    收藏  举报