Skip to content

L01:一条请求到底穿过哪些对象

学习时长:约 45 分钟
本节唯一核心:分清 ChatClientRequestSpecChatClientRequestPrompt 三个阶段。

1. 本节解决什么问题

先看最熟悉的代码:

java
String answer = chatClient.prompt()
    .system("你是一个退款审核助手")
    .user("判断订单 1001 是否需要人工审核")
    .call()
    .content();

这几行代码很容易被脑补成:

text
设置 system
→ 设置 user
→ 立刻组装 JSON
→ 调用 DeepSeek
→ 返回字符串

真实情况不是这样。

本节要精确回答:

  1. prompt() 返回的是什么?
  2. .system().user() 把数据放在哪里?
  3. Prompt 在什么时候正式创建?
  4. call() 到底做了什么?
  5. 真正调用 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.DefaultChatClientRequestSpec

3. 第一层: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() 时立刻变成 SystemMessageUserMessage

真正组装发生在:

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
→ 本次 UserMessage

6. 模板变量在哪里替换

下面代码:

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 text

13. 本节最容易犯的五个错误

错误一:把 RequestSpec 当 Prompt

RequestSpec 是收集状态的装配对象,Prompt 是正式模型输入。

错误二:认为 .user() 已经调用模型

它只是保存文本和参数。

错误三:只打印用户问题,就认为看到了最终 Prompt

Memory、RAG、格式化 Advisor 可能继续加入内容。

错误四:认为 call() 一定已经拿到结果

真正执行通常由后续 content()chatResponse()entity() 驱动。

错误五:把 ChatClientChatModel 当成同一个角色

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 在请求前和响应后分别做了什么。

17. 对应源码

Built with VitePress. Deployed on Cloudflare Pages.