Skip to content

L04:默认配置与本次配置——覆盖、追加和二次合并

版本基准:Spring AI Core 1.1.3
主要模型:DeepSeek
建议时长:35~45 分钟

1. 本节只解决一个问题

下面两部分配置同时存在时,最后到底留下什么?

java
ChatClient chatClient = ChatClient.builder(chatModel)
    .defaultSystem("你是一名谨慎的 Java 架构师")
    .defaultAdvisors(memoryAdvisor)
    .build();

String answer = chatClient.prompt()
    .system("你是一名精通 Spring AI 的源码专家")
    .advisors(loggingAdvisor)
    .user("分析 ChatClient")
    .call()
    .content();

老板已经正确预测:

text
最终 SystemMessage:1 个
最终 system 内容:本次 system 覆盖默认 system
最终 Advisor:memoryAdvisor + loggingAdvisor

但只记住这三个答案不够。真正需要掌握的是:

Spring AI 不是用一条统一规则合并全部配置,而是不同字段采用不同策略。


2. 先建立一张合并规则表

配置类型默认配置复制到本次请求本次再配置时的行为最终结果
systemText赋值覆盖通常只有一个 system 文本
userText赋值覆盖本次 user 覆盖默认 user
system/user 参数 MapputAll同名 key 由后写值覆盖
Advisor 列表addAll默认与本次 Advisor 共存
显式 Message 列表addAll默认消息与本次消息追加
ToolCallback 列表先追加,之后再合并进 Tool Options默认和本次工具通常共存
ChatOptions是,并执行 copy.options(...) 替换 RequestSpec 中的 options到模型层还会和模型默认参数二次合并
Advisor context 参数putAll同名 key 由本次值覆盖

不要粗暴总结成“默认被覆盖”或“默认会合并”。

准确说法是:

text
标量字段通常覆盖
集合字段通常追加
Map 通常按 key 合并
ChatOptions 在不同层次会发生多次处理

3. 为什么本次 system 会覆盖默认 system

创建 ChatClient 时,Builder 内部维护一份默认的:

text
DefaultChatClientRequestSpec defaultRequest

执行:

java
chatClient.prompt()

会复制这份默认 RequestSpec,生成本次请求专属的 RequestSpec。

简化模型:

java
DefaultChatClientRequestSpec request = copy(defaultRequest);

此时本次 RequestSpec 已经带有默认 system:

text
systemText = "你是一名谨慎的 Java 架构师"

接着调用:

java
.system("你是一名精通 Spring AI 的源码专家")

核心行为不是追加,而是重新赋值:

java
this.systemText = text;

因此最终传入 DefaultChatClientUtils.toChatClientRequest() 的只有后一个字符串。

最终只创建一个:

text
SystemMessage("你是一名精通 Spring AI 的源码专家")

关键边界

下面两种写法不是同一回事。

写法一:覆盖默认 system

java
chatClient.prompt()
    .system("本次专属 system")
    .user("问题")
    .call()
    .content();

最终通常只有本次 system。

写法二:显式加入另一个 SystemMessage

java
chatClient.prompt()
    .messages(new SystemMessage("额外 system"))
    .user("问题")
    .call()
    .content();

这会进入显式 messages 列表。

如果默认 system 仍然存在,最终可能出现多个 SystemMessage。Spring AI 的 RequestSpec 并不会替你判断多个 system 是否符合目标模型协议和业务语义。

生产代码不要用 .messages(new SystemMessage(...)) 模拟 .system(...) 的覆盖逻辑。


4. 为什么 Advisor 是追加而不是覆盖

默认 Advisor 会先进入默认 RequestSpec:

java
ChatClient.builder(chatModel)
    .defaultAdvisors(memoryAdvisor)
    .build();

执行 prompt() 时,默认 Advisor 列表被复制到本次 RequestSpec:

text
advisors = [memoryAdvisor]

随后调用:

java
.advisors(loggingAdvisor)

内部行为相当于:

java
this.advisors.add(loggingAdvisor);

最终:

text
advisors = [memoryAdvisor, loggingAdvisor]

但列表中的先后顺序还不是最终执行顺序。

构建 Advisor Chain 时还会根据:

java
advisor.getOrder()

重新排序。

所以需要区分三个概念:

text
注册顺序

列表当前顺序

Advisor 最终执行顺序

最终执行顺序由 order 主导。


5. 参数 Map 为什么是“同名覆盖、不同名保留”

例如默认 user 模板:

java
ChatClient chatClient = ChatClient.builder(chatModel)
    .defaultUser(u -> u
        .text("用户 {name} 正在分析 {topic}")
        .param("name", "默认用户")
        .param("topic", "Java"))
    .build();

本次调用:

java
chatClient.prompt()
    .user(u -> u
        .param("name", "老板")
        .param("topic", "Spring AI"))
    .call()
    .content();

Map 合并可简化为:

java
this.userParams.putAll(runtimeParams);

最终:

text
name  = 老板
 topic = Spring AI

如果本次只覆盖 name

java
.user(u -> u.param("name", "老板"))

那么结果是:

text
name  = 老板
 topic = Java

一个容易踩的坑

如果本次调用同时重新设置 user 文本:

java
.user(u -> u
    .text("请分析 {framework}")
    .param("framework", "Spring AI"))

默认 user 文本会被覆盖,但默认参数 Map 仍可能保留。

保留的旧参数没有被新模板引用时,不一定报错,却会让调试数据出现无效上下文。

工程上建议:

text
默认 user 只放真正稳定的模板
动态业务问题尽量在本次调用中完整提供
不要让默认模板和运行时模板共享一堆隐式参数

6. ChatOptions 为什么最容易被误判

先看两层配置:

java
DeepSeekChatModel
└─ defaultOptions

ChatClient RequestSpec
└─ chatOptions

它们不是同一个“默认配置”。

第一层:ChatClient 默认 options

java
ChatClient.builder(chatModel)
    .defaultOptions(defaultChatOptions)
    .build();

执行 prompt() 时,这份 options 会被复制到本次 RequestSpec。

本次调用再执行:

java
.options(runtimeOptions)

会直接替换 RequestSpec 当前保存的 chatOptions 引用:

java
this.chatOptions = runtimeOptions;

第二层:DeepSeekChatModel 再次合并

进入:

java
DeepSeekChatModel.buildRequestPrompt(prompt)

模型适配层还会把:

text
Prompt 中的运行时 options
+
DeepSeekChatModel 的 defaultOptions

合并成最终请求参数。

所以真实过程是:

text
ChatClient 默认 options
→ 复制进本次 RequestSpec
→ 可能被本次 .options(...) 替换
→ 进入 DeepSeekChatModel
→ 再与 DeepSeek 模型默认 options 合并
→ ChatCompletionRequest

正确排错方式

出现 temperature、model、maxTokens 或工具开关不符合预期时,不要只查 YAML。

按顺序看:

text
1. DeepSeekChatModel.defaultOptions 是什么
2. ChatClient.defaultOptions 是什么
3. 本次请求有没有调用 .options(...)
4. Advisor 有没有修改 Prompt.options
5. buildRequestPrompt() 合并后的 DeepSeekChatOptions 是什么
6. createRequest() 生成的最终 ChatCompletionRequest 是什么

7. 最小可运行观察实验

目标:不猜合并结果,直接在进入模型前观察最终请求。

java
import org.springframework.ai.chat.client.ChatClientRequest;
import org.springframework.ai.chat.client.ChatClientResponse;
import org.springframework.ai.chat.client.advisor.api.AdvisorChain;
import org.springframework.ai.chat.client.advisor.api.BaseAdvisor;

public final class FinalRequestProbeAdvisor implements BaseAdvisor {

    private final int order;

    public FinalRequestProbeAdvisor(int order) {
        this.order = order;
    }

    @Override
    public ChatClientRequest before(
            ChatClientRequest request,
            AdvisorChain chain) {

        System.out.println("===== FINAL PROMPT =====");
        request.prompt().getInstructions().forEach(message ->
            System.out.printf(
                "type=%s, text=%s%n",
                message.getMessageType(),
                message.getText()
            )
        );

        System.out.println("options=" + request.prompt().getOptions());
        System.out.println("context=" + request.context());

        return request;
    }

    @Override
    public ChatClientResponse after(
            ChatClientResponse response,
            AdvisorChain chain) {
        return response;
    }

    @Override
    public int getOrder() {
        return this.order;
    }
}

注册时把它放到靠近模型的位置:

java
FinalRequestProbeAdvisor probe =
    new FinalRequestProbeAdvisor(Integer.MAX_VALUE - 100);

为什么不能直接使用 Integer.MAX_VALUE

因为链底的 ChatModelCallAdvisor 使用最低优先级。自定义 Advisor 必须仍然排在模型调用之前。

实验调用:

java
ChatClient chatClient = ChatClient.builder(chatModel)
    .defaultSystem("默认 system")
    .defaultAdvisors(memoryAdvisor)
    .build();

String answer = chatClient.prompt()
    .system("运行时 system")
    .advisors(probe)
    .user("解释合并规则")
    .call()
    .content();

预期观察:

text
SYSTEM: 运行时 system
USER: 解释合并规则

Advisor 链中同时存在 memoryAdvisor 与 probe

实验限制

这个 Probe 看到的是它之前所有 Advisor 已经处理过的请求。

如果某个更靠近模型的 Advisor 仍会修改 Prompt,那么 Probe 看到的还不是绝对最终值。

要观察最靠近模型的请求,需要:

text
让 Probe 的 order 大于其他业务 Advisor
但仍小于 ChatModelCallAdvisor 的最低优先级位置

8. Demo、工程方案与生产方案

教学 Demo

直接在 Advisor 中 System.out.println

缺失:

  • 脱敏;
  • 日志级别;
  • 请求关联 ID;
  • 大文本截断;
  • 流式场景支持;
  • 性能控制。

工程可用方案

自定义观察 Advisor,记录:

text
conversationId
model
message 类型和长度
Advisor 名称与 order
工具名称
options 摘要
响应耗时
finishReason

不要默认记录完整 Prompt。

生产级方案

还必须考虑:

  • 用户隐私和密钥脱敏;
  • Prompt 日志采样;
  • 超长消息截断;
  • reasoningContent 是否允许落日志;
  • 异常请求与成功请求的差异;
  • 流式取消时是否有完整结束日志;
  • Micrometer Observation 与业务日志关联;
  • 日志成本与检索成本。

9. 本节必须形成的判断规则

看到默认配置与本次配置时,依次问:

text
这个字段是标量、List 还是 Map?
RequestSpec 的 setter 是赋值、addAll 还是 putAll?
prompt() 复制默认值时是否执行深拷贝?
Advisor 后续是否还会修改它?
模型适配层是否还会做二次合并?

不要凭“Spring 一般怎么做”猜。


10. 掌握验证

先不要看答案,预测下面代码。

java
ChatClient chatClient = ChatClient.builder(chatModel)
    .defaultSystem("默认 {role}")
    .defaultAdvisors(advisorA)
    .build();

chatClient.prompt()
    .system(s -> s
        .text("运行时 {role}")
        .param("role", "源码专家"))
    .advisors(advisorB)
    .messages(new UserMessage("历史外的显式消息"))
    .user("当前问题")
    .call()
    .content();

回答:

  1. 最终 system 文本是什么?
  2. advisorA 是否还存在?
  3. advisorB 是否会覆盖 advisorA
  4. 显式 UserMessage 与当前 .user() 谁在前?
  5. Advisor 的注册先后是否等于执行先后?
  6. 本次 .options(...) 是否意味着 DeepSeek 模型默认参数全部失效?

11. 本节结论

text
ChatClient 默认配置不是一个不可变模板
而是每次 prompt() 时复制出的初始 RequestSpec

随后:

text
system/user 文本:通常覆盖
Advisor/Message/Tool 列表:通常追加
参数和 context Map:按 key 合并
ChatOptions:RequestSpec 层替换,模型层还可能再次合并

下一节进入更容易出错的部分:

多个 Advisor 同时存在时,order 怎样决定 before 和 after 的真实执行顺序?

源码入口

Built with VitePress. Deployed on Cloudflare Pages.