这几天我把一个内部小工具的后台用 Java 重写,顺手完整体验了一次 Spring AI。说实话,第一次跑通是有点意外的:整个接入过程几乎没有手写 HTTP 客户端,也没有手工拼 JSON——项目里加好依赖,在 application.yml 里配好模型服务的地址、密钥、模型名,然后一个 ChatClient 对象加上四步链式调用就能聊起来:prompt() 起对话,system()/user() 加消息,call() 发请求,content() 取文本。
如果你是一名 Java 后端开发者,想在 Spring Boot 项目里接入 AI 聊天能力,这篇初体验应该能帮你少走不少弯路。我会讲清楚 Spring AI 到底解决了什么问题、yml 配置该怎么写、ChatClient 那四步链式调用是怎么来的,以及跑通之后我踩过的高频坑。示例代码都是可以直接复制去跑的,但我会提醒你注意版本差异。
1. 为什么我在重写项目时决定尝鲜 Spring AI
在 Spring AI 出现之前,Java 后端要接入一个模型服务,最朴素的做法就是直接用 HTTP 客户端发请求。以一次聊天调用为例,你需要手动做的事情大概有这么几件:构造请求体,把 system、user 这些角色按模型协议的格式拼进去;处理一层一层嵌套的 JSON 响应,有的服务还会把文本放在不同的字段层级里;自己管理鉴权头、超时时间、错误响应码,甚至还要考虑重试策略。
实际写下来通常是这样一坨:
String json = """ { "model": "your-model", "messages": [ {"role": "system", "content": "你是一个助手"}, {"role": "user", "content": question} ], "temperature": 0.7 } """; HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(modelUrl)) .header("Authorization", "Bearer " + apiKey) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(json)) .build(); String responseBody = httpClient.send(request, HttpResponse.BodyHandlers.ofString()).body(); String answer = parseContentFromJson(responseBody);这套代码不是不能跑,但问题非常明显:拼接请求、解析响应、处理异常的逻辑散落在各个业务方法里,一旦换了模型服务商,或者协议稍微调整,整个接入层都要重写。更别提如果项目里要同时接多个模型,每个模型一套客户端、一套解析逻辑,维护成本直接起飞。
Spring AI 做的事情,是把模型服务抽象成 Spring 风格的“数据访问层”:底层不同厂家的模型服务实现由框架统一,业务代码只需要面向 ChatModel 或 ChatClient 写代码。你不再关心请求 JSON 长什么样,也不用自己解析响应。它给我的第一印象很像 Spring Data:仓库接口帮你封装好了数据库访问,你只需要定义方法。
我第一次体验时的最大感受是:它没有引入新的编程模型,还是熟悉的 Spring Boot 自动配置 + 依赖注入。classpath 里加入对应 starter,自动配置生效后,随便找个类注入对象就能用。这一点对已经有 Spring 工程经验的人非常友好,学习成本几乎只剩“记住几个 API 名”。
当然,Spring AI 不可能替你做好所有事。它不会帮你写提示词,不会决定这个模型适不适合你的场景,也不能消除模型本身的幻觉问题。它解决的只是“接入模型服务”这条链路里重复、繁琐、容易出错的部分,而不是“让 AI 变得完美”。这个预期摆正了,后面学起来会顺很多。
2. 配好 yml 就能聊:一份配置背后的真相
“配好 yml 就能聊”不是说随便填两个 key 就能裸跑,而是说 Spring AI 的自动配置把 Bean 的创建、模型客户端的初始化、参数绑定都接管了。我们需要做的,只是把模型相关配置填进 Spring 的配置体系里。
先加依赖。我是在一个 Spring Boot 3.x 工程里做的集成,需要在 pom.xml 中引入与模型服务对应的 Spring AI starter。这里我以兼容常见聊天接口协议的模型服务为例,给出最小依赖写法。需要注意版本号要和你使用的 Spring AI 版本保持一致,不同版本的包名、依赖坐标都可能调整,建议以官方文档为准。
<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-model</artifactId> <version>${spring-ai.version}</version> </dependency>接着写 application.yml。我把模型服务相关的配置抽成了环境变量,没有让任何密钥出现在源码里:
server: port: 8080 spring: ai: model: chat: api-key: ${AI_API_KEY} base-url: ${AI_BASE_URL} options: model: ${AI_MODEL_NAME} temperature: 0.6逐项解释一下:
api-key:调用模型服务需要的密钥,对应请求头里的 Authorization 信息。base-url:模型服务的接口地址。如果你接的是云端模型服务,就填服务商给的 endpoint;如果是本地部署的模型服务,填本机地址即可。model:你要使用的模型名称,这个值最终会写进请求体的 model 字段。temperature:控制生成内容的随机程度,一般 0 到 1 之间。不是每个模型都支持,但多数聊天模型都认这个参数。
补充一句:不同厂家的 starter 配置前缀和字段名会有些差异,比如有的把 api-key 放在spring.ai.xxx.api-key下,有的则统一收敛到spring.ai.model.chat下。我这里给的是示意结构,关键是把“地址、密钥、模型名”三件事表达清楚。真正动手时,先打开你引入的 jar 包里的自动配置类看一眼,或者直接查官方文档中对应版本的配置示例,比盲猜字段要快。
如果你第一次启动项目后报“找不到 api-key”或者“无可用 ChatModel”的错,九成是配置项没被正确读取。排查时先确认环境变量是否生效,再用 debug 模式看ChatModel相关的自动配置日志,Spring Boot 会打印失败分析信息。另外,base-url 一定要按你实际开通的服务填。填错通常比填漏更让人困惑——密钥缺失会直接报错,而 base-url 配错可能返回 404 或者是一个格式完全不同的错误体。
我强烈建议先手动验证一下地址可不可达。具体操作很简单:在命令行直接向这个地址发一个最简单的请求,确认网络链路和鉴权都是通的,再回头查 Spring 的问题。这样能把“配置没生效”和“服务本身不通”两类问题快速分开,省得在日志里绕圈。
配置这关过了以后,Spring Boot 会自动在容器里创建底层模型客户端,并且为我们准备一个ChatClient.Builder。接下来业务代码里真正的主角是ChatClient,它就是我们和模型对话的主入口。
3. ChatClient 四步链式调用:prompt、组装、call、content
现在到了标题里最核心的部分。Spring AI 把一次聊天调用设计成了链式调用,我在实际代码里最常用的是下面这条四步链:
String answer = chatClient.prompt() .system("你是一位经验丰富的 Java 技术导师,回答时尽量口语化、直击重点。") .user("请用大白话解释一下什么是 IoC,并给一个生活中的例子。") .call() .content();第一眼看是个“能跑的最小示例”,但如果把这串代码拆开看,它其实就是四层动作。
3.1 第一步:prompt() 铺开对话草稿纸
prompt()是每次对话的起点。它不会直接发网络请求,而是创建一个 prompt 构建器,相当于铺开一张空白的对话草稿纸。后续的 system、user、variables、options 都会往这张纸上添加内容。可以理解为:你要发起一次模型调用,首先得有一个承载请求的东西,这个东西就是 prompt。
3.2 第二步:system() / user() 组装消息
system()设置系统角色提示词,用来定义模型的行为边界,比如“你是一位严谨的翻译”“只回答与产品相关的问题”,它的职责是给模型立规矩。user()设置用户消息,也就是你这次要问的内容。除了这两个,链上还有advisor()、options()、variables()这些扩展方法,它们都是在组装“这次请求长什么样”。
你完全可以把system()和user()理解为往草稿纸上写字:先写“你是谁”,再写“我要什么”。模型最终看到的 prompt,就是这些内容按照消息角色被组合成的一个结构化对象。
3.3 第三步:call() 真正触发请求
call()是真正触发模型调用的动作。到这一步,客户端才会把前面组装好的 prompt 序列化、发送出去,并同步等待模型返回。返回值是一个完整的ChatResponse对象,里面包含生成的文本、token 统计、结束原因等元信息。如果没有call(),前面所有步骤都只是构建器在手写,什么也没发生。
3.4 第四步:content() 取出文本
content()是一个简单访问器,从ChatResponse里取出最核心的生成文本。如果业务只需要回答文字,这一句就能满足需求。如果想拿更完整的数据,可以使用chatResponse(),或者用entity()把响应转成自己的业务对象。
把四步连起来看,本质上就是流式编程和链式 Builder 的逻辑:先建立对象,再逐步添加上下文,最后用一个终点操作触发执行。对初学者来说,只要记住prompt()开始、call()发送、content()取结果,中间system()和user()是在写字,读代码就很顺畅了。
四步还可以玩出很多变体。如果一个问题不需要 system 提示词,可以直接去掉.system();如果想在运行时动态替换提示词里的占位符,可以用variables():
String answer = chatClient.prompt() .system("你是一位擅长用浅显语言讲解{field}知识的老师。") .user("请用 200 字左右解释「{concept}」,并配一个生活化类比。") .variables(Map.of( "field", "计算机科学", "concept", "缓存雪崩" )) .call() .content();这个方法我很推荐,因为它比字符串拼接提示词要安全得多。只要在提示词里写{name}这种占位符,再用variables()传入值,就能避免自己拼字符串时漏掉引号、或拼出难以维护的长文本。如果以后提示词要改成从数据库读取,结构也不用变。
再比如只想拿结构化对象,不想处理字符串时,可以用entity()代替content():
Summary result = chatClient.prompt() .user("请把以下文本总结成 3 句话:" + longText) .call() .entity(Summary.class);只要定义好Summary类,Spring AI 会要求模型按 JSON 结构返回并完成反序列化。这个能力在信息抽取、列表解析场景里非常实用,比返回一大段文本再用正则清洗舒服多了。
4. call 和 content 为什么要拆开?链式调用背后的请求生命周期
新手第一次看到这段链式调用,多半会问:call 和 content 明明可以合成一步,为什么 API 要拆成两个方法?其实这两个方法承担的逻辑完全不同。
call()是网络交互的终点,它负责把 prompt 发出去、取回完整的响应对象。content()是从响应对象里抽取某个字段的访问器。拆开的好处是:调用链既能在“拿到完整响应”的层面工作,也能在“只要正文文本”的层面工作,还能在“只要结构化对象”的层面工作。
call()返回的类型是ChatResponse,它内部包含一个或多个生成结果、token 用量、结束原因等。有时候我会在一段对话后打印 token 消耗,用来估算成本,那就要这样写:
ChatResponse response = chatClient.prompt() .user("写一段 30 字的商品介绍文案。") .call() .chatResponse(); Generation generation = response.getResult(); String text = generation.getOutput().getText(); ChatGenerationMetadata metadata = generation.getMetadata(); System.out.println("本次输出 token 数:" + metadata.getUsage().getCompletionTokens());当然,demo 项目里大多数时候只需要 text,所以官方把content()设计成一条最顺手的快速路径。你要用哪种,完全取决于要什么:只要字符串,用content();要做成本统计和链路观测,用完整的 response;要做数据抽取,用entity()。
再往深一层看,链式调用通过方法返回类型限制了每一步能做什么。比如prompt()之后可以继续追加提示词,但在call()之后返回的是响应对象,你不可能误把新的 user 消息塞给一个已经结束的请求。IDE 自动补全也会根据返回类型缩小候选方法,这对提升编码效率非常友好。
从请求生命周期看,一条链式调用从代码层面很简洁,但内部其实发生了这么几件事:
prompt()创建请求构建器,初始化默认参数。system()/user()向消息列表追加聊天消息。call()触发时,客户端把 prompt 转换成模型接口需要的请求体,补充 api-key、模型名、temperature 等参数,然后通过网络发出请求。- 模型返回响应后,框架把响应解析成
ChatResponse。 - 最后,
content()/entity()从响应对象中提取你需要的视图。
这整个生命周期里,只有call()是真正需要等待网络的时间点。这也是为什么它被设计成“终点操作”——就像 Stream API 里的collect()。
提到call()就绕不开流式调用。模型生成文字是需要时间的,一个长回答可能让同步接口耗时十几秒甚至更久。如果系统里很多请求同时触发,很容易占满连接资源。Spring AI 的 ChatClient 也支持流式返回,把.call()换成.stream(),配合响应式 API 可以逐段处理结果:
Flux<String> stream = chatClient.prompt() .user("请用三句话介绍 Spring AI。") .stream() .content();对 WebFlux 项目来说,这几乎可以把长文本生成的等待时间转换成逐字输出的体验。哪怕你在传统 Servlet 项目里,也可以用它构建类似“打字机”的对话效果。
这里要特别说明一个并发问题:ChatClient本身是设计为可复用对象,一个应用只要注入一次即可,不要在每次业务请求里 new 一个。它内部包含 HTTP 客户端和配置模型,频繁创建会带来额外开销,也容易让人忽略共享对象的线程安全边界。我在项目里就让每个业务模块各自注入一个ChatClient,比如“对话助手”一个、“文案生成”一个,后续给它们配置不同的 system 提示词和默认参数也会更容易管理。
5. 跑通第一个接口后,我踩过的几个高频坑
初体验能跑通只是第一步,真正把功能做得可用,问题都藏在细节里。下面是我整理的几个高频坑,按踩到的概率排序。
5.1 版本差异导致配置项或依赖名对不上
Spring AI 的迭代速度很快,网上很多教程写的配置字段、starter 名称,甚至ChatClient的 API 形态,在不同版本之间可能差别很大。同一个拿文本的方法,旧版本里可能是text(),新版本里改成了content();配置前缀不同版本也调整过。最稳的办法不是照着某篇博客死磕,而是先去官方文档确认“你用的版本”对应的包名、类名和属性前缀,再动手写代码。如果项目里已经引入了多个 Spring AI 相关依赖,最好统一升到同一个 release line,避免类冲突。
5.2 多轮聊天不会自动记忆历史
第一次做对话功能的人都想马上看到“多轮记忆”,但默认的ChatClient.prompt()并不会把之前聊过的内容自动传回来。每次prompt()都是全新的请求,模型只知道当前这轮你塞进去的消息。想实现多轮对话,需要自己维护会话历史:简单方案是把历史消息和当前问题一并拼进提示词;进阶方案是用 Spring AI 提供的 ChatMemory 和 Advisor 机制,给 ChatClient 挂上消息窗口,让框架自动管理最大条数。第一次做的时候别指望“配好就能记”,把这个心智模型记牢,能省很多排查时间。
5.3 同步调用的超时和失败重试
如果服务端模型生成很慢,而 HTTP 客户端的读超时又设得很短,请求很容易在生产环境莫名中断。我测试时遇到过模型生成回答要十几秒的情况,前端等得够久,后端却已经因为读超时抛异常。建议先在调用链外层的配置里把连接超时和读超时间调大,再根据业务需要增加超时后的降级处理。如果模型服务偶发返回限流错误,不要盲目重试,先看限流策略。至少要使用指数退避,而不是循环重试,否则可能会把服务打得更紧张。
5.4 结构化输出并非所有模型都稳定支持
用entity()做结构化返回很爽,前提是模型真能稳定地按 JSON 格式输出。不同模型对“强制 JSON 模式”的支持程度差别很大,有的模型偶尔会在 JSON 前后多出一段解释性文字,导致反序列化失败。稳妥做法是先在不加任何 system 提示词的情况下做一次最小复现,确认模型返回格式稳定,再给entity()场景加一个“只输出 JSON,不要额外说明”的 system 提示词,同时做好解析失败时的兜底。
5.5 密钥安全与中文乱码问题
把 api-key 直接写在 application.yml 并提交到仓库,几乎是体验过程中最容易忽略的安全问题。哪怕仓库是私有的,也建议改成环境变量注入,避免密钥随代码扩散。中文乱码问题相对简单,多数是终端编码问题,控制台窗口和日志的字符集统一用 UTF-8。如果在 Windows 上测试,启动 JVM 时加-Dfile.encoding=UTF-8,同时确保代码里显式声明了 UTF-8 编码。
5.6 自动配置被自定义 Bean 覆盖
Spring AI 会自动优先使用容器里已有的 Bean。如果你自己定义了一个同名的 ChatModel 或 ChatClient 配置,自动配置里的参数可能被覆盖,表现就是“yml 改了没反应”。排查时可以打开 Spring Boot 的自动配置报告,看看哪些自定义 Bean 被优先选中,也可以先去掉自定义配置再测试自动配置是否恢复生效。这个坑不常见,但一旦碰上会非常迷惑。
6. 从“能聊”到“好用”:我建议这样做封装和测试
跑通 ChatClient 之后,我做的第一件事不是接着加功能,而是把 AI 调用从 Controller 里抽出来。原因很简单:Controller 只负责接收 HTTP 请求和返回结果,它不应该知道模型叫什么、system 提示词怎么组织。我会创建一个独立的 service,把 ChatClient 藏在后面。
@Service public class ChatAssistant { private final ChatClient chatClient; public ChatAssistant(ChatClient.Builder builder) { this.chatClient = builder .defaultSystem("你是一位审慎的写作助手,不要输出无依据的信息。") .build(); } public String ask(String question) { return chatClient.prompt() .user(question) .call() .content(); } }这样做有两个直接好处。第一,业务方法语义清晰,后续要接多轮对话、加提示词模板,都在这个 service 里调整。第二,测试时可以 mock 掉ChatAssistant,只验证业务逻辑,不在测试环境里真打模型接口。模型调用有成本、有延迟,把大量测试请求打到真实模型上既不经济,也让测试结果不稳定。
如果你担心 ChatClient 本身不好 mock,那可以让业务完全依赖自己写的接口。比如先定义AiAnswerService,ChatAssistant是它的一种实现,业务模块只引用接口。后续想换模型服务商,只需替换实现类,调用方完全感知不到变化。这就是典型的“面向接口编程”,在 AI 能力快速变化的阶段,这种设计能给你保留很大的调整空间。
等到“能聊”的目标达成后,我建议再往两个方向扩展。一个是 RAG,把私有知识库或文档切成片段,通过向量检索找出相关内容塞进提示词,让模型基于你的资料回答问题,适合内部问答和文档助手。另一个是工具调用,让模型在回答过程中触发你自己定义的方法,比如查订单、查天气、执行简单脚本,适合和现有系统打通。这两个方向都建立在 ChatClient 之上,先把四步链路理解透,再学它们会轻松很多。
我在实际操作中最深的体会是:Spring AI 并没有增加什么玄学门槛,它把 Java 工程师熟悉的“配置驱动 + 依赖注入 + 链式 API”三大习惯,平移到了 AI 接入这件事上。配 yml 解决的是“模型从哪来”,ChatClient 的 prompt 四步链解决的是“怎么和模型说话”。把这一层的心理模型建立起来,后面接功能就不再是每次都要翻文档的迷宫。如果你也正在 Spring Boot 里折腾 AI,建议先按最小依赖跑通一次,再回头对照这篇里的坑逐个检查,进度会快很多。