深入 Spring AI ChatClient:从一行代码到 LLM 调用的完整旅程
难度:★★★☆☆
当你写下
chatClient.prompt().user("Tell me a joke").call().content()时,Spring AI 在背后做了什么?
目录
- 引言:一个问题
- 第一层:初见------最小可用示例
- [第二层:创建 ChatClient 的三种姿势](#第二层:创建 ChatClient 的三种姿势 "#3-%E7%AC%AC%E4%BA%8C%E5%B1%82%E5%88%9B%E5%BB%BA-chatclient-%E7%9A%84%E4%B8%89%E7%A7%8D%E5%A7%BF%E5%8A%BF")
- [姿势一:自动配置的 prototype Builder](#姿势一:自动配置的 prototype Builder "#31-%E5%A7%BF%E5%8A%BF%E4%B8%80%E8%87%AA%E5%8A%A8%E9%85%8D%E7%BD%AE%E7%9A%84-prototype-builder")
- 姿势二:多模型场景的工程化方案
- [姿势三:多个 OpenAI 兼容端点](#姿势三:多个 OpenAI 兼容端点 "#33-%E5%A7%BF%E5%8A%BF%E4%B8%89%E5%A4%9A%E4%B8%AA-openai-%E5%85%BC%E5%AE%B9%E7%AB%AF%E7%82%B9")
- [第三层:Fluent API 与响应类型全景](#第三层:Fluent API 与响应类型全景 "#4-%E7%AC%AC%E4%B8%89%E5%B1%82fluent-api-%E4%B8%8E%E5%93%8D%E5%BA%94%E7%B1%BB%E5%9E%8B%E5%85%A8%E6%99%AF")
- [prompt() 的三个入口](#prompt() 的三个入口 "#41-prompt-%E7%9A%84%E4%B8%89%E4%B8%AA%E5%85%A5%E5%8F%A3")
- [call() 的惰性执行](#call() 的惰性执行 "#42-call-%E7%9A%84%E6%83%B0%E6%80%A7%E6%89%A7%E8%A1%8C")
- entity():结构化输出
- EntityParamSpec:让结构化输出更"硬"
- stream():流式响应
- [第四层:Prompt 模板与消息元数据](#第四层:Prompt 模板与消息元数据 "#5-%E7%AC%AC%E5%9B%9B%E5%B1%82prompt-%E6%A8%A1%E6%9D%BF%E4%B8%8E%E6%B6%88%E6%81%AF%E5%85%83%E6%95%B0%E6%8D%AE")
- [模板变量与 TemplateRenderer](#模板变量与 TemplateRenderer "#51-%E6%A8%A1%E6%9D%BF%E5%8F%98%E9%87%8F%E4%B8%8E-templaterenderer")
- 消息元数据:给消息打标签
- [第五层:Builder 默认值------配置一次,处处生效](#第五层:Builder 默认值——配置一次,处处生效 "#6-%E7%AC%AC%E4%BA%94%E5%B1%82builder-%E9%BB%98%E8%AE%A4%E5%80%BC%E9%85%8D%E7%BD%AE%E4%B8%80%E6%AC%A1%E5%A4%84%E5%A4%84%E7%94%9F%E6%95%88")
- [第六层:Advisor------Spring AI 的灵魂](#第六层:Advisor——Spring AI 的灵魂 "#7-%E7%AC%AC%E5%85%AD%E5%B1%82advisorspring-ai-%E7%9A%84%E7%81%B5%E9%AD%82")
- 设计思想:拦截器体系
- [AdvisorSpec 配置与顺序语义](#AdvisorSpec 配置与顺序语义 "#72-advisorspec-%E9%85%8D%E7%BD%AE%E4%B8%8E%E9%A1%BA%E5%BA%8F%E8%AF%AD%E4%B9%89")
- [内置 Advisor 一览](#内置 Advisor 一览 "#73-%E5%86%85%E7%BD%AE-advisor-%E4%B8%80%E8%A7%88")
- [自定义 Advisor](#自定义 Advisor "#74-%E8%87%AA%E5%AE%9A%E4%B9%89-advisor")
- 工具调用:ToolCallingAdvisor
- [第七层:聊天记忆 ChatMemory](#第七层:聊天记忆 ChatMemory "#8-%E7%AC%AC%E4%B8%83%E5%B1%82%E8%81%8A%E5%A4%A9%E8%AE%B0%E5%BF%86-chatmemory")
- 总结
1. 引言:一个问题
在 Spring AI 中,最常用的"魔法"就是这段代码:
java
@RestController
class MyController {
private final ChatClient chatClient;
public MyController(ChatClient.Builder chatClientBuilder) {
this.chatClient = chatClientBuilder.build();
}
@GetMapping("/ai")
String generation(String userInput) {
return this.chatClient.prompt()
.user(userInput)
.call()
.content();
}
}
三行链式调用,一次 LLM 请求就完成了。没有 HTTP 客户端,没有 JSON 拼装,没有手动解析------就像在写 RestClient 一样自然。
问题来了:ChatClient 到底是接口还是实现?call() 和 content() 谁才是真正发起模型调用的方法?为什么一个 ChatClient 能同时支持同步、流式、结构化输出、工具调用和 RAG?
本文将从官方参考文档出发,逐层拆解这个 API。你会发现:ChatClient 不是"又一个 HTTP 封装",而是一整套可组装、可观测、可插拔的 LLM 调用架构。
2. 第一层:初见------最小可用示例
ChatClient 是 Spring AI 面向开发者的核心门面(Facade),提供:
- 流式 API(Fluent API):用链式调用拼装 Prompt、调用模型、解析响应;
- 同步 + 流式双编程模型 :既能
call()拿到完整结果,也能stream()拿到Flux增量流; - 统一的模型抽象:OpenAI、Anthropic、DeepSeek、Ollama......换模型不改业务代码;
- Advisor 拦截链:把记忆、RAG、日志、工具调用、安全护栏等横切能力织入调用链。
最小闭环只有三个动作:
java
chatClient.prompt() // ① 开启 Fluent 链(构造 Prompt)
.user(userInput) // ② 设置用户消息
.call() // ③ 声明同步调用
.content(); // ④ 取出纯文本响应
其中 prompt() 负责把"用户消息、系统消息、工具、参数、Advisor"组装成一个 Prompt 对象------Prompt 从 API 角度上看就是一组消息的集合 :UserMessage(用户直接输入)与 SystemMessage(系统生成的对话引导),消息中常含占位符,运行时由用户输入替换。此外还有 Prompt 选项(如模型名称、控制随机性的 temperature)。
3. 第二层:创建 ChatClient 的三种姿势
3.1 姿势一:自动配置的 prototype Builder
最省事的方式:Spring AI 为每个 ChatModel 的自动配置都准备了一个 prototype 作用域 的 ChatClient.Builder bean,直接注入:
java
@RestController
class MyController {
private final ChatClient chatClient;
public MyController(ChatClient.Builder chatClientBuilder) {
this.chatClient = chatClientBuilder.build();
}
}
为什么是 prototype 作用域? 因为 Builder 是"一次性模具":build() 之后,builder 内部的默认配置会被复制 进 ChatClient。如果 Builder 是单例,所有 ChatClient 会共享同一个 builder 实例,状态互相污染;prototype 保证每次注入都是全新实例。
这也带来一个能力:同一个模型类型,可以轻松构建多个不同配置的 ChatClient:
java
@Configuration
class ChatClientConfig {
@Bean
ChatClient defaultChatClient(ChatClient.Builder builder) {
return builder.build();
}
@Bean
ChatClient customChatClient(ChatClient.Builder builder) {
return builder.defaultSystem("You are a helpful assistant.").build();
}
}
3.2 姿势二:多模型场景的工程化方案
真实项目里几乎不会只有一个模型:复杂推理用大模型、简单任务用小模型、某个供应商挂了要降级......文档给出了多模型的完整工程方案。
新手最容易踩的坑 :用 ChatClient.create(chatModel) 或 ChatClient.builder(chatModel) 直接手工创建客户端------这会绕过自动配置,导致可观测性(Observability)和 ChatClientBuilderCustomizer 全部失效。
正确姿势是注入 ChatClientBuilderConfigurer------它会在内部应用所有 Customizer 并接好观测埋点,镜像自动配置的行为:
java
@Configuration
public class ChatClientConfig {
@Bean
@Primary
public ChatClient openAiChatClient(OpenAiChatModel chatModel, ChatClientBuilderConfigurer configurer,
ObjectProvider<ObservationRegistry> observationRegistry,
ObjectProvider<ChatClientObservationConvention> chatClientObservationConvention,
ObjectProvider<AdvisorObservationConvention> advisorObservationConvention,
ObjectProvider<ToolCallingAdvisor.Builder<?>> toolCallingAdvisorBuilder) {
ChatClient.Builder builder = ChatClient.builder(chatModel,
observationRegistry.getIfUnique(() -> ObservationRegistry.NOOP),
chatClientObservationConvention.getIfUnique(),
advisorObservationConvention.getIfUnique(),
toolCallingAdvisorBuilder.getIfAvailable());
return configurer.configure(builder).build();
}
}
之后用 @Qualifier("openAiChatClient") / @Qualifier("anthropicChatClient") 注入即可按需切换模型。注意 :当容器里存在多个 ChatModel bean 时,ChatModel 与 ChatClient 都需要显式标记 @Primary 以消解自动配置的歧义。
3.3 姿势三:多个 OpenAI 兼容端点
Groq、DeepSeek、vLLM 等大多兼容 OpenAI 协议,用 OpenAiChatModel.builder() 指定 baseUrl 就能一网打尽:
java
OpenAiChatModel groqModel = OpenAiChatModel.builder()
.options(OpenAiChatOptions.builder()
.baseUrl("https://api.groq.com/openai/v1")
.apiKey(System.getenv("GROQ_API_KEY"))
.model("llama3-70b-8192")
.temperature(0.5)
.build())
.build();
String response = ChatClient.builder(groqModel).build().prompt(prompt).call().content();
4. 第三层:Fluent API 与响应类型全景
4.1 prompt() 的三个入口
prompt() 有三种重载,对应不同的拼装起点:
| 方法 | 用途 |
|---|---|
prompt() |
最常用,自由拼装 user / system / tools / advisors |
prompt(Prompt prompt) |
传入已构造好的 Prompt 对象 |
prompt(String content) |
便捷方法,直接以文本开头 |
java
// 便捷入口,等价于 prompt().user(text)
chatClient.prompt("What is the capital of France?").call().content();
4.2 call() 的惰性执行
关键认知 :.call() 本身不会 触发模型调用------它只是在声明"我要用同步方式"。真正的模型调用发生在 .content()、.chatResponse() 等终止方法(Terminal Method)上。这与 WebClient 的延迟语义一脉相承。
call() 之后有这些返回值可选:
| 方法 | 返回类型 | 典型用途 |
|---|---|---|
content() |
String |
最常用,纯文本 |
chatResponse() |
ChatResponse |
完整响应对象,含多个 Generation 及元数据(如 token 用量------计费审计依据) |
chatClientResponse() |
ChatClientResponse |
ChatResponse + 执行上下文(可拿到 RAG 检索到的文档等 Advisor 中间产物) |
entity(...) |
Java 类型 | 结构化输出,直接映射成对象 |
responseEntity(...) |
ResponseEntity<T> |
ChatResponse 与结构化实体"双全" |
java
// 完整响应对象:token 用量是计费的关键指标
ChatResponse chatResponse = chatClient.prompt()
.user("Tell me a joke")
.call()
.chatResponse();
responseEntity() 需要同时拿到元数据和结构化实体时非常有用:
java
ResponseEntity<ActorFilms> re = chatClient.prompt()
.user("Generate the filmography for a random actor.")
.call()
.responseEntity(ActorFilms.class);
4.3 entity():结构化输出
entity() 是生产环境最高频的能力------让 LLM 输出直接落入类型安全的 Record:
java
record ActorFilms(String actor, List<String> movies) {}
ActorFilms actorFilms = chatClient.prompt()
.user("Generate the filmography for a random actor.")
.call()
.entity(ActorFilms.class);
// 泛型集合用 ParameterizedTypeReference
List<ActorFilms> films = chatClient.prompt()
.user("Generate the filmography of 5 movies for Tom Hanks and Bill Murray.")
.call()
.entity(new ParameterizedTypeReference<List<ActorFilms>>() {});
entity() 的所有重载:
java
entity(Class<T> type)
entity(ParameterizedTypeReference<T> type) // 泛型集合
entity(StructuredOutputConverter<T> converter) // 自定义转换器
entity(Class<T> type, Consumer<EntityParamSpec> spec) // + 高级配置
entity(ParameterizedTypeReference<T> type, Consumer<EntityParamSpec> spec)
entity(StructuredOutputConverter<T> converter, Consumer<EntityParamSpec> spec)
4.4 EntityParamSpec:让结构化输出更"硬"
带 spec 参数的形式是 Spring AI 2.0 的重点,它开启两个独立的高级行为:
① 原生结构化输出(useProviderStructuredOutput())
默认情况下,JSON Schema 是以文本指令的方式追加进用户消息 ("请按以下 JSON Schema 格式输出")。开启后,Schema 会作为结构化约束直接传给模型供应商 API,由模型端保证格式:
java
ActorFilms actorFilms = chatClient.prompt()
.user("Generate the filmography for a random actor.")
.call()
.entity(ActorFilms.class, spec -> spec.useProviderStructuredOutput());
⚠️ 架构师提示 :原生结构化输出默认关闭 ,因为各模型支持度参差不齐------比如 Ollama 的推理/思考模式可能返回纯文本而非 JSON;OpenAI 不支持顶层数组 Schema。生产环境按模型实测后再开启。它等价于在调用级设置
AdvisorParams.ENABLE_NATIVE_STRUCTURED_OUTPUT,因此也可以用.advisors(AdvisorParams.ENABLE_NATIVE_STRUCTURED_OUTPUT)全局开启。
② Schema 校验 + 自动重试(validateSchema())
模型返回的 JSON 不符合 Schema 时,校验失败信息会被追加回用户消息、让模型重新生成 ,最多重试 maxRepeatAttempts 次(默认 3):
java
ActorFilms actorFilms = chatClient.prompt()
.user("Generate the filmography for a random actor.")
.call()
.entity(ActorFilms.class, spec -> spec
.useProviderStructuredOutput()
.validateSchema());
注意:
validateSchema()激活时不支持流式 (校验需要完整响应)。其内部使用StructuredOutputValidationAdvisor实现。
两种模式对比:
| 维度 | 默认 Prompt 指令模式 | Provider Native 模式 |
|---|---|---|
| Schema 传递方式 | 嵌入 Prompt 文本 | 作为 API 参数传递 |
| 可靠性 | 靠 LLM 自觉遵守 | 模型 API 原生约束 |
| 适用性 | 所有模型 | 仅支持 StructuredOutputChatOptions 的模型 |
| 开启方式 | 默认 | spec -> spec.useProviderStructuredOutput() |
4.5 stream():流式响应
流式返回 Reactor 的 Flux,适合打字机效果的对话界面:
java
Flux<String> output = chatClient.prompt()
.user("Tell me a joke")
.stream()
.content(); // Flux<String>
Flux<ChatResponse> responses = chatClient.prompt()
.user("Tell me a joke")
.stream()
.chatResponse(); // 含元数据的完整流
流式 + 结构化输出目前没有 entity() 便捷方法,需借助 BeanOutputConverter 自行聚合:
java
var converter = new BeanOutputConverter<>(new ParameterizedTypeReference<List<ActorsFilms>>() {});
Flux<String> flux = this.chatClient.prompt()
.user(u -> u.text("Generate the filmography for a random actor. {format}")
.param("format", this.converter.getFormat()))
.stream()
.content();
String content = flux.collectList().block().stream().collect(Collectors.joining());
List<ActorsFilms> actorFilms = converter.convert(content);
call() 与 stream() 对照:
| 终止方法 | call() 之后 | stream() 之后 |
|---|---|---|
| 纯文本 | String content() |
Flux<String> content() |
| 完整响应 | ChatResponse chatResponse() |
Flux<ChatResponse> chatResponse() |
| 含上下文 | ChatClientResponse chatClientResponse() |
Flux<ChatClientResponse> chatClientResponse() |
| 结构化 | entity(...) / responseEntity(...) |
借助 BeanOutputConverter 聚合 |
5. 第四层:Prompt 模板与消息元数据
5.1 模板变量与 TemplateRenderer
不要用字符串拼接拼 Prompt。ChatClient 原生支持模板变量:
java
String answer = ChatClient.create(chatModel).prompt()
.user(u -> u
.text("Tell me the names of 5 movies whose soundtrack was composed by {composer}")
.param("composer", "John Williams"))
.call()
.content();
内部由 TemplateRenderer 渲染,默认实现 StTemplateRenderer 基于开源 StringTemplate 引擎(另有 NoOpTemplateRenderer 用于完全跳过模板处理)。
架构师提示 :如果你的 Prompt 里要内嵌 JSON(例如给模型输出格式示例),{} 分隔符会与 JSON 语法冲突。此时换用自定义分隔符:
java
String answer = ChatClient.create(chatModel).prompt()
.user(u -> u
.text("Tell me the names of 5 movies whose soundtrack was composed by <composer>")
.param("composer", "John Williams"))
.templateRenderer(StTemplateRenderer.builder()
.startDelimiterToken('<').endDelimiterToken('>').build())
.call()
.content();
注意:
.templateRenderer()只作用于 ChatClient 链上直接定义的模板(.user()/.system()),不会 影响QuestionAnswerAdvisor等 Advisor 内部使用的模板------它们有各自的模板定制机制。
5.2 消息元数据:给消息打标签
ChatClient 支持给 user / system 消息附加元数据,用于链路追踪、日志分析、下游处理:
java
String response = chatClient.prompt()
.user(u -> u.text("What's the weather like?")
.metadata("messageId", "msg-123")
.metadata("userId", "user-456")
.metadata("priority", "high"))
.call()
.content();
// 系统消息同理
String response2 = chatClient.prompt()
.system(s -> s.text("You are a helpful assistant.")
.metadata("version", "1.0")
.metadata("model", "gpt-4"))
.user("Tell me a joke")
.call()
.content();
元数据会体现在生成的 UserMessage / SystemMessage 的 getMetadata() 中,在自定义 Advisor 里读取消息元数据是常见的埋点手段。
校验规则 (防御性设计,早失败优于静默丢失):key 不能为 null / 空,value 不能为 null,Map 中任何 null 元素都会抛 IllegalArgumentException。
6. 第五层:Builder 默认值------配置一次,处处生效
在生产项目里,系统提示词、默认工具、RAG Advisor 不应散落在每个 Controller 里,而应在 ChatClient.Builder 层固化。Builder 提供全套 default* 方法:
java
@Configuration
class Config {
@Bean
ChatClient chatClient(ChatClient.Builder builder) {
return builder
.defaultSystem("You are a friendly chat bot that answers question in the voice of a {voice}")
.defaultAdvisors(QuestionAnswerAdvisor.builder(vectorStore).build())
.defaultTools(myToolCallbacks)
.defaultOptions(OpenAiChatOptions.builder().temperature(0.7).build())
.build();
}
}
默认配置 API 全景:
| 方法 | 作用 |
|---|---|
defaultSystem(String/Resource/Consumer) |
默认系统提示词(可带模板占位符) |
defaultUser(String/Resource/Consumer) |
默认用户消息 |
defaultOptions(ChatOptions) |
默认模型参数(temperature、model 等) |
defaultTools(Object...) |
默认工具注册,每次请求都可用 |
defaultToolContext(Map) |
工具执行默认上下文 |
defaultTemplateRenderer(TemplateRenderer) |
默认模板渲染器 |
defaultAdvisors(Advisor...) / defaultAdvisors(Consumer<AdvisorSpec>) |
默认 Advisor 链 |
运行时按需覆盖同名的无 default 前缀方法即可------运行时配置优先,二者自动合并:
java
// 系统提示词中的占位符在运行时动态注入
Map<String, String> completion = chatClient.prompt()
.system(sp -> sp.param("voice", voice)) // 覆盖 defaultSystem 的 {voice}
.user(message)
.call()
.content();
mutate() 还能从已有 ChatClient(或一次请求配置)派生新客户端,继承全部默认设置再微调:
java
ChatClient.Builder derived = chatClient.mutate().defaultSystem("...").build();
ChatClient.Builder fromRequest = chatClient.prompt().user("...").mutate();
7. 第六层:Advisor------Spring AI 的灵魂
如果说 ChatClient 是门面,Advisor 就是 Spring AI 的拦截器(Interceptor)体系 ------类似 Spring MVC 的 HandlerInterceptor 或 Servlet 的 Filter。它是 RAG、对话记忆、工具调用、日志、安全护栏等一切横切能力的载体。
7.1 设计思想:拦截器体系
一次调用会经过一条按 getOrder() 排序的 Advisor 链 ,链尾是框架自动添加的"发请求给模型"的终结点。核心接口(org.springframework.ai.chat.client.advisor.api 包):
java
// 同步
public interface CallAdvisor extends Advisor {
ChatClientResponse adviseCall(ChatClientRequest request, CallAdvisorChain chain);
}
// 响应式
public interface StreamAdvisor extends Advisor {
Flux<ChatClientResponse> adviseStream(ChatClientRequest request, StreamAdvisorChain chain);
}
栈式执行模型(与 AOP 环绕通知一致):
- 低 order 值(高优先级)先执行;
- 第一个处理请求的 Advisor,最后处理响应;
- 链上的
ChatClientRequest和ChatClientResponse都携带一个共享的advise-context,用于跨 Advisor 传递状态(比如 RAG 检索到的文档); - Advisor 可以选择阻断请求 (不调用
chain.next*()),此时它必须自行填充响应------这正是安全护栏类 Advisor 的拦截原理。
csharp
用户 Prompt
│
▼
[Advisor 1 order=最低] ← 最先处理请求
│ ... 可修改 Prompt / 阻断请求
[Advisor 2]
│
[ChatModelCallAdvisor] ← 框架自动添加,真正调用 LLM
│
▼ 响应原路返回
[Advisor 2] ← 后处理响应
[Advisor 1] ← 最后处理响应
7.2 AdvisorSpec 配置与顺序语义
ChatClient 通过 AdvisorSpec 配置 Advisor:
java
interface AdvisorSpec {
AdvisorSpec param(String k, Object v);
AdvisorSpec params(Map<String, Object> p);
AdvisorSpec advisors(Advisor... advisors);
AdvisorSpec advisors(List<Advisor> advisors);
}
顺序就是语义------每个 Advisor 都会修改 Prompt 或上下文,修改结果传递给下一个。看官方示例:
java
ChatClient.builder(chatModel)
.build()
.prompt()
.advisors(a -> a
.advisors(
MessageChatMemoryAdvisor.builder(chatMemory).build(), // 先:注入对话历史
QuestionAnswerAdvisor.builder(vectorStore).build() // 后:基于历史做检索
)
.param(ChatMemory.CONVERSATION_ID, conversationId)) // 记忆必需的会话 ID
.user(userText)
.call()
.content();
MessageChatMemoryAdvisor 先把历史拼进 Prompt,QuestionAnswerAdvisor 再基于"用户问题 + 历史"做向量检索------顺序反了,检索质量就会下降。
⚠️ 使用记忆类 Advisor 时,
ChatMemory.CONVERSATION_ID必须 在每次调用通过.param()提供,否则运行期抛IllegalArgumentException。
7.3 内置 Advisor 一览
| 类别 | Advisor | 作用 |
|---|---|---|
| 记忆 | MessageChatMemoryAdvisor |
从记忆库取历史,作为消息列表并入 Prompt |
| 记忆 | VectorStoreChatMemoryAdvisor |
从向量库检索历史,并入系统提示词 |
| RAG | QuestionAnswerAdvisor |
朴素 RAG:向量检索 + 上下文注入 |
| RAG | RetrievalAugmentationAdvisor |
模块化 RAG 架构的完整实现 |
| 推理 | ReReadingAdvisor |
RE2 技术:让模型重读问题提升推理 |
| 工具 | ToolCallingAdvisor |
工具调用循环(默认自动注册) |
| 安全 | SafeGuardAdvisor |
防止生成有害/不当内容 |
| 日志 | SimpleLoggerAdvisor |
打印请求/响应,调试利器 |
日志 Advisor 推荐放在链尾,并配合配置开启 DEBUG:
properties
logging.level.org.springframework.ai.chat.client.advisor=DEBUG
也可以定制日志内容(控制敏感信息不外泄):
java
SimpleLoggerAdvisor customLogger = new SimpleLoggerAdvisor(
request -> "Custom request: " + request.prompt().getUserMessage(),
response -> "Custom response: " + response.getResult(),
0
);
7.4 自定义 Advisor
只需实现 CallAdvisor / StreamAdvisor(或 BaseAdvisor),用 before 修改请求、after 修改响应。下面是一个官方风格的 RE2(Re-Reading)推理增强 Advisor------它把用户输入改造成"问题 + 再读一遍":
java
public class ReReadingAdvisor implements BaseAdvisor {
private static final String DEFAULT_RE2_ADVISE_TEMPLATE = """
{re2_input_query}
Read the question again: {re2_input_query}
""";
@Override
public ChatClientRequest before(ChatClientRequest chatClientRequest, AdvisorChain advisorChain) {
String augmentedUserText = PromptTemplate.builder()
.template(DEFAULT_RE2_ADVISE_TEMPLATE)
.variables(Map.of("re2_input_query",
chatClientRequest.prompt().getUserMessage().getText()))
.build()
.render();
return chatClientRequest.mutate()
.prompt(chatClientRequest.prompt().augmentUserMessage(augmentedUserText))
.build();
}
@Override
public ChatClientResponse after(ChatClientResponse chatClientResponse, AdvisorChain advisorChain) {
return chatClientResponse;
}
@Override
public int getOrder() { return 0; }
}
最佳实践(官方文档提炼):
- 每个 Advisor 职责单一,保持模块化;
- 用
advise-context在 Advisor 间共享状态; - 尽量同时实现流式与非流式两个版本;
- 仔细编排链上的 order,确保数据流正确;
- 需要"请求/响应两头都是第一个"的 Advisor 时,拆成两个 Advisor 并分别设置 order,用 context 传状态。
7.5 工具调用:ToolCallingAdvisor
ChatClient 会自动注册 ToolCallingAdvisor(默认 order 为 Ordered.HIGHEST_PRECEDENCE + 300),确保即使工具是在运行时由其他 Advisor 动态注入的,也能被正确处理:
java
String response = ChatClient.builder(chatModel)
.build()
.prompt("What day is tomorrow?")
.tools(new DateTimeTools()) // ToolCallingAdvisor 自动注册
.call()
.content();
按需禁用:
properties
# 全局禁用(所有调用):工具定义仍会发给模型,但模型发起的工具调用不再自动执行
spring.ai.chat.client.tool-calling.enabled=false
java
// 单次调用禁用(例如想自己驱动工具调用循环、逐轮转发给前端)
chatClient.prompt("What day is tomorrow?")
.tools(new DateTimeTools())
.advisors(AdvisorParams.toolCallingAdvisorAutoRegister(false))
.call()
.content();
小知识 :
ToolAdvisor是一个标记接口------只要链上已存在实现了它的 Advisor,框架就不会再自动注册第二个ToolCallingAdvisor。所以传入自定义 ToolAdvisor 也会自动抑制默认注册。
8. 第七层:聊天记忆 ChatMemory
模型 API 是无状态的------告诉模型你的名字,下一轮它就忘了。对话记忆必须由应用层维护。Spring AI 提供了 ChatMemory 接口与开箱实现 MessageWindowChatMemory:
- 维护固定大小消息窗口(默认 20 条),超出后淘汰旧消息;
- 系统消息永远保留;新系统消息加入时会清掉旧系统消息;
- 底层存储由
ChatMemoryRepository抽象,提供多种实现:InMemoryChatMemoryRepository、JdbcChatMemoryRepository、CassandraChatMemoryRepository、Neo4jChatMemoryRepository、MongoChatMemoryRepository、RedisChatMemoryRepository------从单机到分布式按需取用。
配合 MessageChatMemoryAdvisor 使用,即实现了"带记忆的多轮对话":
java
ChatClient.builder(chatModel)
.defaultAdvisors(MessageChatMemoryAdvisor.builder(chatMemory).build())
.build()
.prompt()
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId))
.user(userText)
.call()
.content();
9. 总结
从一行 chatClient.prompt().user(...).call().content() 到底层机制,ChatClient 体系经历了 8 层抽象:
- 门面层 (
ChatClient):声明式 Fluent API,开发者只关心"问什么、拿什么" - 创建层 (
ChatClient.Builder):prototype 作用域,自动配置 / 多模型 / 多端点三姿势 - 请求层 (
prompt()):三种入口,统一组装 Prompt 的各个组成部分 - 响应层 (
call()/stream()):惰性执行,五种返回值按需取用 - 结构化层 (
entity()/EntityParamSpec):JSON Schema 的两种传递策略 + 校验重试 - 模板层 (
TemplateRenderer):占位符渲染,可自定义分隔符 - 拦截层 (
Advisor):栈式执行链,横切能力可插拔 - 记忆层 (
ChatMemory):有界窗口 + 可插拔存储
关键设计思想
| 设计 | 体现 |
|---|---|
| 门面模式 | ChatClient 统一屏蔽底层 ChatModel 差异 |
| 惰性执行 | call() 声明模式,content() 等终止方法才真正调用 |
| Builder 模式 | prototype 作用域 + default* 默认配置,配置一次处处生效 |
| 拦截器模式 | Advisor 链栈式执行,横切能力与业务解耦 |
| 责任链 | 每个 Advisor 修改 Prompt/上下文后传递给下一个 |
| 标记接口 | ToolAdvisor / MemoryAdvisor 抑制重复自动注册 |
| 策略模式 | EntityParamSpec 控制 JSON Schema 的传递策略 |
架构师速查
| 诉求 | 用哪个能力 |
|---|---|
| 一次性问答 | prompt().user().call().content() |
| 打字机效果 | stream().content() + WebFlux |
| 解析模型输出为对象 | entity(Class) / entity(ParameterizedTypeReference) |
| 计费审计 / 质量监控 | chatResponse() 拿 token 元数据 |
| 多轮对话 | MessageChatMemoryAdvisor + ChatMemory.CONVERSATION_ID |
| 私有知识库问答 | QuestionAnswerAdvisor / RetrievalAugmentationAdvisor |
| 让模型调用你的 API | tools(...) + ToolCallingAdvisor |
| 拦截 / 观测 / 增强 | 自定义 CallAdvisor / StreamAdvisor |
三条铁律:
- 能用 Builder 层配置的,绝不放运行时 ------
defaultSystem/defaultAdvisors/defaultOptions是团队约定,运行时覆盖是例外; - 多模型场景务必走
ChatClientBuilderConfigurer------否则可观测性和 Customizer 全部丢失,线上排查困难; - 把 Advisor 顺序当业务逻辑对待------它是栈式执行,顺序错了,RAG 检索质量、记忆注入、工具调用都可能出错。
下次当你写下 .call().content() 时,希望你能想起:这条链上可能正跑着记忆注入、向量检索、敏感词守卫、工具调用循环------而它们,都只是这条 Advisor 链上按 order 排列的普通一环。
本文基于 Spring AI 2.0.0 官方参考文档(Chat Client API 与 Advisors API)整理,参考:docs.spring.io/spring-ai/r... 。