Spring AI 跟大模型打交道的两个核心 API,一个是 ChatModel,一个是 ChatClient。

ChatModel 是底层接口,一问一答,干净利落。但真到了生产环境,要结构化输出、要对话记忆、要日志审计、要工具调用,只用 ChatModel 会写出一大堆样板代码。
ChatClient 就是 Spring AI 给出的解决方案,在 ChatModel 之上包了一层 Fluent API,把这些高频需求内置了。
这篇内容从 ChatClient 的基础用法讲起,再拆解它的进阶能力,包括提示词模板、结构化输出、流式调用、Advisor 拦截链,最后看看求职派在生产环境中是怎么封装 ChatClient 的。
01、为什么需要 ChatClient?
先看一个具体场景,让大模型返回结构化数据。
用 ChatModel 的话,需要先创建 BeanOutputConverter,然后把格式描述拼进提示词,再从返回值里取文本,最后调转换方法。
4 个步骤,每一步都需要手动去完成。
换成 ChatClient,同样的事情一行代码就能搞定。
ActorsFilms films = ChatClient.create(chatModel)
.prompt("帮我返回五个{actor}导演的电影名")
.call().entity(ActorsFilms.class);
结构化输出的转换器创建、格式注入、结果解析,ChatClient 全部内置了。

这还只是冰山一角。
ChatClient 更有价值的地方在于它的 Advisor 机制,可以在请求和响应的处理流程上挂载拦截器,实现对话记忆、日志记录、工具调用等能力,而且支持链式组合。
02、ChatClient 怎么创建?
创建 ChatClient 有两种典型场景,分别对应两种写法。
单模型场景?
如果项目里只接了一个大模型供应商,并且用的是 Spring AI 官方的 starter(比如 spring-ai-openai-spring-boot-starter),Spring Boot 会自动装配一个 ChatClient.Builder,在构造方法里注入后调用 builder.build() 就能拿到 ChatClient 实例。
这种方式最省事,Spring Boot 帮我们搞定了模型配置、API Key 注入等所有细节。
不过自动装配的 Builder 只有在容器中恰好有一个 ChatModel Bean 时才会生效。
如果同时引入了多个模型 starter,启动时会因为无法确定注入哪个 ChatModel 而报错。
多模型场景?
求职派同时对接了 DeepSeek、智谱等多个供应商,一个 Builder 不知道该绑哪个模型。
这时候需要手动指定 ChatModel 来创建,写法是 ChatClient.builder(chatModel).build()。

求职派更进一步,会根据用户消息中有没有图片,自动切换 TEXT 和 VISION 模型。
ModelConfig.ModelType model = ModelConfig.ModelType.TEXT;
if (prompt.getUserMessages().stream()
.anyMatch(m -> !CollectionUtils.isEmpty(m.getMedia()))) {
model = ModelConfig.ModelType.VISION;
}
var chatModel = (ChatModel) modelProviders
.getModel(user.jobClawUserId(), model);
用户发了张岗位截图过来,系统就自动切到视觉模型去解析,不需要业务层操心。
这段逻辑在 SimpleLlmCaller 的 getClient() 方法里,意味着所有通过 LlmCaller 发起的调用都自动享受模型自适应。
OpenAI 兼容 API
Spring AI 还支持通过 mutate() 方法派生新的模型实例,适合对接那些兼容 OpenAI 协议的第三方服务,比如 DeepSeek。
用法是先从现有的 OpenAiApi 实例 mutate 出一个新的 API 对象(修改 baseUrl 和 apiKey),再从现有的 OpenAiChatModel mutate 出新的模型实例(绑定新 API 和模型参数),最后用新模型创建 ChatClient。
一个基础模型实例可以 mutate 出多个变体,共享配置但各自独立。
03、提示词怎么传?
ChatClient 支持三种方式传入提示词。

最简单的是直接传字符串 chatClient.prompt("为我写首诗").call().content(),适合快速测试。
需要精细控制消息结构时可以传 Prompt 对象 chatClient.prompt(new Prompt(new UserMessage("为我写首诗"))),Prompt 可以装多条消息,还能携带图片和文件等多模态内容。
生产代码最常用的是 Fluent 链式调用,可以分开设置系统提示和用户输入。
chatClient.prompt()
.system("你现在扮演盛唐著名的诗人李白")
.user("为我写首诗")
.call().content();
求职派在处理用户上传的岗位截图时,就需要构造包含图片的 Prompt 对象,把图片数据和文字指令一起传给视觉模型。
这种场景下字符串方式就力不从心了。
模板变量替换
Fluent API 支持在系统消息和用户消息中使用模板变量,运行时动态替换。
通过 Lambda 表达式传入 .system(s -> s.text("你现在扮演著名的诗人{role}").param("role", role)),就可以在运行时把 {role} 替换成实际值。
用户消息同理,用 .user(u -> u.text("我的提问是 {msg}").params(Map.of("msg", msg))) 传入多个参数。
默认使用 {} 作为变量占位符。
但如果提示词里包含 JSON,大括号就冲突了。Spring AI 提供了 StTemplateRenderer,可以把分隔符换成尖括号 <>,在写复杂提示词时能省掉不少转义的麻烦。
04、返回值处理
ChatClient 的返回值处理有三种模式,覆盖了从简单到复杂的各种场景。
同步调用
最基础的 .call().content() 返回纯文本字符串。
如果需要更多信息,比如 token 用量统计,可以调 .call().chatResponse() 拿 ChatResponse 对象,再通过 response.getMetadata().getUsage() 获取消耗数据。
ChatResponse 里装的是 Generation 数组,用来应对一次请求多个候选回复的场景。
大多数情况下取第一个就够了,调用 response.getResult() 即可。
token 用量统计在生产环境中很有用,可以用来做成本核算、限额控制或异常检测。如果某次调用的 token 数突然飙到平时的 10 倍,大概率是提示词或上下文出了问题。
结构化输出
.entity() 方法可以把大模型返回的文本直接映射成 Java 对象。

Spring AI 底层会自动生成 JSON Schema 注入提示词,引导模型输出符合格式的 JSON,再反序列化成目标类型。
record Poem(String title, String content) {}
Poem poem = chatClient.prompt()
.user("写一首关于春天的诗")
.call().entity(Poem.class);

集合类型需要用 ParameterizedTypeReference,写法是 .call().entity(new ParameterizedTypeReference<List<Poem>>() {})。

求职派的岗位数据采集就重度依赖这个能力。
用户上传一张招聘表格的截图,系统用 entity() 方法把图片中的岗位信息直接解析成结构化列表。
这段代码在 GatherAiAgent.gatherByImg() 里,一张图片进去,一个结构化的岗位列表出来,中间的格式约束、JSON 解析全由 ChatClient 代劳了。
流式调用
同步调用要等模型把所有内容生成完才返回,用户体验上会有明显的等待。
流式调用让模型一边生成一边推送,用 stream() 替换 call() 就行。
接口方法加上 produces = "text/event-stream" 注解,返回 Flux<String> 类型,调用 .stream().content() 获取文本流。

流式场景下做结构化输出稍微麻烦一点,因为内容是分块到达的,没办法中途解析 JSON。
一种实用的做法是先用 stream() 拿流式内容,再用 collectList().block() 收集完整文本,最后交给 BeanOutputConverter 解析。
这种写法牺牲了流式的实时性,但在需要完整 JSON 的场景下是最稳妥的方案。
05、Advisor 增强机制
Advisor 是 ChatClient 最有价值的进阶能力。
思路和 Spring AOP 类似,在请求发送前和响应返回后插入自定义逻辑。但 Advisor 更灵活,可以在运行时动态修改提示词、注入上下文、拦截工具调用。
对话记忆
MessageChatMemoryAdvisor 是最常用的 Advisor,用来实现多轮对话。
它在每次请求前自动把历史消息注入提示词,用法是在 .advisors() 中传入 MessageChatMemoryAdvisor.builder(chatMemory).build()。

没有这个 Advisor,用户问完"推荐 Java 岗位"再追问"薪资 25k 以上的",模型根本不知道前一句话说了什么,第二句就变成了一个莫名其妙的筛选条件。
它的工作原理也不复杂。

在请求发出前,从 ChatMemory 里取出当前会话的历史消息,和新消息合并后一起发给模型。
响应回来后,再把新的用户消息和助手回复存回 ChatMemory。整个过程对业务代码完全透明,调用方只需要挂上这个 Advisor,不用自己管理任何历史消息。
求职派还对这个 Advisor 做了定制,加入了消息去重(通过 LinkedHashSet)和系统消息前置排序,确保多次会话中不会出现重复的历史记录,同时系统提示永远排在消息列表的最前面。
日志记录和执行顺序
SimpleLoggerAdvisor 用来记录 ChatClient 的请求和响应,方便调试。
创建实例后传入 .advisors() 即可,需要把 org.springframework.ai.chat.client.advisor 包的日志级别设为 DEBUG 才能看到输出。如果默认格式不满意,构造时可以传入自定义的格式化 Lambda。
传入多个 Advisor 时,顺序很重要。
每个 Advisor 都会修改提示词或上下文,修改结果会传递给链中的下一个。
chatClientBuilder
.defaultAdvisors(new SimpleLoggerAdvisor())
.defaultAdvisors(
ReActAdvisor.builder()
.chatModel(chatModel).build(),
MessageChatMemoryAdvisor
.builder(chatMemory).build());
求职派生产环境中这三个 Advisor 分工明确:
- SimpleLoggerAdvisor 负责日志,
- ReActAdvisor 负责工具调用循环,
- MessageChatMemoryAdvisor 负责对话记忆。
06、求职派的实战封装
看完了 ChatClient 的标准用法,最后看看求职派在生产环境中是怎么用的。
求职派没有让业务代码直接调用 ChatClient,而是在它之上建了一个三层 LLM 调用器体系。
- SimpleLlmCaller 是基础层,封装了 ChatClient 的创建和调用,自动根据消息内容切换 TEXT/VISION 模型
- BizAgentLlmCaller 是业务层,在基础层之上增加了 ChatMemory 集成、工具注册、会话 ID 绑定
- UserPreferenceBasedLlmCaller 是完整层,会加载用户身份画像、注入个性化系统提示、挂载全套工具栈(文件操作、Shell、MCP 服务等)

业务 Agent 不需要关心 ChatClient 怎么创建、Advisor 怎么组装、模型怎么选择,调用 LlmCaller 的 call() 或 stream() 方法就行了。
新增一个业务 Agent 时,只需要实现 BizAgent 接口,LLM 调用的复杂度被 LlmCaller 接管了。
为什么不让业务 Agent 直接用 ChatClient?

因为生产环境中创建一个 ChatClient 涉及的东西太多了,要根据用户 ID 查供应商配置,要判断消息类型选模型,要加载用户画像构建个性化系统提示,要注册工具栈,要挂 Advisor。
如果每个 Agent 自己做这些事情,同样的代码会在每个 Agent 里重复一遍,而且任何一个环节改了所有 Agent 都要跟着改。
LlmCaller 把这些共性逻辑集中了,业务 Agent 只需要关心"给用户说什么"这一件事。
默认配置与工具注册
ChatClient.Builder 提供了一组 defaultXxx 方法,在构建时设置默认值,调用时可以覆盖。
求职派的 LlmCaller 通过 defaultSystem() 设置默认系统提示,通过 defaultOptions() 设置模型参数(如 maxTokens),每次调用时只需要传用户消息和模板参数,不用重复写系统提示。调用时用不带 default 前缀的方法可以覆盖默认值。

ChatClient 也支持在构建时注册默认工具。
求职派在完整层调用器里通过 defaultTools() 注册了 CheckListTool、TaskTool、McpTool、ShellTools、FileSystemTools 等一整套工具栈,模型可以根据用户意图自主决定调用哪个。这比在每次 prompt 调用时手动传工具要干净得多。
chatClientBuilder.defaultTools(
CheckListTool.builder().build(),
TaskTool.builder().taskManager(taskManager).build(),
McpTool.builder().configurationManager(cm).build(),
ShellTools.builder().build(),
FileSystemTools.builder().build());
不过求职派做了一个有意思的设计选择,禁用了 Spring AI 的自动工具执行(internalToolExecutionEnabled(false)),改用自定义的 ReActAdvisor 来手动控制工具调用循环。
Spring AI 默认的工具执行是全自动的,模型说要调哪个工具,框架就立刻执行,业务层完全无感知。
但在求职派的场景中,有些工具调用需要前置校验(比如检查用户权限),有些需要后置处理(比如记录调用日志),全自动模式就不够用了。
自定义 ReActAdvisor 让求职派在每次工具调用前后都能插入自己的逻辑,同时还支持流式场景下的工具调用,模型一边输出文本一边触发工具调用,两者互不阻塞。
ending
ChatClient 是 Spring AI 里日常用得最多的 API。
从最简单的 prompt("xxx").call().content() 到完整的 Advisor 链 + 工具注册 + 结构化输出,它的能力覆盖了从原型验证到生产部署的全部场景。
【求职派把 ChatClient 封装成了三层 LlmCaller,业务 Agent 只管对话逻辑,模型选择、记忆管理、工具调用这些脏活累活全部下沉。】
这大概就是框架该有的样子,用的时候感觉不到它的存在。
如何把求职派写到简历上?
项目名称:求职派(JobClaw)
项目简介:基于 Spring AI 的多 Agent 求职系统,对接微信/钉钉/飞书三个 IM 渠道,支持意图识别、岗位推荐、身份采集等多种业务 Agent。
技术栈:Java 21、Spring Boot 4.0.5、Spring AI 2.0.0-M4、LangGraph4J
核心职责:
- 基于 Spring AI 的 ChatClient 封装了一个三层 LLM 调用器,实现模型自适应选择、对话记忆管理和工具链自动注册
- 基于 ChatClient 的 Advisor 机制实现自定义 ReActAdvisor,手动控制工具调用并注入生命周期钩子,支持同步和流式两种模式的工具执行拦截
- 基于 ChatClient 的 entity() 方法实现多模态岗位数据采集,支持从图片、文件、网页中结构化提取招聘信息并自动映射为业务对象
- 基于 ModelProviders 实现多供应商模型路由,根据用户消息内容自动切换文本/视觉模型,支持 智谱、DeepSeek 等多个供应商的统一接入
- 基于自定义 ChatMemory 实现多 Agent 会话隔离,通过五层复合 ID(渠道+用户+Agent+会话+轮次)确保不同 Agent 的对话上下文互不干扰
回复