前阵子团队要做内部客服系统的AI问答助手选型讨论会上两派意见僵持不下。一方主张直接调云端大模型的原生SDK理由是新功能跟得快另一方坚持用海外社区流行的LangChain4j给出的生态库更多。吵到最后谁也没说服谁。我的意见其实很朴素一个技术栈全是Spring Boot的Java团队接入AI能力最顺的路就是Spring AI——它把接入大模型这件本来要写一堆胶水代码的事变成了往工程里加一个Starter、写几行配置和几行业务代码。Spring AI是Spring官方推出的AI应用开发框架目标不是再造一个模型聚合平台而是延续Spring Boot那套自动配置、开箱即用的理念给Java生态提供一套统一的AI应用开发抽象。它解决的痛点非常现实不同模型服务商提供的SDK风格不同、参数各异今天接这家明天换那家业务代码要跟着重写企业内部系统还得把RAG、工具调用、数据库查询这些能力编排进对话链路没有统一模型根本编排不动。Spring AI把这些能力收拢成ChatClient、ChatModel、ToolCallback、Advisor等一组相对稳定的API让开发者像写普通Spring服务一样写AI功能。这篇文章不打算官方文档复述一遍而是按我实际搭项目的路径走先搞清楚Spring AI到底解决了什么问题再跑通一个最小对话应用接着扩展出结构化输出、流式响应、工具调用与MCP集成最后落到生产化要处理的异常、可观测性和成本问题顺带交代我在2.0版本里踩过的几个坑。适合正在做技术选型的Java开发也适合想快速给Spring Boot项目加AI能力的朋友。1. 为什么Java团队需要Spring AI从裸调SDK到统一抽象1.1 各模型SDK各自为战的现场有多痛不夸张地说我刚接触AI应用开发时也干过裸调SDK的事。给运营写一个周报生成器直接拿某家模型服务商的Java SDK请求、解析、重试逻辑全写在Service里。第一个版本跑通很快两周后需求就来了要换成另一家模型服务商以降低成本结果SDK的类名、方法名、参数风格全不一样解析代码里到处都是if分支判断是哪个模型返回的数据。更麻烦的是业务编排。AI不是光聊天就行它要去查订单、算报表、调内部接口不同模型对函数调用的描述语法各有差异。这就像十多年前Java访问数据库的混乱状态——每个数据库驱动都有自己的API业务代码被各种驱动细节污染。当时Spring JDBC做的正是统一数据访问接口让开发人员面向DataSource编程数据库换了驱动也不用改业务代码。Spring AI干的也是同一件事只不过它抽象的从关系型数据源变成了大模型。它提供一套统一的模型访问接口上层业务调用ChatClient不用关心底层连的是哪家模型服务商、走的是什么协议、返回结构长什么样。1.2 Spring AI的抽象哲学像Spring JDBC统一数据库一样统一模型Spring AI的核心抽象可以理解成三层。第一层是模型适配层它把各家模型服务商的接口封装成统一的ChatModel、EmbeddingModel、ImageModel。第二层是应用抽象层也就是开发者直接打交道的ChatClient、ToolCallback、ChatMemory、Advisor这些组件解决怎么友好地跟模型对话怎么让模型调用业务工具怎么管理多轮上下文这类通用问题。第三层是集成扩展层向量数据库、MCP服务、可观测性系统都从这一层接入。这套设计带来的最直接好处是模型可替换。我用ChatClient写好的对话逻辑配置里换一个模型服务商的base-url、api-key、model名字其余代码不用动。真实项目里这类需求非常频繁线上主用某家的模型备用另一家的模型还要定期对比新模型的回答质量。没有抽象层每次对比都要写两套SDK调用。1.3 什么时候值得用Spring AI什么时候别硬上我的判断标准很简单如果团队技术栈以Java和Spring Boot为主AI能力要嵌入现有业务流程那Spring AI几乎是最优解。它跟Spring Boot天然融合配置走application.yml依赖走Starter鉴权走Spring Security监控走Spring Boot Actuator整个生命周期都在熟悉的环境里。反过来说如果你只是做个一次性脚本或者团队压根不是Java技术栈那没必要引入Spring AI。另外如果你要疯狂压榨某个模型的极端新特性官方SDK都还没对Spring AI提供适配那短期直接调底层SDK也合理长期再考虑抽象。框架的好处是在你要换模型、要多模型并行、要接MCP生态时才真正兑现项目越复杂收益越明显。2. 环境准备与版本选择Spring AI 2.0的依赖到底怎么加2.1 版本对应关系先搞明白Spring AI从2024年起迭代节奏明显加快。1.0.0 GA版本在2024年底发布对应的Spring Boot 3.4.x很多生产项目就是从这版开始用的。到了2.0模块结构做了调整MCP集成、可观测性ObservationHandler体系成为重点API层面跟1.0的部分写法有差异。选版本时最忌讳的事情是随手在网上复制一个pom版本号结果跟自己的Spring Boot版本不匹配项目一启动就NoClassDefFoundError。建议直接以官方BOM依赖为准Spring AI发布时会明确标注它兼容哪种Spring Boot主线。下面是我目前在2.0项目里用的版本对应关系供参考Spring AI版本Spring Boot版本要求JDK要求主要变化1.0.03.2.x / 3.3.x / 3.4.xJava 17首个GA版本开通Core与模型适配1.1.x3.4.x / 3.5.xJava 17增强工具调用、持续细化MCP支持2.0.x3.5.x及以上Java 17MCP标准化、ObservationHandler、模块重构特此说明实际版本对应请以官方发布说明为最终依据小版本之间也可能存在接口调整升级跨大版本前务必看release notes。2.2 创建工程与引入依赖创建工程建议直接去start.spring.ioSpring Boot版本选3.5.x或你实际在用的主线版本依赖里加Spring Web这是最省事的方式。生成完工程后手工在pom.xml里加两样东西一个spring-ai-bom做依赖管理一个你需要的模型Starter。关键点在于Spring AI的Starter不能脱离BOM单独用否则传递依赖的版本会失控。BOM统一管版本Starter只声明组件。dependencyManagement dependencies dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-bom/artifactId version2.0.0/version typepom/type scopeimport/scope /dependency /dependencies /dependencyManagement以兼容OpenAI协议的服务商为例加一个Starter就行dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-starter-model-openai/artifactId /dependency如果用spring-ai-alibaba这类针对国内模型服务商的适配模块思路一致引入对应Starter统一走BOM管理版本代码层面依然面向ChatClient编程不会被具体模型服务商的SDK绑架。实际生产里我习惯同时配两个模型Starter一个主用一个备用通过配置切换后面会专门说。2.3 配置文件和密钥管理Spring AI 2.0的配置粒度比1.0细前缀采用spring.ai.model.*这样按模型种类分层的结构。以兼容OpenAI协议的模型为例application.yml大致长这样spring: ai: model: chat: openai: api-key: ${OPENAI_API_KEY} base-url: https://api.example.com/v1 options: model: gpt-4o-mini temperature: 0.7 max-tokens: 1024这里要特别强调api-key千万别硬编码在yml里。环境变量注入是底线${OPENAI_API_KEY}这种占位符让部署环境去提供真实值。如果你的配置中心是Spring Cloud Config那配置内容要么加密存储要么确保配置中心本身的访问控制足够严格。我在项目里见过把api-key提交进git仓库的事故换key重发不说审计时相当尴尬。另外多说一句不同模型服务商对base-url的处理不完全一样有的需要加/v1后缀有的不需要。配置完调用老报401或404时先检查base-url拼接是否正确这是最常见的低级错误。3. 10分钟跑通第一个对话应用ChatClient入门实战3.1 最小的你好大模型程序Spring AI 2.0里面向开发者的主要入口是ChatClient。它的设计思路类似RestClient和WebClient的结合体用Builder模式构建链式调用prompt和call方法。RestController public class ChatController { private final ChatClient chatClient; public ChatController(ChatClient.Builder builder) { this.chatClient builder.build(); } GetMapping(/chat) public String chat(RequestParam String message) { return chatClient.prompt(message) .call() .content(); } }这段代码跑起来你已经有一个能对话的HTTP接口了。为什么用一个ChatClient.Builder注入而不是直接注入ChatClient因为Builder保留了在请求级定制的能力。比如同一个应用里普通用户问的是日常问题管理员问的是内部系统的问题我们可以基于同一个底层ChatModel构建出两个不同默认配置的ChatClient一个不带工具一个默认挂载管理工具。这样代码意图清楚测试时还能轻松替换成Mock。ChatClient的调用风格分同步和异步两类。.call()是同步阻塞返回ChatResponse.stream()是流式返回返回Flux。异步场景还有.callAsync()内部走CompletableFuture适合批处理任务。3.2 从同步到流式聊天打字机效果对话应用最常见的交互是打字机效果一段一段往外蹦字。要实现这种体验得用流式接口GetMapping(value /chat/stream, produces MediaType.TEXT_EVENT_STREAM_VALUE) public FluxString chatStream(RequestParam String message) { return chatClient.prompt(message) .stream() .content(); }前端用EventSource或fetch的流式读取就能不停收到增量文本。这一步实现的原理是框架把请求发给模型服务商之后不等待完整回复而是按SSEServer-Sent Events协议逐段解析数据块再把这些块原样或处理后推给前端。在业务上流式不只是用户体验问题还关系到接口超时。非流式调用如果模型生成时间很长HTTP网关默认的超时阈值很容易把你卡死。切了流式后连接一直有数据在传网关不会因为长时间无响应断开首字延迟也大幅下降。3.3 让模型按格式返回结构化输出聊天型接口只能返回字符串到真实业务里远远不够。很多时候我们希望模型直接返回一个对象比如让用户说一句查一下股票000001今天走势系统需要返回股票代码、收盘价、涨跌幅这些字段最好直接落到一个POJO里。Spring AI的结构化输出用.entity()方法处理public record StockPrice(String stockCode, BigDecimal price, BigDecimal changePercent) {} GetMapping(/stock) public StockPrice stock(RequestParam String stockName) { return chatClient.prompt(查询股票 stockName 的最新价格和涨跌幅) .call() .entity(StockPrice.class); }它背后的逻辑并不神秘框架在调用模型时额外注入了一个系统提示要求模型必须输出符合目标类型结构的JSON然后拿到原始响应后做一次JSON反序列化把字符串映射成Java对象。如果模型输出的JSON里夹带了markdown代码块框架也会自动剥离。这里有三个容易踩的坑。第一POJO的字段名尽量跟自然语言里的概念一致模型更不容易猜错。第二字段描述越明确返回值越可靠必要的时候用javadoc注释辅助说明字段含义。第三如果你要的是集合直接用.entity(new ParameterizedTypeReferenceList () {})别用Class数组泛型擦除会让反序列化失败。3.4 多轮对话ChatMemory与会话状态默认情况下ChatClient每次调用都是无状态的模型不记得上一轮聊了什么。要做真正的对话应用得把历史消息塞进请求。Spring AI提供了ChatMemory和Advisor机制来解决。最简单的做法是给ChatClient默认挂一个MessageChatMemoryAdvisor再指定会话IDChatMemory chatMemory new InMemoryChatMemory(); ChatClient chatClient ChatClient.builder(chatModel) .defaultAdvisors(new MessageChatMemoryAdvisor(chatMemory)) .build(); // 每次请求带上会话ID chatClient.prompt(我上轮问了什么) .advisors(a - a.param(chat_memory_conversation_id, user-1001)) .call() .content();Advisor是Spring AI非常巧妙的设计它像过滤器一样包裹在ChatClient调用链外层在执行实际模型调用前追加消息、在调用后处理结果。MessageChatMemoryAdvisor做的事情就是根据conversationId读取历史消息拼到当前请求里同时把最新这轮对话写回记忆。生产环境里可以把InMemoryChatMemory换成Redis实现会话数据就能跨实例共享。4. 进阶扩展结构化输出、工具调用与MCP服务集成4.1 动态调整参数Prompt与ChatOptionsChatClient写起来舒服还在于它允许在每次调用时临时覆盖默认参数。比如默认temperature是0.7做代码生成时希望更严谨改成0.2不用动yml调用链上直接设置chatClient.prompt(写一个Java冒泡排序) .options(ChatOptions.builder() .model(gpt-4o-mini) .temperature(0.2) .build()) .call() .content();这对多租户系统特别有用。不同的请求方可以拥有不同的模型参数策略代码层面只是调Options组合。ChatOptions还能带上frequencyPenalty、presencePenalty、topP这些模型推理参数需要调优时不必改代码把它们挪到配置里按环境覆盖即可。4.2 Function Calling让AI真正动手做事这是AI应用从聊天走向干活的关键机制。所谓Function Calling就是让模型在回答过程中识别用户意图决定调用你预先注册好的某个Java方法方法的返回值会作为上下文再送回模型最终由模型组织成自然语言答案。我举一个订单查询的例子。给一个Tool注解的方法Component public class OrderTools { Tool(description 根据用户ID查询最近一笔订单信息) public Order getLatestOrder(ToolParam(description 用户ID) Long userId) { return orderService.findLatestByUserId(userId); } }然后把工具注册进ChatClientChatClient chatClient ChatClient.builder(chatModel) .defaultTools(new OrderTools()) .build();用户问我最近的订单发货了吗模型看到请求文本里隐含的用户身份信息如果有或问题里明确给了userId会解析出工具名、参数然后框架调用getLatestOrder方法拿到Order对象后回填给模型模型再生成一段您的订单昨天已发货预计后天到达这样的回答。整个过程对上层业务透明你只需要关心方法实现和属性描述。描述越准确模型选对工具的概率就越高。我在生产里给工具方法写的description通常是一句完整的话包含输入参数的含义和返回值的使用场景生成的调用准确度明显好于泛泛的查询订单。4.3 MCP服务集成把工具沉淀为协议Function Calling解决的是单个应用里注册方法但如果不同系统都要提供工具给AI用每个系统各写一套私有协议集成成本很高。这就是MCPModel Context Protocol存在的意义它是一种开放的工具和数据接入协议相当于给AI应用定了一个标准USB口任何系统只要实现MCP服务任何AI应用只要实现MCP客户端双方就能对话。Spring AI 2.0对MCP的支持已经比较成熟。服务端可以用Tool方法直接暴露为MCP工具客户端可以连接外部提供的MCP服务配置。你在项目里只需要引入spring-ai-starter-mcp-client然后配置服务发现spring: ai: mcp: client: stdio: servers-configuration: classpath:mcp-servers.jsonmcp-servers.json里声明了要接入的MCP服务名、命令或地址。外部服务方只要按照MCP协议给出服务定义你这边几乎不用写代码ChatClient通过ToolCallback就能调用到那些远程工具。这也是spring-ai-alibaba生态里常见的接入方式——别人提供标准MCP服务你的工程直接消费。我对MCP的判断是它会让AI应用的工具编排从每个项目自定义进化到行业标准互联。现在很多外部能力商已经在提供MCP服务以后接工具会像前端接npm包一样标准化。团队在做AI平台选型时MCP的兼容性优先级要放得很高。4.4 一个综合示例自然语言查数据库把上面这些能力凑起来就能做一个小而完整的智能数据查询功能。用户问上季度销量前十的商品有哪些流程是模型先调用注册的querySales工具工具内部执行SQL查询结果返回给模型模型整理成自然语言答案。Component public class DatabaseQueryTools { Tool(description 执行SQL查询并返回结果列表) public ListMapString, Object queryBySql(ToolParam(description 合法的SQL查询语句) String sql) { return jdbcTemplate.queryForList(sql); } }这个工具代码本身很简单真正的难点在于权限和安全设计。生产环境我建议做两层限制第一层允许执行的SQL只能是SELECT开头且通过词法校验第二层强制查到的数据做脱敏。这些校验代码写在工具方法内部看起来是额外工作量但能拦住大量误操作和恶意请求。有了这个组合AI就不只是聊天机器人了它是一个能持续操作业务系统的数字员工。我的经验是先让AI通过工具读数据再逐步放开写操作写操作一定要人工审批兜底。5. 生产化细节异常处理、可观测性与成本控制5.1 模型调用失败的统一异常处理模型服务商接口的可用性远没有数据库和本地接口那么稳。限流、超时、内容审核、上下文长度超限各种异常都可能发生。Spring AI的异常体系涵盖了几类典型错误模型服务不可达的AiAccessException、密钥或权限问题的AiAuthenticationException、请求内容触发内容审核的AiContentModerationException等等。如果你不做统一处理默认错误信息会直接带着框架堆栈抛给前端既不友好也泄露内部细节。我习惯用RestControllerAdvice统一兜底RestControllerAdvice public class AiExceptionAdvice { ExceptionHandler(AiContentModerationException.class) public ResponseEntityMapString, String handleModeration(AiContentModerationException e) { return ResponseEntity.status(HttpStatus.BAD_REQUEST) .body(Map.of(error, 输入或输出未通过安全审核)); } ExceptionHandler(AiAccessException.class) public ResponseEntityMapString, String handleAccess(AiAccessException e) { return ResponseEntity.status(HttpStatus.BAD_GATEWAY) .body(Map.of(error, 模型服务暂时不可用)); } }异常处理不是写个Advice就完事还要考虑重试。模型接口偶发超时直接重试往往能成功但要注意重试次数别把服务商打限流了一般1到2次足够。配合Spring的Retryable用注意区分哪些异常可重试超时、网络抖动哪些不可重试4xx参数错误、鉴权失败。5.2 ObservationHandler与全链路可观测性Spring AI 2.0在可观测性上的投入很明显核心就是ObservationHandler体系。它把一次模型调用包装成Micrometer Observation事件你可以在开始、结束、异常等不同阶段拿到对应的AI观测数据包括模型名、请求与响应token数、耗时、是否命中缓存等。我在项目里实现了一个轻量Handler把每次AI调用的指标打到日志和监控系统Component public class AiObservationHandler implements ObservationHandlerAiObservationContext { private static final Logger log LoggerFactory.getLogger(AiObservationHandler.class); Override public void onStop(AiObservationContext context) { AiObservationData data context.getObservationData(); log.info(AI调用, model{}, inputTokens{}, outputTokens{}, durationMs{}, data.getModel(), data.getInputTokens(), data.getOutputTokens(), data.getDuration()); } Override public boolean supportsContext(Observation.Context context) { return context instanceof AiObservationContext; } }有了这个Handler你就能回答两个灵魂拷问这个功能每月模型费用花在哪了接口变慢是哪家模型服务导致的同时把数据接进Prometheus/GrafanaAI调用量、失败率、token消耗就都是可观测的指标了。没有可观测性之前AI应用出了问题只看到一个黑盒定位困难接上之后至少能知道是模型服务问题还是业务逻辑问题。5.3 成本与并发控制大模型接口是按token计费的成本跟生成长度强相关。我在项目里做成本控制主要靠三板斧。第一max-tokens设得越紧越好业务上不需要长输出的场景能1000解决别给2048。第二能复用就复用比如FAQ类型的问答加一层缓存或向量检索先命中本地知识库就不再调用模型。第三并发调用要限流不用的openai批量任务一次发500个请求很可能被服务商限流而且费用瞬间飙升。用Resilience4j或Redis信号量把并发压到合理水位。5.4 多模型切换与降级生产系统不能把命脉压在单一模型服务上。Spring AI的配置化切换让多模型策略落地成本很低。你可以在配置里同时启用两个模型服务商代码里通过Qualifier区分ChatModelRestController public class AiGatewayController { private final ChatClient primaryClient; private final ChatClient fallbackClient; public AiGatewayController(Qualifier(openAiChatModel) ChatModel openAiChatModel, Qualifier(secondaryChatModel) ChatModel secondaryChatModel) { this.primaryClient ChatClient.builder(openAiChatModel).build(); this.fallbackClient ChatClient.builder(secondaryChatModel).build(); } public String chatWithFallback(String message) { try { return primaryClient.prompt(message).call().content(); } catch (AiAccessException e) { return fallbackClient.prompt(message).call().content(); } } }我实测这种做法比代码里写死切换逻辑要干净得多。模型服务商之间的切换几乎零代码改动核心配置动一动、Bean的装配动一动就完成。6. 踩坑实录从版本冲突到运行时异常的排查链路6.1 版本冲突NoClassDefFoundError的经典来源NoClassDefFoundError和ClassNotFoundException是两回事。ClassNotFoundException是从头到尾就没找到这个类NoClassDefFoundError则是编译期这个类还在运行时却加载失败绝大多数情况是依赖版本冲突或传递依赖被错误排除导致的。Spring AI项目里最容易出现这类错误的就是没有引入BOM。有人图省事直接在dependencies里写spring-ai-starter-model-openai的版本号结果它的传递依赖跟Spring Boot自带的库版本冲突启动时报各种类找不到。解决办法就是前面强调过的在dependencyManagement里用spring-ai-bom统一版本业务模块只写Starter坐标不写版本号。这样Spring AI全家桶的内部依赖版本完全对齐。6.2 时报NoClassDefFoundError的完整排查链路有次同事反馈应用启动时抛了类似uncaught exception java.lang.NoClassDefFoundError: java/applet/applet这样的栈第一眼很诡异java.applet是JDK老模块按理说现代项目根本不会碰它。排查链路的思路值得分享第一步先看JDK版本。Spring AI 2.0基线是Java 17如果开发机装了多个JDKIDE里选了Java 21Maven命令行却用的Java 8编译出来的字节码版本和运行环境不匹配就容易出现莫名其妙的NoClassDefFoundError。执行java -version和mvn -version确认两者用的是同一个JDK。第二步mvn dependency:tree查看依赖树。重点找spring-ai相关依赖是否有多个版本并存。实际遇到过项目A模块引了spring-ai 1.0B模块引了spring-ai 2.0最后BOM没统一运行时加载到的类被混用堆栈报错五花八门。统一版本后问题直接消失。第三步注意是编译期错误还是运行期错误。如果是编译期找不到类大概率是Starter没引入或BOM没生效如果是运行期找不到类优先怀疑某个依赖在打包时被排除了。Spring Boot的spring-boot-maven-plugin会把所有依赖打进fat jar但要确保没有把spring-ai相关的scope配成provided或test。6.3 模型返回JSON偶尔解析失败结构化输出不是100%可靠的哪怕用了.entity()偶尔也会遇到模型在一串JSON外面包了json代码块或者字段值多了逗号导致JSON不合法。Spring AI的默认解析器已经做了代码块剥离但极端格式仍可能让Jackson抛异常。我的对策有两个。一是调低temperature让模型输出更稳定不要给它太多创造力这个在结构化输出场景很关键。二是接一个本地兜底解析器先尝试标准反序列化失败后做一次sanitize去掉多余字符再解析。这个兜底逻辑放在一个Bean里统一处理比散落在各个controller里好得多。6.4 密钥泄露和配置前缀不一致的尴尬最后说一个看起来低级但实际高频的坑密钥泄露和配置前缀不一致。密钥问题前面强调过用环境变量这里再补一句Spring Boot Actuator的/env端点如果在生产环境开了未授权访问配置里的密钥可能被直接拉出来。生产环境务必给Actuator配好权限这不是Spring AI的专属问题但在AI项目里涉及真实付费密钥风险被放大了。配置前缀的问题主要发生在从1.0升级到2.0时。1.0里写spring.ai.openai.api-key2.0可能调整到spring.ai.model.chat.openai.api-key如果没看release notes启动时不报错但调用时就拿不到配置。排查方法很简单日志里看Spring AI启动时是否打印了模型配置加载成功或者用ConfigurationProperties建一个类把对应前缀绑定出来反复核对。我在实际项目里把Spring AI用在工单自动分类、内部知识库问答、周报生成三个场景最大的体会是框架帮你解决了80%的集成问题剩下20%的调优、安全和业务结合依然得自己下功夫。别一开始就想着把RAG、MCP、多模型全上齐先跑通一个最小对话链路再一步步加工具、加记忆、加监控。后续可以往向量数据库RAG方向扩展也可以把团队内部各种内部系统通过MCP服务接入同一个AI网关这比重复造轮子可靠得多。