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 参数 Map是putAll同名 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.