Spring AI 正式版出来之后,ChatClient这个 API 几乎是所有用过的人都绕不开的一个组件。相比直接操作底层的ChatModel,ChatClient提供的是一套更贴近业务思考方式的编程模型——你不需要关心请求是怎么构造的、响应怎么解析,只需要描述“我要让模型做什么”,剩下的交给框架。这篇就聊聊我在实际项目里使用ChatClient的完整拆解,包括环境配置、核心用法、进阶能力,以及那些官方文档里没说透的坑。
1. Spring AI 里为什么推荐主用 ChatClient
1.1 从底层 ChatModel 到 ChatClient 的演进
很多人第一次接触 Spring AI 时,会先看到ChatModel这个接口。它确实很底层,也足够通用,call()方法直接接受一个String或者ChatRequest,返回ChatResponse。看起来很简单,但一旦你开始写真实业务代码,问题就来了:我要维护多轮对话的上下文、要控制 temperature 参数、要接入结构化输出、要处理流式响应,甚至要动态切换模型,这些如果用底层 API 做,所有逻辑都得自己在业务层堆一遍。
ChatClient解决的正是这个问题。它在ChatModel之上做了一层流式风格的包装,把请求构建、参数配置、响应解析都收拢成一个链式调用的 API。我第一次用过之后最直观的感受是:代码从“面向传输协议”变成“面向业务语义”了。以前写一个聊天接口,满脑子都是ChatRequest的字段结构;用ChatClient之后,满脑子只需要思考 prompt 怎么写。
举一个最简单的例子。底层 API 写一个聊天接口,大概是这样:
String response = chatModel.call("给我讲个笑话");看起来也够简单。但当需求变成“只返回笑话的前两句”时,底层 API 就需要你自己解析输出结构,甚至要在代码里写字符串截断。而ChatClient的写法是这样:
String response = ChatClient.create(chatModel) .prompt("给我讲个笑话") .call() .content();如果你想让模型只输出前两句,可以用entity()或者自定义输出转换器。这就是设计层面的差异:底层 API 给的是机制,ChatClient给的是策略。
1.2 ChatClient 真正适合解决的业务场景
从项目实践来看,ChatClient适合的场景非常明确:凡是需要以“对话”为基本交互单元的功能,都可以直接上手。典型的有分批:
- 知识库问答:用户提问,系统拼接上下文后调用模型,要求答案附来源。用
ChatClient做 prompt 模板非常顺手。 - 内容生成工具:比如根据一段录音转写文本生成会议纪要。需要指定输出格式,但不需要多轮交互,
prompt()后接entity()最合适。 - 客服工单分类:输入一段用户描述,输出一个结构化 JSON。用
entity(Class)可以直接反序列化,省掉手工解析。 - 多轮陪伴式聊天:需要长期记忆,需要系统 prompt 干预设角色。
ChatClient的memory()方法直接挂载聊天记忆。
我实际遇到的一个典型场景是做客服工单自动分类。早期用ChatModel写,代码里要手动拼 JSON 解析,每换一个模型类就要检查一次字段;换成ChatClient之后,定义一个TicketCategory类,加上@JsonIgnoreProperties注解,一行entity()搞定。
提示:如果你的项目里只需要一次性调用模型并且对输出格式要求严格,
ChatClient比底层 API 少写一半代码。
2. 环境准备与依赖配置
2.1 从零到一搭一个 Spring Boot 工程
聊代码之前先聊环境。Spring AI 目前对 Spring Boot 版本有明确要求,如果你还在用 Boot 2.x,别硬上,直接升级到 3.x 会省掉大量兼容问题。我用的是 Spring Boot 3.2.x 配合 Spring AI 1.0.0 正式版,起步配置比较干净。
Maven 依赖就一个核心包:
<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-openai</artifactId> <version>1.0.0</version> </dependency>这里有一点容易踩坑:Spring AI 的正式版仓库默认不在 Maven Central 里,需要在pom.xml里额外配置仓库。如果你直接从中央仓库拉包,大概率会得到一堆依赖解析失败的红色报错。我建议配置 Spring 官方提供的里程碑和发布仓库:
<repositories> <repository> <id>spring-milestones</id> <name>Spring Milestones</name> <url>https://repo.spring.io/milestone</url> <snapshots> <enabled>false</enabled> </snapshots> </repository> </repositories>有些版本还需要spring-snapshots仓库,正常开发用 milestone 就够了。拉完依赖后,配置项主要集中在application.yml里。最核心的是 API Key 和基础地址:
spring: ai: openai: api-key: ${OPENAI_API_KEY} base-url: https://api.openai.com chat: options: model: gpt-4o-mini temperature: 0.7如果你用的是国内模型厂商的兼容接口,base-url改成对应的网关地址即可。这里的temperature是全局默认值,可以在每次调用时用.temperature()方法局部覆盖。
2.2 配置项背后的作用逻辑
很多初学者会把temperature当成“随机种子”来理解,实际上它的作用是调节模型输出的概率分布。值越低,模型越倾向于选最高概率的 token,输出更保守、可复现性更强;值越高,输出的多样性越强,但也更容易胡说八道。
我在一个生成摘要的项目里对比过:temperature=0.2时,摘要结构稳定,用词变化极小;temperature=1.0时,同一篇文档每次生成的摘要重点会漂移。这是需要注意的——如果你的场景是问答客服、分类、抽取,temperature调到 0.3 以下会明显提升准确率。如果场景是创意文案、故事生成,再考虑调到 0.8 以上。
还有一个配置项值得关注:max-tokens。它控制的是模型单次回复的最大 token 数,而不是总对话累计量。这里有个常见误解:有人以为设成 4096 就代表整个上下文的 token 限制,实际上 OpenAI 系模型的上下文还要包含 prompt 部分,不同模型的上下文窗口需要你根据后端限制自行裁量。实测下来,如果一次性塞入很长的文档,max-tokens设置太大会挤占 prompt 的空间,直接触发请求失败。
配置完成后,启动项目时验证一下环境是否正确。Spring AI 没有强制启动时校验,所以你调用时报错才知道配置错了。我会习惯性地写一个 ApplicationRunner 做一次最小调用测试:
@SpringBootApplication public class DemoApplication { public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); } @Bean ApplicationRunner smokeTest(ChatClient.Builder builder) { return args -> { ChatClient client = builder.build(); String reply = client.prompt("ping").call().content(); System.out.println("连接成功,模型回复:" + reply); }; } }这个测试跑通后,说明网络、Key、参数配置都没问题,可以放心往下写业务。
3. 核心用法实操:从静态消息到流式对话
3.1 最基础的 call() 调用链拆解
明确了依赖后,我用一个实际例子来走通ChatClient的主链路。要使用它,第一步是构建一个客户端实例。最简单的方法是用ChatClient.create(chatModel),但更推荐的做法是注入ChatClient.Builder,因为它会帮你自动绑定全局的配置项:
@Service public class ChatService { private final ChatClient chatClient; public ChatService(ChatClient.Builder builder) { this.chatClient = builder .defaultSystem("你是一个乐于助人的智能助手,回答要简洁准确。") .build(); } public String ask(String question) { return chatClient.prompt() .user(question) .call() .content(); } }这里的关键点是defaultSystem()方法。它设置的“系统提示词”相当于给模型定义了一个默认角色和行为准则。你不需要在每次调用时都重复写系统消息,只需要写用户消息。
调用链本身非常直观:prompt()创建一个 prompt 构建器,.user(question)添加用户消息,.call()发起同步调用,.content()从返回的ChatResponse里提取文本内容。这个方法族的设计思路和 Spring 的RestClient很像,熟悉的人几乎没有学习成本。
3.2 流式输出:打字机效果的实现原理
如果用过 ChatGPT 网页版,你会发现回复是一个字一个字“蹦”出来的,这种体验就是流式输出。服务端实现的核心是通过 SSE(Server-Sent Events)持续推送 token 片段,而不是把所有内容攒成一整包再返回。
Spring AI 的ChatClient对流式调用支持得很完善。最直接的用法是返回Flux<String>:
public Flux<String> streamAsk(String question) { return chatClient.prompt() .user(question) .stream() .content(); }在 WebFlux 的控制器里直接把Flux<String>作为返回值,框架会自动按 SSE 格式推送给前端:
@RestController public class ChatController { private final ChatService chatService; public ChatController(ChatService chatService) { this.chatService = chatService; } @GetMapping(value = "/chat/stream", produces = "text/event-stream") public Flux<String> streamChat(@RequestParam String question) { return chatService.streamAsk(question); } }前端用原生的EventSource或者fetch的流式读取 API 都能接。这里要特别留意的是返回类型。
我在 WebFlux 环境之外用过传统 Servlet 容器(Tomcat),直接返回Flux<String>也能工作,但会有一些限制,比如异步超时时间要按默认的 30 秒来处理。长文本生成场景下,30 秒不一定够,需要显式配置spring.mvc.async.request-timeout或者直接改用 WebFlux。
注意:流式输出时,每次流结束后,底层会自动处理结束标志。但如果你手动拼了 SSE 格式,可能会遇到前端收到多个
[DONE]标志的问题——尽量用框架提供的方式,不要自己造 SSH 轮子。
3.3 多轮对话怎么保持上下文
聊到多轮对话,新手最容易掉进“每次调用都是全新会话”的坑。模型本身没有记忆,上一次说什么它完全不记得,除非你把历史消息重新发给它。
ChatClient提供了.memory()方法,可以注入一个ChatMemory实例。这个概念和 Spring 的Session有点类似,但语义不同:ChatMemory管理的是“模型对话的历史消息列表”,而不是前后端会话。
最简单的内存实现是InMemoryChatMemory,适合单机场景。用法如下:
@Service public class MemoryChatService { private final ChatClient chatClient; public MemoryChatService(ChatClient.Builder builder) { this.chatClient = builder .defaultSystem("你是一个贴心的朋友,回答时带着温度,但不要刻意煽情。") .build(); } public String chatWithMemory(String sessionId, String message) { return chatClient.prompt() .user(message) .memory(new InMemoryChatMemory()) .call() .content(); } }这里memory()的语义是“本次调用结束后,把这段对话保存到指定 memory 中”。如果你每次调用都传同一个空的新实例,等于没记忆。所以实际项目里,ChatMemory实例应该按会话维度复用。正确做法是:
@Component public class SessionMemoryManager { private final Map<String, ChatMemory> memoryStore = new ConcurrentHashMap<>(); public ChatMemory getOrCreate(String sessionId) { return memoryStore.computeIfAbsent(sessionId, k -> new InMemoryChatMemory()); } }每次调用时传入同一个会话的ChatMemory实例,模型才能顺着历史消息理解上下文。这里也带出一个调优点:历史消息会占用大量 token。默认实现里,OpenAI 的 token 数限制需要自己管理,你可以用PromptTemplate进行裁剪,也可以在查询前通过ChatMemory的清理接口主动合并历史消息。我的经验是,超过 6 轮对话后,可以把历史消息压缩成一段摘要,再作为新消息注入,这样既能节省 token,又能保留关键信息。
4. 进阶能力:模板、记忆与结构化输出
4.1 用 PromptTemplate 替代字符串拼接
真实业务里的 prompt 从来都不是纯静态的,往往需要往里面填充变量。我早期用字符串拼接写过:
String prompt = "你是" + role + ",请根据以下内容回答:" + content;这种做法的问题在于:prompt 内容一多,字符串里全是转义和拼接符号,可读性极差,还容易出错。如果用户输入里包含了特殊的引号或换行,还需要额外处理。
ChatClient配合PromptTemplate能把这个痛点解决得比较干净。模板文件放在resources/prompts/目录下:
你是一名资深的产品经理,请根据下面这个用户反馈给出你的分析结论。 用户反馈: {userFeedback} 要求: 1. 分析用户的核心痛点 2. 判断问题的严重程度 3. 给出符合实际的解决建议 4. 用中文回答,不超过200字然后通过模板变量渲染:
Map<String, Object> variables = Map.of( "userFeedback", "这个功能太难用了,上传文件经常失败,而且没有提示!" ); String result = chatClient.prompt() .user(promptTemplate -> promptTemplate .template(templateText) .variables(variables) .render()) .call() .content();这样把 prompt 与代码分离,后续调整 prompt 时不需要改 Java 代码,直接改模板文件就够了。尤其当团队里有擅长写 prompt 但不会写 Java 的成员时,这个工作流会顺畅很多。
4.2 用 entity() 直接拿到结构化对象
模型返回的一长串文本对业务系统不友好。你想象一个场景:对话系统需要判断用户的意图,返回“问候、咨询、投诉、其他”四类之一。最蠢的方法是用字符串包含判断,比如reply.contains("投诉")。稍微认真一点就是让模型输出 JSON,然后反序列化。
ChatClient原生支持结构化输出,关键方法是.entity()。假设我们定义了一个意图枚举:
public enum IntentType { GREETING, CONSULT, COMPLAINT, OTHER }调用时:
IntentType intent = chatClient.prompt() .user("你们的快递太慢了,我要投诉!") .call() .entity(IntentType.class); System.out.println(intent); // COMPLAINT这里.entity(IntentType.class)做的事情远不止反序列化那么简单。框架会主动构造一个“输出 JSON 格式的结果”的系统提示注入到请求中,并把模型返回的 JSON 字符串反序列化成指定类型。如果你定义的是一个复杂对象,比如客服工单:
public record TicketInfo( String category, String urgency, String description ) {}同样可以直接映射:
TicketInfo ticket = chatClient.prompt() .user("网络连接不上,已经重启路由器三次了,还是不行") .call() .entity(TicketInfo.class);实测下来,模型对结构化输出的稳定性依赖 prompt 中是否明确“只能输出 JSON”。entity()内部已经处理了这层逻辑,但你仍然需要在user消息里讲清楚任务背景,否则模型可能生成一堆解释性文字包裹 JSON,解析就会失败。
4.3 用 Advisor 实现横切逻辑
当我打开 ChatClient 的高级能力时,发现Advisor是一个几乎被低估的关键机制。它类似于 Spring MVC 里的拦截器,可以在调用模型之前和之后对请求或响应做处理。
最典型的使用场景是日志记录。我想记录每一次调用模型的消息数量和 token 用量,如果不用 Advisor,就得在每个 service 方法里重复写记录代码。用 Advisor 的话,逻辑非常干净:
@Component public class LoggingAdvisor implements ChatClientAdvisor { private static final Logger log = LoggerFactory.getLogger(LoggingAdvisor.class); @Override public ChatResponse aroundCall(AdvisedRequest request, CallAroundAdvisorChain chain) { long start = System.currentTimeMillis(); ChatResponse response = chain.nextAroundCall(request); long cost = System.currentTimeMillis() - start; log.info("模型调用耗时:{}ms,prompt tokens:{},回复 tokens:{}", cost, response.getMetadata().getUsage().getPromptTokens(), response.getMetadata().getUsage().getCompletionTokens()); return response; } }还有一类常用 Advisor 是消息改写。比如多轮对话前自动注入一条“你是一个保险专家”的系统消息,或者在用户消息前自动加上时间提示词。这些横切逻辑用 Advisor 统一处理,以后要改所有对话的行为规则,只改一个类就行。
5. 实测踩坑:那些文档里没写透的细节
5.1 并发场景下的 ChatClient 线程安全
有一种很常见的担心是:ChatClient能不能做成单例,多个线程同时调用会不会出问题?我在刚开始落地的时候也犹豫过,于是翻了源码。从实现来看,ChatClient内部持有的是不可变的配置和构建器状态,它依赖ChatModel来做实际请求,每次call()都是独立的请求上下文。实际压测下来,用一个全局的ChatClient实例撑住每秒 50 个并发请求,没有出现问题。
需要注意的是,如果你在ChatClient上配置了memory(),那就不能把ChatClient做成无状态单例了,因为记忆本身是会话维度的。正确的做法是把ChatClient当成无状态组件,记忆通过每次调用的.memory()参数传入,或者通过DefaultChatMemory按会话 id 存取。
MyBatis 里说SqlSession非线程安全,需要用线程局部变量;ChatClient反过来,它是无状态的,所以可以放心共享。这也意味着,你在ChatClient上配置的defaultOptions不会在并发环境下互相污染。
5.2 长上下文的 token 管理策略
用过一段时间之后,最让人头疼的就是 token 爆掉。模型不是无限记忆的,上下文窗口吃满之后,最早的对话会被强制丢弃,这种“截断式”的遗忘会让对话质量大幅下降。更麻烦的是,有些模型接口被截断后不会主动报错,只是默默丢掉上下文,导致输出的答案越来越奇怪。
我现在的管理策略是分三步:
第一步,控制单条消息的长度。原始文档或日志如果超过某个阈值,先做预切分,只在必要时把关键段落拼进 prompt。
第二步,历史消息滚动摘要化。每轮对话结束后,把当前轮次的主要内容用模型压缩成一句话,然后合并进历史摘要。下次调用时,历史消息用摘要替代几个完整轮次。
第三步,开启 token 计数。Spring AI 的OpenAiChatModel支持返回usage信息,在流式结束时将总 token 数记录到日志中,便于监控和调优。
简单来说,token 管理不是 ChatClient 框架层面能自动解决的,它取决于你的业务策略。官方提供了一些ChatMemory的清理接口,但最终怎么取舍内容,还是要靠业务规则。
5.3 传统 MVC 和 WebFlux 下的超时问题
如果要给前端一个同步接口,那么 Tomcat 容器默认有一个异步请求超时时间(通常 30 秒)。但一个复杂的生成任务,加上上下文 prompt 超大时,模型响应超过 30 秒是很常见的事。流式接口不会受影响,因为 SSE 是持续写出的;但如果你用同步call()再加上@GetMapping返回普通 JSON,就可能出现 Timeout 异常。
这里有两个方案:
方案一,调整容器超时时间。在application.yml里配置:
spring: mvc: async: request-timeout: 120000方案二,把接口改成流式返回。即使前端不需要打字机效果,流式接口也可以由后端缓存完整响应后一次性返回,这样做的好处是后端不受单次请求超时限制。
我在一个内容生成系统中,原本用同步call()返回全文,结果用户在生成长文时频繁看到超时错误。改成Flux<String>之后,问题直接消失,用户体验还变好了。
5.4 模型限流与重试策略
真实线上环境里,模型服务商一定有速率限制,尤其是并发高的时候。Spring AI 默认没有自动重试机制,但底层RestClient本身是 Spring 生态的标准组件,所以这种问题交给spring-retry最顺手。
引入依赖:
<dependency> <groupId>org.springframework.retry</groupId> <artifactId>spring-retry</artifactId> </dependency>然后在配置类上开启重试:
@Configuration @EnableRetry public class RetryConfig { }在调用方法上声明重试策略:
@Retryable( retryFor = {OpenAiApiException.class}, maxAttempts = 3, backoff = @Backoff(delay = 1000, multiplier = 2) ) public String ask(String question) { return chatClient.prompt().user(question).call().content(); }需要注意,retryFor要匹配实际抛出的异常类型。OpenAI 的限流错误一般是 HTTP 400 或 429,在 Spring AI 中往往包装成OpenAiApiException,建议在本地打日志再看具体包装类,不要盲目配重试。否则重试了也不生效。还有一种情况是超时异常,比如ReadTimeoutException,这种重试是可以的。但如果是请求本身有问题(比如 prompt 内容违规返回 400),重试多少次都没用,反而会加重负载。所以我把重试策略限定在明确可恢复的异常上。
6. 常见问题排查速查表
表格整理会方便很多,把平时被问得最多的问题做成一个速查表,希望对你排查问题有所帮助。
| 问题现象 | 可能原因 | 排查思路 |
|---|---|---|
| 构建项目时依赖拉取失败 | 未配置 Spring 仓库 | 检查pom.xml中的 repository 配置 |
| 启动正常但首次调用超时 | 网络访问外网不通 | 先用 curl 测试模型接口连通性 |
| 返回内容为空但无异常 | max-tokens设置过小 | 调大max-tokens,观察 usage 信息 |
| 流式接口一直无数据 | MVC 异步超时 | 改用 WebFlux 或调大async.request-timeout |
| 多轮对话中模型“失忆” | ChatMemory未按会话复用 | 用 sessionId 维度管理 memory 实例 |
| 结构化输出解析异常 | 模型返回了额外文本 | 定义 record 时加@JsonIgnoreProperties(ignoreUnknown=true) |
| 并发高时大量 429 | 触发模型限流 | 开启 spring-retry 并设置退避策略 |
补充一点:如果你接入的是国产模型的 OpenAI 兼容接口,很多厂商对/chat/completions的实现细节有差异,比如不支持某些参数,或者温度范围不一样。遇到这种问题,用curl -X POST直接请求厂商接口,对比一下返回体和 OpenAI 手册的差异,就能找到原因。
7. 我的一些使用体会
从底层ChatModel切到ChatClient之后,明显感觉代码组织方式变好了。最直观的变化是:以前写聊天机器人,业务代码里夹杂着各种String.format、objectMapper.readValue、try-catch,看着就累。现在ChatClient把这些都收拢成了声明式 API,模板、参数、输出类型都在一个链路上完成,一眼就能看清意图。
实际项目中我还发现一个容易被忽略的用法:ChatClient不只可以用作聊天场景,凡是要让大模型“按固定格式产出一个结果”的需求,比如抽取简历、分类工单、生成标签、情感判断,都值得考虑用它。它本质上是一个“把自然语言输入转化为结构化结果”的适配层,聊天只是其中最典型的一种形式。
如果你正在做集成,建议先从一个最小可用的call()开始,跑通以后再逐步加 memory、加 Advisor、加流式。不要一开始就全链路配齐,否则出了问题,你很难定位是配置的问题,还是网络的问题,还是业务 prompt 的问题。我见过太多同事在集成阶段一把梭,最后排查到凌晨还在测模板变量是否渲染成功。
另外一个很实用的技巧是:线上排查时,把真正发给模型的完整请求体打印出来看。ChatClient对开发者屏蔽了很多细节,但排查问题时这些细节往往才是答案。你可以通过自定义 Advisor 拦截AdvisedRequest,把prompt和options完整输出。这一步就像拿到了钥匙,很多“怎么我的系统说得不对”的疑问,一看请求体就明白了。