1. 为什么要在 Spring Boot 3.x 里做 AI 智能体
先说一个这两年我观察到的现象:不少 Java 后端团队看到 Python 生态里 LangChain、LlamaIndex 满天飞,第一反应是“完了,Java 是不是要被 AI 时代淘汰了”。然后转头一看,公司的核心业务系统全是 Spring Boot,用户数据、订单系统、权限体系都在 Java 这边。让 AI 能力跑在 Python 服务里,再通过 HTTP 和 Java 服务来回调,也不是不行,但多了一层网络开销不说,链路长了排查问题也麻烦。
Spring Boot 3.x 集成 AI 智能体,解决的就是这个问题。把大模型的能力当成一个普通 Bean 注入到你的 Spring 容器里,和注入 JdbcTemplate、RedisTemplate 没什么本质区别。业务代码里该 @Service 就 @Service,该 @Transactional 就 @Transactional,AI 调用只是其中一个环节。对 Java 团队来说,学习成本低,维护成本也可控,不需要维护两套技术栈。
这个主题适合谁?我建议这几类人重点看:
- 后端 Java 工程师,想在自己熟悉的技术栈里接入大模型,又不想额外维护 Python 服务。
- 技术负责人,正在评估给现有业务系统加 AI 能力要投入多少成本。
- 对 AI Agent(智能体)概念感兴趣,但不想一上来就学一堆新框架的开发者。
在正式动手之前,我先把核心结论放前面:这个方案能跑通,而且生产可用的关键不在“调 API”,而在“让模型能调你的方法”。我后面会花大篇幅讲这个。
2. 方案选型:别急着写代码,先把框架看清楚
2.1 三个主流的 Java AI 框架怎么选
目前 Java 生态里做 AI 集成,绕不开三个选项:Spring AI、LangChain4j、以及直接手写 HTTP 调用各家大模型 API。
直接手写 HTTP 调用我就不多说了,简单场景能用,但一旦涉及多轮对话、工具调用、流式输出、上下文记忆,代码量会指数级膨胀,而且每家模型的接口格式还不一样。除非只是做个小 Demo,否则不建议。
Spring AI 是 Spring 官方团队出的项目,思路和 Spring Data 很像——把各种大模型提供方抽象成统一接口。你今天接的是通义千问,明天要换 DeepSeek,改一行配置就行。API 风格也是 Spring 程序员熟悉的 ChatClient、PromptTemplate 这套,学习曲线很平缓。
LangChain4j 则是社区项目,理念更接近 Python 的 LangChain,Agent、Memory、RAG 这些概念模型都给封装好了,功能丰富但抽象层更厚,出问题时调试链路更长。
我自己的建议是:如果你的团队已经深度使用 Spring Boot,优先选 Spring AI。它的版本迭代确实快,API 变化也频繁,但胜在和后端框架的契合度高,遇到问题社区也活跃。LangChain4j 更适合想用更“Agent 原生”的开发体验、且团队有充足时间踩坑的场景。
2.2 Spring AI 的版本和模型兼容性
Spring AI 的版本从 0.8.x 一路迭代,到 1.0.0 GA 之后 API 才算基本稳定。如果项目刚起步,请直接上 1.0.0 或更新版本,别去碰老版本的教程——我看过太多从 0.9 迁移到 1.0 时被改名改到怀疑人生的案例。
目前模型支持这块,Spring AI 已经覆盖了主流的 OpenAI、通义千问、DeepSeek、智谱、Ollama 本地模型等等。通过 spring-ai-starter-model- 前缀的依赖区分。大部分国内模型服务都是 OpenAI 兼容协议,所以哪怕官方没有单独出 Starter,也可以用 OpenAI 的配置指向你的服务商地址,只是要改一下 base-url。
这里顺便说一个我从同事那里听来的坑:Spring AI 对同一模型提供方的支持也有差异,比如某些国产模型的 Function Calling 虽然兼容 OpenAI 格式,但在工具描述过长时会被模型忽略,导致 Agent 不调工具。这个我后面在“常见问题”章节会细讲。
3. 项目搭建与基础集成
3.1 创建工程和镜像源配置
新建一个 Spring Boot 3.3.x 或 3.4.x 的项目,Java 版本建议 17 以上,我习惯用 21,因为虚拟线程对高并发场景有帮助。构建工具可以用 Maven 或 Gradle,我自己用 Maven 多一点,配置直观。
依赖这块,官方文档推荐用 BOM 统一管理版本:
<dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-bom</artifactId> <version>1.0.0</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement>然后加你需要的模型依赖,比如用通义千问:
<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-model-dashscope</artifactId> </dependency>国内环境下,Maven 中央仓库直接拉 Spring AI 的包可能比较慢,实测经常卡在下载那几 MB 的 jar 上。建议在 Maven 的 settings.xml 里配置阿里云镜像仓库,速度提升非常明显,我身边几乎所有团队都是这么干的。
注意:Spring Boot 3.x 和 Spring AI 的版本兼容矩阵要核对清楚,新版本 Spring Boot 不一定能直接用最新版 Spring AI。如果启动时报 NoSuchBeanDefinitionException 或者类似的问题,先查版本。
3.2 配置模型参数并跑通第一个对话
在 application.yml 里配置模型的 API Key 和基础参数:
spring: ai: dashscope: api-key: ${DASHSCOPE_API_KEY} chat: options: model: qwen-plus temperature: 0.7API Key 强烈建议用环境变量注入,不要硬编码在配置文件里。项目代码一旦上传到 Git 仓库,Key 泄漏就是安全事故。
然后写一个最基础的服务类:
@Service public class ChatService { private final ChatClient chatClient; public ChatService(ChatClient.Builder builder) { this.chatClient = builder.build(); } public String chat(String message) { return chatClient.prompt(message).call().content(); } }这里 ChatClient 是 Spring AI 的核心门面,它把 Prompt、模型调用、响应解析全包了。运行一个测试,输入“用一句话介绍你自己”,能看到模型正常返回,集成就算通了。
3.3 Prompt 模板和结构化输出
硬编码字符串拼接 Prompt 是最原始的玩法,稍微复杂一点就会出问题。比如你要做一个“旅游攻略生成器”,Prompt 可能是:
你是一个旅游规划师,请为去{城市}旅游的用户生成一份{天数}天的攻略,风格要求:{风格}这里的城市、天数、风格都是变量,用 PromptTemplate 来处理:
PromptTemplate template = new PromptTemplate(""" 你是一个旅游规划师,请为去{city}旅游的用户生成一份{days}天的攻略, 风格要求:{style} """); Message message = template.createMessage(Map.of( "city", "成都", "days", "3", "style", "轻松休闲,美食优先" ));还有一个很实用但容易被忽略的功能:结构化输出。默认情况下模型返回的是自然语言文本,你得自己写正则或者调用另一个模型去解析,麻烦且不稳定。Spring AI 支持让模型直接输出 JSON 并映射到 Java 对象。
比如你做一个“招聘简历初筛 Agent”,希望模型返回候选人的技能列表和匹配度评分:
public record CandidateEvaluation( String name, List<String> skills, int matchScore, String suggestion ) {} ChatClient chatClient = ...; CandidateEvaluation result = chatClient.prompt() .user("候选人的简历内容是:...") .call() .entity(CandidateEvaluation.class);这里内部逻辑是:Spring AI 在生成 Prompt 时会追加“请以 JSON 格式输出,字段分别为 name, skills, matchScore, suggestion”的指令,然后把模型的输出反序列化成对象。我踩过一个坑:record 的字段名如果和模型输出的 key 不一致,会解析失败。这时候要么统一命名,要么用 @JsonProperty 指定别名。
4. 核心机制:Function Calling 让模型有手有脚
4.1 为什么单纯的“聊天”不算智能体
把上面的对话、模板、结构化输出都做了,充其量叫做“带 AI 功能的 Web 服务”,和智能体还差一层窗户纸——模型只能生成文本,不能真正触发业务动作。
举个例子,你做了一个“企业 IT 工单助手”,用户说“帮我查一下工单 INC20240101 的处理进度”。如果模型不认识工单系统,它只能回一句“很抱歉,我暂时无法查询工单进度”。这就是为什么很多大模型聊得头头是道,一落到实际业务就哑火。
Function Calling(工具调用)解决了这个问题:模型在回答之前,会先生成一个结构化的“调用请求”,告诉你的程序“我需要调用 getTicketStatus 这个函数,参数是 INC20240101”。你的程序负责真正执行这个函数,然后把结果回传给模型,模型再基于结果组织最终回答。
理解了这个机制,你就掌握了智能体的技术核心:模型是“大脑”,负责推理和决策;函数是你业务系统的“手脚”,负责执行。
4.2 用 @Tool 注解暴露业务方法
Spring AI 给 Function Calling 做了很友好的封装。在 Spring AI 1.0 里,你只需要在 Bean 的方法上加上 @Tool 注解,然后注册给 ChatClient 即可。
比如给工单助手写一个查询方法:
@Service public class TicketTools { @Tool(description = "根据工单号查询工单当前状态,输入参数为工单号") public String getTicketStatus(String ticketId) { // 这里调用真实的工单系统接口,或者查数据库 return ticketService.queryStatus(ticketId); } }然后在构造 ChatClient 的时候把 Tool 关联进去:
@Bean ChatClient chatClient(ChatClient.Builder builder, TicketTools ticketTools) { return builder .defaultTools(ticketTools) .build(); }当用户问“工单 INC20240101 什么进度”,系统内部执行流程是:
- 模型收到问题,发现需要工具,输出一个函数调用指令,类似 {“name”: “getTicketStatus”, “arguments”: {“ticketId”: “INC20240101”}}。
- Spring AI 拦截这个指令,找到对应的 @Tool 方法并调用。
- 方法返回真实工单状态字符串“处理中,预计今天 18:00 前解决”。
- 这个结果被附加到对话上下文中,模型继续生成最终回答:“您好,工单 INC20240101 当前状态为处理中,预计今天 18:00 前解决。”
整个过程用户是感知不到的,体感上就是 AI 真的“查”了工单系统。
4.3 多工具协作与参数类型选择
真实业务里不可能只有一个工具。一个售前咨询机器人,需要查库存、算运费、查优惠券、创建订单…… 这些方法都暴露出去。
这里有几个实操注意事项:
第一,工具方法的参数类型尽量用简单类型。Spring AI 通过解析方法签名生成模型的 JSON Schema,如果参数是一个嵌套的复杂对象,模型大概率生成不出正确的 JSON 参数。我踩过最狠的一次:定义一个参数为 List<Map<String, Object>> 的工具,十次调用里有八次参数是空的。后来改成 String 类型,先把 JSON 传进去再手动解析,稳如老狗。
第二,@Tool 的 description 要写得足够清楚。模型靠这段描述来决定“什么情况下调用这个方法”,描述写得太笼统,模型该调用的时候不调用;写得太复杂,模型又会过度调用。一个比较靠谱的摸板是:“当用户需要查询X时使用,输入参数为Y,返回值是Z”。把触发条件、入参语义、返回内容都讲清楚。
第三,工具内部要做超时和容错。模型在业务高峰可能并发调用工具,你的方法要有熔断机制,失败时返回一个友好的错误信息,而不是抛出异常让整个链路崩溃。
5. 实战:搭建一个多工具智能客服
5.1 业务场景和预期效果
这一节我拿一个模拟项目 X 来演示:一个电商平台的售前客服机器人。这个机器人的目标是回答用户的商品咨询、库存查询、优惠计算和简单订单创建。
先定义清楚这个智能体要会什么:
- 用户问“这款手机还有货吗”,Agent 调库存接口。
- 用户说“买两个能便宜多少”,Agent 算优惠。
- 用户说“帮我下一单”,Agent 校验收货地址后创建订单。
注意最后一件事——创建订单涉及写操作,比单纯查询风险高得多。生产环境里对这类工具要有额外的确认机制,比如先让模型生成一个“待确认订单”JSON 返回给前端,由前端用户点击确认后,再调用真正的下单方法。我在项目里就是这么干的,用户体验上只多了一次点击,但安全性提升了一个量级。
5.2 编排多个工具方法的完整示例
工具层,把库存、优惠、订单三个 Service 包装成可被 @Tool 识别的方法:
@Service public class EcommerceTools { private final InventoryService inventoryService; private final PriceService priceService; private final OrderService orderService; public EcommerceTools(InventoryService inventoryService, PriceService priceService, OrderService orderService) { this.inventoryService = inventoryService; this.priceService = priceService; this.orderService = orderService; } @Tool(description = "查询商品实时库存,入参为商品ID,返回可售库存数量") public String queryStock(String productId) { int stock = inventoryService.getAvailableStock(productId); return "商品" + productId + "的库存为" + stock + "件"; } @Tool(description = "计算购买商品总价,入参为商品ID和购买数量,返回原价和优惠后价格") public String calculatePrice(String productId, int quantity) { PriceInfo price = priceService.calculate(productId, quantity); return "原价" + price.originalAmount() + "元,优惠后" + price.payAmount() + "元"; } @Tool(description = "根据商品ID、数量、收货人信息创建订单,返回订单号") public String createOrder(String productId, int quantity, String receiverAddress) { String orderId = orderService.createDraftOrder(productId, quantity, receiverAddress); return "订单" + orderId + "已创建,待用户确认"; } }5.3 让模型决定操作顺序
智能体和传统规则引擎最大的区别是:调用哪个工具、调用的顺序是什么,不靠程序员 if-else,而是让模型自己判断。
面对“有货的话给我买两个送到某某地址”这种复合指令,模型会自己规划:
- 先调用 queryStock,确认库存充足。
- 再调用 calculatePrice,算价。
- 最后调用 createOrder,发起创建订单。
这在代码层面不需要额外编排,只要把工具都注册给 ChatClient,模型的推理能力会自行决定。但要让这个流程可靠运行,我建议在系统级 Prompt 里做一层约束,比如:
你是电商售前客服,帮助用户解答问题并完成购物。你必须遵守以下规则: 1. 回答库存、价格相关问题前,先调用对应工具获取真实数据。 2. 用户未确认下单前,不要直接调用 createOrder 工具。 3. 如果工具返回的结果包含错误信息,如实告知用户,不要编造。这段规则看着不起眼,但它大幅提升了工具调用的正确率。没有规则约束时,模型经常在用户还没确认时就“自作主张”下单,有了规则后,它至少会先问一句“确认下单吗”。
5.4 会话记忆:从无状态到上下文感知
到这里,你会遇到第二个关键问题:多轮对话怎么保持上下文?
用户先说“帮我查一下手机”,模型调工具返回了库存信息。过一会儿用户追问“那两个呢?”,如果模型不记得之前说的“手机”是哪个型号,这对话就废了。
Spring AI 提供了 ChatMemory 抽象。最简单的做法是开启会话级记忆,把同一会话的历史消息存起来,每次请求时自动携带:
ChatClient chatClient = ChatClient.builder(chatModel) .defaultSystem("你是电商客服") .defaultAdvisors(new MessageChatMemoryAdvisor(chatMemory)) .build();这里 ChatMemory 的实现可以是内存版(InMemoryChatMemory),单机部署够用;如果是多实例部署,建议用 Redis 自己实现一个,因为内存版本在多实例下各自独立,用户的请求打到不同实例上就记忆错乱了。我实际项目里用的是 Redis 存储消息列表,序列化成 JSON 存 String,读取时再反序列化。
还有一个实际中的矛盾:大模型的上下文窗口是有限且按 token 计费的。无限存历史消息,很快窗口就爆了。处理策略是滑动窗口——只保留最近 N 轮对话。Spring AI 也提供窗口大小的配置,比如只保留最近 20 条消息。做生产项目时这个配置必须调优,历史上有个同事的项目,因为没限制窗口大小,在上下文里积累了上万条消息,单次请求的 token 消耗高得离谱,一个低价值会话烧掉几块钱。
6. 进阶玩法:多智能体协作与 RAG 知识库接入
6.1 把一个复杂 Agent 拆成多个协作的垂直智能体
电商客服如果只管售前咨询,一个 Agent 就够了。但真实业务里还有售后、物流、投诉、知识问答等不同场景,全塞进一个 Agent 会导致两个问题:一是系统提示词过长,模型容易“遗忘”后面的规则;二是工具数量太多,模型在“该调哪个”上犯错的概率急剧增加。
切分为多智能体是更稳的做法。比如拆成:
- 售前 Agent:负责商品咨询、库存、优惠。
- 售后 Agent:负责退换货、发票、售后进度。
- 物流 Agent:负责查物流轨迹、修改收货地址。
实现方式可以靠一个“调度者”角色来做意图路由,也可以用 Spring AI 的 ModelContext 机制做父子上下文传递。我这里分享一个最简洁的实现思路:用一个路由 Agent 判断用户问题属于哪个领域,然后调用对应领域的 Agent。
路由 Agent 本身也用 ChatClient 实现,只不过它的工具是“切换到售前”“切换到售后”“切换到物流”这几个方法:
@Tool(description = "当用户询问商品、库存、尺码、价格时调用") public String routeToPreSales() { return "pre-sales"; }拿到路由结果后,程序再把用户消息转发给对应那个 Agent。这个方案的好处是每个垂直 Agent 都保持干净的上下文,不会被无关场景干扰。
6.2 用向量数据库解决“模型不知道的知识”
还有一个高频需求:企业的售后政策、退换货规则这些文档知识,模型训练时没见过。你问它“你们七天无理由退换的条件是什么”,它开始一本正经地编造政策,这在生产里是不可接受的。
解决思路是 RAG(检索增强生成):把企业文档向量化存入向量数据库,用户提问时,先检索最相关的文档片段,和用户问题一起发给模型,让模型基于给定文档回答。
Spring AI 原生支持向量数据库抽象,和 Redis、PostgreSQL(pgvector)、Milvus 等都能集成。我自己实战常用的组合是:
- 文档切分:按固定大小 500 token 切分,重叠 50 token,避免语义断裂。
- 向量化:用 embedding 模型,把切好的文本转成向量。
- 存储检索:用 pgvector,因为直接用现有 PostgreSQL 不引入新组件,运维最省心。
配置上:
@Bean VectorStore vectorStore(JdbcTemplate jdbcTemplate, EmbeddingModel embeddingModel) { return new PgVectorStore(jdbcTemplate, embeddingModel); }写入文档时,对每段文本调 vectorStore.add()。查询时:
List<Document> docs = vectorStore.similaritySearch( SearchRequest.builder() .query("七天无理由退换有什么条件") .topK(3) .build() );把检索到的文档内容拼到系统 Prompt 里。注意拼的时候要标清来源,比如在每段文档前加“参考资料:”前缀,否则模型容易把检索到的和它自己内化的知识混在一起,一旦检索到的资料和它的偏见冲突,它大概率还是会按自己的来。我实际测试发现,加上“请严格依据下面的参考资料回答,不要使用参考资料之外的知识”这句话,输出质量显著提升。
7. 生产环境的常见问题与排查经验
7.1 启动报依赖冲突
Spring AI 1.x 引入了大量新依赖,和已有的 Spring Boot 应用集成时,最常见的报错是 NoClassDefFoundError,尤其集中在 Jackson、Spring Core 这些组件上。DeepSeek 或者通义千问 Starter 内部依赖的版本和业务代码里固定版本的 Jackson 冲突。
我的排查思路是:先看完整堆栈,定位到缺失的类;再用 mvn dependency:tree 看一下依赖冲突路径,确认是不是新旧版本混用。一般情况下,把 Spring AI BOM 放在 dependencyManagement 的最前面,能解决大部分版本冲突。
7.2 模型不触发工具调用
工具方法都写好了,可模型就是不调,用户问“库存多少”它非要说“我无法查询实时库存”。这类问题我遇过好多次,原因通常出在:
- 工具描述不够明确。描述里没有包含“当用户问库存时”的触发条件,模型不知道这个方法能解决用户的问题。
- 模型本身不支持 Function Calling,或者是系统 Prompt 里暗示了“不要使用工具”。
- 工具方法抛了异常,Spring AI 在 1.0 版本里对工具异常处理不友好,异常消息被吞掉后模型表现得很困惑。
排查手段:把 Spring AI 的日志级别调到 DEBUG,观察请求里是否携带了 tools 列表,响应里是否有 function_call 字段。日志里看不到 function_call,说明模型压根没打算调用工具,直接检查描述;有 function_call 但执行失败,就检查返回的 JSON 参数格式。
7.3 结构化输出解析失败
用 .entity() 解析结构化输出时,偶尔会报 JsonMappingException,原因是模型返回的 JSON 里字段多了或少了。Spring AI 的解析器要求相对严格,多一个未知字段在某些配置下又会报错。
保险做法是在 record 或者实体类的 ObjectMapper 配置上启用 FAIL_ON_UNKNOWN_PROPERTIES=false。如果不想改全局配置,也可以用传统的 Map 接收,然后手动转换,牺牲一点优雅程度换稳定性。
7.4 成本失控
这是上线之后最容易被忽视的问题。对话历史的 token 随着轮数增长,每轮请求都在重复发送之前的所有消息;再加上工具返回的结果也被塞进上下文,一个十轮以内的会话就可能烧掉上万个 token。
我建议做一个简单的 CostFilter,请求前估算消息的 token 数并记录日志,超过阈值直接拒绝。同时给 ChatMemory 设置窗口大小,只保留必要内容。很多团队做 AI 项目真正失败的原因不是技术跑不通,而是账单先爆了。
8. 一些个人的体会
做 Spring Boot 3.x 集成 AI 智能体这个项目,最大的感悟是:AI 集成没有想象中那么“玄学”,它和你以前做 HTTP 接口、消息队列、定时任务一样,是一种普通的工程能力。差别在于,以前你写的代码是确定性的,而现在模型的输出是概率性的。承认这一点,你就不会期待“每次调用都完美”,而是会把精力花在怎么让“不完美的概率降到最低”上——写清楚工具描述、加好系统规则、做好异常兜底。
踩过几次坑之后,我现在的固定套路是:先在本地用最便宜的国产模型跑通全链路,再换商业模型做效果调优。因为开发阶段大把时间花在调试工具调用上,用便宜模型能省下不少成本;等到验证没问题了,再切高价模型提升回答质量。这个流程看着不起眼,但项目里帮我们团队省了至少一半的 API 账单。
这套东西后续还能往两个方向扩展:一个是把 RAG 文档源从手动导入改成定时同步线上知识库;另一个是给多智能体的调度加一版可观测面板,把每次路由决策、工具调用链都记录下来,方便分析模型行为。总之,框架已经把路铺好了,剩下的就看你在自己业务里的想象力了。