切换主题
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();回答:
- 最终 system 文本是什么?
advisorA是否还存在?advisorB是否会覆盖advisorA?- 显式 UserMessage 与当前
.user()谁在前? - Advisor 的注册先后是否等于执行先后?
- 本次
.options(...)是否意味着 DeepSeek 模型默认参数全部失效?
11. 本节结论
text
ChatClient 默认配置不是一个不可变模板
而是每次 prompt() 时复制出的初始 RequestSpec随后:
text
system/user 文本:通常覆盖
Advisor/Message/Tool 列表:通常追加
参数和 context Map:按 key 合并
ChatOptions:RequestSpec 层替换,模型层还可能再次合并下一节进入更容易出错的部分:
多个 Advisor 同时存在时,
order怎样决定 before 和 after 的真实执行顺序?