在 AI 应用爆发的今天,市面上充斥着各种 Agent 工具。但作为技术人,我们不仅要会“用”,更要懂“如何集成到业务”。ai-agent-chat 项目正是为了带你从浅入深理解市面上 Agent 能力的底层原理。本文将基于实战,拆解一个具备“大脑(LLM)、手脚(Tool Use / Function Calling)、记忆(Memory)、规划(Planning / ReAct)、系统提示词(System Prompt)”的 Agent 是如何炼成的。
源码获取:点击获取源码
本项目作为 spring-ai-lab 的子模块,版本受父 POM 统一管控。
下面是ai-agent-chat模块需要用到的父类依赖
Spring Boot: 3.3.3
Spring AI: 1.1.4 (引入 spring-ai-bom 抹平依赖)
Spring Cloud Alibaba: 2023.0.3.4 (集成了 Nacos)
Spring Redis Data: (后面分布式存储Memory 会用到)
父 POM 关键配置展示:
<properties> <spring-ai-version>1.1.4</spring-ai-version> <spring-cloud-alibaba.version>2023.0.3.4</spring-cloud-alibaba.version> </properties> <dependencyManagement> <dependencies> <!-- 引入 Spring AI bom 统一版本 --> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-bom</artifactId> <version>${spring-ai-version}</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> </dependencies>
当前ai-agent-chat模块引入依赖
<properties> <fastjson2.version>2.0.47</fastjson2.version> </properties> <!-- 集成deepseek公司依赖 用于DeepSeek 模型--> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-model-deepseek</artifactId> </dependency> <dependency> <groupId>com.alibaba.fastjson2</groupId> <artifactId>fastjson2</artifactId> <version>${fastjson2.version}</version> </dependency>
本项目由 Nacos 进行分布式配置管理,application.yml 中定义了动态配置导入逻辑,方便在不同环境下切换 Redis 和 AI 密钥。
server: port: 10005 spring: application: name: ai-agent-chat profiles: active: dev cloud: nacos: config: server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} username: ${NACOS_USERNAME:nacos} password: ${NACOS_PWD:nacos} file-extension: yaml namespace: b0486ef8-e9ac-4c88-881f-8eef86f122a5 group: DEFAULT_GROUP discovery: server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} username: ${NACOS_USERNAME:nacos} password: ${NACOS_PWD:nacos} namespace: b0486ef8-e9ac-4c88-881f-8eef86f122a5 config: import: - nacos:${spring.application.name}-${spring.profiles.active}.${spring.cloud.nacos.config.file-extension} - nacos:redis-common.${spring.cloud.nacos.config.file-extension}
nacos中ai-agent-chat-dev.yaml中配置
spring: ai: deepseek: api-key: # 登录DeepSeek官方:https://platform.deepseek.com/usage 购买api密钥(如果只是用于测试10元远远够用了) chat: options: model: deepseek-chat temperature: 1.0
nacos中redis-common.yaml中配置
spring: data: redis: port: 6379 host: # ip password: # 密码 timeout: 5000ms # 注意:建议加上单位 ms lettuce: pool: max-active: 5000 # 注意:属性名用横线分隔 max-idle: 30 min-idle: 5 max-wait: 2000ms cluster: refresh: adaptive: true period: 60s
Agent 与普通聊天机器人的本质区别在于其拥有 Tool Use(功能调用)的能力。
我们要让 模型 通过 Java 代码去“感知”外部世界。
本文模拟一个天气查询和订单查询的"触手",分别对应两个 Function Bean。
// 天气查询触手 @Configuration public class WeatherToolFunction { // 关键:LLM 参数识别载体。LLM 会解析用户输入并填充到这个 record 中 public record Weather(String city) { } @Bean @Description("查询今天天气") // 模型的“说明书”:告诉 LLM 什么时候调用这个 Bean public Function<Weather, String> weatherFunction() { return weather -> { if("成都".equals(weather.city)) return "成都晴,25°C"; return "未找到该城市天气信息"; }; } }
// 订单查询触手 @Configuration public class OrderToolFunction { public record Order(String orderId) { } @Bean @Description("查询订单信息") public Function<Order, String> orderFunction() { return order -> { if("D123456".equals(order.orderId)) return "订单 D123456,金额 100.00,已完成"; return "未找到该订单"; }; } }
Record 参数识别机制:当你定义
record Weather(String city)时,Spring AI 会将该类的元数据(字段名、注释)转换成 JSON Schema 发送给大模型。例如:用户问“成都天气如何?”,LLM 识别到意图与
weatherFunction匹配,并自动提取“成都”填充进 JSON{"city": "成都"},最后 Spring AI 将该 JSON 反序列化成 Java 对象传给你的方法。这就是“触手”的自动化原理。
这是一场极其简单的入门赛,演示如何通过 chatModel 直接发起调用。并加入上面实现的Tool函数
@RestController @RequestMapping("/ai/agent") public class DeepSeekToolChatController { @Resource private DeepSeekChatModel chatModel; @GetMapping("/call/toolFunction/chat") public String toolFunctionCallChat(@RequestParam String message) { return chatModel.call(new Prompt(message, DeepSeekChatOptions.builder() .toolNames("weatherFunction", "orderFunction") .build() )).getResult().getOutput().getText(); } }
获取今天成都天气信息

获取今天重庆天气信息,会返回获取不到,因为我们没有配置重庆天气信息

获取订单信息

在生产环境下,我们更倾向于使用 ChatClient,因为它在 ChatModel 之上构建了强大的业务闭环。这里会产生一个疑问:既然刚才用了 DeepSeekChatModel 发起对话,为什么在这儿又要用 ChatClient 呢?
| 维度 | ChatModel (底层驱动层) | ChatClient (上层应用层) |
|---|---|---|
| 打比方 | JDBC 的 java.sql.Connection | MyBatis-Plus 的 LambdaQueryWrapper |
| 纯度 | 极度纯粹,只接收 Prompt 对象发送 HTTP 请求 | 开发体验极佳的流式 API (Fluent API) |
| 功能 | 不懂什么是“记忆”、不懂什么是“拦截器” | 内置大量业务功能:自动管理记忆 (ChatMemory)、自动挂载系统预设 (System Prompt)、自动将大模型输出映射为 Java POJO |
| 隔离性 | 你必须显式声明特定的子类(如 DeepSeekChatModel) | 屏蔽底层差异:如果有一天你把底层模型换成 OpenAI,只要你不硬编码特定模型的参数,使用 ChatClient 写的业务代码连一行都不用改! |
没有记忆的大模型,每次对话都是“出厂设置”;有了记忆,它才能知道“刚才发生了什么”。Spring AI 官方提供了 ChatMemory接口。只要实现这个接口,不管你存在内存里还是 Redis 里,ChatClient 都能用同一种方式加载。
让我们来看 AiConfig 配置类,这里体现了架构师最看重的“拔插式加载”:
方案A:本地 JVM 内存版 (这里代码中直接给出,觉得方案B麻烦的直接拷贝方案A代码即可)
方案B:分布式 Redis 版 (生产推荐方案,下面按照本方案梳理)
@Configuration public class AiConfig { // 【方案 A:本地 JVM 内存版】 // 优势:速度极快,无需外部中间件。 // 劣势:服务重启即丢,无法多实例共享(非分布式)。如果不配置 Redis 的话,使用这个最简单。 // @Bean public ChatMemory chatMemory() { InMemoryChatMemoryRepository repository = new InMemoryChatMemoryRepository(); return MessageWindowChatMemory.builder() .chatMemoryRepository(repository) .maxMessages(20) // 保留最近的 20 条对话 .build(); } // 【方案 B:分布式 Redis 版】 // 优势:持久化、跨实例共享,适合真正的微服务生产环境。 // 劣势:涉及网络 IO,存在严重的 JSON 序列化陷阱。 @Bean public ChatMemory chatMemory(StringRedisTemplate messages) { return new RedisChatMemory(messages, 50, 7); } }
RedisChatMemory(继承了 ChatMemory接口)@Slf4j public class RedisChatMemory implements ChatMemory { private final StringRedisTemplate stringRedisTemplate; private final int maxMessages; private final long expireDays; private static final String KEY_PREFIX = "ai:agentChat:memory:"; // 【核心架构设计:脱离框架绑定的纯净 DTO】 @Data public static class MessageDto { private String type; private String content; public MessageDto() {} // 关键:满足无参构造要求 public MessageDto(String type, String content) { this.type = type; this.content = content; } } public RedisChatMemory(StringRedisTemplate stringRedisTemplate, int maxMessages, long expireDays) { this.stringRedisTemplate = stringRedisTemplate; this.maxMessages = maxMessages; this.expireDays = expireDays; } @Override public void add(@NonNull String conversationId, @NonNull List<Message> messages) { String key = KEY_PREFIX + conversationId; // 省略合并历史记录代码... // 【降维打击 - 存入】:把复杂的多态 Message 剥离成干净的 DTO List<MessageDto> dtos = mutableHistory.stream() .map(m -> new MessageDto( m.getMessageType().getValue(), // "user", "assistant" m.getText() != null ? m.getText() : "")) .collect(Collectors.toList()); // 像存普通业务数据一样存进去,极其稳健 stringRedisTemplate.opsForValue().set(key, JSON.toJSONString(dtos), expireDays, TimeUnit.DAYS); } @Override public List<Message> get(@NonNull String conversationId) { String key = KEY_PREFIX + conversationId; String jsonStr = stringRedisTemplate.opsForValue().get(key); if (jsonStr == null || jsonStr.isEmpty()) return new ArrayList<>(); try { // 【降维打击 - 取出】:先用 Fastjson2 解析成我们的 DTO List<MessageDto> dtos = JSON.parseArray(jsonStr, MessageDto.class); // 然后手动 new 出大模型需要的标准对象 return dtos.stream().map(dto -> { String type = dto.getType(); if ("user".equalsIgnoreCase(type)) return new UserMessage(dto.getContent()); if ("assistant".equalsIgnoreCase(type)) return new AssistantMessage(dto.getContent()); if ("system".equalsIgnoreCase(type)) return new SystemMessage(dto.getContent()); return new UserMessage(dto.getContent()); // 兜底 }).collect(Collectors.toList()); } catch (Exception e) { log.warn("解析缓存异常,已清空脏数据: {}", e.getMessage()); stringRedisTemplate.delete(key); return new ArrayList<>(); } } @Override public void clear(@NonNull String conversationId) { stringRedisTemplate.delete(KEY_PREFIX + conversationId); } }
深入理解这个收益:我们利用物理级别的解耦,彻底切断了业务持久化数据与 Spring AI 第三方框架源码的绑定。无论未来 Spring AI 版本如何狗血地重构内部类,存在 Redis 中的对话数据永远是向后兼容的。这就是架构防腐。
当我们打算上线时,自然首选上述的“方案 B”(Redis)。但由于 Spring AI 框架处于早期迭代阶段,你直接存官方的 Message多态对象会让你怀疑人生!
【核心痛点:为什么原生 Jackson 会彻底崩溃?】
Spring AI 底层的Message(如UserMessage,AssistantMessage)设计初衷是组装 HTTP 请求载荷发给大厂。这种面向外部环境的过度设计,忽略了 Java 的 POJO 序列化规范:它们没有无参构造函数(Jackson 根本反射不出来)。
它们充斥着复杂的嵌套多态。
导致默认的 Jackson (或者任何没有开挂的 JSON 类库)反序列化直接报错!
【解法:引入 Fastjson2 与降维 DTO + ACL 防腐隔离】
我们摒弃通过修改 Jackson 全局配置(如强制打@class)去迎合不成熟框架的“补丁”做法!采用领域驱动设计(DDD)中的 防腐层 (ACL):我们引入了
fastjson2,以便于更轻量、宽容地处理纯字符串 JSON。我们不存
Message,我们只存极简的MessageDto对象结构。
接下来,我们在 AgentChatController 中看一下如何挂载这个拔插式的 Memory 接口并发起对话:
@RestController @RequestMapping("/ai/agent") public class AgentChatController { //... @GetMapping("/chat/memory") public String chat( @RequestParam String chatId, // 模拟不同用户的独立记忆 @RequestParam String message) { return chatClient.prompt() .user(message) // 挂载记忆拦截器参数:通过 chatId 精准打击多并发下的用户路由 .advisors(a -> a.param("chat_memory_conversation_id", chatId)) .call() .content(); } //... }
这行代码背后就是
MessageChatMemoryAdvisor将每次的历史记录自动与当次对话合并。有了ChatMemory接口兜底,上面的业务代码无需关心底层用的到底是方案 A 还是方案 B。
有了拔插式记忆的辅佐,为了让 Agent 绝不“胡言乱语”,我们需要在 AgentChatController 初始化时设定最高“宪法”(包含了角色定位、业务边界、工作规则等):
@RestController @RequestMapping("/ai/agent") public class AgentChatController { private final ChatClient chatClient; // 构造函数注入全局 Client 和 Memory public AgentChatController(ChatClient.Builder builder, ChatMemory chatMemory) { String systemPrompt = """ 你是一个高级电商后台微服务架构的智能运维助手。 你的主要职责是协助开发者和运营人员排查订单流转问题,并提供相关的天气物流建议。 【核心规则】 1. 你的语气必须专业、严谨,像一个资深的 Java 后端架构师,可以适时使用“接口响应”、“兜底策略”等技术术语。 2. 业务边界:如果用户询问订单或天气,请果断调用你拥有的工具获取真实数据。 3. 安全护栏:如果用户询问与技术、订单、天气无关的问题(如娱乐八卦、政治、让你写诗等),你可以基于上下文记忆,礼貌且极其简短地(不超过1句话)回应用户的非业务闲聊以保持对话温度,但回应后,必须立刻用专业术语将话题强制拉回订单排查或系统运维上。严禁长篇大论讨论非业务话题。 4. 总结要求:务必言简意赅。 """; this.chatClient = builder .defaultSystem(systemPrompt) // 1. 挂载系统宪法 .defaultToolNames("weatherFunction", "orderFunction") // 2. 全局预装触手 // 3. 将我们上面配置的 ChatMemory (拔插后的 Redis 或 JVM 内存)包在 Advisor 中全局生效 .defaultAdvisors(MessageChatMemoryAdvisor.builder(chatMemory).build()) .build(); } // ... 下接我们上面展示的 chat() 方法 }
注:如果设置了系统提示词职责边界,memory记忆上下文可能会失效,不会回答职责意外的问题。
设置系统边界确实会“框住”记忆的联想能力,这是大模型安全机制的必然代价。各位“架构师”,我们要做的就是通过不断打磨 Prompt 的颗粒度,在“绝对安全”和“像个人类”之间找那个最完美的平衡点。
将天气和查询订单结合在一起对话
先问是否可以取订单D123456(去掉系统提示词)

再问今天成都天气是否合适出门取(去掉系统提示词)

测试系统提示词规定的职责边界是否生效(使用系统提示词)

查看redis memory缓存数据结构

企业级项目中常常需要引入多个大模型(例如同时使用 DeepSeek
做逻辑推理,OpenAI 做兜底)。如果你在 pom.xml里同时引入了这两个模型的 Starter,Spring Boot 启动时会立刻抛出极其经典的 NoUniqueBeanDefinitionException。
原因:Spring 找到了多个 ChatModel 的实现类(DeepSeekChatModel, OpenAiChatModel),它不知道应该自动注入哪一个给 ChatClient.Builder。
【架构解法:基于 @Configuration 的精准声明与注入】
我们需要取消自动装配的偷懒做法,手动暴露不同名称的 ChatClient Bean,并在使用处通过 @Qualifier 进行精准匹配。
1. 显式声明 Bean AiConfig.java
@Configuration public class AiConfig { // 1. 专门为 DeepSeek 定制的客户端 @Bean("deepseekClient") public ChatClient deepseekClient(DeepSeekChatModel deepseekModel) { // 直接把 deepseek 的底层模型塞给 Builder return ChatClient.builder(deepseekModel) .defaultSystem("你是一个由 DeepSeek 驱动的助手") .build(); } // 2. 专门为 OpenAI (ChatGPT) 定制的客户端 @Bean("openAiClient") public ChatClient openAiClient(OpenAiChatModel openAiModel) { // 直接把 OpenAI 的底层模型塞给 Builder return ChatClient.builder(openAiModel) .defaultSystem("你是一个由 GPT-4 驱动的高级分析师") .build(); } }
2. 业务层的精准注入
在使用时,通过 @Qualifier 明确告诉 Spring 你到底要哪个。
@RestController public class MultiModelController { private final ChatClient deepseekClient; private final ChatClient openAiClient; // 明确告诉 Spring,哪个变量对应哪个 Bean 定制器 public MultiModelController( @Qualifier("deepseekClient") ChatClient deepseekClient, @Qualifier("openAiClient") ChatClient openAiClient) { this.deepseekClient = deepseekClient; this.openAiClient = openAiClient; } }
通过这种解耦模式,我们就能完美地在一个微服务里面实现“多模型自由切换”,让系统更加健壮和灵活。
通过 ai-agent-chat 的实战演示,我们可以看到:
构建一个 Agent 不仅仅是调一个“问答接口”。从 父 POM 的 bom 版本管控,到 Record 自动推导的大模型 Tool 识别参数黑魔法,到 对 ChatModel 与 ChatClient 职责的区别与选型,再到最重要的 通过 DTO + Fastjson2 架构来解决极其复杂的 Spring AI Redis 对象序列化反序列化危机……
[!IMPORTANT]
版本适配提示:Spring AI 目前尚处于版本快速变动的成长期,核心 API 的废弃与重构时有发生。请大家在实战中时刻关注版本特性。但有了我们上面的“记忆防腐层”等架构理念加持,无论官方怎么变,我们系统核心依然稳如泰山!