前几天想在现有的 Spring Boot 项目里加一个调用大模型聊天的功能。一开始的思路是直接用 RestClient 或者三方 SDK 去调模型接口,后来顺手试了试 Spring AI 这个官方生态,才发现它已经把大模型相关的接入、流式响应、结构化解析、提示词模板都包装成了 starter,于是花了大概一个下午就把最小可运行的工程跑通了。这篇内容就用我实际操作过的 Spring Boot 3.5 + Spring AI 1.0 来记录完整过程,包括同步对话、流式输出、多轮上下文、结构化输出、本地 Ollama 模型的接入方式,以及那些看起来不起眼、但确实能卡住半天的坑。适合刚听说 Spring AI、想快速判断值不值得用在真实项目里的 Java 开发者。
1. 快速体验的第一步:理解 Spring AI 到底帮你做了什么
1.1 从"手动拼 HTTP 请求"到"一个 ChatClient 搞定"
直接调模型接口这件事,做过的人都懂:请求体要按各家模型的协议拼 JSON,接口返回的格式各不相同,流式接口得自己解析边接收边输出的数据块,出错时还要处理各种状态码和限流策略。如果只是写个 Demo 还好,一旦项目里要同时支持两三家模型,或者要把模型返回结果映射成 Java 对象,这些重复代码会迅速膨胀。
Spring AI 的思路其实很朴素:把大模型当成 Spring 里的一个普通 Bean 来用。底层负责跟模型厂商通信的细节由自动配置完成,你面对的核心抽象就是ChatModel,日常写代码主要用的是它的上层封装ChatClient。这个设计最大的价值不是省掉几十行代码,而是让业务代码和具体的模型供应商解耦。我今天用 OpenAI 的模型,明天想换成别的厂商,改的就是依赖和配置,业务代码基本不用动。
我自己动手前总觉得这种抽象层会有很多限制,实际试下来发现ChatClient的 API 设计比想象中顺手,尤其是同步调用.call()和流式调用.stream()这两种姿势,基本覆盖了绝大多数聊天场景。快速体验的第一课不是急着写代码,而是先理解这个抽象层次,否则后面遇到配置不生效、响应格式不对的时候容易乱猜。
1.2 版本节奏与兼容性:先看 BOM 再看 starter
Spring AI 从 2023 年开始就一直在一个比较快的迭代节奏里,命名上也经历了各种后缀的变化。我印象中它早期还是 0.8.x、0.9.x 这种版本,API 变动非常频繁,网上很多教程用的都是旧代码,直接抄很容易编译失败。到 2025 年 9 月底发布了 1.0.0 正式版之后,整体才开始稳定下来。
我这里用的版本组合是 Spring Boot 3.5.x 加 Spring AI 1.0.x。如果你还在用 Boot 3.4 也没问题,1.0.0 对 3.4 和 3.5 都有适配。JDK 直接用 17 或者 21,Boot 3.x 本身要求就是 17 起步。
| 组件 | 我使用的版本 | 说明 |
|---|---|---|
| Spring Boot | 3.5.x | 3.4.x 也可以 |
| Spring AI BOM | 1.0.x | 统一管理 AI 相关依赖版本 |
| JDK | 17 / 21 | Spring Boot 3.x 的最低要求 |
有一个容易忽略的点:Spring AI 的依赖不是随便把 starter 写进dependencies就完事的,它通常需要你先把对应的 BOM 导入到dependencyManagement里,starter 本身不写版本号。这样做的目的是让整个体系内几十个模块的版本保持一致。这点在 Spring Initializr 里选 AI 相关依赖时会被自动处理好,但如果你是在已有项目里手动加依赖,最容易犯的错就是少导入了 BOM,结果项目里混进多个不兼容的模块版本。
2. 把最小工程搭起来:JDK、依赖与配置的三件套
2.1 Maven 工程的最小依赖配置
我先说结论:一个能跑通对话的最小工程,核心依赖就两个——spring-boot-starter-web用来快速提供 HTTP 入口,spring-ai-starter-model-openai用来接入 OpenAI 格式的模型 API。下面是我实际用的简化版 pom 片段:
<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> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-model-openai</artifactId> </dependency> </dependencies>如果你不想折腾 BOM 和版本对应关系,最省事的做法是直接用 start.spring.io 生成项目,在 Dependencies 里搜 AI,把需要的模型 starter 勾上,生成的工程基本不会出现版本组合问题。手动往老项目加依赖时,才需要严格按照上面这个结构。
值得一提:Spring AI 1.0 之前有些版本需要额外配置 Spring 的里程碑仓库(milestone repository)才能拉到依赖,1.0 GA 发布之后核心模块已经进了中央仓库,正常联网就能下载,不用再折腾仓库配置了。
2.2 application.yml 里最容易被忽略的配置项
依赖加好之后,配置就是下一关。最小配置只需要一个 API Key 和模型名,我放在application.yml里:
spring: application: name: spring-ai-quickstart ai: openai: api-key: ${OPENAI_API_KEY} chat: options: model: gpt-4o-mini temperature: 0.7几点经验:
- API Key 别硬编码进配置文件,用环境变量占位符
${OPENAI_API_KEY}是基本操作,方便本地调试和部署时替换。 model最好显式指定,不要依赖服务端默认值。默认模型可能会随服务商策略调整而变化,显式指定能让你的程序行为可预期。temperature控制随机性,0 到 1 之间。我在做结构化抽取时会调到 0.2 左右,做文案生成时会用 0.7 以上。只是个参考,具体按场景试。
这里有个非常容易踩的坑:很多人启动项目后没报错,就以为配置成功了,实际上 Spring AI 的自动配置在启动阶段并不会立刻调用模型接口,只有第一个真实的 Prompt 发出去时才会去连模型服务。所以"启动成功"和"配置正确"完全是两回事,一定要用一个真实请求验证过,才叫跑通。
2.3 启动验证:确认 Bean 真的装配好了
最小验证方式就是写一个 Controller,把ChatClient注入进来,暴露一个接口:
@RestController public class ChatController { private final ChatClient chatClient; public ChatController(ChatClient chatClient) { this.chatClient = chatClient; } @GetMapping("/hello") public String hello(@RequestParam String message) { return chatClient.prompt() .user(message) .call() .content(); } }启动后直接用浏览器或 curl 测一下:
curl --get --data-urlencode "message=你好,用一句话介绍你自己" http://localhost:8080/hello如果返回了一段正常的模型回复,说明整个链路已经通了。我说一下这里为什么用/hello而不是ChatClient的默认效果:Spring AI 会自动装配一个ChatClient.Builder,上面的代码是我在 Controller 里直接用 Builder 创建ChatClient的简化版本,实际上你也可以把它定义成独立的 Bean,统一配置默认系统提示词,后面再演示。
如果启动时报No qualifying bean of type 'ChatClient',优先检查依赖是不是没引入,或者是否有多个模型的 starter 同时存在导致自动配置不知道该装配哪个。这条我在后面专门展开讲。
3. ChatClient 实战:同步、流式、多轮对话一起跑通
3.1 最常用的同步调用写法
ChatClient的同步调用是.prompt().user(...).call().content()这条链式写法。上面的/hello接口就是一个典型例子。prompt()开启一次新的对话,user()设置用户输入,call()发起请求并等待完整响应,content()直接取出文本内容。
如果你想拿到更多信息,比如 token 消耗、生成完成的原因,就不要用.content(),而是获取完整的ChatResponse:
ChatResponse response = chatClient.prompt() .user("帮我把这句话翻译成英文:你好,今天天气不错") .call(); String answer = response.getResult().getOutput().getText();这么做的好处是可以拿到响应里的元数据。不过大部分场景下.content()已经够了,代码也更简洁。
我之前习惯在 Controller 里直接注入ChatClient,后来发现更好的做法是把它定义成一个配置类里的 Bean,统一设置默认系统提示词,这样所有调用点都不用重复声明角色设定:
@Configuration public class ChatConfig { @Bean ChatClient chatClient(ChatClient.Builder builder) { return builder .defaultSystem("你是一名有耐心的 Java 架构师,回答尽量结构化,结论先行。") .build(); } }之后注入这个ChatClient时,每次请求都会自动带上这段 System Prompt。这个模式对项目后期维护非常友好。
3.2 流式输出与 Flux 的注意点
聊天体验要像样,流式输出基本绕不开。Spring AI 里流式调用只比同步多一个关键字:
Flux<String> content = chatClient.prompt() .user("用三句话介绍 Spring AI 是什么") .stream() .content();注意,.stream().content()返回的是Flux<String>,不是Flux<ChatResponse>,这是 1.0 版本对类型收窄后的结果。如果你把.stream()的返回对象直接塞给老代码里期望Flux<ChatResponse>的地方,会编译不过。
在 Spring MVC 的 Web 接口里暴露流式响应,最直接的方式是用 SSE:
@GetMapping(value = "/chat/sse", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public Flux<String> chatSse(@RequestParam String message) { return chatClient.prompt() .user(message) .stream() .content(); }这里有两个细节值得注意。第一,如果你在响应方法里对Flux做了block()阻塞等待,等于把异步的优势全部丢掉了,前端收到的还是一整段内容,不如直接用同步接口。第二,SSE 的produces一定要设置成TEXT_EVENT_STREAM_VALUE,否则部分 HTTP 客户端会把流当成普通 JSON 一次性接收,表现和没开流一样。
3.3 多轮上下文该怎么组织
用过一段时间后你会发现,ChatClient默认是无状态的。每一次.prompt()都是一次全新的请求,模型只能看到你当前传进去的内容。想要多轮对话,就得自己维护会话历史,把之前每一轮的 User 消息和 Assistant 消息都带上下一次请求。
List<Message> chatHistory = new ArrayList<>(); chatHistory.add(new UserMessage("帮我起一个项目代号,要求三个字母。")); ChatResponse first = chatClient.prompt() .messages(chatHistory) .call(); chatHistory.add(first.getResult().getOutput()); chatHistory.add(new UserMessage("再用它写一句简介。")); String second = chatClient.prompt() .messages(chatHistory) .call() .content();这里最关键的代码是chatHistory.add(first.getResult().getOutput()),这一步把 AI 自己的回复作为AssistantMessage存回历史。少了这一步,模型在下一次请求里就不知道它上一次说了什么,对话会变得非常割裂。
实际开发时,这个历史列表通常要跟用户会话绑定,比如存在 Redis 或者数据库里,而不是放在内存变量里。对快速体验来说,先理解这个手动维护的机制就够了。
4. 从"能聊天"到"能干活":结构化输出与提示词模板
4.1 用 entity() 把模型回答变成对象
聊了几轮之后,下一个需求很自然地出现:我不想每次拿的都是字符串,我想让模型直接返回一个对象,比如从用户评论里提取商品信息。手写 JSON Schema 再去拼接提示词的方案太痛苦了,Spring AI 提供了一个非常实用的能力——.entity()。
public record ProductInfo( @JsonPropertyDescription("商品名称") String name, @JsonPropertyDescription("商品价格,仅保留数字") BigDecimal price, @JsonPropertyDescription("商品分类") String category ) {} @GetMapping("/parse") public ProductInfo parse(@RequestParam String text) { return chatClient.prompt() .user("从下面的用户评论中提取商品信息:\n" + text) .call() .entity(ProductInfo.class); }它的底层逻辑是:Spring AI 根据你传入的目标类型动态生成一段 JSON Schema 描述,塞进提示词里,要求模型严格按这个格式返回,然后再把返回的 JSON 解析成目标对象。全程不用自己写解析代码。
我给两点实操建议。第一,字段上的@JsonPropertyDescription注解一定要写,字段描述越具体,模型提取越准确。比如价格字段写"仅保留数字",模型就不会给你带货币符号。第二,如果你的方法返回的是一个 List,需要配合ParameterizedTypeReference:
List<ProductInfo> products = chatClient.prompt() .user("从下面的商品列表里逐个提取商品信息:\n" + text) .call() .entity(new ParameterizedTypeReference<List<ProductInfo>>() {});4.2 提示词模板的参数化写法
之前我习惯用字符串拼接去组装用户消息,比如"请为" + product + "写一段广告"。这种方式在小项目里没问题,但提示词一旦变长,拼接处很容易出错,也不好维护。Spring AI 天然支持模板参数:
chatClient.prompt() .user( "请为 {product} 写一段产品卖点,语气要 {style},不超过 50 字。", Map.of("product", "智能保温杯", "style", "轻松活泼") ) .call() .content();{}里面的占位符会自动被 Map 里的值替换。这个写法比较推荐,尤其是提示词里多个变量的时候,可读性比拼接好一个数量级。
注意一个历史遗留问题:早期版本的PromptTemplate类所在的包路径和 1.0 版本不一样。网上很多示例代码用的是旧包名,你直接抄过来很可能会提示 import 不到。我现在的习惯是优先用ChatClient自带的消息模板重载方法,也就是上面这种.user(模板, 参数)的写法,这样能绕开包路径变更的坑。
4.3 实测一个"商品信息抽取"小例子
我实际测试时给的输入是:
我买了一个智能保温杯,199 元,外壳是磨砂质感的,保温效果真的不错。请求上面的/parse接口后,模型返回的ProductInfo对象大致是:
{ "name": "智能保温杯", "price": 199, "category": "家居用品" }这个结果直接用 Jackson 序列化回去,前端就能拿到干净的 JSON。整个链路里没有出现那种"输出里带一堆解释文字"的情况,entity()内部的解析器帮我把多余内容都挡掉了。
不过我必须提醒一句:模型本质上仍然是在做概率生成,字段值有可能跟用户原话不完全一致,比如把品牌名脑补进去,或者在看不到明确信息时猜一个分类。所以结构化输出可以解决"格式解析"的问题,但"数据准确性"问题还需要业务侧做校验。我一般会在拿到对象后加一层简单校验,价格不能为负数,名称不能为空,这些成本很低但很有用。
5. 不花 API 费用也能体验:接入本地 Ollama 模型的差异
5.1 本地模型的依赖与配置
线上 API 需要密钥、按量计费,想快速验证一些想法的时候,本地模型是个不错的替代方案。我用 Ollama 跑了一个开源模型来体验,过程也不复杂。
先引入依赖:
<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-model-ollama</artifactId> </dependency>然后配置:
spring: ai: ollama: base-url: http://localhost:11434 chat: options: model: qwen2.5:7b temperature: 0.8在使用之前,先确保本地已经拉取过模型。以 qwen2.5:7b 为例,命令行执行ollama pull qwen2.5:7b,然后在终端里ollama run qwen2.5:7b验证能正常对话,再接进 Spring Boot 项目就不容易出幺蛾子。
代码层面,如果你只引入了 Ollama 这个 starter,直接注入ChatClient或ChatModel时自动配置会把 Ollama 的模型 Bean 装配进去。如果在一个项目里同时保留了 OpenAI 和 Ollama 两个 starter,注入ChatModel就会产生歧义,需要指定限定符:
@Qualifier("ollamaChatModel") private final ChatModel ollamaChatModel;我这里特别提一下:两个 starter 同时存在时,Spring AI 的自动配置会各自生成不同名字的模型 Bean,能装配但容易选错。最稳妥的做法是,除非真的需要动态切换供应商,否则一个项目只留一个模型 starter。
5.2 两种接入方式的差异对比
本地模型和云端模型在 Spring AI 里的使用方式高度一致,同步调用、流式、提示词模板这些写法完全复用,这是这个框架做得比较聪明的地方。差异主要集中在配置和效果层面。
| 维度 | 云端模型 API | 本地 Ollama |
|---|---|---|
| API Key | 需要 | 不需要 |
| 网络要求 | 需要能访问模型服务商接口 | 本机即可 |
| 关键配置 | api-key、model | base-url、model |
| 成本 | 按调用量计费 | 只消耗本机算力 |
| JSON 结构化输出 | 通常稳定 | 模型太小时容易不稳定 |
| 响应速度 | 依赖网络 | 依赖本地显卡和内存 |
从我实际体验看,qwen2.5:7b这种规模的模型做简单对话和基本的结构化提取没问题,但生成速度取决于机器配置,显存不足时响应会有明显延迟。
5.3 本地模型最容易出现的两类问题
第一类是连不上服务。启动项目后请求接口直接报连接异常,九成是 Ollama 服务没启动,或者base-url配错了端口。Windows 上 Ollama 默认监听 11434,Linux 上如果用了容器映射,端口要按实际映射来填。
第二类是结构化输出不稳定。云端大模型对.entity()的支持很成熟,但本地小模型经常在 JSON 前后加解释文字,或者干脆返回纯文本。遇到这种情况,我的处理方式比较简单粗暴:先在 Ollama 的配置里试着开启 JSON 输出格式,比如format: json;如果还不行,就换大一点的模型;再不行,就用entity(String.class)拿到原始文本,自己在代码里做一层兜底解析。
6. 跑完这个小项目后,我总结的几条避坑经验
6.1 版本不匹配时先从 BOM 查起
体验过程中最容易碰到的错误,不是代码逻辑问题,而是版本冲突。症状很典型:代码参考了 1.0 的新写法,但项目里的依赖还是 0.9 或更早版本,导致ChatClient、PromptTemplate这类类要么找不到,要么方法签名对不上。
遇到这种问题,不要先去改代码,先查依赖树:
mvn dependency:tree -Dincludes=org.springframework.ai看看实际引入的 spring-ai 模块版本是不是你期望的那个。绝大多数版本混乱的根因,都是没有通过 BOM 统一管理,或者项目里某个老模块又传递引用了旧版本。在我的经验里,先把依赖理清,代码问题通常就消失了一半。
另外一个容易迷惑人的点是:Spring AI 的依赖模块非常多,比如spring-ai-model、spring-ai-chat、某个模型对应的 starter。你不需要手动逐个添加,只要引入对应的 starter,它会把该带的模块都传递依赖进来。手动加一堆中间模块反而是制造版本冲突的捷径。
6.2 同时存在多个模型 Bean 时的选择问题
我在把 OpenAl 和 Ollama 两个 starter 都引入后,遇到了一个典型问题:注入ChatModel chatModel时 Spring 启动报expected single matching bean but found 2。这个错误信息很直白,两个模型候选都满足注入条件。
解决方式有两种。一是删除不用的 starter,让自动配置只装配一个模型 Bean;二是保留两个 starter,但注入时用@Qualifier("ollamaChatModel")或@Qualifier("openAiChatModel")显式指定。如果项目只是体验阶段,我强烈建议选第一种,多个 starter 的环境对新手来说非常容易混淆。
同样的道理也适用于ChatClient。如果你在一个配置类里手动创建了ChatClientBean,自动配置可能又创建了一个,注入时也会报歧义。这种情况我通常会指定@Primary,或者调整 Bean 方法命名让注入明确。
6.3 日志、超时与联调:把问题暴露在最早阶段
最后一条建议:不要一上来就追求优雅代码,先把application.yml里的日志级别调高,观察请求和响应到底发生了什么:
logging: level: org.springframework.ai: DEBUG这一步的效果非常明显。AI 调用是典型的外部依赖调用,如果日志里看不到请求内容、响应内容、耗时信息,出了问题就只能靠猜。打开 DEBUG 之后,至少能确认发送出去的 Prompt 是不是你想要的,模型返回了什么样的原文,是解析层出了问题还是模型本身输出有问题。
关于超时,我的经验是默认配置很多时候不够应对大模型接口的响应时间,尤其是流式场景。如果你在联调时发现请求经常超时,可以在对应模型模块的连接配置里调大超时阈值。不同模型模块的配置前缀略有差异,动手前先看一眼当前版本的文档,避免配了不生效还找不到原因。
我现阶段最顺手的组合是:日常调试用云端接口,一个环境变量就能切换 Key;做离线验证或者不方便联网时,切到 Ollama,代码基本不用动。唯一要注意的就是别把两个模型 starter 同时留在同一个工程里,那是我这次体验里踩得最深的一个坑。