news 2026/10/20 4:23:14

Spring AI 1.0实战:基于Spring Boot 3.5快速接入大模型聊天与结构化输出

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Spring AI 1.0实战:基于Spring Boot 3.5快速接入大模型聊天与结构化输出

前几天想在现有的 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 Boot3.5.x3.4.x 也可以
Spring AI BOM1.0.x统一管理 AI 相关依赖版本
JDK17 / 21Spring 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、modelbase-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 同时留在同一个工程里,那是我这次体验里踩得最深的一个坑。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/20 4:21:46

TortoiseGit推送报错密码认证已移除?用个人访问令牌PAT解决

1. 这个报错到底在说什么如果你在用 TortoiseGit 往远端仓库推送代码时&#xff0c;突然弹出一行英文提示&#xff1a;Support for password authentication was removed on August 13, 2021. Please use a personal access token instead.&#xff0c;别慌&#xff0c;这不是你…

作者头像 李华
网站建设 2026/10/20 4:20:53

STM32F373 SDADC高精度采集实战:从差分输入到偏移校准的完整指南

简介&#xff1a;这份资料围绕STM32F373的SDADC&#xff08;逐次逼近型模数转换器&#xff09;展开&#xff0c;面向嵌入式开发者和工业控制方向的学习者&#xff0c;帮助理解16位高精度模拟采集的配置方法&#xff0c;以及双SDADC同步采样、多通道扫描等实际用法。包内共159个…

作者头像 李华
网站建设 2026/10/20 4:20:52

.NET多仓库下的虚拟单体仓库:同步机制设计与实践

先说清楚&#xff0c;虚拟单体仓库不是把代码仓库合并&#xff0c;也不是搞一层复杂的代码镜像&#xff1b;它要解决的事情其实很朴素&#xff1a;让原本分散在多个仓库里的 .NET 项目&#xff0c;在某个约定的时刻&#xff0c;能被当作一个整体来分析、构建和发布。我们团队从…

作者头像 李华
网站建设 2026/10/20 4:20:51

AI绘画重塑内容生产:创新赋能与合规实践指南

AI绘画这几年从“新奇玩具”变成“生产力工具”&#xff0c;速度比大多数人预想得快。我自己最早把它正式接进工作流&#xff0c;是在一个连续加班四天的深夜——需求方把概念图方向改了又改&#xff0c;整个人已经麻了。后来用AI跑出一批候选图给对方挑&#xff0c;十几分钟就…

作者头像 李华
网站建设 2026/10/20 4:20:51

行人摔倒预测系统:从姿态估计到双通道风险预警

再聊一聊行人摔倒与预测系统我做这个项目最深的感受是&#xff1a;摔倒检测做得再好&#xff0c;本质上还是"事后通知"。摄像头拍到人已经躺在地上了&#xff0c;系统再准确地把这条消息推给护理员&#xff0c;中间损失的这几秒钟可能就决定了后续处理的难度。所以当…

作者头像 李华