第一次用 Spring AI 给我留下最深印象的一件事,不是模型本身有多智能,而是整个接入过程短到让我怀疑是不是漏了步骤:加依赖、配一段 yml、注入一个 ChatClient,四步链式调用就能把大模型接进 Spring Boot 项目。整个过程不需要手写 HTTP 调用、不需要自己管理 Prompt 组装、更不需要知道底层走了什么协议。这篇文章就是围绕这次“初体验”展开的,重点讲清楚两件事:yml 里到底该配什么,以及 ChatClient 那四步链式调用背后每一行代码都在做什么。适合想快速把大模型能力接入 Java 后端的开发者,也适合那些被网上新旧版本资料绕晕、想弄清当前主流写法的朋友。1. 为什么 Spring AI 值得你花一个下午试试1.1 先感受一下“最少配置”带来的冲击我刚开始接触 Spring AI 的时候,其实带着一种很原始的怀疑:Java 生态做大模型接入,怎么可能比写一个 Python 脚本还省事?但实际情况是,一次最小可用的对话,抛开启动 Spring Boot 的时间,真正的代码量只有十几行,而且有一半是配置。那时候我把官方文档里的示例代码复制下来,先是照着建了一个空项目,接着发现两个现象:第一,项目里不需要写任何 HTTP 客户端代码,不用拼 JSON,不用手动解析响应。以前如果要在 Spring Boot 里接一个大模型接口,通常会新建一个 RestClient,设置 header、构造请求体、再处理流式响应,这套流程至少半小时起步。Spring AI 把这一切全部封装掉了,外面只留一个封装良好的方法调用。第二,配置和模型通道是解耦的。你换一个模型服务商时,大部分业务代码不用动,变的只是 yml 里的密钥、接入地址、模型名,以及启动时引入的 starter 不同。也就是说,“配好 yml 就能聊”这句话在 Spring AI 里不是夸张,而是设计目标。这种体验让我意识到,Spring AI 真正的价值不在于提供了某个独家模型,而在于它替你把“大模型接入”这个和业务无关的繁琐过程标准化了。1.2 Spring AI 在 Java 生态里解决的是哪一类问题要理解 Spring AI 的价值,可以先回忆一下传统的大模型接入流程。在没有框架辅助时,一个后端团队要接一个大模型,通常要做这些事情:确定模型服务的鉴权方式、接入点地址和请求格式;设计一套自己的 Prompt 组装逻辑,比如系统提示词放哪、用户输入怎么拼、历史消息怎么传递;手动处理超时、重试、熔断这类非功能性需求;写一套响应解析层,把模型吐出来的文本流转成业务对象;如果要做流式输出,还得处理 SSE 或者 WebSocket 的长连接。这些工作本身没有太多创新空间,但它们占用了大量开发时间。Spring AI 做的事情就是把常见的接入模式和细节固化下来:它提供了 ChatModel 抽象、ChatClient 封装、Message 体系、选项配置机制,以及一套和 Spring Boot 自动装配深度结合的启动方式。你在业务代码里只依赖抽象接口,具体是哪个模型服务商,由配置决定。这和当年 JDBC 解决“Java 连接各种数据库”的问题很像。JDBC 不关心底层是哪种数据库,只要你提供了对应的驱动和连接串,业务代码就能通过统一的接口操作数据。Spring AI 对模型的抽象思路也是一样:先定义一套通用的聊天接口,再让不同模型服务商通过 starter 实现接入。搞清楚这一点,你就明白为什么网上资料里会出现那么多相似代码——因为 Spring AI 的核心 API 与模型厂商无关,ChatClient 是通用组件。1.3 这篇初体验适合哪些读者,以及你需要先准备的底子如果你是下面这类读者,这篇内容会比较合适:已经能正常写 Spring Boot 项目,熟悉基本的依赖注入和 yml 配置;对大模型的 API 有初步概念,知道什么叫模型名称、什么叫 API Key;想在 Java 项目里快速跑通一个“接收用户问题、返回回答文本”的聊天能力,但不想从零开始搭代码;或者你正在评估 Spring AI 到底值不值得引入团队,需要一个快速验证方案。这篇内容默认你用的是 Java 17 以上的版本,Spring Boot 采用 3.x 系列,并配合当前主流的 Spring AI 稳定版。如果你用的是更老的版本,部分配置前缀和 starter 坐标可能有差异,遇到问题不要慌,后面我专门留了一节讲版本漂移的问题。2. 把 yml 配好,让项目具备对话能力2.1 引入依赖:starter 选择与版本注意事项Spring AI 的依赖引入方式不是“一个包打天下”,而是根据你选择的模型通道有所区分。如果你在官方文档里看 Quick Start,一般会让你引入一个总的 starter,再引入对应通道的 starter。示例依赖大概长这样:<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter</artifactId> <version>1.0.0</version> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-model-your-channel</artifactId> <version>1.0.0</version> </dependency>注意,这里的 “your-channel” 需要替换成你实际选的模型服务商标识。不同通道的 starter 命名规则是有规律的,你只要把你接的那个服务商的名字换进去,一般就能对得上。我在第一次接入时,就是因为对这个命名规则不熟,翻了半天仓库也没有找到“一个 jar 解决所有模型”的依赖,后来才明白 Spring AI 刻意把通道拆开,目的是让最终打包体积和依赖面保持精简。还要特别提一个细节:Spring AI 的版本迭代速度非常快,网上大量资料停留在 0.8.x 或 1.0.0-M 系列。如果你照着老文章复制依赖坐标,很可能会碰到版本不存在或者 API 对不上的问题。我的建议是直接打开当前稳定版本的官方文档,使用文档描述的大版本,不要盲目追求最新快照,否则你排查问题时查资料都会很痛苦。2.2 最简配置:密钥、接入地址、模型名称三件套依赖加好之后,项目真正要配的东西,总结下来就是三个值:API Key、Base URL、模型名称。只要这三样是有效的,项目就具备“能聊”的基础。一个典型的 Spring AI 配置样例长这样:spring: ai: # 不同通道的标识不同,请替换为你实际接入的通道名 your-channel: api-key: ${AI_API_KEY:sk-你的密钥} base-url: ${AI_BASE_URL:https://api.example.com} chat: options: model: ${AI_MODEL:your-model-name}我来逐行解释下这几项的作用。api-key:也就是调模型服务时需要的身份凭证,相当于后端服务的登录密码。我习惯把它放进环境变量,而不要直接写在 yml 里提交到仓库。上面样例里的${AI_API_KEY:...}是 Spring 占位符的写法,含义是:优先读取环境变量 AI_API_KEY,如果环境变量没配,就使用冒号后面的默认值。base-url:模型服务商的接入地址。这里最容易踩坑的点在于,有的服务商会要求你把路径写到二级甚至三级,比如包含版本号或路径前缀;有的则要求你只写域名。具体规则只能以你接入的服务商文档为准。model:模型名称。不要以为模型名称只是给服务商看的“展示名”,它在多数服务商那边是决定计费和能力的参数。填一个不存在的模型名,启动通常不会报错,但真正调用时会直接失败。有些文章还会提到 temperature、max-tokens 之类的参数,它们也在 yml 里可以设置,不过我建议第一次先不要动,保持默认即可,原因后面会说。2.3 启动时的验证方法:日志、健康检查还是直接写个测试配置完成后的第一件事,当然是启动项目。但很多人对这个“启动不报错”产生了误解,认为启动成功就等于模型接入成功。实际上 Spring AI 的自动装配非常宽容:只要依赖在 classpath 里,并且配置项没有明显缺失,项目会正常启动,哪怕你的 API Key 是错的,启动时也不会抛异常。真正的错误会推迟到你第一次调用时爆发。所以验证接入是否成功,最直接的方式既不是看日志,也不是依赖健康检查,而是写一段最小调用代码,真实地发起一次对话。我建议先把 Controller 忽略掉,直接在启动类附近写一个 CommandLineRunner,或者在测试类里注入 ChatClient.Builder,然后发起一句非常简单的 Prompt,比如“请回复‘你好’两个字”。这样能最快确认链路是否通。@SpringBootApplication public class DemoApplication { private final ChatClient chatClient; public DemoApplication(ChatClient.Builder builder) { this.chatClient = builder.build(); } public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); } }这里已经出现了一个重要组件:ChatClient.Builder。你不用自己 new,Spring Boot 的自动装配会把它注入进来。这也是整个 Spring AI 体验里最“魔法”的一环,后面我会专门拆开讲。3. ChatClient 四步链式调用:每一行代码都在做什么3.1 先认识几个容易被忽略的名词在拆解四步链式调用之前,先花一点时间认识名字有些接近的组件,这对理解后续代码很关键。Spring AI 里有几个容易混的概念:ChatModel、ChatClient、Prompt、Message。ChatModel 是底层模型调用的抽象接口,类似 JDBC 里抽象的数据库连接能力。它负责真正把 Prompt 发给模型服务商,再把返回的响应转换为统一的 ChatResponse 对象。业务代码基本上不需要直接操作 ChatModel,但你要知道它存在,因为 ChatClient 就是建立在它之上的。ChatClient 是面向业务开发者的更高层封装。它把 ChatModel 以及 Prompt 组装、响应解析、参数设置这些琐碎工作揉合成了一个流畅的链式 API。你平时写的业务代码,接触最多的就是它。Prompt 则是“一次完整请求”的载体,里面包含若干条 Message 和一系列模型参数。Message 代表一条消息,消息又区分角色,比如系统消息(System Message)是用来设定人设或约束的,用户消息(User Message)就是使用者问的问题,助手消息(Assistant Message)通常用于多轮对话中的历史上下文。这些概念不用背得特别清楚,只要理解一个大方向即可:ChatClient 负责编排,Prompt 负责打包,ChatModel 负责传输,Message 负责描述对话中某一方的发言。3.2 四步拆解:prompt、user、call、content现在可以直接看核心代码了。一次完整的 ChatClient 链式调用如下:String answer = chatClient.prompt() .user("用一句话介绍 Spring AI") .call() .content();这段代码只有四步,但每一步处理的事情都不同。第一步,prompt()。它的作用是开启一次新的对话组装流程,返回一个 PromptSpec 对象。看到这个命名,你可能会想:我还没有传任何消息,怎么就生成 Prompt 了?实际上,这一步只是创建了一个“待填充”的 Prompt 构建器,真正的内容路由到你调用 user 或 system 时才生效。类比的话,它就像一个空白的表单,先打开等着你填。第二步,user("...")。把当前用户的问题作为一条 User Message 写入这个待填充的 Prompt。你可以在这一步多次调用 user 或 system,它们都会按顺序追加到消息列表里。这里需要注意,user()返回的还是同一类型的 PromptSpec,所以链式调用还能继续。第三步,call()。这是一个阻塞操作,它会把组装好的 Prompt 提交给 ChatModel,然后同步等待模型响应。这里的“同步等待”意味着在网络往返完成之前,当前线程会一直阻塞,直到拿到完整的响应。call() 执行完以后,返回的是一个 ChatResponse 对象,里面包含了响应内容、token 统计信息、响应元数据等。第四步,content()。从 ChatResponse 中提取出模型生成的文本内容,转为 String 返回。之所以需要这一步,是因为 ChatResponse 结构里包含的信息很多,不是纯粹的文本,比如有的响应会有多候选结果、有的是空内容但填充了 usage 统计。content() 方法相当于帮你取了“最常用”的那个字段,直接得到适合返回给前端或继续处理的字符串。如果你把每一步的返回类型写出来,就非常清楚了:prompt() -> PromptSpec (组装器准备) user() -> PromptSpec (写入用户消息) call() -> ChatResponse (同步调用模型) content() -> String (提取文本答案)3.3 为什么要设计成链式调用而不是普通方法刚接触这套 API 的人通常都会问一个问题:为什么不直接封装成一个方法,比如chat(String userText),非要搞一个链式调用?我的理解是,链式调用本质上是在解决“一次请求的可选维度太多”的问题。一次完整的大模型请求,除了用户问题之外,还可能有系统提示词、历史消息、温度参数、最大 token 数、输出格式要求、工具调用配置等。如果把这些全部放在一个方法的参数列表里,调用方会非常痛苦。假设你只是想简单问一句话,你也得传十几个参数,其中大部分还可能是空值。链式调用通过“只写你关心的维度”来解决这个问题。基础场景只需要 user 和 call,就已经组成一条完整链路;进阶场景随时可以插入 system、messages、options、stream 等节点,其他代码不需要大改。这种设计思路对业务代码的演进很友好:先跑通最小链路,再逐步增加能力。另外,链式调用每一个中间对象的类型是一致的,这意味着你可以根据条件动态地决定“要不要追加一条 system”。比如:var spec = chatClient.prompt().user(question); if (StringUtils.hasText(customPrompt)) { spec.system(customPrompt); } String answer = spec.call().content();这种写法放在普通方法参数列表里会变得很别扭,但在链式 API 里面就非常自然。3.4 链的顺序能换吗:幂等性与中间状态链式的顺序并不是完全随意,它存在严格的阶段约束。prompt()必须是整个链路的起点,这一点没有替代方案。user()、system()、messages()这些“内容填充”操作必须在call()之前完成。call()执行之后,链路就进入了“响应阶段”,不能再回头往里添加消息。content()必须在call()之后,没有响应对象,内容也就无从提取。具体来说,下面两种写法就是有问题的:// 错误一:消息还没填就发起调用 chatClient.call().content(); // 错误二:已经拿到响应对象再回头加消息 chatClient.prompt() .user("你好") .call() .system("你现在是客服"); // 编译不会错,但语义上没有任何效果我特意说“语义上没有任何效果”,是因为某些链式 API 内部对顺序并不严格校验,错误写法可能不报编译错误,但它不会把 system 消息发出去。这个坑比较隐蔽,容易在实际代码里留下逻辑 bug。另外还有一个非常实用的点:call()是可以重复执行的次数吗?从代码上看,同一个 PromptSpec 理论上可以被多次调用 call(),但实际项目中不要这么做。一次应答通常意味着一次计费调用,如果需要一个全新的回答,应该重新构建一条链路,而不是复用同一个组装对象去调用。这也解释了为什么链式 API 的设计倾向于“一次组装、一次执行”。4. 从能聊到聊好:参数调节与流式体验4.1 system() 在哪一步注入:人设和约束的正确放置四步链式调用只是最小可用形态,真实项目中几乎没有人会只用四步。最常插入的第一个新节点,就是system()。system()用来传入系统消息,它承担的是“给模型设定边界”的任务。你可以把它理解成入职培训手册:模型本身不知道自己该以什么身份回答问题,是 system 告诉它“你是客服”“你是老师”“你是翻译助手”“不要输出多余解释”。放在链式调用的哪个位置,逻辑上没有硬性要求,但我的习惯是放在user()之前,让整个消息列表的阅读顺序更接近真实对话的先后结构。示例代码如下:String answer = chatClient.prompt() .system("你是一名耐心、简洁的客服助手,答案不超过60个字。") .user("我的订单已经三天没有发货了,怎么办?") .call() .content();这里有一个非常微妙的地方:system 消息不一定能让模型百分之百按照边界执行。模型本质上是通过概率生成文本,它对 system 指令的遵循程度会受到模型能力、温度参数等多方面影响。所以不要指望在 system 里写了“绝不输出多余内容”,模型就真的只回复干巴巴一句话。更好的做法是在 system 里写明目标,同时再结合后面的温度参数一起控制。如果是多轮对话场景,还需要额外注意历史消息怎么放置。你可以用messages()一次传入多组消息,把之前几轮的 User Message 和 Assistant Message 按顺序拼好。Spring AI 不会自动帮你维护对话记忆,所以想要“记住前文”,目前仍然需要你自行组装历史上下文。4.2 温度与 token 限制:在链式调用中逐次调优temperature 是影响生成结果最直接的参数。它的作用可以粗略理解为“随机性”:取值越低,模型越倾向于选择高概率的候选输出,回答更确定、更稳;取值越高,模型的输出越发散,可能更有创造性,但也更容易跑偏。我在初体验阶段强烈建议不要碰这个参数,先用默认值。当你已经能正常对话、开始对回答质量提出要求时,再考虑调整。比如客服场景通常希望回答稳定保守,可以把 temperature 调到 0.2 左右;如果是创意写作或者头脑风暴,可以往 0.8 以上调。Spring AI 里可以在 yml 中设置默认值:spring: ai: your-channel: chat: options: model: ${AI_MODEL:your-model-name} temperature: 0.4也可以在链式调用中,针对某一次请求临时覆盖默认值:String answer = chatClient.prompt() .system("你是产品文案。") .user("为这款水杯写三句广告词。") .options(yourChannelChatOptions.builder() .withTemperature(0.9) .build()) .call() .content();注意,yourChannelChatOptions需要替换成你接入的通道对应的选项实现类。这个类由通道 starter 提供,每家命名略有不同。至于maxTokens,它控制的是单次生成的最大 token 数量,可以防止模型答到一半或者超长输出导致成本失控。不过首次体验时保持默认即可,等真正出现截断问题再调整。4.3 把 call() 换成 stream():打字机效果的实现方式call() 是同步等待完整响应,如果模型生成内容较长,用户看到结果前的等待时间会非常明显。真实产品里更常见的体验是流式输出,也就是模型一个字一个字往外吐,像打字机一样。Spring AI 对流式输出的支持不需要改架构,只需要把 call() 换成 stream():Flux<String> flux = chatClient.prompt() .system("你是一名导游。") .user("介绍一下杭州西湖。") .stream() .content(); flux.doOnNext(chunk -> { System.out.print(chunk); }).subscribe();这里返回的是 Reactor 的 Flux ,意味着流式输出的体验是异步的。每一段文本到达时,doOnNext 都会触发一次回调,把所有片段拼接起来就是完整的回答。订阅之后,记得要在应用关闭时正确处理 Flux 的生命周期,否则会留下资源未释放的风险。我在第一次改成 stream 时,踩过一个很典型的坑:流式返回了,但是 Spring MVC 接口拿到 Flux 后不知道如何封装。后来才发现,如果 Controller 直接返回 Flux ,配合 produces 指定 text/event-stream,前端用 fetch 或 EventSource 就能接住。这个方向后续我会单独开一篇讲,这里先提示一下。另外,流式输出对排查问题也更友好。你可以从第一片 chunk 到达的时间判断链路是否存在明显延迟,而不是一直等到完整响应出来才发现慢。5. 初次接入最常见的三个坑与排查思路5.1 401 权限错误:先分清是密钥问题还是通道问题初次接入时出现最多的错误,就是调用时返回 401 或者其他鉴权失败响应。有些人一看到 401 就怀疑 API Key 写错了,但我建议不要急着改密钥,先按下面顺序排查。第一步,确认配置是否真正覆盖到了目标。检查你是否配置了多个 profile 导致 yml 被其他配置覆盖。Spring Boot 的配置优先级很容易让人忽略,有时候你以为生效的是 application.yml,实际可能被 application-local.yml 覆盖了。第二步,确认环境变量是否真的存在。如果你使用${AI_API_KEY}这种占位符写法,但环境变量没有配置,应用日志里通常会透出占位符未被解析的信息。此时会发生注入值直接等于整个${AI_API_KEY}字符串的情况,看起来像有值,实际是错的。第三步,确认鉴权方式。不同模型服务商对 API Key 的传递要求不同,有的要求 header 里带特定前缀,有的要求走 OAuth。Spring AI 的 starter 通常已经帮你处理好了这些细节,但如果你换用了自定义接入通道或旧版本配置,这些细节就可能漏掉。第四步,如果以上都正常,用你选择的 HTTP 工具直接手动调用一次接口。这个动作的目的是把“框架层的问题”和“服务商的问题”隔离开来——如果手动调用也返回 401,那就是服务商侧拒绝了你;如果手动调用正常而框架里报错,问题就出在配置或代码上。5.2 启动了但一直超时:接入地址和模型名的隐含前提比 401 更折磨人的问题是:项目启动一切正常,配置看起来也对,但每次调用都是持续等待直到超时。这一类问题的根源通常集中在两个点上:接入地址和模型名。接入地址方面,很多服务商不是简单的“域名 + /chat/completions”,而是要求把版本号、项目 ID 等路径都拼进去。如果你少拼了一段路径,服务商可能返回 404,也可能不返回任何响应导致超时。我的经验是先打开你手动请求的工具,把请求 URL 和框架使用的 base-url 做并排对比,确认每一次路径拼接。模型名方面,某些服务商的模型名不是“一个名字”这么简单,它可能包含版本日期或规格后缀。如果你填了一个不存在的名字,服务商一般不会立即拒绝,而是会尝试等待或者抛出超时。这时最好先到服务商控制台确认当前可用的模型标识,而不是在代码里试图猜测。另一个比较隐蔽的点是依赖冲突导致的启动阶段静默异常:如果你引入了多个通道 starter,Spring AI 可能会同时创建多个候选模型,自动装配时选择了你没有配置的那个。我见过一种情况,配置写好了某个通道,但另一个通道的 starter 也被引入 classpath,ChatClient 默认绑定上了未配置的模型,结果每次调用都用错误的通道去联网。解决办法是保持 classpath 只包含你真正用到的通道 starter,避免同时引入多个。5.3 版本漂移:网上的老代码为什么经常报编译错误最后聊一个可能困扰你很久的问题:为什么网上搜到的示例代码,复制过来总是报编译错误?原因只有一个大方向:Spring AI 的版本演进而非细节完美。在早期版本里,ChatClient.Builder 的构建方式可能是ChatClient.builder(chatModel).build(),其中 chatModel 需要手动从容器里拿出来;而在稳定版本中,自动配置已经帮你把 Builder 注入好了。用法相似不代表 API 相同,如果搜索到的资料是旧版本,直接照抄一定会过不去编译。解决这个问题有两个思路。第一个思路是不要复制零散代码,而是以当前版本官方文档的完整范例为准。第二个思路是理解“概念不变,写法常变”这个规律:不管怎么变,ChatClient、Prompt、Message、ChatResponse 这些核心概念是稳定的,你只要理解了组装顺序,换了版本看提示重新改写即可。如果编译报错提示某个类不存在,可以先看导入语句是否匹配当前版本;如果提示某个方法不存在,大概率是该版本的 Method 或字段名发生了变化。我的习惯是直接把旧代码里的类名去掉前缀,让 IDE 自动提示当前版本的替代类,靠编译器帮忙完成迁移。结尾:我实际体验下来的一点体会如果你一路看到这里,其实已经完成了从“配好 yml 就能聊”到“理解 ChatClient 四步链式调用”的入门闭环。我个人在实际操作中最深的体会是:Spring AI 的入门门槛确实低,但它的低是用大量封装换来的,如果你不花时间理解封装背后的组装逻辑,遇到问题时会非常被动。我自己被 system 消息位置和流式类型这两个问题卡住过,后来想明白一个原则:只要你能把“消息列表”和“参数选项”的正确位置搞清楚,就掌握了 ChatClient 一多半的用法。下一步你可以尝试把聊天能力封装成一个真正的业务接口,比如接入连续多轮对话的上下文管理,或者用 Advisor 实现通用的前后处理逻辑。再往后,可以研究一下 RAG 相关的向量化检索,让模型基于你自己的文档回答问题。这些方向离“初体验”越来越远,但从这次四步链式调用出发,每一个后续功能都是在中间插入一个新的节点而已。把最小链路跑通之后,剩下的,就是往链上不断挂载你真正需要的能力。
第一次用 Spring AI 给我留下最深印象的一件事,不是模型本身有多智能,而是整个接入过程短到让我怀疑是不是漏了步骤:加依赖、配一段 yml、注入一个 ChatClient,四步链式调用就能把大模型接进 Spring Boot 项目。整个过程不需要手写 HTTP 调用、不需要自己管理 Prompt 组装、更不需要知道底层走了什么协议。这篇文章就是围绕这次“初体验”展开的,重点讲清楚两件事:yml 里到底该配什么,以及 ChatClient 那四步链式调用背后每一行代码都在做什么。适合想快速把大模型能力接入 Java 后端的开发者,也适合那些被网上新旧版本资料绕晕、想弄清当前主流写法的朋友。1. 为什么 Spring AI 值得你花一个下午试试1.1 先感受一下“最少配置”带来的冲击我刚开始接触 Spring AI 的时候,其实带着一种很原始的怀疑:Java 生态做大模型接入,怎么可能比写一个 Python 脚本还省事?但实际情况是,一次最小可用的对话,抛开启动 Spring Boot 的时间,真正的代码量只有十几行,而且有一半是配置。那时候我把官方文档里的示例代码复制下来,先是照着建了一个空项目,接着发现两个现象:第一,项目里不需要写任何 HTTP 客户端代码,不用拼 JSON,不用手动解析响应。以前如果要在 Spring Boot 里接一个大模型接口,通常会新建一个 RestClient,设置 header、构造请求体、再处理流式响应,这套流程至少半小时起步。Spring AI 把这一切全部封装掉了,外面只留一个封装良好的方法调用。第二,配置和模型通道是解耦的。你换一个模型服务商时,大部分业务代码不用动,变的只是 yml 里的密钥、接入地址、模型名,以及启动时引入的 starter 不同。也就是说,“配好 yml 就能聊”这句话在 Spring AI 里不是夸张,而是设计目标。这种体验让我意识到,Spring AI 真正的价值不在于提供了某个独家模型,而在于它替你把“大模型接入”这个和业务无关的繁琐过程标准化了。1.2 Spring AI 在 Java 生态里解决的是哪一类问题要理解 Spring AI 的价值,可以先回忆一下传统的大模型接入流程。在没有框架辅助时,一个后端团队要接一个大模型,通常要做这些事情:确定模型服务的鉴权方式、接入点地址和请求格式;设计一套自己的 Prompt 组装逻辑,比如系统提示词放哪、用户输入怎么拼、历史消息怎么传递;手动处理超时、重试、熔断这类非功能性需求;写一套响应解析层,把模型吐出来的文本流转成业务对象;如果要做流式输出,还得处理 SSE 或者 WebSocket 的长连接。这些工作本身没有太多创新空间,但它们占用了大量开发时间。Spring AI 做的事情就是把常见的接入模式和细节固化下来:它提供了 ChatModel 抽象、ChatClient 封装、Message 体系、选项配置机制,以及一套和 Spring Boot 自动装配深度结合的启动方式。你在业务代码里只依赖抽象接口,具体是哪个模型服务商,由配置决定。这和当年 JDBC 解决“Java 连接各种数据库”的问题很像。JDBC 不关心底层是哪种数据库,只要你提供了对应的驱动和连接串,业务代码就能通过统一的接口操作数据。Spring AI 对模型的抽象思路也是一样:先定义一套通用的聊天接口,再让不同模型服务商通过 starter 实现接入。搞清楚这一点,你就明白为什么网上资料里会出现那么多相似代码——因为 Spring AI 的核心 API 与模型厂商无关,ChatClient 是通用组件。1.3 这篇初体验适合哪些读者,以及你需要先准备的底子如果你是下面这类读者,这篇内容会比较合适:已经能正常写 Spring Boot 项目,熟悉基本的依赖注入和 yml 配置;对大模型的 API 有初步概念,知道什么叫模型名称、什么叫 API Key;想在 Java 项目里快速跑通一个“接收用户问题、返回回答文本”的聊天能力,但不想从零开始搭代码;或者你正在评估 Spring AI 到底值不值得引入团队,需要一个快速验证方案。这篇内容默认你用的是 Java 17 以上的版本,Spring Boot 采用 3.x 系列,并配合当前主流的 Spring AI 稳定版。如果你用的是更老的版本,部分配置前缀和 starter 坐标可能有差异,遇到问题不要慌,后面我专门留了一节讲版本漂移的问题。2. 把 yml 配好,让项目具备对话能力2.1 引入依赖:starter 选择与版本注意事项Spring AI 的依赖引入方式不是“一个包打天下”,而是根据你选择的模型通道有所区分。如果你在官方文档里看 Quick Start,一般会让你引入一个总的 starter,再引入对应通道的 starter。示例依赖大概长这样:<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter</artifactId> <version>1.0.0</version> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-model-your-channel</artifactId> <version>1.0.0</version> </dependency>注意,这里的 “your-channel” 需要替换成你实际选的模型服务商标识。不同通道的 starter 命名规则是有规律的,你只要把你接的那个服务商的名字换进去,一般就能对得上。我在第一次接入时,就是因为对这个命名规则不熟,翻了半天仓库也没有找到“一个 jar 解决所有模型”的依赖,后来才明白 Spring AI 刻意把通道拆开,目的是让最终打包体积和依赖面保持精简。还要特别提一个细节:Spring AI 的版本迭代速度非常快,网上大量资料停留在 0.8.x 或 1.0.0-M 系列。如果你照着老文章复制依赖坐标,很可能会碰到版本不存在或者 API 对不上的问题。我的建议是直接打开当前稳定版本的官方文档,使用文档描述的大版本,不要盲目追求最新快照,否则你排查问题时查资料都会很痛苦。2.2 最简配置:密钥、接入地址、模型名称三件套依赖加好之后,项目真正要配的东西,总结下来就是三个值:API Key、Base URL、模型名称。只要这三样是有效的,项目就具备“能聊”的基础。一个典型的 Spring AI 配置样例长这样:spring: ai: # 不同通道的标识不同,请替换为你实际接入的通道名 your-channel: api-key: ${AI_API_KEY:sk-你的密钥} base-url: ${AI_BASE_URL:https://api.example.com} chat: options: model: ${AI_MODEL:your-model-name}我来逐行解释下这几项的作用。api-key:也就是调模型服务时需要的身份凭证,相当于后端服务的登录密码。我习惯把它放进环境变量,而不要直接写在 yml 里提交到仓库。上面样例里的${AI_API_KEY:...}是 Spring 占位符的写法,含义是:优先读取环境变量 AI_API_KEY,如果环境变量没配,就使用冒号后面的默认值。base-url:模型服务商的接入地址。这里最容易踩坑的点在于,有的服务商会要求你把路径写到二级甚至三级,比如包含版本号或路径前缀;有的则要求你只写域名。具体规则只能以你接入的服务商文档为准。model:模型名称。不要以为模型名称只是给服务商看的“展示名”,它在多数服务商那边是决定计费和能力的参数。填一个不存在的模型名,启动通常不会报错,但真正调用时会直接失败。有些文章还会提到 temperature、max-tokens 之类的参数,它们也在 yml 里可以设置,不过我建议第一次先不要动,保持默认即可,原因后面会说。2.3 启动时的验证方法:日志、健康检查还是直接写个测试配置完成后的第一件事,当然是启动项目。但很多人对这个“启动不报错”产生了误解,认为启动成功就等于模型接入成功。实际上 Spring AI 的自动装配非常宽容:只要依赖在 classpath 里,并且配置项没有明显缺失,项目会正常启动,哪怕你的 API Key 是错的,启动时也不会抛异常。真正的错误会推迟到你第一次调用时爆发。所以验证接入是否成功,最直接的方式既不是看日志,也不是依赖健康检查,而是写一段最小调用代码,真实地发起一次对话。我建议先把 Controller 忽略掉,直接在启动类附近写一个 CommandLineRunner,或者在测试类里注入 ChatClient.Builder,然后发起一句非常简单的 Prompt,比如“请回复‘你好’两个字”。这样能最快确认链路是否通。@SpringBootApplication public class DemoApplication { private final ChatClient chatClient; public DemoApplication(ChatClient.Builder builder) { this.chatClient = builder.build(); } public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); } }这里已经出现了一个重要组件:ChatClient.Builder。你不用自己 new,Spring Boot 的自动装配会把它注入进来。这也是整个 Spring AI 体验里最“魔法”的一环,后面我会专门拆开讲。3. ChatClient 四步链式调用:每一行代码都在做什么3.1 先认识几个容易被忽略的名词在拆解四步链式调用之前,先花一点时间认识名字有些接近的组件,这对理解后续代码很关键。Spring AI 里有几个容易混的概念:ChatModel、ChatClient、Prompt、Message。ChatModel 是底层模型调用的抽象接口,类似 JDBC 里抽象的数据库连接能力。它负责真正把 Prompt 发给模型服务商,再把返回的响应转换为统一的 ChatResponse 对象。业务代码基本上不需要直接操作 ChatModel,但你要知道它存在,因为 ChatClient 就是建立在它之上的。ChatClient 是面向业务开发者的更高层封装。它把 ChatModel 以及 Prompt 组装、响应解析、参数设置这些琐碎工作揉合成了一个流畅的链式 API。你平时写的业务代码,接触最多的就是它。Prompt 则是“一次完整请求”的载体,里面包含若干条 Message 和一系列模型参数。Message 代表一条消息,消息又区分角色,比如系统消息(System Message)是用来设定人设或约束的,用户消息(User Message)就是使用者问的问题,助手消息(Assistant Message)通常用于多轮对话中的历史上下文。这些概念不用背得特别清楚,只要理解一个大方向即可:ChatClient 负责编排,Prompt 负责打包,ChatModel 负责传输,Message 负责描述对话中某一方的发言。3.2 四步拆解:prompt、user、call、content现在可以直接看核心代码了。一次完整的 ChatClient 链式调用如下:String answer = chatClient.prompt() .user("用一句话介绍 Spring AI") .call() .content();这段代码只有四步,但每一步处理的事情都不同。第一步,prompt()。它的作用是开启一次新的对话组装流程,返回一个 PromptSpec 对象。看到这个命名,你可能会想:我还没有传任何消息,怎么就生成 Prompt 了?实际上,这一步只是创建了一个“待填充”的 Prompt 构建器,真正的内容路由到你调用 user 或 system 时才生效。类比的话,它就像一个空白的表单,先打开等着你填。第二步,user("...")。把当前用户的问题作为一条 User Message 写入这个待填充的 Prompt。你可以在这一步多次调用 user 或 system,它们都会按顺序追加到消息列表里。这里需要注意,user()返回的还是同一类型的 PromptSpec,所以链式调用还能继续。第三步,call()。这是一个阻塞操作,它会把组装好的 Prompt 提交给 ChatModel,然后同步等待模型响应。这里的“同步等待”意味着在网络往返完成之前,当前线程会一直阻塞,直到拿到完整的响应。call() 执行完以后,返回的是一个 ChatResponse 对象,里面包含了响应内容、token 统计信息、响应元数据等。第四步,content()。从 ChatResponse 中提取出模型生成的文本内容,转为 String 返回。之所以需要这一步,是因为 ChatResponse 结构里包含的信息很多,不是纯粹的文本,比如有的响应会有多候选结果、有的是空内容但填充了 usage 统计。content() 方法相当于帮你取了“最常用”的那个字段,直接得到适合返回给前端或继续处理的字符串。如果你把每一步的返回类型写出来,就非常清楚了:prompt() -> PromptSpec (组装器准备) user() -> PromptSpec (写入用户消息) call() -> ChatResponse (同步调用模型) content() -> String (提取文本答案)3.3 为什么要设计成链式调用而不是普通方法刚接触这套 API 的人通常都会问一个问题:为什么不直接封装成一个方法,比如chat(String userText),非要搞一个链式调用?我的理解是,链式调用本质上是在解决“一次请求的可选维度太多”的问题。一次完整的大模型请求,除了用户问题之外,还可能有系统提示词、历史消息、温度参数、最大 token 数、输出格式要求、工具调用配置等。如果把这些全部放在一个方法的参数列表里,调用方会非常痛苦。假设你只是想简单问一句话,你也得传十几个参数,其中大部分还可能是空值。链式调用通过“只写你关心的维度”来解决这个问题。基础场景只需要 user 和 call,就已经组成一条完整链路;进阶场景随时可以插入 system、messages、options、stream 等节点,其他代码不需要大改。这种设计思路对业务代码的演进很友好:先跑通最小链路,再逐步增加能力。另外,链式调用每一个中间对象的类型是一致的,这意味着你可以根据条件动态地决定“要不要追加一条 system”。比如:var spec = chatClient.prompt().user(question); if (StringUtils.hasText(customPrompt)) { spec.system(customPrompt); } String answer = spec.call().content();这种写法放在普通方法参数列表里会变得很别扭,但在链式 API 里面就非常自然。3.4 链的顺序能换吗:幂等性与中间状态链式的顺序并不是完全随意,它存在严格的阶段约束。prompt()必须是整个链路的起点,这一点没有替代方案。user()、system()、messages()这些“内容填充”操作必须在call()之前完成。call()执行之后,链路就进入了“响应阶段”,不能再回头往里添加消息。content()必须在call()之后,没有响应对象,内容也就无从提取。具体来说,下面两种写法就是有问题的:// 错误一:消息还没填就发起调用 chatClient.call().content(); // 错误二:已经拿到响应对象再回头加消息 chatClient.prompt() .user("你好") .call() .system("你现在是客服"); // 编译不会错,但语义上没有任何效果我特意说“语义上没有任何效果”,是因为某些链式 API 内部对顺序并不严格校验,错误写法可能不报编译错误,但它不会把 system 消息发出去。这个坑比较隐蔽,容易在实际代码里留下逻辑 bug。另外还有一个非常实用的点:call()是可以重复执行的次数吗?从代码上看,同一个 PromptSpec 理论上可以被多次调用 call(),但实际项目中不要这么做。一次应答通常意味着一次计费调用,如果需要一个全新的回答,应该重新构建一条链路,而不是复用同一个组装对象去调用。这也解释了为什么链式 API 的设计倾向于“一次组装、一次执行”。4. 从能聊到聊好:参数调节与流式体验4.1 system() 在哪一步注入:人设和约束的正确放置四步链式调用只是最小可用形态,真实项目中几乎没有人会只用四步。最常插入的第一个新节点,就是system()。system()用来传入系统消息,它承担的是“给模型设定边界”的任务。你可以把它理解成入职培训手册:模型本身不知道自己该以什么身份回答问题,是 system 告诉它“你是客服”“你是老师”“你是翻译助手”“不要输出多余解释”。放在链式调用的哪个位置,逻辑上没有硬性要求,但我的习惯是放在user()之前,让整个消息列表的阅读顺序更接近真实对话的先后结构。示例代码如下:String answer = chatClient.prompt() .system("你是一名耐心、简洁的客服助手,答案不超过60个字。") .user("我的订单已经三天没有发货了,怎么办?") .call() .content();这里有一个非常微妙的地方:system 消息不一定能让模型百分之百按照边界执行。模型本质上是通过概率生成文本,它对 system 指令的遵循程度会受到模型能力、温度参数等多方面影响。所以不要指望在 system 里写了“绝不输出多余内容”,模型就真的只回复干巴巴一句话。更好的做法是在 system 里写明目标,同时再结合后面的温度参数一起控制。如果是多轮对话场景,还需要额外注意历史消息怎么放置。你可以用messages()一次传入多组消息,把之前几轮的 User Message 和 Assistant Message 按顺序拼好。Spring AI 不会自动帮你维护对话记忆,所以想要“记住前文”,目前仍然需要你自行组装历史上下文。4.2 温度与 token 限制:在链式调用中逐次调优temperature 是影响生成结果最直接的参数。它的作用可以粗略理解为“随机性”:取值越低,模型越倾向于选择高概率的候选输出,回答更确定、更稳;取值越高,模型的输出越发散,可能更有创造性,但也更容易跑偏。我在初体验阶段强烈建议不要碰这个参数,先用默认值。当你已经能正常对话、开始对回答质量提出要求时,再考虑调整。比如客服场景通常希望回答稳定保守,可以把 temperature 调到 0.2 左右;如果是创意写作或者头脑风暴,可以往 0.8 以上调。Spring AI 里可以在 yml 中设置默认值:spring: ai: your-channel: chat: options: model: ${AI_MODEL:your-model-name} temperature: 0.4也可以在链式调用中,针对某一次请求临时覆盖默认值:String answer = chatClient.prompt() .system("你是产品文案。") .user("为这款水杯写三句广告词。") .options(yourChannelChatOptions.builder() .withTemperature(0.9) .build()) .call() .content();注意,yourChannelChatOptions需要替换成你接入的通道对应的选项实现类。这个类由通道 starter 提供,每家命名略有不同。至于maxTokens,它控制的是单次生成的最大 token 数量,可以防止模型答到一半或者超长输出导致成本失控。不过首次体验时保持默认即可,等真正出现截断问题再调整。4.3 把 call() 换成 stream():打字机效果的实现方式call() 是同步等待完整响应,如果模型生成内容较长,用户看到结果前的等待时间会非常明显。真实产品里更常见的体验是流式输出,也就是模型一个字一个字往外吐,像打字机一样。Spring AI 对流式输出的支持不需要改架构,只需要把 call() 换成 stream():Flux<String> flux = chatClient.prompt() .system("你是一名导游。") .user("介绍一下杭州西湖。") .stream() .content(); flux.doOnNext(chunk -> { System.out.print(chunk); }).subscribe();这里返回的是 Reactor 的 Flux ,意味着流式输出的体验是异步的。每一段文本到达时,doOnNext 都会触发一次回调,把所有片段拼接起来就是完整的回答。订阅之后,记得要在应用关闭时正确处理 Flux 的生命周期,否则会留下资源未释放的风险。我在第一次改成 stream 时,踩过一个很典型的坑:流式返回了,但是 Spring MVC 接口拿到 Flux 后不知道如何封装。后来才发现,如果 Controller 直接返回 Flux ,配合 produces 指定 text/event-stream,前端用 fetch 或 EventSource 就能接住。这个方向后续我会单独开一篇讲,这里先提示一下。另外,流式输出对排查问题也更友好。你可以从第一片 chunk 到达的时间判断链路是否存在明显延迟,而不是一直等到完整响应出来才发现慢。5. 初次接入最常见的三个坑与排查思路5.1 401 权限错误:先分清是密钥问题还是通道问题初次接入时出现最多的错误,就是调用时返回 401 或者其他鉴权失败响应。有些人一看到 401 就怀疑 API Key 写错了,但我建议不要急着改密钥,先按下面顺序排查。第一步,确认配置是否真正覆盖到了目标。检查你是否配置了多个 profile 导致 yml 被其他配置覆盖。Spring Boot 的配置优先级很容易让人忽略,有时候你以为生效的是 application.yml,实际可能被 application-local.yml 覆盖了。第二步,确认环境变量是否真的存在。如果你使用${AI_API_KEY}这种占位符写法,但环境变量没有配置,应用日志里通常会透出占位符未被解析的信息。此时会发生注入值直接等于整个${AI_API_KEY}字符串的情况,看起来像有值,实际是错的。第三步,确认鉴权方式。不同模型服务商对 API Key 的传递要求不同,有的要求 header 里带特定前缀,有的要求走 OAuth。Spring AI 的 starter 通常已经帮你处理好了这些细节,但如果你换用了自定义接入通道或旧版本配置,这些细节就可能漏掉。第四步,如果以上都正常,用你选择的 HTTP 工具直接手动调用一次接口。这个动作的目的是把“框架层的问题”和“服务商的问题”隔离开来——如果手动调用也返回 401,那就是服务商侧拒绝了你;如果手动调用正常而框架里报错,问题就出在配置或代码上。5.2 启动了但一直超时:接入地址和模型名的隐含前提比 401 更折磨人的问题是:项目启动一切正常,配置看起来也对,但每次调用都是持续等待直到超时。这一类问题的根源通常集中在两个点上:接入地址和模型名。接入地址方面,很多服务商不是简单的“域名 + /chat/completions”,而是要求把版本号、项目 ID 等路径都拼进去。如果你少拼了一段路径,服务商可能返回 404,也可能不返回任何响应导致超时。我的经验是先打开你手动请求的工具,把请求 URL 和框架使用的 base-url 做并排对比,确认每一次路径拼接。模型名方面,某些服务商的模型名不是“一个名字”这么简单,它可能包含版本日期或规格后缀。如果你填了一个不存在的名字,服务商一般不会立即拒绝,而是会尝试等待或者抛出超时。这时最好先到服务商控制台确认当前可用的模型标识,而不是在代码里试图猜测。另一个比较隐蔽的点是依赖冲突导致的启动阶段静默异常:如果你引入了多个通道 starter,Spring AI 可能会同时创建多个候选模型,自动装配时选择了你没有配置的那个。我见过一种情况,配置写好了某个通道,但另一个通道的 starter 也被引入 classpath,ChatClient 默认绑定上了未配置的模型,结果每次调用都用错误的通道去联网。解决办法是保持 classpath 只包含你真正用到的通道 starter,避免同时引入多个。5.3 版本漂移:网上的老代码为什么经常报编译错误最后聊一个可能困扰你很久的问题:为什么网上搜到的示例代码,复制过来总是报编译错误?原因只有一个大方向:Spring AI 的版本演进而非细节完美。在早期版本里,ChatClient.Builder 的构建方式可能是ChatClient.builder(chatModel).build(),其中 chatModel 需要手动从容器里拿出来;而在稳定版本中,自动配置已经帮你把 Builder 注入好了。用法相似不代表 API 相同,如果搜索到的资料是旧版本,直接照抄一定会过不去编译。解决这个问题有两个思路。第一个思路是不要复制零散代码,而是以当前版本官方文档的完整范例为准。第二个思路是理解“概念不变,写法常变”这个规律:不管怎么变,ChatClient、Prompt、Message、ChatResponse 这些核心概念是稳定的,你只要理解了组装顺序,换了版本看提示重新改写即可。如果编译报错提示某个类不存在,可以先看导入语句是否匹配当前版本;如果提示某个方法不存在,大概率是该版本的 Method 或字段名发生了变化。我的习惯是直接把旧代码里的类名去掉前缀,让 IDE 自动提示当前版本的替代类,靠编译器帮忙完成迁移。结尾:我实际体验下来的一点体会如果你一路看到这里,其实已经完成了从“配好 yml 就能聊”到“理解 ChatClient 四步链式调用”的入门闭环。我个人在实际操作中最深的体会是:Spring AI 的入门门槛确实低,但它的低是用大量封装换来的,如果你不花时间理解封装背后的组装逻辑,遇到问题时会非常被动。我自己被 system 消息位置和流式类型这两个问题卡住过,后来想明白一个原则:只要你能把“消息列表”和“参数选项”的正确位置搞清楚,就掌握了 ChatClient 一多半的用法。下一步你可以尝试把聊天能力封装成一个真正的业务接口,比如接入连续多轮对话的上下文管理,或者用 Advisor 实现通用的前后处理逻辑。再往后,可以研究一下 RAG 相关的向量化检索,让模型基于你自己的文档回答问题。这些方向离“初体验”越来越远,但从这次四步链式调用出发,每一个后续功能都是在中间插入一个新的节点而已。把最小链路跑通之后,剩下的,就是往链上不断挂载你真正需要的能力。