第一次看到 Spring AI 的 ChatClient 链式调用,我其实有点不以为然。毕竟直接调大模型 API 也不是什么难事,一个 HTTP 请求加一段 JSON 解析就搞定了。但当我真正在一个 Spring Boot 项目里加上依赖、填好 yml 配置,然后看着几行链式代码把对话跑通的时候,我才意识到这玩意儿确实把 Java 接入大模型这件事的门槛拉低了一大截。
Spring AI 给我的第一感觉,就是“配好 yml 就能聊”这句话并不是夸张。它把大模型服务商对接、鉴权、参数读取、客户端管理等一大堆重复工作收敛到了自动配置里,业务测剩下的就是注入一个 ChatClient,然后按照它设计好的链路去组装一次对话请求。这篇文章就从我自己的实战角度,把 Spring AI 初体验里最关键的几个点拆开聊一聊:为什么选它、yml 里到底配了什么、ChatClient 那四条链子每一步在干什么,以及那些文档里不会明说的坑。
1. Spring AI 到底是什么,为什么要用它
1.1 传统接入方式的痛点
先说一个最实际的场景。假设你要在 Java 服务里接一个大模型的对话能力,过去最朴素的写法是这样的:引入一个 HTTP 客户端,构建请求头,拼 JSON 请求体,把用户问题塞进去,然后发起 POST 请求,等模型返回,再解析嵌套了好几层的 JSON,最后拿出 content 字段。
这套流程本身不算复杂,但真正生产落地的时候会冒出来一堆额外的事。比如多个模型服务商之间的切换,今天用这家、明天换那家,协议细节大概率不一样,你得为每一家写适配代码。再比如鉴权失败的识别、超时重试的策略、流式响应的解析、token 数超限的报错处理,这些如果全手写,很快就能看出来这是一个重复轮子的过程,而且不同团队写的轮子接口风格还不一样。
如果模型服务商还提供了官方 SDK,那又是另一套引入方式——它的实体类、调用风格、初始化方式全都要单独学。结果就是一个组织内部可能同时存在几个完全不兼容的“AI 接入层”,维护成本很高。
1.2 Spring AI 的定位与核心组件
Spring AI 做的事情,其实跟 Spring Data 统一数据库访问、Spring Cloud 统一微服务组件的思路一脉相承:它给“Java 应用接入 AI 能力”定义了一套统一抽象。无论你底层接的是哪家模型服务,对应用代码来说,核心对象是 ChatClient、ChatModel、ChatOptions 这套统一 API。切换供应商的时候,大部分业务代码不用动,主要改改 yml 配置和依赖坐标就行。
它的核心组件大致分三层。最底层是 ChatModel,它封装了“发给模型一次请求并拿到响应”这个基础能力,不同的 starter 实现了对接不同服务商的 ChatModel。往上一层是 ChatClient,它是面向业务开发的中间层,提供了 prompt、user、system、call、stream 这套链式 API,让你不用直接面对 ChatModel 的底层方法。再往上是 Advisor、ChatMemory 这类扩展组件,负责做多轮记忆、内容过滤、提示词模板等增强能力。
初学阶段不用把每一层都吃透,只需要理解一个概念:Spring AI 把模型服务商的那层差异挡在了自动配置里面。你配好 yml,Boot 就帮你创建好合适的 ChatClient,你的代码只管组织问题、接收回答就好。
2. 环境准备:依赖和 yml 配置
2.1 版本选择:Boot 3.x + Java 17 起步
先说版本,这一点容易踩坑。Spring AI 不是独立运行的框架,它挂在 Spring Boot 之上。官方要求最低 Java 17,Spring Boot 版本建议 3.2 或者更高。我本地用的是 Spring Boot 3.3 加 JDK 21,跑起来没任何问题。如果你手头还在用 Spring Boot 2.x,那大概率是接不上的,得先升级基础框架。
Spring AI 的版本号变化也很快,早期有 0.8.x 的里程碑版本,后来发布了 1.0.0。不同版本的 API 有些细节出入,比如某些早期版本的 ChatClient 接口方法名在不同分支之间有过调整。我实际使用的是 1.0.0 GA,也就是正式版,包路径和命名已经趋于稳定。引用依赖的时候,建议用和你的 Spring Boot 版本匹配的 BOM 统一管理,避免一个项目里出现多套 Spring AI 版本互相冲突。
2.2 最小依赖配置
先拿最常用的 OpenAI 兼容接口举个例子。在 pom.xml 里加这么一段即可:
<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-openai</artifactId> <version>1.0.0</version> </dependency>如果你的工程里已经通过 spring-ai-bom 统一管理过版本,那么 dependency 里的 version 可以省略。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>然后正常加 spring-ai-starter-openai 即可,不用再写 version。如果只是本地玩一玩、不想依赖外部网络,也可以换成 Ollama 方案,依赖是 spring-ai-starter-ollama,base-url 指向本地的 Ollama 服务,模型随意选一个本地已拉取的权重。这个方案的好处是完全离线、没有鉴权成本,适合刚开始练手的时候随便造。
2.3 yml 里到底在配什么
以 OpenAI 兼容的配置为例,application.yml 里最核心的内容是这样:
spring: ai: openai: api-key: ${OPENAI_API_KEY} chat: options: model: gpt-4o-mini temperature: 0.7 max-tokens: 1024这里的思路很直白:spring.ai.openai 前缀下面的内容,就是把你平时手动构造 HTTP 请求时需要填的那些信息,全部交给了自动配置处理器。
第一项 api-key 是鉴权凭证,它对应 HTTP Header 里的 Authorization 字段。这里有一个非常重要的实践:不要在 yml 里硬编码真实密钥。像我上面的写法,用的是环境变量占位符 ${OPENAI_API_KEY},密钥放在本机环境变量或者部署平台的安全配置里,配置文件本身可以进仓库,密钥不会泄露。这个习惯最好从一开始就保持,否则一旦仓库被推送到公开平台,密钥就等于当场泄露。
第二项 model 是要使用的模型标识。它必须和模型服务商定义的模型名完全一致,比如 gpt-4o-mini、gpt-4o、以及本地 Ollama 的 llama3.2 等。模型名写错会在请求时收到服务商返回的 model not found 或类似错误。
第三项 temperature 和 max-tokens 是生成参数。temperature 控制回答的随机性,值越低越稳定保守,适合代码生成、分类判断;值越高越发散,适合文案创意。max-tokens 控制生成内容的最长 token 数,设得太短会出现回答被截断的情况。这两项是可以被请求级别的 options 覆盖的,yml 里配置相当于给所有请求设置了一套默认值,也就是 default options。
如果你是接本地 Ollama,配置则长这样:
spring: ai: ollama: base-url: http://localhost:11434 chat: options: model: llama3.2 temperature: 0.7启动后如果没报错,Boot 已经自动创建好了 ChatClient Bean,这一步就是“配好 yml 就能聊”的底气所在。
3. ChatClient 四步链式调用拆解
3.1 第一步:prompt() 打开输入构建
先看一个完整的请求示例:
String answer = chatClient.prompt() .system("你是一名耐心的AI助手,回答要简洁准确。") .user("用一句话解释什么是依赖注入") .call() .content();这段代码是使用 Spring AI 最常见的形态。从 chatClient.prompt() 开始,链式调用依次经过 user()、call()、content(),正好对应标题里说的“四步链式调用”。其中 prompt() 本身并不同后端发送任何请求,它的作用只是打开一个“请求构建器”,相当于 Builder 模式里的起始方法。从这一步开始,你拿到的就是一个可以继续往下拼接的请求规范对象。
这一步的意义在于把“构建请求”和“执行请求”分开。ChatClient 自己持有模型客户端和默认参数,而 prompt() 之后产生的对象专门负责承载某一次对话的参数。所以同一个 ChatClient 可以在不同线程里分别发起多个 prompt() 构建动作,互不干扰。这一点对 Web 服务场景特别重要,因为同一个 Controller 实例会被很多请求同时使用,如果每次调用共享可变状态,并发环境下容易出现数据串味。
还有一个容易忽略的点: prompt() 是可以带参数的。它支持 Prompt 对象、ChatOptions 或者函数式回调。所以如果你想给某次请求动态指定一个特殊模型名或者更高的 temperature,可以在构建链里使用 options() 方法来完成,而不必影响全局配置。
3.2 第二步:user() 填充用户消息
进入到请求构建阶段后,最核心的填充方法是 user()。它把用户输入的内容写进消息列表里,最终会序列化成模型可读的 user 角色消息。这个方法接受字符串,也接受方法参数、数据库查出来的文本、甚至动态模板生成的文本。你可以连续调用多次 user(),最终会合并成一条或者多条 user 消息发给模型。
在填充用户消息之前,还有两个常见的附加操作:system() 和 messages()。system() 用来设置系统提示词,它定义了模型扮演的角色和行为规则。一个比较实用的经验是:把系统的“人设”和“约束条件”放在 system 里,把用户的具体问题放在 user 里。这样模型能更清晰地理解哪些是规则、哪些是待处理的任务。
如果要做多轮对话,可以先用 messages() 把历史消息一次性塞进去,再追加当前用户问题。历史消息本质上是 Message 对象的列表,可以包含不同类型的消息。这段操作在链路结构上保持统一,因此实际的调用链可能会比四步长一些,但核心骨架仍然是 prompt -> user -> call -> content。加上 system、messages、options 这些,只是因为业务上需要更多的控制能力。
我在这一步踩过一个小坑:早期我把用户消息直接拼进一个长字符串里,比如“假设你现在是一名旅游顾问,用户问题为:北京三天怎么玩”,然后不写 system(),全塞进 user()。跑起来也能出结果,但模型经常“记不住”前面的人设,回答跳跃性很强。后来改成 system() 明确人设、 user() 只放具体问题,回答稳定性明显提升。这也是链式 API 设计的好处——不同角色的消息各归其位,模型理解起来更准确。
3.3 第三步:call() 阻断执行
链式调用走到 call() 这一步,才算真正触发了模型服务。这时请求会走完从消息组装到网络发送、再到响应解析的全过程,整个链路是同步阻塞的。也就是说,call() 会一直等到模型返回结果才继续往后走,所以它不适合放在对响应延迟要求极高的场景里,也挺适合那种“用户问一句、我等答案回来再展示”的传统打问答流程。
这一步也是异常集中地出现的环节。网络超时、鉴权失败、模型不存在、内容被审查,都会在 call() 这里抛出异常或者返回错误状态。因此生产环境里最好对这段链式调用做一层 try-catch,或者配合 Spring 的全局异常处理器,把模型端的错误转换成用户能看懂的提示。同时,因为 call() 是同步阻塞的,如果模型响应普遍偏慢,接口内部线程可能长时间处于等待状态,这会快速消耗掉线程池资源。这里有两个常见的处理方向:一是给超时时间设置合理的上限,二是改用第四种玩法——stream() 流式调用,这个后面再详细展开。
如果你看源码,会发现 call() 内部其实还有一个分支逻辑:当它拿到模型响应后,返回的对象是 CallResponseSpec。这个对象里并不是直接装着字符串,而是带着元数据、token 用量统计等信息的响应容器。它等待下一步方法来决定最终输出什么类型。
3.4 第四步:content() 取文本
call() 返回之后,链条的最后一步是 content(),它从响应容器中取出纯文本内容。这个方法的背后其实就是从解析好的模型响应里提取 content 字段,并处理了部分服务商在不同返回结构上的差异。
content() 返回的是一个 String,适合直接返回给前端展示。但实际项目里经常有比“展示一段文本”更复杂的需求,比如需要模型返回结构化数据,那就不适合用 content() 然后自己再做 JSON 解析,更好的做法是用 entity() 方法。它可以直接把模型返回的 JSON 反序列化成你指定的类型。
举个例子,如果你让模型“生成一个包含城市、温度、穿衣建议的 JSON”,可以这样写:
record WeatherInfo(String city, double temperature, String suggest) {} WeatherInfo info = chatClient.prompt() .user("查询北京今天的天气,并给出穿衣建议,输出JSON") .call() .entity(WeatherInfo.class);这样一步就拿到了一个强类型对象,比 content() 拿字符串再手写 JSONObject 解析干净得多。
所以第四步看似简单,其实有两个分支:要正文用 content(),要结构化数据用 entity()。我自己的习惯是:给前端聊天框返回纯文本优先 content(),给业务系统做数据提取则一律 entity()。
4. 进阶处理:参数微调、结构化输出、流式输出与多轮对话
4.1 通过 options 参数微调生成行为
ChatClient 的默认参数可以从 yml 里配置,但实际业务里,不同请求往往需要不同的生成风格。比如“工作总结”场景希望回答稳定靠谱,而“文案创意”场景希望回答发散有新意。这种差异可以通过请求级别的 options() 来覆盖默认配置。
典型代码如下:
String codeAnswer = chatClient.prompt() .options(ChatOptions.builder() .temperature(0.2) .maxTokens(512) .build()) .system("你是一名Java开发专家,只输出代码,不做额外解释。") .user(code) .call() .content();参数表格整理如下:
| 参数 | 控制对象 | 经验参考 |
|---|---|---|
| temperature | 回答随机性 | 代码/判断类任务 0.2 左右,文案创作类 0.7-1.0 |
| maxTokens | 生成内容上限 | 按业务预估长度设置,不要给太大导致等待时间增加 |
| topP | 核采样范围 | 一般和 temperature 二选一微调,不强烈建议同时大改 |
| model | 具体模型 | 必须和服务商的模型列表一致,可全局配置也可请求级覆盖 |
这些参数最终会被合并进发送给模型服务商的请求体里。Spring AI 的默认值通常比较中庸,所以业务侧最好根据实际场景做一次显式设定,而不是完全依赖默认值。实测下来,同一个任务用 temperature 0.2 和 0.9 跑出来的结果差异会非常明显,0.9 往往在事实类问答里更容易“编”出不太靠谱的细节。所以做知识库问答、合同审查这类任务,宁可牺牲点文采,也要把 temperature 压低。
4.2 结构化输出
一说到结构化输出,很多刚上手的人会下意识想到“我让模型返回 JSON,然后自己用 Jackson 解析”。这招能用,但不省心,因为模型偶尔会在 JSON 前后补一些解释性文字,导致 parse 失败。Spring AI 的 entity() 方法做的事情其实就是在内部完成“让模型按 JSON 结构返回”和“把 JSON 映射到目标类型”这两步,能少踩不少雷。
我在用的时候有一个额外技巧:定义 record 时字段名尽量与模型自然语言中的语义词对齐,并且在 prompt 里明确告诉它“只输出 JSON”。比如让模型“提取订单信息,字段为 orderId、amount、status”,然后定义 record 用同样的字段名,解析成功率会非常高。如果想进一步提升稳定性,还可以把 JSON Schema 的描述词直接写进 system 提示词中,让模型严格遵循结构生成。
4.3 流式输出与多轮对话
回到 call() 那条链的不足。如果模型生成回答需要几秒,用户在网页上等待那几秒的空白体验是很差的。更现代的做法是流式输出,让第一个字尽快到用户屏幕上,就像输入法逐字出现一样。Spring AI 对这件事有专门的链式分支:把 call() 换成 stream(),返回值就变成了一个响应式 Flux。在 WebFlux 环境下配合 text/event-stream 类型,可以做到逐字或逐段推送。
典型写法如下:
@PostMapping(value = "/chat-stream", produces = "text/event-stream") public Flux<String> chatStream(@RequestBody String question) { return chatClient.prompt() .user(question) .stream() .content(); }这里有一点要特别注意: stream() 返回的是响应式流,整个链路是异步非阻塞的。如果你在传统的 Spring MVC 同步接口里直接返回 Flux 并等待订阅执行,前端会一直等不到完整响应,除非接口本身支持异步返回。所以流式输出一般适用于 WebFlux 或支持异步响应的 Web 环境。我第一次在 Spring MVC 里试 stream() 的时候,前端拿到的是一个空响应,排查了好久才意识到是接口模型的差异。
多轮对话稍微特殊一些。ChatClient 本身是一个无状态组件,它不会自动帮你记住之前聊过什么。要实现连续的上下文,需要自己做历史消息的管理,然后在每次请求时通过 messages() 把历史塞进去。伪代码思路如下:
List<Message> history = new ArrayList<>(); history.add(new UserMessage("介绍一下Spring AI")); history.add(new AssistantMessage("Spring AI是Spring生态的AI集成模块,提供统一的AI客户端抽象。")); history.add(new UserMessage("那它和LangChain比呢?")); String answer = chatClient.prompt() .messages(history) .user("请基于上面的历史继续回答") .call() .content();这套做法的优点是逻辑透明,缺点是随着历史消息增多,每次请求带的 token 数也会变大,费用和耗时都会上升。更优雅的方式是借助 ChatMemory 和 Advisor,自动管理消息裁剪和摘要。但初学者建议先手动管理历史,理解消息列表的本质之后再上框架级组件。
5. 常见问题与排查技巧实录
5.1 高发问题速查表
把整个跑通过程汇总一遍,下面这七个问题是我见过或者实际踩过的高频问题,基本覆盖了从配置到调用的主要卡点。
| 现象 | 常见原因 | 处理方式 |
|---|---|---|
| 启动报 No qualifying bean of type ChatClient | 未引入对应 starter,或 Spring Boot 版本过低 | 检查依赖,确认 Boot 3.2+ |
| 请求返回 401 Unauthorized | api-key 未正确配置或环境变量没生效 | shell 里 echo $OPENAI_API_KEY 确认变量;检查配置前缀 |
| connect timed out | base-url 指向错误,或地址不可访问 | 先 curl 一下模型地址看是否通;Ollama 场景检查本机服务是否启动 |
| HTTP 400 model not found | 模型名写错或服务商不提供该模型 | 从服务商文档复制模型 ID,不要手打 |
| 回答经常被截断 | max-tokens 设置过小 | 调大 maxTokens,或调整 prompt 要求更短回答 |
| 偶尔出现 429 Too Many Requests | 触发服务商限流 | 增加重试退避策略,或降低并发请求 |
| 中文乱码或特殊字符丢失 | 请求响应编码问题 | 检查服务器的编码配置,响应头里补上 UTF-8 |
这里面的 429 问题值得多说一句。Spring AI 自己带了一套默认的重试机制,底层通过 RetryTemplate 实现,配好之后能对临时性网络错误和限流错误做自动退避。如果堆叠了很多并发请求仍然 429,那就要从两个角度考虑:一是降低业务侧的并发量,二是去模型服务商侧申请更高的速率配额,靠重试只能应对偶发抖动,治不了持续限流。
5.2 我印象最深的三次排障
第一次是“环境变量吃掉了 api-key”。当时我在 yml 里写了 ${OPENAI_API_KEY},本地跑得好好的,部署到预发环境后突然开始报 401。查了半天发现是预发环境的环境变量名写错了,而且配置文件被多环境共享,加载顺序导致预发环境实际读不到对应变量。这个问题的教训是:密钥相关的环境变量要在部署平台每个环境单独核对,最稳妥的做法是配一个启动自检接口,打印配置是否生效。
第二次是流式接口被网关缓冲。前端反馈说接口能连通,但始终等不到首字,像卡住了一样。后来发现是网关层对响应内容做了缓冲,把 SSE 流一次性攒住不推给客户端。排查完之后在网关配置里把该路径的响应缓冲关掉,前端立刻就能逐字看到输出了。这件事说明:流式输出不止是“后端返回 Flux”,链路里的每一个中间件都可能成为缓冲层。
第三次是结构化输出字段全 null。我让模型返回“城市、气温、穿衣建议”的 JSON,用 entity() 解析之后对象拿到了,但字段全是 null。查了底层日志发现模型的返回结构和我的 record 字段名不完全一致,比如字段名是“message.suggestions”,我定义的是“suggest”。模型返回了大写字母开头的键,而 record 字段是驼峰,映射不上。后来我统一在提示词里写明“严格按照以下JSON字段输出:city、temperature、suggest”,并且让模型只输出 JSON 不输出解释,解析就稳定了。
5.3 避免踩坑的几个习惯
结合上面的问题,我慢慢养成了几个习惯,这里可以直接抄作业。
第一,所有模型服务商相关配置,尽量通过环境变量或者配置中心注入,不要实体化到业务代码里。这样后续不同环境切换模型服务商,只需要改配置,代码零改动。
第二,在自定义的 AI 服务类外面包一层防腐层。业务代码不直接调用 ChatClient,而是调用你自己的方法接口。这样一旦底层从某个模型切到另一个模型,或者从 Call 改成 Stream,上层接口保持不变。我见过很多代码里直接注入 ChatClient 到 Controller 里,看起来简洁,实际上后续改造成本很高。
第三,把模型返回的原始内容做一次日志记录。不要只记录最终输出的字符串,把请求模型名、token 用量、耗时这些元数据也打一版日志。排查“为什么回答变差了”的时候,这些数据比再运行一次代码有用得多。
第四,涉及金额、合同这类高风险场景,必须在提示词里要求模型“无法确认时直接说不确定”,同时把 temperature 调低到 0.2 以下。大模型在低温度下仍然有幻觉概率,所以也要在业务侧加兜底校验,不要把模型输出直接当作最终事实。
6. 一些实际操作后的个人心得
把 Spring AI 用到现在的最大感受是:它真正把 Java 生态里“集成大模型”这件事从手写 HTTP 客户端带到了框架级抽象。以前我做一个 AI 对话功能,先要写一个 service 封装请求和解析,再考虑超时重试、错误映射、参数透传;现在用 ChatClient,最基础的四步链式调用已经覆盖了八成场景,剩下的是调参和做边界处理。
给新手的建议是先别急着上 Advisor、ChatMemory 这些进阶组件。把 yml 配好、把四步链式调用跑通、把不同参数的输出差异体会一遍,再根据业务需求逐步加复杂度。我最开始接手项目里的 AI 集成时,总想着一次把结构设计得很完整,结果被版本差异和模型返回格式折腾得够呛。现在接新需求,我的第一版永远是最简单的链式调用——prompt 组织、call 同步、content 拿文本,跑通闭环之后再谈流式、结构化、多轮记忆这些增强能力。
另外一个小技巧:Spring AI 官方仓库里有不少现成示例代码,尤其是 OpenAI 和 Ollama 这两个 starter 的例子,拿来改改就能用。配合官方文档一起看,能省掉很多摸索时间。如果你刚开始接触,建议先在自己电脑上把 Ollama 跑起来,完全离线、免费、不会因为密钥和网络问题卡在第一步。等你对 ChatClient 的链式结构有了手感,再换到云模型服务上,会发现整个迁移过程基本就是换依赖、换配置,以及调整几个参数那几个位置。
配好 yml,先聊起来,后面的事都好说。