深入 Spring AI ChatClient:从一行代码到 LLM 调用的完整旅程

深入 Spring AI ChatClient:从一行代码到 LLM 调用的完整旅程

难度:★★★☆☆

当你写下 chatClient.prompt().user("Tell me a joke").call().content() 时,Spring AI 在背后做了什么?


目录

  1. 引言:一个问题
  2. 第一层:初见------最小可用示例
  3. [第二层:创建 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")
  4. [第三层: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")
  5. [第四层: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")
  6. [第五层: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")
  7. [第六层: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
  8. [第七层:聊天记忆 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")
  9. 总结

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 时,ChatModelChatClient 都需要显式标记 @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 / SystemMessagegetMetadata() 中,在自定义 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,最后处理响应;
  • 链上的 ChatClientRequestChatClientResponse 都携带一个共享的 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; }
}

最佳实践(官方文档提炼):

  1. 每个 Advisor 职责单一,保持模块化;
  2. advise-context 在 Advisor 间共享状态;
  3. 尽量同时实现流式与非流式两个版本;
  4. 仔细编排链上的 order,确保数据流正确;
  5. 需要"请求/响应两头都是第一个"的 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 抽象,提供多种实现:InMemoryChatMemoryRepositoryJdbcChatMemoryRepositoryCassandraChatMemoryRepositoryNeo4jChatMemoryRepositoryMongoChatMemoryRepositoryRedisChatMemoryRepository------从单机到分布式按需取用。

配合 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 层抽象

  1. 门面层ChatClient):声明式 Fluent API,开发者只关心"问什么、拿什么"
  2. 创建层ChatClient.Builder):prototype 作用域,自动配置 / 多模型 / 多端点三姿势
  3. 请求层prompt()):三种入口,统一组装 Prompt 的各个组成部分
  4. 响应层call() / stream()):惰性执行,五种返回值按需取用
  5. 结构化层entity() / EntityParamSpec):JSON Schema 的两种传递策略 + 校验重试
  6. 模板层TemplateRenderer):占位符渲染,可自定义分隔符
  7. 拦截层Advisor):栈式执行链,横切能力可插拔
  8. 记忆层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

三条铁律

  1. 能用 Builder 层配置的,绝不放运行时 ------defaultSystem / defaultAdvisors / defaultOptions 是团队约定,运行时覆盖是例外;
  2. 多模型场景务必走 ChatClientBuilderConfigurer------否则可观测性和 Customizer 全部丢失,线上排查困难;
  3. 把 Advisor 顺序当业务逻辑对待------它是栈式执行,顺序错了,RAG 检索质量、记忆注入、工具调用都可能出错。

下次当你写下 .call().content() 时,希望你能想起:这条链上可能正跑着记忆注入、向量检索、敏感词守卫、工具调用循环------而它们,都只是这条 Advisor 链上按 order 排列的普通一环。


本文基于 Spring AI 2.0.0 官方参考文档(Chat Client API 与 Advisors API)整理,参考:docs.spring.io/spring-ai/r...

相关推荐
麻瓜code6 小时前
【Agent】Spring AI RAG 实战:本地向量库 + 云端知识库
java·人工智能·spring
水巷石子7 小时前
学习langChain4j的第二天,体验springBoot中的starter
java·spring boot·学习·spring·langchain4j
ly76897 小时前
Spring Bean生命周期全流程:从BeanDefinition到销毁
java·后端·spring·bean生命周期·beandefinition·初始化回调
计算机学姐8 小时前
基于SpringBoot的旅游系统的设计与实现
java·vue.js·spring boot·后端·spring·java-ee·旅游
大模型码小白9 小时前
【AI大模型】DeepSeek Harness 深度解析:大模型评测框架的架构与实践
java·运维·人工智能·spring·架构·自动化
苏渡苇9 小时前
Spring Insight 里上报 Span 为什么用 JDK HttpClient 而不是 RestTemplate
java·后端·spring·spring cloud·springboot·性能监控
卓怡学长10 小时前
w157基于springboot北部湾地区助农平台
java·spring boot·spring·maven·intellij-idea
步行cgn11 小时前
Spring Environment 详解:Spring Boot 配置管理的核心接口
spring boot·python·spring
Wang's Blog11 小时前
Java框架快速入门: Spring Security+OAuth2之工程结构与开发环境配置
java·spring·状态模式