
Spring AI 正式版出来之后ChatClient这个 API 几乎是所有用过的人都绕不开的一个组件。相比直接操作底层的ChatModelChatClient提供的是一套更贴近业务思考方式的编程模型——你不需要关心请求是怎么构造的、响应怎么解析只需要描述“我要让模型做什么”剩下的交给框架。这篇就聊聊我在实际项目里使用ChatClient的完整拆解包括环境配置、核心用法、进阶能力以及那些官方文档里没说透的坑。1. Spring AI 里为什么推荐主用 ChatClient1.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 groupIdorg.springframework.ai/groupId artifactIdspring-ai-starter-openai/artifactId version1.0.0/version /dependency这里有一点容易踩坑Spring AI 的正式版仓库默认不在 Maven Central 里需要在pom.xml里额外配置仓库。如果你直接从中央仓库拉包大概率会得到一堆依赖解析失败的红色报错。我建议配置 Spring 官方提供的里程碑和发布仓库repositories repository idspring-milestones/id nameSpring Milestones/name urlhttps://repo.spring.io/milestone/url snapshots enabledfalse/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输出更保守、可复现性更强值越高输出的多样性越强但也更容易胡说八道。我在一个生成摘要的项目里对比过temperature0.2时摘要结构稳定用词变化极小temperature1.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 网页版你会发现回复是一个字一个字“蹦”出来的这种体验就是流式输出。服务端实现的核心是通过 SSEServer-Sent Events持续推送 token 片段而不是把所有内容攒成一整包再返回。Spring AI 的ChatClient对流式调用支持得很完善。最直接的用法是返回FluxStringpublic FluxString streamAsk(String question) { return chatClient.prompt() .user(question) .stream() .content(); }在 WebFlux 的控制器里直接把FluxString作为返回值框架会自动按 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 FluxString streamChat(RequestParam String question) { return chatService.streamAsk(question); } }前端用原生的EventSource或者fetch的流式读取 API 都能接。这里要特别留意的是返回类型。我在 WebFlux 环境之外用过传统 Servlet 容器Tomcat直接返回FluxString也能工作但会有一些限制比如异步超时时间要按默认的 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 MapString, 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字然后通过模板变量渲染MapString, 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(模型调用耗时{}msprompt 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()返回全文结果用户在生成长文时频繁看到超时错误。改成FluxString之后问题直接消失用户体验还变好了。5.4 模型限流与重试策略真实线上环境里模型服务商一定有速率限制尤其是并发高的时候。Spring AI 默认没有自动重试机制但底层RestClient本身是 Spring 生态的标准组件所以这种问题交给spring-retry最顺手。引入依赖dependency groupIdorg.springframework.retry/groupId artifactIdspring-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(ignoreUnknowntrue)并发高时大量 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完整输出。这一步就像拿到了钥匙很多“怎么我的系统说得不对”的疑问一看请求体就明白了。