Spring AI实战生产落地:从架构设计到工具调用完整拆解 📅 发布时间:2026/9/19 17:16:24 👁 浏览次数: 最近不少朋友在问同一件事Spring官方那个AI框架到底能不能用在生产项目里我的答案是能而且已经在这么干了。今天这篇就基于我一整个项目周期的实际体验把Spring AI从架构设计、结构化输出、工具调用到记忆管理的完整链路拆开讲清楚。项目里我们接的是DeepSeek和通义千问中间因为兼容性、上下文管理、结构化输出踩了不少坑这些都会逐一说明。如果你是Java开发者想在Spring Boot项目里正经对接AI能力这篇应该能帮你省下几周的试错时间。顺便也解释一下为什么我最终没有选LangChain4j。1. Spring AI的架构设计与核心组件——它到底解决了什么问题1.1 兼容层设计一套API统一接入各家模型先聊最底层的设计动机。你写一个AI功能第一反应是直接调OpenAI SDK或者通义的HTTP接口。单个模型这么搞没问题但一旦业务要求你同时兼容ChatGPT、通义千问、DeepSeek、Ollama本地模型麻烦就来了——每家的接口风格不一样参数命名不一样返回格式不一样换一次模型等于重写一遍客户端逻辑。Spring AI最核心的价值就是把这一层差异全部吃掉。它定义了一套统一的ChatModel、EmbeddingModel接口底层通过各个ModelClient适配具体厂商的API。你的业务代码只依赖Spring AI的接口至于背后是OpenAI还是DashScope完全由配置决定。我在项目里的实际配置是这样的spring: ai: dashscope: api-key: ${DASHSCOPE_API_KEY} chat: options: model: qwen-plus后面要换成DeepSeek只需要引入对应依赖再把配置改成spring: ai: openai: base-url: https://api.deepseek.com api-key: ${DEEPSEEK_API_KEY} chat: options: model: deepseek-chat业务代码一行不用动。这个特性在LangChain4j里也可以实现但Spring AI作为Spring官方项目和Spring Boot的ConfigurationProperties绑定、自动装配、Starter机制结合得更自然。你引入spring-ai-starter-model-dashscope依赖所有配置项自动就有了补全提示几乎不会出现配置写错导致启动失败的情况。1.2 ChatClient、Prompt与Advisor的关系这三个概念搞明白Spring AI就算入门了一半。ChatClient是门面负责对外提供prompt()、stream()、call()这些方法Prompt是请求载体里面封装了一组MessageAdvisor则是拦截器类似Spring MVC里的HandlerInterceptor在请求前后插入逻辑。我画个粗粒度的心智模型ChatClient等同于RestTemplatePrompt等同于HttpEntityAdvisor等同于Filter。这三个组件配合起来才能实现上下文记忆、日志记录、RAG检索增强这类附加能力。Advisor是一个被很多人忽略的关键设计。你后续要加的日志监控、Token统计、多轮记忆、敏感词过滤全部通过实现Advisor接口塞进去而不是在业务代码里写一堆重复的前置逻辑。这是整个Spring AI框架设计最聪明的地方。2. 环境准备与第一个模型调用依赖配置、Starter选择与跑通对话2.1 依赖引入和Starter选择别一上来就全量引入Spring AI目前主流的Starter有这么几个使用场景推荐Starter说明通义千问/百炼spring-ai-starter-model-dashscope阿里云百炼平台国内访问稳定OpenAI/DeepSeekspring-ai-starter-model-openaibase-url换成DeepSeek即可本地模型spring-ai-starter-model-ollamaOllama跑本地模型数据不出内网智谱GLMspring-ai-starter-model-zhipuai智谱开放平台我的建议是一开始只引入你当前需要的那一个。Spring AI的Starter之间虽然理论上可以共存但多个Starter同时存在时配置项会变复杂排查问题难度翻倍。我在早期就是同时引入了OpenAI和DashScope两个Starter结果两边模型配置互相干扰花了整整半天才定位到是某个自动装配类被条件注解误触发。官方文档还有个坑Spring AI的版本号要跟Boot版本匹配。Spring AI 1.0.0 GA版本对应Spring Boot 3.4.x如果你用的是Spring Boot 3.2或者3.3需要选择对应的Spring AI版本否则容器启动会直接报NoSuchMethodError。2.2 从跑通到用对ChatClient的三种调用形态依赖配好之后第一步就是注入ChatClient跑通一个最简单的对话。我直接贴代码Service public class ChatService { private final ChatClient chatClient; public ChatService(ChatClient.Builder builder) { this.chatClient builder .defaultSystem(你是一个精通Java的开发助手回答尽量简洁必要时给出代码示例。) .build(); } public String chat(String message) { return chatClient.prompt() .user(message) .call() .content(); } }这段代码里有两个容易忽略的细节。第一defaultSystem()设置的是系统级指令后续每次调用都会自动带上不需要重复传第二ChatClient.Builder由Spring AI自动装配注入但你可以在构建时统一配置系统提示词、模型参数如温度、最大Token数让所有请求走同一套默认策略。跑通同步调用之后你要知道ChatClient还有两种调用形态stream()流式调用和entity()结构化调用。流式调用适合对话类场景体验像打字机一样逐字输出entity()则直接返回Java对象省去手动解析JSON的步骤。这两个后面单独讲。我再强调一个实战细节call()方法返回的ChatResponse里除了content()外还有一个getMetadata()方法可以拿到Token用量。很多人只在调试时关注这个但它其实是生产环境成本控制的关键数据来源。3. 结构化输出把模型返回的JSON直接变成Java对象3.1 BeanOutputConverter与JsonMapper注解告别手动JSON解析大多数AI应用不能只返回文本你要的是一个能直接塞进数据库或回传给前端的Java对象。比如让模型从一段对话里提取客户姓名、联系电话、意向等级这时候用String接收再手动ObjectMapper.readValue()遇到模型返回格式稍微漂移就报错。Spring AI提供了BeanOutputConverter和JsonMapper注解来解决问题。BeanOutputConverter的作用是提示模型你务必只输出JSON结构是XXX而JsonMapper可以在用户提示词中动态拼接这个结构约束。看下我的写法public record CustomerInfo(String name, String phone, String intentLevel, String summary) {} public CustomerInfo extractCustomer(String dialogText) { var converter new BeanOutputConverter(CustomerInfo.class); return chatClient.prompt() .user(u - u.text( 请从下面的客服对话中提取客户信息只输出JSON不要输出其他任何内容。 对话内容 {dialog} 输出格式要求 {format} ) .param(dialog, dialogText) .param(format, converter.getFormat())) .call() .entity(CustomerInfo.class); }这段代码的关键在于converter.getFormat()会生成一段Schema描述告诉模型字段的类型和含义。entity()方法内部自动完成JSON反序列化不需要手动写TypeReference。这里有一个显著的避坑提示如果字段是长文本且内容里可能包含换行或反斜杠记得要在对应字段上加JsonDescription注解提供足够的字段描述否则模型可能生成不符合预期的JSON结构。另外如果你的模型上下文窗口较小getFormat()生成的Schema建议精简字段数量实体字段太多时模型容易忽略某些字段。3.2 结构化输出在业务中的实际应用自动生成作业数据我最近在做的一个项目需要模拟各种难度的Java作业题目用来测试在线判题系统。这里就是结构化输出的完美应用场景。定义一个记录类型public record HomeworkQuestion( String title, String difficulty, String description, String starterCode, String solutionCode, ListString testCases ) {}然后通过BeanOutputConverter一次批量生成多道题目public ListHomeworkQuestion generateQuestions(String topic, int count) { return chatClient.prompt() .user(u - u.text( 请生成{count}道关于{topic}的Java编程题难度覆盖简单、中等、困难。 严格按JSON数组格式输出每道题包含title、difficulty、description、starterCode、solutionCode、testCases字段。 testCases是包含输入和期望输出的字符串数组每个元素格式为输入期望输出。 输出格式要求 {format} ) .param(topic, topic) .param(count, count) .param(format, new BeanOutputConverterListHomeworkQuestion() {} .getFormat())) .call() .entity(new ParameterizedTypeReferenceListHomeworkQuestion() {}); }这里注意一点泛型列表不能直接用entity(List.class)因为运行时泛型信息会丢失必须用ParameterizedTypeReference包装。这个API的坑我踩过一次不传这个类会导致反序列化出ListLinkedHashMap而不是ListHomeworkQuestion。这套代码在我们当前项目里运行了约两个月生成准确率稳定在95%以上。有时候模型会在主题比较偏的情况下生成重复题目后续加上去重校验和相似度检测就能解决。4. 工具调用实战用Tool注解让模型具备操作能力4.1 Tool注解定义函数模型自己决定什么时候调用结构化输出解决了模型输出数据的问题但如果业务需求是让模型根据用户意图查询数据库、调用接口那就轮到工具调用登场了。Spring AI通过Tool注解把Java方法暴露给模型模型内部根据用户请求决定要不要调用、传什么参数。用过LangChain的都知道给模型挂工具的核心工作有两块一是生成函数描述参数叫啥、干啥用的二是把模型返回的函数调用参数映射到真实方法上。Spring AI的Tool注解把这两步简化到极致你只需要在方法上标注Tool并写上ToolParam描述框架会通过反射自动生成函数Schema。一个体验最好的版本是把Tool标注在Component的类方法上Spring AI自动装配时会扫描并注册到ChatClient。看代码Component public class HomeworkTools { private final JdbcTemplate jdbcTemplate; public HomeworkTools(JdbcTemplate jdbcTemplate) { this.jdbcTemplate jdbcTemplate; } Tool(name queryHomeworkByKeyword, description 根据关键词查询作业库中的题目返回题目列表) public String queryHomeworkByKeyword( ToolParam(description 查询关键词如多线程、集合、Spring Boot) String keyword) { ListMapString, Object list jdbcTemplate.queryForList( SELECT title, difficulty, description FROM homework WHERE title LIKE ? LIMIT 5, % keyword %); if (list.isEmpty()) { return 没有找到相关题目; } return list.toString(); } }4.2 实际使用中的体验和限制模型调用工具并不是每次都准确。我刚才分享的经验是工具方法的description写得好不好直接决定调用准确率。描述得越具体模型越容易判断这个用户问题应该调用哪个工具。比如查询作业库中的题目这个描述就太宽泛改成当用户提到要看题目、要刷题、要根据知识点找题目时使用此工具查询字面匹配关键词的题目会好很多。还有一个容易出问题的地方模型会自己为参数赋值所以工具方法参数类型尽量用简单类型比如String、int。不要用自定义对象或枚举否则模型可能构造不出合适的参数值调用报错。Tool当前的局限性是它无法处理需要模型连续调用多个工具再聚合结果的场景。比如先查一下用户有哪些课程再根据课程查作业模型会调用第一个工具拿到结果后自主决定是否继续调用第二个。Spring AI目前的实现是一轮工具调用之后需要你自己在代码里再次call()不会自动循环执行。我尝试过用ChatClient的advisors加一层递归逻辑来模拟这个能力能用但比较繁琐这块相比LangChain的Agent机制还差一点。5. 记忆与上下文从无状态到有状态的会话体验5.1 MessageChatMemoryAdvisor把聊天记录变成上下文大模型本身是无状态的。你每次调用chatClient.prompt().user(你好).call()它都无法记住上一轮你说了什么。做聊天机器人必须自己管理对话历史然后每次请求把历史拼进Prompt里发给模型。Spring AI提供的MessageChatMemoryAdvisor就是干这个活的。用法极其简单ChatMemory chatMemory new InMemoryChatMemory(); ChatClient chatClient ChatClient.builder(chatModel) .defaultAdvisors(new MessageChatMemoryAdvisor(chatMemory)) .build();MessageChatMemoryAdvisor会根据会话ID默认从请求参数conversationId中取自动保存和加载历史消息再用一个ChatMemory实例存储消息列表。这样你写业务代码的时候完全不用关心历史拼接用户每轮发来的消息都会自动带上历史上下文。这个设计唯一的缺点是Token消耗会随对话轮次线性增长。模型上下文窗口是有限的一旦历史消息太长要么超限报错要么费用飙升。Spring AI提供了MessageWindowChatMemoryAdvisor只保留最近N条消息但它默认策略比较粗糙只截断消息条数。在客服、教育这类场景中用户可能连续询问多个独立问题上下文窗口选择不当会影响回答质量。5.2 无状态环境如何保存会话记忆Redis方案InMemoryChatMemory的问题是内存存储服务重启就丢多实例部署也无法共享会话记录。生产环境必须把消息存到Redis或数据库里。Spring AI自带了RedisChatMemory但当前版本对序列化方式的处理有些别扭。我项目里踩过坑这里直接给出可行的方案。用一种更可控的方式——自己实现ChatMemory接口把消息以JSON结构写入Redis的List中配合过期时间自动清理Component public class RedisChatMemory implements ChatMemory { private final StringRedisTemplate redisTemplate; public RedisChatMemory(StringRedisTemplate redisTemplate) { this.redisTemplate redisTemplate; } Override public ListMessage get(String conversationId, int lastN) { var list redisTemplate.opsForList().range(chat: conversationId, -lastN, -1); if (list null || list.isEmpty()) { return new ArrayList(); } return list.stream() .map(json - (Message) JSON.parseObject(json, AbstractMessage.class)) .collect(Collectors.toList()); } Override public void add(String conversationId, ListMessage messages) { for (Message message : messages) { redisTemplate.opsForList().rightPush(chat: conversationId, JSON.toJSONString(message)); } redisTemplate.expire(chat: conversationId, Duration.ofHours(24)); } Override public void clear(String conversationId) { redisTemplate.delete(chat: conversationId); } }这样设置的好处是Redis的过期清理机制可以自动释放存储多实例部署共享一套会话记忆会话ID在网关层透传保证同一用户在多个实例间路由一致。我们用这种方式为单位内的设备巡检知识库问答服务做过压测单Pod并发100个会话完全没有问题。6. 工程化落地可观测性、流式响应与多模型切换6.1 用日志Advisor做完整的调用链路分析平时开发调试AI功能最常遇到的问题就是模型到底返回了什么和这次调用消耗了多少Token。Spring AI默认是没有请求日志输出的你只能自己在调用前后打印。Spring AI提供的SimpleLoggerAdvisor可以自动记录ChatClient的请求和响应摘要。引入配置Bean public Advisor loggerAdvisor() { return new SimpleLoggerAdvisor(); }但我更推荐配置AbstractChatClientAdvisor自定义日志输出因为内置的SimpleLoggerAdvisor默认只打印请求和响应头信息不打印完整内容。调试的时候你照样看不到模型返回的完整JSON。我自己封装了一个最精简的日志AdvisorComponent public class LoggingAdvisor implements Advisor { private static final Logger log LoggerFactory.getLogger(LoggingAdvisor.class); Override public Advisors before(AdvisedRequest request) { log.info(AI请求 - conversationId: {}, 用户消息: {}, request.conversationId(), request.userText()); return request; } Override public Advisors after(AdvisedRequest request, ChatResponse response) { var metadata response.getMetadata(); if (metadata ! null) { log.info(AI响应 - 耗时: {}ms, Token用量: 输入{} / 输出{}, metadata.get(usage) ! null ? metadata.get(usage) : N/A, metadata.get(inputTokens) ! null ? metadata.get(inputTokens) : N/A, metadata.get(outputTokens) ! null ? metadata.get(outputTokens) : N/A); } return null; } }注意after方法返回null表示不修改响应直接放行。这里加日志的好处是你可以在日志系统里按会话ID聚合直接算出一段时间内单个用户的AI调用频率和Token成本这在做成本归因时价值极高。6.2 流式响应与异步调用别让AI拖垮你的接口接入AI之后最常见的性能问题就是接口RT飙升。一次大模型调用动辄3到10秒如果采用同步call()模式会让整个请求链路阻塞前端用户等到超时。Spring AI提供了stream()方法返回FluxString配合Spring WebFlux可以实现典型打字机效果GetMapping(value /chat/stream, produces MediaType.TEXT_EVENT_STREAM_VALUE) public FluxString streamChat(RequestParam String message) { return chatClient.prompt() .user(message) .stream() .content() .doOnError(e - log.error(流式响应出错: , e)); }前端只需要用EventSource或fetch流式读取即可。这个方案被评为AI应用UI体验最好的交互方式用户等待耐心从3秒变成无限——只要字在动用户就愿意等。如果你不想引入WebFlux也可以用CompletableFuture把同步调用包一层返回Callable给Spring MVC异步线程池里执行模型调用。但这种方式不会逐字输出只有等待中和完整结果两个状态。做聊天产品我强烈建议上stream()。流式调用有一个需要注意的地方凡是需要拿到完整Token数量做计费审计的场景流式模式拿不到最终usage信息。目前Spring AI在流式模式下ChatResponse的metadata里没有完整Token统计。我现在的方案是为流式接口单独做一些估算或者在前端断开时收集最后一条流式消息中的usage字段部分模型支持。6.3 多模型切换与故障降级Spring AI Alibaba的价值生产系统最怕的不是模型回答质量问题而是模型供应商不稳定。阿里、OpenAI、DeepSeek各家都有过某个时间段接口超时或限流的情况。Spring AI的多模型配置能力让它成为模型网关的天然基座。我在项目里维护了一个ModelRouter组件根据优先级和健康状态动态选择模型Component public class ModelRouter { private final MapString, ChatClient clients; public ModelRouter(ListChatClient clientList) { this.clients clientList.stream() .collect(Collectors.toMap( c - c.getClass().getSimpleName(), Function.identity() )); } public ChatClient route() { // 按优先级返回可通过配置中心动态调整 return clients.get(dashscopeChatClient) ! null ? clients.get(dashscopeChatClient) : clients.values().iterator().next(); } }这里用到了Spring AI一个隐藏特性多个ChatClientBean会被自动命名为xxxChatClient的格式你可以通过Qualifier或Bean名称精准获取。结合Spring AI Alibaba项目还可以做更完善的降级策略。Spring AI Alibaba不只支持DashScope模型还提供RedisVectorStore、AlibabaCloudDashscopeChatModel等扩展并且在官方Spring AI基础上增强了不少工程化能力。在注册中心里给不同模型配置权重配合Sentinel做限流是这套框架在企业场景下的标准打法。写在最后的几点体验跑完这套Spring AI全套流程我最想强调的体会是Spring AI非常适合标准的企业级AI功能开发尤其是那些需要和Spring Boot生态深度整合、需要多模型兼容、需要工程化保障落地的项目。对Java团队来说学习成本远低于Python系的LangChain跟现有代码的融合也最平滑。个人觉得它当前最大的短板一是Agent能力还比较薄弱、工具多轮调用的自动编排不如LangChain灵活二是结构化输出对部分模型的兼容性还有优化空间。但只要你的场景是对话客服、知识库问答、结构化信息抽取、作业自动生成这类偏任务型的应用Spring AI完全够用而且在稳定性和维护性上比你手绕HTTP接口高出不少。如果你现在正在纠结团队用什么AI框架我建议直接拿Spring AI搭个Demo跑两周真实业务比看什么对比评测都管用。