Spring AI 这套东西,核心就一句话:把接大模型这件事,从手写 HTTP 请求、手拼 JSON、自己处理鉴权,降级成了“配好 yml 就能聊”。ChatClient 则是它最顺手的一张牌,我最近拿它做了个最小可运行的 Demo,全程没写一行裸 HTTP 代码,从依赖到跑通不到十分钟。这篇就把我的完整过程、四步链式调用的拆解,以及踩过的几个坑一起写出来。适合已经用过 Spring Boot、正准备把 AI 能力塞进业务里的朋友,也适合刚想上手、不知道从哪开始的人。
1. 先搞清楚:Spring AI 到底解决了什么
1.1 没有 Spring AI 之前,接入大模型有多痛苦
我最早接大模型的时候,流程是这样的:先去翻服务商的接口文档,确认鉴权方式、请求体结构、流式返回格式;然后写一个 HTTP 客户端封装,把 messages 数组拼成 JSON;再处理各种错误码、超时、限流;最后还要把返回里那串可能夹杂着多余解释文字的文本清洗出来。如果换一家服务商,以上过程基本重来一遍,因为每家请求体都不一样。最难受的是流式输出,需要自己解析 SSE 格式,处理事件流中断、重连、半包,工作量不亚于重新写个消息中间件客户端。
那时候我经常跟同事吐槽:这哪是调 API,这是在跟每个平台的文档搏斗。尤其是多个模型对比测试的时候,每个模型写一套适配代码,表格里多一个模型选项,代码里就多一个分支。项目越往后越像一堆补丁叠补丁,根本不是什么“灵活接入”。
1.2 一个模型一个样,适配层才是重点
各家大模型接口虽然千差万别,但抽象到业务层面其实是同一件事:你给一段文本,它返回一段文本;你给一个对话上下文,它返回一个回答。Spring AI 做的事情,本质上就是把这一层“差异”全部收编到统一接口后面,让你面对的是稳定的编程模型,而不是随时会变的厂商协议。
这一点特别像当年 JDBC 的意义。没有 JDBC,你连 MySQL 和 PostgreSQL 都得写两套访问逻辑;有了 JDBC,SQL 是方言,连接、事务、结果集交给驱动去适配。Spring AI 也是类似思路,ChatModel 是统一入口,各家服务商在底层做适配,你只跟 ChatClient 打交道。换模型供应商的时候,多数情况下只需要改 yml 配置,Java 代码甚至一行都不用动。
1.3 自动配置:为什么配好 yml 就能聊
“配好 yml 就能聊”这句话的关键在 Spring Boot 的自动装配。你引入一个spring-ai-starter-model-ollama或者spring-ai-starter-model-openai依赖,Spring Boot 启动时就会通过自动配置把对应的ChatModel、ChatClient.Builder这些 Bean 注册到容器里,同时把 yml 里的spring.ai.*配置项绑定到对应的 Properties 类上。
所以你不用手动写什么创建客户端的工厂类。引入依赖,在 yml 里填上服务地址、模型名、Key,然后直接注入ChatClient.Builder,调用.build()拿实例,就能发起对话。这套流程对老 Spring 用户来说太亲切了——把“接模型”拉回到了“写配置”的舒适区。我第一次跑通的时候只有一个感觉:早该这样了。
2. 跑通第一个“能聊”的 Demo
2.1 版本与依赖怎么选
我用的环境是 Spring Boot 3.4.x 加 Spring AI 1.0.0 GA。Spring AI 的模块命名在 1.0 之后统一成了spring-ai-starter-model-*,找依赖比早期版本直观很多。先在pom.xml里引入 BOM 统一管理版本,再按需加 starter。
<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>本地调试我用的是 Ollama,因为不需要申请 Key,起一个本地模型服务就能验证整个链路。
<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-model-ollama</artifactId> </dependency>如果你要接在线服务商,把依赖换成spring-ai-starter-model-openai,然后在 yml 里填api-key和base-url即可。注意这里的openai是配置命名空间,很多国内服务商都提供 OpenAI 兼容接口,照样走这个 starter,只是把base-url改成对应服务商的地址。
2.2 yml 配置详解:每个字段是干嘛的
最简配置长这样:
spring: ai: ollama: base-url: http://127.0.0.1:11434 chat: options: model: llama3.1:8b temperature: 0.7如果接的是在线服务商:
spring: ai: openai: api-key: ${LLM_API_KEY} base-url: https://api.example.com/v1 chat: options: model: gpt-4o-mini temperature: 0.7几个容易踩的配置点先列出来:
| 配置项 | 含义 | 备注 |
|---|---|---|
| base-url | 模型服务的根地址 | 结尾不要带/chat/completions,框架自己拼路径 |
| model | 模型标识 | 本地模型要先ollama pull拉取 |
| temperature | 采样随机性 | 0-2 之间,越高越发散,越低越确定 |
| api-key | 在线服务密钥 | 建议用环境变量注入,别硬编码进 yml |
| max-tokens | 最大输出长度 | 不是所有服务商都支持,按需配置 |
我当时的误区是把base-url一路写到了聊天的完整地址,结果框架又自动拼了一层路径,直接 404。这里记一下:base-url 只需要写到服务的根路径,比如 Ollama 就是http://127.0.0.1:11434。
2.3 最小可运行代码
配置好了,代码就是注入一个ChatClient.Builder,然后调用。下面是一个最小的 Service 示例:
@Service public class ChatDemoService { private final ChatClient chatClient; public ChatDemoService(ChatClient.Builder builder) { this.chatClient = builder.build(); } public String chat(String question) { return chatClient.prompt() .system("你是一个严谨的Java技术顾问,回答尽量简洁。") .user(question) .call() .content(); } }Controller 那边就是简单的接口转发:
@RestController public class ChatController { private final ChatDemoService chatDemoService; public ChatController(ChatDemoService chatDemoService) { this.chatDemoService = chatDemoService; } @GetMapping("/chat") public String chat(@RequestParam String q) { return chatDemoService.chat(q); } }启动应用后访问http://localhost:8080/chat?q=什么是依赖注入,就能看到模型返回答案。这一步跑通的意义是验证整条链路:自动配置是否生效、yml 是否被正确绑定、 ChatModel 是否能连上后端服务。链路通了你再深入,链路不通后面全是盲人摸象。
3. ChatClient 四步链式调用详解
3.1 四步是哪四步:把调用拆给你看
很多人第一次看链式调用会懵,尤其是一行代码里连着五六个方法。我当时把它拆成四个阶段之后,整个框架一下就清晰了。这四个阶段是:
- 进入组装状态:
prompt()返回一个PromptSpec,就像点菜时服务员拿来菜单和点餐单。 - 把话说明白:用
system()、user()、messages()填充角色消息和上下文,决定模型“以什么身份回答、回答什么”。 - 发起请求:用
call()做同步调用,或者用stream()做流式调用,这一步才真正和模型服务打交道。 - 消费结果:用
content()拿纯文本,用entity()直接得到对象,用chatResponse()拿完整响应元数据。
经典的完整示例是这一段:
String answer = chatClient.prompt() .system("你是体育新闻编辑,擅长写短评。") .user("用三句话点评今天这场决赛。") .call() .content();方法数量看起来是五个,但本质上就是上面四步。链式调用的好处在于每一步都处在正确的上下文状态里,你不可能跳过组装阶段直接发起请求,因为prompt()不调用,后面根本没有.call()可以触发。这种设计把“流程正确性”提前到了编译期,而不是运行期。
3.2 每一步背后的逻辑
先看prompt()。它返回的不是一个简单的 POJO,而是一堆规范组成的PromptSpec。这意味着从这一步开始,你进入的是一个专门用来装配 Prompt 的上下文,后面能调用的方法都被限制在“组装消息”这个范围内。这种强约束的设计,比一个通用对象加一堆可选 setter 要安全得多。
再看消息组装。system()和user()分别对应 SystemMessage 和 UserMessage。System 负责定基调,比如“你是严谨的工程师”“只输出 JSON”;User 是用户的真实输入。顺序上 System 在先、User 在后是更常见的写法,因为模型通常会把 System 消息当作最高优先级的上下文。如果你有历史消息,可以用messages()一次性塞进去,它的参数是List<Message>,支持你手工组装完整对话历史。
第三步call()和stream()的选择要根据场景。同步调用适合普通接口,拿到结果再返回;流式调用适合打字机效果,用户期望一个字一个字蹦出来。Spring AI 的流式基于 Reactor,返回的是Flux<String>,底层帮你处理了 SSE 的解析。
第四步消费结果,三种方式对应的场景非常清晰:
| 方法 | 返回值 | 适合场景 |
|---|---|---|
| content() | 字符串 | 聊天、生成文案 |
| entity() | 类型化对象 | 信息抽取、结构化输出 |
| chatResponse() | 完整响应对象 | 需要 token 统计、结束原因等元数据 |
3.3 链式设计辨析:Builder 模式的精髓
有朋友问我:为什么不直接提供一个chat(String prompt)方法,搞得这么复杂?答案很简单:因为真实调用链有太多可变参数。System 消息、历史消息、模型参数、输入输出格式、调用方式、返回类型,每一项都有多种选择。如果都塞进一个方法签名里,那个方法会有十几个参数,调用方看文档都看不明白。
Builder 模式的精髓在这里体现得很充分:把复杂对象的构造过程拆成多个明确步骤,每一步都有上下文约束,且可以随时停下、按需取用。比如你想先拿ChatClient实例,再决定每次怎么组装 Prompt,这两件事的复杂度就被ChatClient.Builder和PromptSpec剥离了。链式调用还天然适合阅读,代码读下来就像一句话,人脑不需要跳转来跳转去。
这里提醒一句:ChatClient.Builder是注入前构建 ChatClient 用的,prompt()之后的链是每次请求临时创建的。所以不要试图复用同一个PromptSpec或者一个ChatClient实例去并发跑不同上下文,最好让每个请求走完整的链式调用,保证状态隔离。
3.4 结构化输出:从字符串到对象的最后一步
entity()是我觉得 Spring AI 最值钱的功能之一。模型返回的本来是文本,但如果你把要求写明“按数据结构返回 JSON”,entity()可以直接把 JSON 反序列化成你的类型。这段代码我实际验证过:
public record Employee(String name, String department, int age) {} List<Employee> employees = chatClient.prompt() .system("只输出JSON数组,不要输出任何解释文字,不要使用Markdown代码块。") .user("把下面人员转为员工对象:张三,研发部,28;李四,市场部,32;王五,产品部,26") .call() .entity(new ParameterizedTypeReference<List<Employee>>() {});注意,泛型类要用ParameterizedTypeReference而不是直接传List.class,否则反序列化会丢失元素类型信息,变成一堆LinkedHashMap。这是 Java 泛型擦除的老问题,Spring AI 没有偷懒,提供了正确的工具。
踩坑的点在于:模型不是 JSON 序列化工具,它偶尔会输出“好的,这是你要的JSON:”这种废话,或者在数组前后加 Markdown 代码块。所以system()里一定要强调“只输出 JSON,不要解释”。如果模型支持 JSON Schema 格式约束,优先在配置里开启,能显著降低解析失败概率。
4. 调用链路与生产化配置:细节解剖
4.1 从 ChatClient 到模型服务,一次调用经历了什么
表面上看就是一行chatClient.prompt().user(...).call().content(),实际上请求链路是这样的:ChatClient 把链式调用的参数组装成一个Prompt对象,交给底层ChatModel;ChatModel根据你选的服务商适配器,把 Prompt 转成对应的 HTTP 请求,通过 WebClient 或 RestClient 发出;模型服务返回响应后,适配器再解析成统一的ChatResponse结构;最后 ChatClient 按你的消费方式把结果取出来。
中间有几个可插拔的环节值得一提。Advisor是 Spring AI 里的拦截器,作用类似 Web 的 Filter,可以在请求前后插入逻辑,比如记录日志、注入上下文、自动维护聊天记忆。官方提供了MessageChatMemoryAdvisor这类实现,你可以通过ChatClient.Builder的defaultAdvisors注册它。Observation则负责可观测性,配合 Micrometer 能拿到 token 消耗、延迟等指标,生产环境排查性能问题很好用。
4.2 超时、重试与降级,生产环境必做的三件事
初体验的 Demo 不用管超时,但一旦上生产,默认超时大概率不够。尤其是本地 Ollama 冷启动加载模型那几十秒,写个前端接口十有八九超时报错。在线服务商接口偶发抖动,也需要重试机制兜底。
超时这块,OpenAI 风格服务的配置大致如下:
spring: ai: openai: client: connect-timeout: 10s read-timeout: 60sOllama 本地服务也建议把初始化超时拉大:
spring: ai: ollama: init: pull-model-strategy: when-missing timeout: 60s重试可以用 Spring Retry 的@Retryable注解,也可以直接在代码里手动处理。我实际用的方案是给对话方法加@Retryable(maxAttempts = 3, backoff = @Backoff(delay = 1000, multiplier = 2)),对偶发的网络错误很有效。降级则是在重试仍然失败时返回兜底文案,比如“AI 服务暂时不可用,请稍后再试”,同时记录告警日志。生产环境最忌讳的是把异常直接甩给用户。
4.3 上下文记忆与多轮对话的正确姿势
ChatClient 本身是无状态的。每次调用都是一次独立的请求,模型不记得你上一句话说了什么。这既是个坑,也体现了框架的设计原则——它不替你做对话管理,但已经给了你做管理的工具。
最简单的多轮记忆方式是手动维护历史消息:
List<Message> history = new ArrayList<>(); history.add(new SystemMessage("你是一个负责的客服助手。")); history.add(new UserMessage("我上次问你什么是依赖注入,你解释过了。")); history.add(new AssistantMessage("是的,依赖注入是把对象依赖关系交给容器管理。")); history.add(new UserMessage("那我现在要尝试动手写一个例子,给我一点建议。")); String answer = chatClient.prompt() .messages(history) .call() .content();更工程化的做法是用MessageChatMemoryAdvisor,把对话历史交给内存或数据库存储,通过 ChatClient 自动注入messages。这样业务代码不需要手工维护history列表,顾问模式会帮你把最近的上下文拼进每次请求。不过要注意上下文有长度上限,无限累积聊天记录迟早会撑爆 Token,生产环境要控制单轮消息条数或总长度。
4.4 流式输出的正确姿势
流式输出最典型的应用就是聊天窗口的打字机效果。Spring AI 的stream()返回的是Flux<String>,配合 WebFlux 的 Controller 可以直接输出 SSE:
@GetMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public Flux<String> stream(@RequestParam String q) { return chatClient.prompt() .user(q) .stream() .content(); }前端用EventSource或者fetch+ReadableStream就能逐段接收。这块有个容易踩的坑:如果项目不是 WebFlux 而是传统 MVC,Flux<String>返回给前端可能会变成数组而不是 SSE 流。你需要在配置或者 Controller 上显式声明text/event-stream。我调试的时候一度发现前端收到的是一整包 JSON 数组,查了半天才意识到是 produces 类型没设对。
5. 常见问题与排雷实录
5.1 报错速查表
我把接入过程中常见的问题整理成了一张表,遇到类似报错可以直接对号入座:
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| Connection refused | 本地模型服务没启动,或 base-url 写错 | 启动 Ollama,用 curl 验证服务可用 |
| 401 Unauthorized | api-key 缺失、写错或已过期 | 检查环境变量注入,确认 Key 有效 |
| Model Not Found / 404 | 本地没拉取该模型,或在线模型名不精确 | ollama pull 模型名,对照服务商文档核实 ID |
| connect timed out | 默认超时过短,或模型冷启动慢 | 调大 connect-timeout、read-timeout,预热模型 |
| JsonMappingException | 模型输出里混了解释文字或 Markdown | System 消息强调只输出 JSON,开启格式约束 |
| 回复内容被截断 | 输出长度达到 max-tokens 上限 | 调大 max-tokens,或要求模型分条输出 |
| 同一问题答案差异大 | temperature 设置过高 | 降到 0 到 0.3,并给 system 加确定性约束 |
5.2 排查顺序:从配置到代码
遇到问题,我建议按下面这个顺序排查,不要上来就改代码。
第一步,验证 yml 配置是否真的生效。启动时如果有spring-boot-starter-actuator,直接查环境配置:
curl http://localhost:8080/actuator/env | grep -n "spring.ai"能看到对应的base-url、model值,说明配置绑定成功;看不到或者值是空的,说明 bean 没加载或者 yml 缩进有问题。
第二步,验证模型服务本身是否可用。Ollama 的话直接查看已拉取的模型列表:
curl http://127.0.0.1:11434/api/tags在线服务商就用自己的接口文档提供的方式测一个最小请求。这步能在 10 秒内分清是框架的问题还是服务的问题。
第三步,把链路缩小到最小单元。写一个 Spring Boot 的单元测试,只注入ChatClient.Builder,发一个固定 Prompt,观察返回。如果最小测试也失败,问题大概率在配置或环境;如果最小测试通过,问题就在你业务代码的组装逻辑上。
5.3 我踩过的三个坑
第一个坑是temperature=0我本以为结果会完全确定,实际试下来发现同一个 Prompt 在不同时间仍可能有细微差异。原因是模型推理本身有采样机制,temperature 只是影响分布,并不保证确定性。要追求稳定输出,建议配合固定 Prompt 模板和 JSON Schema 约束,而不是单纯依赖 temperature。
第二个坑是entity()解析大对象时,模型输出超过max-tokens被截断,导致 JSON 不完整,直接抛出解析异常。表面上看是“JSON 格式错误”,实际上根因是输出长度不足。排查时注意看chatResponse()里的finishReason,如果返回的是长度截断而不是正常结束,就往max-tokens方向调整。
第三个坑是 Ollama 本地模型首次加载非常慢,尤其是 7B 以上参数量的模型。第一次请求等模型从磁盘加载到显存,耗时可能二三十秒,远超默认超时。解决方法是启动后先发一个预热请求,或者在配置里加大超时时间,别一上来就怪框架。
6. 从“能聊”到“能用”:下一步可以怎么玩
6.1 RAG 与文档问答
聊完了基础调用,可以往 RAG 方向走。Spring AI 提供了VectorStore抽象和 EmbeddingModel,配合 PostgreSQL 的 pgvector 或者 Redis 的向量检索能力,可以让模型基于你自己的文档回答问题,而不是凭空发挥。核心流程是:文档切块 -> Embedding 入库 -> 问题向量化 -> 相似度检索 -> 把命中的片段拼进 Prompt -> 模型结合片段回答。这一套在 Spring AI 里都有现成的 API,你只需要准备好文档和向量库配置。
6.2 工具调用(Function Calling)
再进一步就是让模型“动手操作”。Spring AI 支持把业务方法暴露给模型作为工具,模型在需要的时候会携带参数调用这些方法。比如定义一个“查询库存”的工具,用户问“这个商品还有货吗”,模型会自动把商品名解析成参数并调用库存查询方法。这一步是 Agent 的基础,本质上模型不再只是“生产文本”,而是变成了“理解意图并按需执行动作”的入口。
6.3 可观测性与成本控制
上生产之前,一定得关注 token 消耗和延迟。Spring AI 集成了 Micrometer Observation,配置好management.tracing.sampling.probability=1.0,就能在监控系统里看到每次调用的 token 数、耗时、是否触发重试等指标。成本控制上,优先给不同功能配置不同模型,高频简单任务用便宜的小模型,复杂推理才用大模型;同时在业务侧做缓存和限流,避免重复请求烧钱。
我个人在实际操作中的体会是,Spring AI 最大的价值不是帮你省掉几行 HTTP 代码,而是把“接模型”这件事从文档逆向工程变成了标准开发流程。Yml 一配、ChatClient 一注入,剩下的精力可以全部投入到业务场景本身。它还在快速迭代,1.0 之后的 API 也时有调整,我的建议是锁好版本、关注官方更新说明、多写单元测试,把基础链路的稳定性先保住。如果你刚拿到这套东西,别急着去追 RAG 和 Agent,先拿一个本地模型跑通 yml 和四步调用,把底层的请求、响应、异常都摸透了,后面那些高级玩法都是在这条链上加砖而已。