切换主题
L01:一条请求到底穿过哪些对象
学习时长:约 45 分钟
本节唯一核心:分清ChatClientRequestSpec、ChatClientRequest、Prompt三个阶段。
1. 本节解决什么问题
先看最熟悉的代码:
java
String answer = chatClient.prompt()
.system("你是一个退款审核助手")
.user("判断订单 1001 是否需要人工审核")
.call()
.content();这几行代码很容易被脑补成:
text
设置 system
→ 设置 user
→ 立刻组装 JSON
→ 调用 DeepSeek
→ 返回字符串真实情况不是这样。
本节要精确回答:
prompt()返回的是什么?.system()、.user()把数据放在哪里?Prompt在什么时候正式创建?call()到底做了什么?- 真正调用 DeepSeek 发生了吗?
2. 先预测
阅读代码:
java
var requestSpec = chatClient.prompt()
.system("你是 {role}")
.user(u -> u
.text("请分析订单 {orderId}")
.param("orderId", "1001"));
System.out.println(requestSpec.getClass().getName());先回答:
- 此时模板变量已经替换了吗?
- 此时已经创建
UserMessage了吗? - 此时已经创建
Prompt了吗? - 此时 Advisor 执行了吗?
- 此时 DeepSeek 收到请求了吗?
正确答案全部是:没有。
此时只是得到一个请求规格对象,默认实现是:
text
DefaultChatClient.DefaultChatClientRequestSpec3. 第一层:ChatClient 是门面,不是模型
ChatClient 的职责类似 Spring MVC 中的 RestClient 门面:
text
提供好用 API
→ 收集调用参数
→ 连接扩展链
→ 委托底层模型
→ 提取结果它不是:
- DeepSeek HTTP 客户端;
- 历史记录数据库;
- Agent;
- 工作流引擎;
- 自动记忆系统。
调用:
java
chatClient.prompt()源码入口:
java
@Override
public ChatClientRequestSpec prompt() {
return new DefaultChatClientRequestSpec(this.defaultChatClientRequest);
}关键动作只有一个:
text
复制默认请求配置
→ 产生一次调用专用的 RequestSpec为什么要复制?
因为 ChatClient 可以保存默认配置:
java
ChatClient chatClient = ChatClient.builder(chatModel)
.defaultSystem("你是退款审核助手")
.defaultAdvisors(memoryAdvisor)
.build();每次 prompt() 都从默认配置复制一份,然后再加入本次请求配置,避免不同调用互相污染。
4. 第二层:RequestSpec 是“装配台”
在 Spring AI 1.1.3 中,DefaultChatClientRequestSpec 内部主要暂存:
text
ChatModel chatModel
String userText
Map userParams
Map userMetadata
String systemText
Map systemParams
Map systemMetadata
List<Message> messages
ChatOptions chatOptions
List<Advisor> advisors
Map advisorParams
List<ToolCallback> toolCallbacks
List<String> toolNames
Map toolContext
TemplateRenderer templateRenderer所以执行:
java
chatClient.prompt()
.system("你是 {role}")
.user("分析订单")
.options(options)
.advisors(memoryAdvisor);实际更接近:
text
requestSpec.systemText = "你是 {role}"
requestSpec.userText = "分析订单"
requestSpec.chatOptions = options
requestSpec.advisors.add(memoryAdvisor)这一步叫:
text
装配请求规格不是:
text
执行模型请求5. system() 与 user() 什么时候变成 Message
它们不是在调用 .system() 或 .user() 时立刻变成 SystemMessage 和 UserMessage。
真正组装发生在:
text
DefaultChatClientUtils.toChatClientRequest(requestSpec)这个方法由 call() 和 stream() 调用。
核心逻辑可以缩成:
java
List<Message> processedMessages = new ArrayList<>();
// 1. system 放最前面
processedMessages.add(systemMessage);
// 2. 显式 messages 放中间
processedMessages.addAll(messages);
// 3. user 放最后
processedMessages.add(userMessage);最终消息顺序:
text
SystemMessage
→ 手动传入的历史或其他 Message
→ 本次 UserMessage6. 模板变量在哪里替换
下面代码:
java
chatClient.prompt()
.system(s -> s
.text("你是一个 {role}")
.param("role", "退款审核助手"))
.user(u -> u
.text("请分析订单 {orderId}")
.param("orderId", "1001"));参数只是先保存在:
text
systemParams
userParams到 toChatClientRequest() 时,才使用 PromptTemplate 渲染:
text
"你是一个 {role}"
+
role = "退款审核助手"
→
"你是一个退款审核助手"所以调试模板问题时,必须区分:
text
原始模板
运行时参数
渲染后的 Message 文本只打印 Controller 入参,不一定能看到真正发给模型的 Prompt。
7. messages() 和 Memory 不是一回事
你可以手动加入 Message:
java
chatClient.prompt()
.messages(
new UserMessage("我叫老板"),
new AssistantMessage("好的,我记住了")
)
.user("我叫什么?");这是本次调用显式传入消息。
而 Memory 通常是:
text
MessageChatMemoryAdvisor
→ 调用前从 ChatMemory 读取历史
→ 把历史加入 Prompt
→ 调用后再保存新消息两者最后都会进入 Prompt.getInstructions(),但来源和生命周期不同。
8. call() 是第一个关键分界点
源码核心:
java
@Override
public CallResponseSpec call() {
BaseAdvisorChain advisorChain = buildAdvisorChain();
return new DefaultCallResponseSpec(
DefaultChatClientUtils.toChatClientRequest(this),
advisorChain,
observationRegistry,
observationConvention
);
}call() 做两件关键事:
text
1. buildAdvisorChain()
2. toChatClientRequest(this)也就是:
text
请求规格
→ 正式请求对象
Advisor 列表
→ 可执行 Advisor 链但这里有一个容易误判的点。
调用:
java
var responseSpec = chatClient.prompt()
.user("你好")
.call();此时已经创建了 DefaultCallResponseSpec,但真正模型调用发生在你继续调用:
java
responseSpec.content();
responseSpec.chatResponse();
responseSpec.entity(...);这些终结方法内部都会进入:
text
doGetObservableChatClientResponse()
→ advisorChain.nextCall(request)所以准确分层是:
text
prompt() / system() / user()
→ 收集配置
call()
→ 构建请求与同步 Advisor 链
content() / chatResponse() / entity()
→ 真正驱动同步调用链9. stream() 与 call() 在哪里分叉
stream() 与 call() 前面的 RequestSpec 完全共用。
它们都执行:
text
buildAdvisorChain()
toChatClientRequest(this)分叉发生在返回对象:
text
call()
→ DefaultCallResponseSpec
→ advisorChain.nextCall()
stream()
→ DefaultStreamResponseSpec
→ advisorChain.nextStream()后面链底分别是:
text
ChatModelCallAdvisor
ChatModelStreamAdvisor所以不是从 .user() 开始就分为两套请求。
10. 用自定义 Advisor 看见最终 Prompt
下面是一个非流式观察 Advisor:
java
package com.example.springai;
import org.springframework.ai.chat.client.ChatClientRequest;
import org.springframework.ai.chat.client.ChatClientResponse;
import org.springframework.ai.chat.client.advisor.api.CallAdvisor;
import org.springframework.ai.chat.client.advisor.api.CallAdvisorChain;
import org.springframework.core.Ordered;
public final class PromptProbeAdvisor implements CallAdvisor {
@Override
public ChatClientResponse adviseCall(
ChatClientRequest request,
CallAdvisorChain chain
) {
System.out.println("=== Prompt messages ===");
request.prompt().getInstructions().forEach(message -> {
System.out.println(message.getMessageType());
System.out.println(message.getText());
});
System.out.println("=== Chat options ===");
System.out.println(request.prompt().getOptions());
System.out.println("=== Advisor context ===");
System.out.println(request.context());
return chain.nextCall(request);
}
@Override
public String getName() {
return "prompt-probe";
}
@Override
public int getOrder() {
return Ordered.HIGHEST_PRECEDENCE + 100;
}
}使用:
java
String answer = chatClient.prompt()
.system(s -> s
.text("你是一个 {role}")
.param("role", "退款审核助手"))
.user(u -> u
.text("分析订单 {orderId}")
.param("orderId", "1001"))
.advisors(new PromptProbeAdvisor())
.call()
.content();这个 Advisor 看到的是:
text
ChatClientRequest 已经完成初始组装后的 Prompt但注意:
如果它前面还有更高优先级 Advisor,后续 Advisor 仍可能继续修改 Prompt。
所以日志必须同时记录:
text
Advisor 名称
order
进入前 Prompt
调用 next 后返回结果否则你看到的可能不是最终发给模型的版本。
11. DeepSeek 最小配置
依赖使用 Spring AI BOM 固定版本:
xml
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>1.1.3</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-deepseek</artifactId>
</dependency>
</dependencies>配置:
yaml
spring:
ai:
deepseek:
api-key: ${DEEPSEEK_API_KEY}
base-url: https://api.deepseek.com
chat:
options:
model: deepseek-chat
temperature: 0.2不要把真实密钥提交进 Git。
12. 完整执行时间线
这段代码:
java
chatClient.prompt()
.system("你是退款审核助手")
.user("分析订单 1001")
.advisors(probeAdvisor)
.call()
.content();执行时间线是:
text
1. prompt()
复制默认 RequestSpec
2. system()
保存 systemText
3. user()
保存 userText
4. advisors()
加入 Advisor 列表
5. call()
把 system/user/messages/options 组装成 ChatClientRequest
构建同步 Advisor 链
返回 CallResponseSpec
6. content()
启动 advisorChain.nextCall(request)
7. Advisor 链逐层进入
8. 链底 ChatModelCallAdvisor
调用 chatModel.call(prompt)
9. 模型响应逐层返回
10. content()
从 ChatResponse 中提取第一个 Generation 的 Message text13. 本节最容易犯的五个错误
错误一:把 RequestSpec 当 Prompt
RequestSpec 是收集状态的装配对象,Prompt 是正式模型输入。
错误二:认为 .user() 已经调用模型
它只是保存文本和参数。
错误三:只打印用户问题,就认为看到了最终 Prompt
Memory、RAG、格式化 Advisor 可能继续加入内容。
错误四:认为 call() 一定已经拿到结果
真正执行通常由后续 content()、chatResponse() 或 entity() 驱动。
错误五:把 ChatClient 和 ChatModel 当成同一个角色
ChatClient 是高级门面;ChatModel 是模型调用抽象。
14. 工程边界
教学 Demo
当前只观察初始 Prompt:
text
system + messages + user + options工程可用方案
还应记录:
- conversationId;
- traceId;
- Advisor 名称与 order;
- 模型名称;
- 最终消息数量;
- 工具定义数量;
- 请求耗时;
- token usage;
- 错误类型。
生产级注意
禁止直接打印:
- API Key;
- 完整身份证号、手机号;
- 用户隐私;
- 工具上下文中的凭证;
- 超长 RAG 文档全文。
观察 Prompt 必须配合脱敏、长度限制和采样策略。
15. 简短练习
练习 1
下面哪一步正式创建 Prompt?
text
A. prompt()
B. user()
C. call() 内部的 toChatClientRequest()
D. content()练习 2
写出下面消息的最终顺序:
java
chatClient.prompt()
.messages(historyMessage1, historyMessage2)
.system("system")
.user("current user")
.call()
.content();练习 3
解释这三个对象各自负责什么:
text
DefaultChatClientRequestSpec
ChatClientRequest
Prompt练习 4
为什么打印 Controller 中的 question,不能证明这就是最终发给 DeepSeek 的全部内容?
练习 5
请用自己的话解释:
text
call() 与 content() 的职责差异16. 本节掌握标准
不看答案,能够画出:
text
ChatClient
→ RequestSpec
→ toChatClientRequest
→ ChatClientRequest
→ Prompt
→ Advisor Chain并准确指出:
text
真正模型调用还没有在 Prompt 组装阶段发生。下一节进入最容易混乱的位置:Advisor 的 Around 执行顺序,以及 Memory 在请求前和响应后分别做了什么。