ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Spring AI ChatClient实战:从同步调用到流式、工具调用与上下文管理

Spring AI ChatClient实战:从同步调用到流式、工具调用与上下文管理

Spring AI 和 ChatClient 这两个词,最近在 Java 后端圈子里出现频率相当高。说白了,Spring AI 就是 Spring 生态接大模型的那条官方通道,而 ChatClient 是所有对话能力最直接的入口。以前我们对接模型服务,都是自己用 HTTP 客户端拼请求、解析返回 JSON,遇到流式结果还得处理 SSE,一段逻辑写下来非常啰嗦。Spring AI 把这一整套流程抽象成了类似 RestTemplate 的写法:注入 Builder,build 一个 ChatClient,然后 prompt().call()。这篇文章会从最小配置开始,把 ChatClient 的同步、流式、结构化输出、工具调用、上下文记忆和坑点讲透,适合已经用 Spring Boot、想把大模型能力加进业务系统,又不想被某个模型服务商 API 绑死的开发者。

1. Spring AI到底解决了什么问题

1.1 为什么Java开发者需要专用AI框架

很多同事问我:不就是一个 HTTP 调用嘛,我用自己的 RestTemplate 封装一个工具类不行吗?当然行。但你要想清楚,模型接口不是简单一个 URL 拼参数。它涉及鉴权、重试、流式解析、token 限制、多轮上下文、系统提示词、结构化输出、工具调用。这些东西散落在业务代码里,很快就会失控。

我调过一家模型服务商的接口,最初只做了一个call(String prompt)方法,感觉挺干净。后来需求变成“随时携带历史消息”,我不得不加一个List<Message>参数。再过两周要返回 JSON 对象,我又写了个jsonSchema参数。等需要流式输出、需要模型自己调工具的时候,那个工具类已经塞了十几个重载方法,每个方法参数还不一致。最后代码能跑,但团队里没人愿意动它。

Spring AI 的价值不是帮你省掉一行 HTTP 请求,而是把整个“和模型对话”的过程规范化。你可以把它理解成 JdbcTemplate 之于 JDBC:没有它也能连数据库,但有它之后,连接、语句、结果集、异常这些事变得有章可循。它内部已经适配了多家模型提供商的协议,你在代码里面对的是统一的ChatModel和ChatClient,切换服务商时不需要改业务代码,只改配置和依赖。

1.2 用一句话理解Spring AI的集成思路

我习惯用一个公式概括:ChatClient = 统一抽象 + 自动装配 + 链式调用。它不像某些 SDK 那样让你去继承一大堆类,而是让你像写流水账一样组织请求。第一步拿到ChatClient.Builder,第二步 build 出一个ChatClient,第三步.prompt("xxx").call().content(),完事。

结构上分成三层。最底层是ChatModel,负责对接真实模型服务,比如 OpenAI 格式的接口、兼容接口、本地模型的接口。中间层是ChatClient,它不关心底层具体协议,只暴露我们熟悉的prompt、call、stream、entity方法。最上层落在你的业务代码里,你可以用 Controller 接收 web 请求,也可以在 Service 里封装领域逻辑,完全自由。

这样说可能有点抽象。换个角度:ChatModel就像数据库驱动,ChatClient就像 Spring Data 的 Repository 模板。你写业务时操作的是 Repository,而不是直接拼 SQL。同理,你写 AI 功能时操作的是ChatClient,而不是直接拼 HTTP body。理解这句话,后面看每个 API 都会顺畅很多。

2. 从依赖到第一个对话:ChatClient快速上手

2.1 最小依赖配置与自动装配

先说依赖。Spring AI 的 starter 设计得很规整,你只需要引入一个对应模型服务商的 starter。我用xxx占位,是因为具体依赖名取决于你选哪家模型服务,不要照抄网上的固定坐标。

<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-model-xxx</artifactId> <version>${spring-ai.version}</version> </dependency>

如果你的项目里用到了多个模型服务商,也可以引入 BOM 统一管理版本,再单独引入各家的 starter。不过实际业务中,我建议先只接一家,避免刚开始就陷入多厂商兼容的泥潭。

依赖引入后,Spring Boot 会自动装配很多东西。你不需要手动 new 任何连接对象,但一定要在配置文件里提供密钥等信息。以常见配置为例:

spring: ai: model: xxx: api-key: ${MODEL_API_KEY} base-url: ${MODEL_BASE_URL}

这里.xxx换成你引入的 starter 对应的配置前缀。密钥我建议全部放到环境变量,不要提交到 git。哪怕只是内部演示,也最好养成这个习惯,防止哪次手滑把密钥推到远程仓库,然后被扫描工具盯上。

自动装配的意思是,你在代码里直接注入ChatClient.Builder,Spring 会帮你把底层ChatModel准备好。如果配置缺失,启动时会直接报错,而不是等调用时才失败。这其实是好事,问题暴露越早,排查成本越低。

2.2 第一个ChatClient调用与请求响应对象

写一个最简单的 Controller 感受一下整体流程:

@RestController public class ChatController { private final ChatClient chatClient; public ChatController(ChatClient.Builder builder) { this.chatClient = builder.builder()... } }

等等,这里容易犯一个初学错误。ChatClient.Builder不是ChatClient本身,必须先 build。正确写法是:

@RestController public class ChatController { private final ChatClient chatClient; public ChatController(ChatClient.Builder builder) { this.chatClient = builder.build(); } @GetMapping("/chat") public String chat(String message) { return chatClient.prompt(message).call().content(); } }

注意call()返回的不是字符串,而是ChatResponse。ChatResponse里面包裹了完整的模型输出,包括文本结果、token 使用量、返回的 message 对象等。你需要调用.content()才能拿到纯文本。很多新手第一次接 Spring AI 时直接chatClient.prompt(message).call()交给前端,然后发现序列化出来一大包东西,就是这个原因。

如果你只想拿字符串,也可以直接走.entity(String.class),框架会帮你把输出强转成 String。不过这用法比较少见,更常见的是把输出转成自定义对象,后面第 3 章会细讲。

同步调用是最容易理解的方式,实际上大部分业务系统也够用了。真正的难点在流式和结构化输出,接下来一个个拆。

3. ChatClient核心链路:prompt、entity与流式三件套

3.1 用Prompt和消息对象替换裸字符串

chatClient.prompt("一句话")看起来只是传字符串,但框架内部会把字符串包装成一个Prompt对象,里面包含一组Message。如果只传字符串,框架默认把它当成一条用户消息UserMessage。遇到更复杂的场景,你必须自己构建Prompt,明确区分系统消息、用户消息、历史助手消息。

先看一个多消息示例:

Prompt prompt = new Prompt( new SystemMessage("你是一名资深的Java架构师,回答时尽量给出可落地的建议。"), new UserMessage("项目里要引入Spring AI,需要注意什么?") ); String answer = chatClient.prompt(prompt).call().content();

为什么要区分消息类型?因为模型对 SystemMessage 和 UserMessage 的处理权重不同。SystemMessage 相当于人的“性格设定和行为准则”,UserMessage 才是用户当下输入的真实内容。如果你把所有约束都拼进用户消息里,既容易相互干扰,也容易被用户最新输入带偏。把角色边界分清楚,是一种很基础的提示词工程。

.prompt()方法也支持链式写法,比如直接写.system("...").user("..."),效果和构造多个 Message 一样,但代码更易读。我实际项目中更倾向用链式写法,尤其当 prompt 内容来自不同模板时,看起来更清晰。

3.2 结构化输出:让模型直接返回对象

聊天应用之外,更多需求是把模型输出变成业务数据。比如从一段文本里抽取书名、作者、分类。传统做法是让模型吐 JSON,然后你再处理 JSON。但模型经常会在 JSON 外面加 Markdown 代码块,甚至多解释两句,导致JSON.parse失败。

Spring AI 的entity(Class)方法就是为这个场景设计的。它会把你想要的类型描述注入到提示词中,让模型尽量按 JSON 结构返回,同时对返回结果做解析。示例:

public record BookInfo(String title, String author, String category) {} BookInfo info = chatClient.prompt("提取这本书的书名和分类:《某中间件设计与实现》,分类是分布式系统。") .call() .entity(BookInfo.class);

这段代码完成后,info就是干净的实体对象。底层实现会把 record 字段名、类型信息转换成一段“请返回 JSON”的系统提示词,收到模型输出后再做反序列化。这样做的好处是,业务代码里不再出现ObjectMapper,也不用手工拼 JSON Schema。

使用时有几个坑。第一,模型返回的字段名如果和 record 字段名不一致,解析会失败或字段为空。第二,模型如果坚持输出 Markdown 代码块,部分版本解析可能会报错,你需要在系统提示词里明确“只输出原始 JSON,不要用代码块标记”。第三,嵌套对象和 List 也是支持的,但不要让结构太深,太深容易触发模型截断。

3.3 流式响应与SSE对接

现在大部分 AI 应用都追求“打字机效果”。Spring AI 的流式接口很直白:

@GetMapping(value = "/chat/stream", produces = "text/event-stream") public Flux<String> chatStream(String message) { return chatClient.prompt(message).stream().content(); }

返回类型是 Reactor 的Flux<String>。在 WebFlux 环境下,这个接口天然支持 SSE,前端用 EventSource 或者 Fetch API 就能接收。如果你项目是传统 Servlet 容器,也可以把Flux<String>转成SseEmitter,逐条推给前端。

我踩过的一个坑是:流式接口超时时间设置得太保守。模型生成大段内容可能持续十几秒,如果底层 HTTP 超时设置成 5 秒,用户会在第三秒看到连接中断。建议给模型调用单独设置较长的读超时,比如 60 秒以上,同时配合前端的取消机制。客户端断开时,要记得取消订阅,否则模型还在继续生成,白白消耗 token。

流式输出还有一个容易被忽略的点:不能直接把Flux<String>放到普通@ResponseBody里。Spring MVC 对响应类型的推断和 WebFlux 不一样,如果你混用两种模型,最好显式声明produces = "text/event-stream"或者在方法上返回SseEmitter,否则容易报“No suitable converter”错误。

4. 实战进阶:系统提示词、函数调用与上下文记忆

4.1 系统提示词的最佳实践

系统提示词是决定模型行为的第一道闸门,ChatClient 里可以直接这样写:

chatClient.prompt() .system("你是一个只回答后端开发问题的助手。如果问题与后端无关,请明确拒绝,不要展开。") .user("帮我看一段Redis连接池报错日志") .call() .content();

这个例子很常见,但我见过很多团队用了半年 Spring AI,系统提示词还是随便写一句“你是AI助手”。结果模型在严肃场景里回答得天马行空。

我总结了一套实用的系统提示词结构:角色定义、任务目标、输出格式、限制条件。角色定义告诉模型“你是谁”,任务目标告诉模型“这次要干什么”,输出格式约束 JSON、表格还是代码块,限制条件包括“不要编造数据”“遇到不知道的就说不知道”。这几项写清楚,比盲目堆上下文 token 有效得多。

另外注意,系统提示词也是要消耗 token 的。不要把一整套知识库塞进系统提示词,越长的提示词越容易导致后续用户输入被截断。如果内容确实多,优先做检索增强,把相关知识在用户提问后动态拼进去,而不是一股脑放在最前面。

4.2 让模型按需调用本地函数

工具调用是 Spring AI 里比较惊艳的部分。业务需求往往是这样的:用户问“某海滨城市现在多少度”,模型其实不知道实时天气,但它可以在回答之前调用你提供的天气函数,用返回结果再生成答案。

定义工具类时,只需要实现Function接口,入参和出参都定义为 record:

public record WeatherRequest(String city) {} public record WeatherResponse(Double temperature, String condition) {} @Component public class WeatherTool implements Function<WeatherRequest, WeatherResponse> { @Override public WeatherResponse apply(WeatherRequest request) { // 这里在真实项目中会调用天气数据源,演示直接返回固定值 return new WeatherResponse(28.5, "晴"); } }

然后在 ChatClient 请求中挂载这个工具:

chatClient.prompt("帮我查一下某海滨城市的当前温度") .tools(weatherTool) .call() .content();

底层发生了什么?模型看到用户问题后,会判断“需要调用 WeatherTool”,然后生成一个工具调用的意图请求。Spring AI 帮你执行这个函数,拿到WeatherResponse之后,再把结果作为附加信息交回给模型,模型基于真实函数结果组织最终回答。你完全不接触中间那些 JSON 往返。

做工具调用时要注意几点。一是函数的入参定义不能太复杂,字段越多模型越容易填错。二是工具方法要有超时和异常兜底,因为模型不保证每次调用参数都合法。三是做好调用审计,工具内部访问外部接口时要记录耗时和结果,否则线上出了问题很难判断是哪一步出的错。

4.3 多轮对话的上下文缓存策略

聊天场景最麻烦的是上下文。最简单的做法是自己在内存里维护一个消息列表:

List<Message> history = new ArrayList<>(); history.add(new SystemMessage("你是客服助手,回答时保持简洁。")); history.add(new UserMessage("我的订单发货了没有?")); ChatResponse response = chatClient.prompt(new Prompt(history)).call(); AssistantMessage assistantMessage = response.getResult().getOutput(); history.add(assistantMessage);

这个方案能跑,但要注意两点。第一,历史消息会无限增长,直到触发 token 上限。第二,不同用户之间的历史消息不能混在一个 List 里,否则 A 用户的问题会被 B 用户的历史干扰。

我建议给每个会话维护独立的List<Message>,并做一个固定窗口。比如最多保留最近 10 条消息,超出就把最早的移出去。框架本身也提供了带窗口的记忆组件方向,但不管用哪种,核心原则都一样:既保留必要的上下文,又控制 token 消耗。

更复杂的场景可以考虑把早期消息做摘要,再和其他历史一起传入。不过对于大多数项目,固定窗口 + 摘要摘要这种“两级压缩”方案足够用。别一上来就搞向量库,先把简单方案跑通,观察 token 消耗和效果,再决定要不要升级。

5. 常见问题与排查技巧实录

5.1 连接超时和重试策略

模型接口的响应速度通常比普通后端接口慢很多。一个几十 token 的回答可能 1 到 2 秒,长文本生成可能要半分钟。如果配置里用的是默认连接超时,很容易在业务高峰期频繁超时。

排查思路是先把 Spring AI 的日志级别打开:

logging: level: org.springframework.ai: DEBUG

打开之后能看到实际发出的请求、耗时和错误响应码。然后再判断是连接阶段超时,还是读取阶段超时。连接超时通常很小,调 10 秒左右即可;读取超时要给足余量,我一般设置 60 秒。

重试要注意错误类型。如果是鉴权失败、参数错误这类 4xx,重试没有意义,只会浪费时间和 token。如果是限流 429,可以加上退避重试,等待一段时间后再试。我自己写了一个小型重试工具,只针对 429 和部分 5xx,并且最大重试次数控制在 2 次。把重试玩成循环轰炸,模型服务商不封你才怪。

5.2 token超限怎么办

遇到“context length exceeded”是家常便饭。问题通常出在历史消息太多,或者用户粘贴的文章太长。切断点不在调用那一步,而在你真让用户直接传长文本的那一刻。

解决方案按优先级排列:先压缩上下文,只保留最近几轮对话;再考虑对长文本做切片,只取与当前问题相关的片段;最后才是调大模型窗口,因为窗口越大费用越高,而且大部分情况下根本用不到那么大的上下文。

我还在项目里加了一个简单的占位符统计方法,把每条消息的字符数算出来估算 token。中文场景下,粗略可以按 1 个汉字约等于 1 到 2 个 token 来估算。虽然不精确,但足以判断该不该截断。把这套逻辑做成公共组件,比每次靠模型报错再处理要省心很多。

5.3 结构化输出解析失败的坑

entity(SomeDto.class)是 ChatClient 里非常方便的能力,但它依赖模型的稳定性。模型如果心情不好,可能在 JSON 外面加解释,或者把字符串字段写成 null。更常见的是,模型自己“脑补”了对象里不存在的字段,反序列化时直接报异常。

遇到这种事,先别急着改代码。把原始返回打出来看一眼:

String raw = chatClient.prompt("提取信息").call().content();

看到原始 JSON 后,再决定是加系统提示词约束格式,还是调整 DTO 字段。我的经验是,系统提示词里明确“不要使用 Markdown 代码块,不要输出任何解释,只返回 JSON”能解决八成问题。剩余两成是字段名不匹配,需要做好字段别名兼容。

另外建议用 record 定义 DTO 时不要给太多可选字段,最好所有字段都是必填。空的复杂结构很容易让模型“自由发挥”。如果业务上允许字段缺失,可以考虑Map<String, Object>先接收,再手动校验。

5.4 temperature与maxTokens调参心得

说到参数,最常被问到的是 temperature 和 maxTokens。temperature控制随机性:越低越稳定,越高越发散。maxTokens控制模型最多生成多少 token,不控制输入。示例:

chatClient.prompt("写一个二分查找的Java实现") .options(ChatOptions.builder() .temperature(0.2) .maxOutputTokens(1024) .build()) .call() .content();

参数选择要分场景。做代码生成、JSON 抽取、分类打标这类任务,温度调到 0 到 0.3 最靠谱,因为你要的是确定性。做营销文案、头脑风暴,可以把温度调到 0.7 以上,让模型输出更有想象力。如果温度调到 1.0 以上,你会频繁看到答非所问或语法混乱的结果。

maxTokens设置有两个极端:设太短,回答容易被截断;设太长,模型会在空白处疯狂补字,白白花钱。合理做法是估算目标输出长度,再加一点余量。例如要求 200 字总结,设置 500 token 就够了,不用给到 2000。

参数调优没有银弹,最快的方法是记录不同参数下的输出质量。我自己会保留一组“黄金测试集”,每次调参后跑一遍,对比输出结果。没有这组测试集,调参只能靠感觉。

6. 项目落地中的选型思考

6.1 相比自己封装HTTP调用,优势在哪

用 Spring AI 最大的收益是架构上的统一和可替换性。自己封装一个 HTTP 工具类,短期看起来轻巧,长期会面临模型服务商接口升级、业务方新增流式需求、结构化输出等各式问题。Spring AI 把这些都处理成了标准能力。

我整理了一个对比,供选型参考:

维度自己封装HTTPSpring AI
对接成本每家服务商都要单独写一套只需换 starter 和配置
流式处理自己处理 SSE 协议返回 Flux,天然支持
结构化输出自己拼 JSON Schema + 手动解析entity(Class) 完成
工具调用手工处理函数调用的多轮交互tools() 声明式挂载
测试能力需要自己 Mock HTTP有测试支持模块
排查成本代码简单问题时容易摸清框架封装较深,需读文档

这张表不是劝你所有项目都无脑使用 Spring AI。如果你的团队非常熟悉某个模型服务商,而且只做一个很固定的功能,直接调用 HTTP 未必差。但只要预见到功能会迭代、服务商可能替换,抽象层带来的长期收益会越来越大。

6.2 什么时候别用Spring AI

说实话,不是所有项目都适合引入 Spring AI。如果你只是在一个函数里调一次模型做文本分类,没有流式、没有上下文、不会有第二次迭代,引入框架就是增加依赖复杂度。用 RestTemplate 写二十行代码能解决的问题,没必要强行套框架。

还有两种情况要谨慎。第一,如果你的业务严格依赖某个模型服务商独有的参数和协议,Spring AI 的通用抽象可能会限制你。虽然框架在逐步开放底层配置,但你仍然需要写不少扩展代码。第二,Spring AI 本身还在快速迭代,不同小版本之间 API 可能有变化。如果团队没有足够精力跟进版本升级,贸然在生产环境使用,可能会被版本兼容问题缠住。

我的建议是:把 Spring AI 当作一个“评估中的技术选型”,先在原型项目里跑通再决定。尤其是接生产环境之前,锁定一个版本,把依赖版本写死,不要无脑升级。

6.3 从原型到生产的三个建议

第一,把 ChatClient 封装到领域服务里。业务代码不要到处直接注入 ChatClient,最好统一走一个AiAssistantService。这样无论是加日志、加审计、加缓存,都只有一个入口。否则过两个月,你会发现代码里到处都在调模型,根本管不过来。

第二,一定要记录输入输出、token 用量和耗时。AI 接口出错时,你想复现问题会非常困难,因为没有固定输入能稳定复现。记录输入输出日志是成本最低的排障手段。token 用量也要统计,否则月底账单出来,你都不知道钱花在哪。

第三,做好降级方案。模型接口不可用时,不能让用户看到一串报错。降级可以是返回固定文案,也可以是走本地规则引擎。我在一个客服工单分类 Demo 里做过一层降级:模型超时后,就用关键词规则给工单打标签,虽然效果粗一点,但至少不会中断流程。这个思路放到真实项目里也适用。

我个人最后再啰嗦一句:不管框架多方便,都要先想清楚调用边界和日志记录,否则上线之后排查问题会非常痛苦。这个思路放在工具调用和上下文记忆上也一样,先做好可观测性,再谈效果优化。祝大家都能少踩几个 Spring AI 的坑。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进