Spring AI 这个框架刚火起来的时候,我其实持观望态度。毕竟手写 HTTP 调用大模型接口也没多复杂,无非是拼 JSON、发请求、解析返回。直到某次项目里要同时对接两家不同的大模型平台,还得处理流式输出、维护多轮上下文,我才意识到自己维护这套胶水代码有多痛苦。Spring AI 的出现正好补上了这块短板——它把“和大模型对话”这件事抽象成了一组简洁的 Java API,让你像操作数据库一样操作大模型。这篇文章就围绕“对话实战”这个主题,从零开始带你搭一个能跑通的对话接口,再逐步深入到流式响应、上下文管理这些真正会在生产里用到的能力。
如果你已经在用 Spring Boot,又想让项目快速拥有 AI 对话能力,这篇文章会很适合你。我会尽可能把每一步的原理和坑都讲清楚,不绕弯子。
1. 为什么推荐 Spring AI 做对话功能
1.1 它到底解决了什么问题
在没有 Spring AI 之前,Java 后端接入大模型对话能力,路径大概是这样:引入某个 HTTP 客户端,手动封装请求体,把对话历史转成特定格式的消息数组,然后发起请求,再解析返回的 JSON。这套流程本身不复杂,但有两个痛点非常折磨人。
第一个是不同模型平台的协议差异。你换一家模型服务商,请求格式、鉴权方式、流式返回的解析逻辑全都得跟着改。我在某次跨平台项目里,光是维护两套协议适配就写了小千行代码,还不敢随意动公共逻辑。第二个痛点是对话上下文的处理。真正的对话场景里,用户会说“刚才那个问题再解释一下”,你需要把历史消息都组装进请求。没有统一抽象的话,这段逻辑每接一个新模型就要重写一遍。
Spring AI 的思路是把这些共性能力抽象出来。它提供了一个统一的ChatClient接口,无论底层接的是哪家模型,调用方式几乎一致。你只需要做一次配置,之后就像是操作自家 Service 一样自然。它内部帮你在做协议转换、请求组装、流式解析这些脏活。
1.2 适合什么类型的项目接入
我建议以下类型的项目优先考虑 Spring AI。
- 需要快速验证 AI 功能原型的新项目,不想在接入层浪费时间
- 已经是 Spring Boot 技术栈的项目,接入成本几乎为零
- 未来可能切换或同时使用多个大模型平台的中长期项目,架构上需要保留灵活性
- 团队 Java 基础良好但缺乏 AI 接入经验的团队,Spring AI 的抽象可以屏蔽掉大量细节
但如果你只是写一次性脚本,或者对响应延迟极其敏感,需要自己完全掌控网络层行为,那直接手写 HTTP 调用可能更合适。Spring AI 适合的是“工程化”场景。
1.3 核心概念扫盲
在开始写代码之前,有几个概念必须先理解清楚,否则后面看代码会一头雾水。
ChatClient是核心入口,类似于 JdbcTemplate 在 Spring 中的地位,所有对话相关的操作都从它发起。Prompt是发送给模型的消息结构体,它内部包含若干条Message,每条Message有角色(系统、用户、助手)和内容。ChatResponse是模型返回的响应对象,里面封装了生成结果和 Token 消耗等元数据。
还有一个概念叫Advisor,它是 Spring AI 提供的一套拦截器机制,能在对话请求前后做处理。比如自动拼装系统提示词、自动维护上下文历史,都是通过 Advisor 实现的。刚开始可能觉得概念多,但跑通第一个例子之后再回头看,就会觉得这些抽象都是水到渠成。
2. 环境准备与依赖配置
2.1 版本选型建议
版本选型是第一关。Spring AI 的版本迭代节奏很快,而且它的版本号体系和 Spring Boot 并不是完全对应的。在我写这篇文章的时候,稳定可用的版本是 1.0.0 系列,它要求 Spring Boot 3.4 以上的版本。
这里有一个非常容易踩的坑:如果你用了 Spring Boot 3.2 或 3.3,直接引入 Spring AI 高版本依赖,启动时会报版本兼容性错误。我项目里就遇到过NoSuchMethodError,排查了半天,最后发现是版本不匹配导致的二进制兼容问题。稳妥的做法是去 Spring AI 的官方文档页面查看版本对应关系,或者用 Spring Initializr 生成项目时直接勾选 Spring AI 依赖,它会自动配好兼容版本。
如果你还处于学习阶段,我建议直接生成一个 Spring Boot 3.4 + Spring AI 1.0.0 的新项目,不要在旧项目上升级折腾,省下的时间可以多跑通几个例子。
2.2 Maven 依赖引入
Spring AI 默认从 Spring Milestone 仓库发布版本,所以在pom.xml里除了引入依赖本身,还要加上仓库配置。完整依赖如下:
<repositories> <repository> <id>spring-milestones</id> <name>Spring Milestones</name> <url>https://repo.spring.io/milestone</url> <snapshots> <enabled>false</enabled> </snapshots> </repository> </repositories> <dependencies> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-model-openai</artifactId> <version>1.0.0</version> </dependency> </dependencies>注意这里引入的是starter-model-openai,它表示我们使用 OpenAI 兼容协议。市面上绝大多数大模型服务商都提供 OpenAI 兼容的接口,这意味着你可以用同一套 Starter 对接不同的服务商,只是改一下base-url配置就行。
2.3 配置 API Key 与基础参数
在application.yml中,最基础的配置如下:
spring: ai: openai: api-key: ${AI_API_KEY} base-url: https://api.example.com/v1 chat: options: model: gpt-4o-mini temperature: 0.7有几个细节值得注意。api-key我强烈建议不要硬编码在配置文件里,而是通过环境变量注入。万一代码不小心提交到公开仓库,API Key 泄露的后果很严重。base-url这个是关键,如果你用的不是官方模型服务而是国内代理服务,只需要改这里的地址就行,代码完全不用动。
temperature控制生成结果的随机性,范围在 0 到 2 之间。值越低回答越保守和确定,值越高越有创造性。如果做的是客服问答这类对准确性要求高的场景,我会建议设置在 0.3 左右;如果是写文案、头脑风暴,可以调高到 0.8 以上。这是在工程中最常用的一个参数,后面我们还会聊到它。
3. 第一个对话接口的完整实现
3.1 编写一个最简单的对话服务
配置完成之后,写一个最基础的对话功能非常简单。先注入ChatClient,然后调用它的call方法。
@Service public class ChatService { private final ChatClient chatClient; public ChatService(ChatClient.Builder builder) { this.chatClient = builder.build(); } public String chat(String message) { return this.chatClient.prompt(message) .call() .content(); } }这段代码的逻辑很直白:把用户输入的字符串作为 Prompt 发送给模型,call()是同步阻塞调用,content()拿到返回的文本内容。就是这样,一个能对话的服务就已经成型了。再配一个 Controller,就能通过 HTTP 接口直接使用。
@RestController @RequestMapping("/api/chat") public class ChatController { private final ChatService chatService; public ChatController(ChatService chatService) { this.chatService = chatService; } @PostMapping public String chat(@RequestBody String message) { return chatService.chat(message); } }启动项目后反向代理或用 Postman 发一个 POST 请求,body 里放上 “请用一句话介绍你自己”,就能收到模型的回复了。
3.2 解析返回对象里隐藏的信息
.content()只是拿字符串的快捷方法。如果我想拿到更完整的响应元数据,比如 Token 消耗,可以用下面的写法:
ChatResponse response = this.chatClient.prompt(message).call(); String content = response.getResult().getOutput().getText();ChatResponse里面包含完整的结果列表,每个结果都携带输出文本、索引信息、以及底层平台的元数据。Token 消耗信息一般在getMetadata()里,不同的模型实现结构不太一样,但基本都会包含输入输出 Token 数。
这个信息在排查问题和做成本统计时非常有用。我曾经在生产环境里做过一个简单的 Token 用量监控,每天统计所有请求的 Token 消耗总量,和控制台账单对照。这能帮你及时发现是否有调用异常增多的现象。
3.3 显式构建 Prompt 与 Message 的优势
直接传字符串确实简洁,但一旦需求复杂起来,字符串方式的表达能力就不够了。Spring AI 支持更精细地控制消息结构:
Prompt prompt = new Prompt( List.of(new UserMessage("讲一个程序员的笑话")) ); ChatResponse response = this.chatClient.call(prompt);你可能会问,这两种方式有什么区别?区别在于:使用Prompt对象,你可以控制传入的消息列表,包括添加系统消息、历史消息,这在多轮对话中是必须的。而字符串方式只是默认帮你封装了一条用户消息,适合最简单的场景。
我个人的经验是:不管多简单的需求,都建议在一开始就使用Prompt+Message的模式编写代码,后期加功能时改动会小很多。从字符串切换到Prompt对象,重构成本虽然不高,但没必要在建好的代码结构上动刀子。
4. 多轮对话与上下文管理实战
4.1 为什么不能直接把用户问题一股脑发出去
单轮对话很简单,但真实的产品场景几乎都是多轮对话。用户会说“再详细点”“刚才那个不对”,这些指代性的表达只有结合之前的对话才有效。
如果每次请求都只发送当前输入,模型是没有记忆的。它不记得用户前面说过什么,自然也无法理解“刚才那个”指的是哪个。所以实现多轮对话的核心,就是要把历史消息一并传给模型。
协议层面的消息有三种角色。system消息设定模型的整体行为和风格,user消息是用户输入,assistant消息是模型的历史回复。一次完整的多轮请求,消息列表大概是:
[SystemMessage: 你是一个乐于助人的助手] [UserMessage: 什么是依赖注入] [AssistantMessage: 依赖注入是一种设计模式...] [UserMessage: 刚才那个能举个代码例子吗]模型看到完整的历史消息链,才能正确理解最后一句话的含义。
4.2 基于 ChatMemory 的自动上下文管理
Spring AI 提供了一个叫ChatMemory的组件,专门解决消息历史存储的问题。它的作用是在服务端维护每个会话的完整消息记录,并在每次发起请求前自动把历史消息组装进Prompt。
先配置一个基于内存的实现MessageWindowChatMemory,它会在内存里维护最近 N 条消息。如果你的应用是单实例部署且不需要跨实例共享会话状态,这个实现完全够用。
@Bean ChatMemory chatMemory() { return MessageWindowChatMemory.builder() .maxMessages(20) .build(); }然后还需要配置一个关键的Advisor,叫MessageChatMemoryAdvisor。它的作用是在每次调用前从ChatMemory中取出会话对应的历史消息,添加到当前请求的消息列表中;调用完成后,再把这次的新消息存回ChatMemory。这样你就不需要自己拼历史消息了。
@Bean ChatClient chatClient(ChatClient.Builder builder, ChatMemory chatMemory) { return builder .defaultAdvisors(new MessageChatMemoryAdvisor(chatMemory)) .build(); }这里有个会话标识的问题。MessageChatMemoryAdvisor需要知道“当前这次请求属于哪个会话”,它是通过请求中名为chat_memory_conversation_id的 Prompt 参数来识别的。在每次调用时需要传入会话 ID:
public String chat(String conversationId, String message) { return this.chatClient.prompt(message) .advisors(advisorSpec -> advisorSpec .param("chat_memory_conversation_id", conversationId)) .call() .content(); }用一个固定的会话 ID 传进去,比如用户 ID 或客户端生成的 UUID,历史的连续性就自动有了保障。我在真实项目里就直接用用户 ID 作为会话 ID,不同用户之间的上下文天然隔离,互不影响。
4.3 System Prompt 的两种注入方式
在实际项目中,我们几乎总是需要设定模型的“人设”或者行为规则。比如做一个客服机器人,需要告诉它“你只回答商品相关问题,其他问题一律拒绝”。Spring AI 提供了两种注入系统提示词的方式。
一种是在每次请求时手动添加SystemMessage:
Prompt prompt = new Prompt(List.of( new SystemMessage("你是一个只回答技术问题的专家。"), new UserMessage(message) ));这种方式灵活,每次请求可以动态变化,但缺点是每个调用的地方都要写一遍,容易遗漏。
另一种方式是配置默认系统提示词,通过Advisor统一注入:
@Bean ChatClient chatClient(ChatClient.Builder builder) { return builder .defaultSystem("你是一个只回答技术问题的专家。") .build(); }这样所有通过这个ChatClient发起的请求,都会自动带上这条系统提示词。如果需要覆盖全局配置,也可以用advisors指定同类型系统消息覆盖。两种方式可以同时使用,全局配置作为兜底,局部配置处理特殊情况。
5. 进阶能力:流式输出与参数调优
5.1 为什么生产环境一定要用流式输出
同步调用call()有一个体验上的致命问题:如果模型生成回答需要 5 秒或更久,用户在这期间看到的是空白页面,体验极差。大模型的生成是逐 token 进行的,我们完全可以做到生成了一个词就立刻推送到前端。
Spring AI 的流式调用 API 设计得很顺手:
public Flux<String> stream(String message) { return this.chatClient.prompt(message) .stream() .content(); }返回类型是Flux<String>,这是 Reactor 框架的响应式流,本质上是一个异步序列。前端如果走 SSE(Server-Sent Events),后端可以直接把Flux转成 SSE 输出。如果用的是 WebFlux,返回Flux<String>就能自动按 JSON 流或文本流推送。
如果你项目用的还是 Spring MVC(Tomcat),也可以通过SseEmitter进行适配,把Flux的每个元素都写入SseEmitter。虽然代码量会多一些,但效果一样。绝大多数真实用户场景,我都强烈建议采用流式输出,尤其在网络环境不稳定的情况下,用户能很快看到“模型正在回复”的状态,而不是望着转圈等待。
5.2 使用通配符参数控制生成行为
Spring AI 允许你在请求时动态覆盖模型参数。比如对某些请求希望更保守,对另一些请求希望更有创造力。写法如下:
public String chatWithParams(String message, double temperature) { return this.chatClient.prompt(message) .options(OpenAiChatOptions.builder() .withTemperature(temperature) .build()) .call() .content(); }除了temperature,还有一个很实用的参数maxTokens,控制模型生成的最大 token 数量。对于要求简短回答的场景,设置这个参数能有效避免模型“啰嗦”。我自己做摘要功能时就把maxTokens限制在 200 以内,效果比事后截断字符串要好得多,也不会浪费成本。
有一点要提醒:不同的模型对参数的支持度不完全一致。比如某些开源模型对temperature不敏感,或者干脆不支持某些参数。如果发现设置的参数没有生效,先确认你使用的模型是否支持该项,再检查参数名是否正确拼写。Spring AI 底层对很多参数做了映射,但个别模型特有的字段还是需要查文档确认。
5.3 结构化输出:摆脱脆弱的字符串解析
大模型的返回本质是一段文本。但实际业务中,我们经常需要模型返回结构化数据——比如让模型从一段评论中提取出“客户姓名、联系电话、诉求类型、紧急程度”。如果你直接用content()拿到字符串,然后再做正则解析或手动拆分,那简直就是灾难。模型稍微改变措辞,你的解析逻辑就崩了。
Spring AI 提供了结构化输出的能力,可以让模型直接返回 Java 对象。具体用法是定义一个 POJO,然后调用entity()方法:
public record ComplaintInfo(String customerName, String phone, String type, String urgency) {} public ComplaintInfo extractInfo(String text) { return this.chatClient.prompt(text) .call() .entity(ComplaintInfo.class); }Spring AI 在底层会要求模型以 JSON 格式返回内容,然后把 JSON 映射到对应的 Java 类型。你可以传普通类、Record 类型,甚至List<SomeRecord>这种泛型类型。为了提升成功率,建议在系统提示词里明确要求模型严格按照字段定义输出,比如“如果信息缺失,字段设为空字符串”。
这个功能上线后帮我省了大量解析代码。之前我写过一个用正则从模型回答里抽取客户信息的接口,噪声太多,改了十几版;换成entity()之后一次性搞定。如果你正在做信息抽取类的功能,强烈建议试试结构化输出。
6. 常见问题与调试经验记录
6.1 启动报错:找不到 ChatClient Bean
这是一个很常见的配置问题,尤其是在手动搭建项目而不是通过 Initializr 生成的时候。控制台会报类似 “No qualifying bean of type 'ChatClient' available” 的错误。
原因通常是缺少自动配置类。Spring AI 的自动配置是通过spring-ai-starter-model-openai触发的,如果依赖没引入或者版本不对,整条链路都不会生效。排查思路是先确认依赖存在、版本匹配,再检查配置里是否设置了api-key—— 没有 Key 的情况下自动配置同样不会注入相关 Bean。
6.2 超时与重试机制配置
模型服务偶尔会出现响应慢或超时的情况,尤其在高并发时。Spring AI 底层基于 RestClient 或 WebClient 实现,默认超时时间在某些版本里设置得很保守。我在压测时遇到过单次请求超过 60 秒直接被挂起的情况。
如果使用的是基于 RestClient 的实现,可以通过自定义RestClient.Builder来设置连接和读取超时:
@Bean RestClient.Builder restClientBuilder() { return RestClient.builder() .requestFactory(new SimpleClientHttpRequestFactory() {{ setConnectTimeout(5000); setReadTimeout(30000); }}); }更稳妥的做法是在网关或反向代理层设置超时,同时在代码里加上重试逻辑。但要注意,对于非幂等的对话生成接口,盲目重试可能导致重复扣费。建议仅在明确是网络异常(超时、连接重置)时重试一次,不要多重试。我们线上标配是“连接 5 秒 + 读取 30 秒”,重试 1 次,间隔 1 秒,连续失败则快速失败返回给用户。
6.3 Token 限制与消息截断策略
当对话轮次很多时,历史消息加当前输入的总 token 数会超过模型的最大上下文长度,导致请求报错。MessageWindowChatMemory通过限制消息条数来规避这个问题,但“条数”并不直接等价于 token 数。长文本场景下,20 条消息可能就已经超过了模型上限。
如果每次请求的文本都比较长,建议自己实现 token 估算和截断策略。极简的做法是:在每个用户输入写入ChatMemory之前,手动统计 token 数(可以粗略按中文字符 1 字近似 1-2 token),超过阈值就把最早的历史消息丢弃。如果要更精确,可以引入分词器做 tokenizer,但实用性上粗估往往够用。
从我踩坑的经验来看,把最大历史条数设置为 10-15 条、限制单条消息长度不超过 1000 字,既能保持上下文相关性,又基本不会触发上下文超限。这个组合在我们多个项目里运行稳定。
6.4 API Key 泄露的常见场景
这是安全问题,但太常见了,所以必须单独提醒。常见泄露途径是:把 Key 写在application.yml后直接提交到 Git 仓库,然后仓库被设为公开。还有个常见场景是前端代码里直接写 Key——理论上任何人都能抓包拿到,然后盗刷你的额度。
正确的做法包括:Key 通过环境变量注入;在 Git 提交前检查配置文件里是否有硬编码的敏感信息;后端作为代理转发请求,不要暴露任何 Key 给前端。如果发现 Key 泄露,立即到控制台重置 Key,不要心存侥幸。
7. 最后的一点经验之谈
很多初学者在刚接触 Spring AI 时,忍不住一上来就去研究函数调用(Function Calling)、多模态这些华丽能力。以我做过的几个 AI 功能迭代来看,工程上真正决定项目成败的反而是那些最基础的细节:会话是怎么隔离的、参数设置是否合理、超时重试有没有配置。
把这些基本功打扎实,后面加任何能力都是在稳固的底座上生长。
另外提醒一句:Spring AI 版本更新很快,API 在不同版本间可能有不兼容变化。我建议你翻阅当前项目实际用的版本对应的官方文档,不要直接搜索博客里的旧写法复制到新版本里。如果遇到编译错误,优先查看该版本的升级指南,通常都能找到答案。写代码有时候就是这样,慢即是快,把每一步的原理弄明白,后面自然顺。