ChatClient 源码解析:从 HTTP 请求到 AI 响应的全链路拆解(Java 架构师的 AI 工程笔记 02)

@[TOC]

Java 架构师的 AI 工程笔记(二):从一个 HTTP 请求开始,搞懂 ChatClient 的每一层

这是 Spring AI Alibaba 系列的第二篇,聊聊 ChatClient 到底帮你做了什么。 上一篇跑通了 Hello World 对话,但 chatClient.prompt(q).call().content() 这一行代码背后到底发生了什么?这一篇把它拆开来看。


理论篇

一、先忘掉框架------直接用 HTTP 调一下通义千问

上一篇用 ChatClient 跑通了对话,但心里一直有个疑问:框架帮我做了这么多事,不用框架行不行?

在项目里排查过几次框架行为不符合预期的问题后,我养成了一个习惯------遇到不理解的封装,先绕过它,直接看底层。通义千问的 API 就是一个标准的 HTTP 接口,用 curl 就能调:

bash 复制代码
curl -X POST "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions" \
  -H "Authorization: Bearer $AI_DASHSCOPE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "qwen-plus",
    "messages": [
      {"role": "system", "content": "你是一个机票分析师"},
      {"role": "user", "content": "北京到上海的机票"}
    ],
    "temperature": 0.3
  }'

返回的 JSON 长这样(删掉了一些不重要的字段):

json 复制代码
{
  "choices": [
    {
      "message": {
        "role": "assistant",
        "content": "为您查询到以下航班信息..."
      },
      "finish_reason": "stop"
    }
  ],
  "usage": {
    "prompt_tokens": 25,
    "completion_tokens": 150,
    "total_tokens": 175
  }
}

看到这个 JSON,几件事就清楚了:

  1. LLM 对话的本质就是一个 HTTP POST------发一组 messages,拿回一个 response
  2. messages 是一个有序数组 ------每条消息有 role(角色)和 content(内容)
  3. 三种角色system(设定 AI 人格)、user(用户问题)、assistant(AI 回复)
  4. model 和 temperature 是参数------决定用哪个模型、输出多稳定
  5. 返回结果带 token 用量------这就是计费依据
1.1 流式调用长什么样?

上面是同步调用------等模型全部生成完,一次性返回。但生成一段 500 token 的回答可能要 5 秒,用户干等着体验很差。流式调用让模型边生成边返回 ,在 HTTP 层面用的是 SSE(Server-Sent Events) 协议:

bash 复制代码
curl -X POST "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions" \
  -H "Authorization: Bearer $AI_DASHSCOPE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "qwen-plus",
    "messages": [
      {"role": "system", "content": "你是一个机票分析师"},
      {"role": "user", "content": "北京到上海的机票"}
    ],
    "stream": true
  }'

差别就一个字段:"stream": true。返回的不再是一个完整 JSON,而是一连串 data: 行:

css 复制代码
data: {"choices":[{"delta":{"role":"assistant","content":"为"},"index":0}]}

data: {"choices":[{"delta":{"content":"您"},"index":0}]}

data: {"choices":[{"delta":{"content":"查询"},"index":0}]}

data: {"choices":[{"delta":{"content":"到"},"index":0}]}

...

data: {"choices":[{"delta":{"content":""},"finish_reason":"stop","index":0}]}

data: [DONE]

注意几个区别:

  • 同步返回用 message 字段(完整消息),流式用 delta 字段(增量片段)
  • 每个 chunk 只包含一小段文本(通常 1-3 个 token)
  • 最后一个 chunk 的 finish_reason"stop",表示生成结束
  • data: [DONE] 是 SSE 协议的终止信号

Spring AI 的 chatModel.stream() 返回 Flux<ChatResponse>,本质上就是把这些 SSE data: 行解析成了 Reactor 流。 每收到一个 data: chunk,就解析成一个 ChatResponse 对象推给下游。

💡 开发建议 :遇到框架行为不符合预期时,先用 curl 直接调 API 排查。加上 "stream": true 就能看到流式原始数据。把框架问题和模型问题分开,能省很多时间。

这就是全部了。 后面 Spring AI 的所有抽象------Message、Prompt、ChatModel、ChatClient------都是在帮你更方便地构建这个 JSON 请求体、发送请求、解析返回结果。


二、从 HTTP 到 Java 对象------逐层拆解

搞清楚了 HTTP 层面发生的事,接下来看 Spring AI 怎么一层层封装。

2.1 第一层:Message------把 JSON 里的 message 变成 Java 对象

curl 请求里的 messages 数组,每个元素就是一个 {"role": "xxx", "content": "xxx"}。Spring AI 用 Message 接口来表示它:

java 复制代码
// Message 接口核心(源码简化)
public interface Message extends Content {
    MessageType getMessageType();  // SYSTEM / USER / ASSISTANT / TOOL
    String getText();              // 消息文本
    Map<String, Object> getMetadata();  // 元数据
}

四种消息类型,对应 HTTP 请求里的四种 role:

Java 类 HTTP role 干什么用 Java 类比
SystemMessage system 设定 AI 的"人格"和规则 web.xml 全局配置
UserMessage user 用户提问,支持图片/音频 HttpServletRequest
AssistantMessage assistant AI 之前的回复 HttpServletResponse
ToolResponseMessage tool 工具调用的返回值 @Service 方法的 return 值

用 Java 对象代替手写 JSON:

java 复制代码
SystemMessage sys = new SystemMessage("你是机票分析师「票小蜜」");
UserMessage user = new UserMessage("北京到上海的机票");

消息顺序很重要。 LLM 的注意力机制对位置敏感------离输出最近的消息影响力最大。通义千问要求 system 在最前面,然后是 user/assistant 交替出现。如果你把 System Prompt 放在消息列表的末尾,模型可能不太遵守里面的规则。

UserMessage 还支持多模态------传图片或音频。它实现了 MediaContent 接口:

java 复制代码
// 多模态消息------传图片让 AI 分析(第 14 章详解)
UserMessage multiModal = new UserMessage("这张截图里的航班信息",
    List.of(new Media(MimeTypeUtils.IMAGE_PNG, imageUrl)));

这里不展开多模态,只需要知道 Message 接口的设计留了扩展空间------不只是纯文本。

到这一步只是创建了 Java 对象,还没发任何请求。但问题来了:光有消息不够,模型参数(model、temperature)放哪?

2.2 第二层:Prompt------消息 + 模型参数的打包容器

回头看 curl 请求:请求体有两部分------messages 数组和 model/temperature 等参数。Prompt 就是把这两部分打包到一起:

java 复制代码
// Prompt = 消息列表 + 模型参数(源码简化)
public class Prompt implements ModelRequest<List<Message>> {
    private final List<Message> messages;    // 对应 HTTP 的 messages 数组
    private final ChatOptions chatOptions;   // 对应 HTTP 的 model/temperature 等参数
}

Prompt 就是 HTTP 请求体的 Java 映射。 类比 JDBC 里的 PreparedStatement------SQL 语句 + 参数绑定。

java 复制代码
Prompt prompt = new Prompt(
    List.of(sys, user),  // 消息列表------对应 messages 数组
    ChatOptions.builder()
        .model("qwen-plus")      // 对应 "model": "qwen-plus"
        .temperature(0.3)        // 对应 "temperature": 0.3
        .build()
);

ChatOptions 是通用参数接口。但这些参数到底干什么用,光看文档不如动手试。后面实战篇会写一个参数对比实验(5.5 节),这里先建立一个直觉:

参数 一句话解释 什么时候需要调
model 用哪个模型 切模型的时候
temperature 输出有多随机(0=确定,1=随机) Agent 用 0~0.3,创意场景用 0.7+
topP 只从概率最高的一部分 token 里采样 和 temperature 二选一调就行
maxTokens 最多生成多少 token 控制输出长度和成本
stopSequences 遇到指定字符串就停 结构化输出时防止多余内容

⚠️ 注意temperaturetopP 同时设的话效果可能冲突。通义千问的建议是调其中一个,另一个保持默认 。我后面踩过这个坑------同时设 temperature=0 + topP=0.1,输出反而变得不稳定。

通义千问还有厂商特有参数,比如 enableSearch(联网搜索):

java 复制代码
// 通用 ChatOptions------跨模型可移植
ChatOptions generic = ChatOptions.builder()
    .model("qwen-plus").temperature(0.3).build();

// DashScopeChatOptions------通义千问特有
DashScopeChatOptions dashScope = DashScopeChatOptions.builder()
    .withModel("qwen-plus")
    .withTemperature(0.3f)
    .withEnableSearch(true)    // 开启联网搜索,DashScope 特有
    .build();

💡 开发建议 :日常开发用通用 ChatOptions,保持代码可移植。只有需要联网搜索等厂商特有功能时才用 DashScopeChatOptions

到这一步还是在组装数据结构。那谁来真正发 HTTP 请求?

2.3 第三层:ChatModel------真正发 HTTP 请求的执行者

ChatModel 是真正干活的------它接收 Prompt,发 HTTP 请求给 LLM,返回 ChatResponse

java 复制代码
public interface ChatModel extends Model<Prompt, ChatResponse>,
                                   StreamingChatModel {
    default String call(String message) { ... }    // 便捷方法
    ChatResponse call(Prompt prompt);              // 同步调用
    Flux<ChatResponse> stream(Prompt prompt);      // 流式调用
}

Spring AI Alibaba 的实现类是 DashScopeChatModel。我翻了下它的源码,call() 方法内部做了三件关键的事

第一件事:参数合并------buildRequestPrompt()

你可能在好几个地方设了参数------application.ymlChatModel 默认配置、Prompt 运行时传入。谁的优先级高?

DashScopeChatModel.buildRequestPrompt() 的源码:

java 复制代码
// DashScopeChatModel.buildRequestPrompt() 核心逻辑(源码简化)
Prompt buildRequestPrompt(Prompt prompt) {
    // 1. 从 prompt 中提取运行时 options
    DashScopeChatOptions runtimeOptions = null;
    if (prompt.getOptions() != null) {
        // 把通用 ChatOptions 转换成 DashScopeChatOptions
        // 非 null 的字段会被保留,null 的字段会在下一步被默认值填充
        runtimeOptions = ModelOptionsUtils.copyToTarget(
            prompt.getOptions(), ChatOptions.class, DashScopeChatOptions.class);
    }

    // 2. 合并:runtime 覆盖 default(非 null 字段覆盖)
    DashScopeChatOptions merged = ModelOptionsUtils.merge(
        runtimeOptions,       // 高优先级
        this.defaultOptions,  // 低优先级
        DashScopeChatOptions.class
    );

    return new Prompt(prompt.getInstructions(), merged);
}

ModelOptionsUtils.merge() 的逻辑很简单:逐字段比较,如果 runtime 的某个字段不为 null,就用 runtime 的;否则用 default 的。 就像 JavaScript 的 Object.assign({}, defaults, runtime)

所以参数优先级是:

markdown 复制代码
Prompt 运行时传入 > ChatModel 默认配置 > application.yml
         ↑ 最高                              ↑ 最低

这解释了为什么你在 Prompt 里设 temperature(0.1) 能覆盖 yml 里配的 0.7------不是黑魔法,就是 merge 的时候 runtime 非 null 字段胜出。

第二件事:构建 API 请求------createRequest()

合并完参数后,createRequest() 把 Java 对象序列化成通义千问 API 需要的格式。这一步按 Message 类型做不同处理:

java 复制代码
// createRequest() 遍历每个 Message,按类型转换(源码简化)
for (Message message : prompt.getInstructions()) {
    if (message instanceof UserMessage userMsg) {
        // 纯文本 → {"role":"user", "content":"..."}
        // 多模态 → {"role":"user", "content":[{"text":"..."},{"image":"base64..."}]}
    } else if (message instanceof AssistantMessage assistantMsg) {
        // 如果有 toolCalls → 附带 tool_calls 数组
    } else if (message instanceof ToolResponseMessage toolMsg) {
        // → {"role":"tool", "name":"functionName", "tool_call_id":"..."}
    }
}

关键点:多模态消息(图片/音频)不是简单的字符串 content,而是一个数组结构 。这就是为什么 UserMessage 要实现 MediaContent 接口------createRequest() 会根据有没有 getMedia() 走不同的序列化路径。

第三件事:发请求 + 工具调用循环------internalCall()

这是最有意思的一层。internalCall() 不只是发一个 HTTP 请求------它还处理了 Function Calling 的递归调用

java 复制代码
// DashScopeChatModel.internalCall() 核心逻辑(源码简化)
ChatResponse internalCall(Prompt prompt, ChatResponse previousResponse) {
    // 1. 用 RetryTemplate 包装 HTTP 调用(自动重试)
    ChatCompletion completion = retryTemplate.execute(ctx ->
        dashscopeApi.chatCompletionEntity(request).getBody()
    );

    // 2. 转换为 ChatResponse
    ChatResponse response = toChatResponse(completion, previousResponse);

    // 3. 关键:检查模型是否要求调用工具
    if (isToolExecutionRequired(prompt.getOptions(), response)) {
        // 模型返回了 tool_calls → 执行工具函数
        var toolResult = toolCallingManager.executeToolCalls(prompt, response);

        if (toolResult.returnDirect()) {
            // 工具结果直接返回给用户(不再问模型)
            return buildDirectResponse(response, toolResult);
        } else {
            // 把工具结果加入对话历史,递归调用模型
            // 模型拿到工具结果后,组织成自然语言回答
            return internalCall(
                new Prompt(toolResult.conversationHistory(), prompt.getOptions()),
                response  // 传入上一轮响应,用于累积 token 用量
            );
        }
    }

    return response;
}

画重点 :这里有一个递归调用。当模型决定要调用工具时,Spring AI 会:

  1. 执行工具函数,拿到结果
  2. 把工具结果作为 ToolResponseMessage 加入对话历史
  3. 再次调用 internalCall()------让模型基于工具结果生成最终回答

这个循环可能执行多次(模型可能连续调用多个工具)。第 3 章讲 Function Calling 时会详细展开。

另外注意 RetryTemplate------DashScope API 偶尔会返回 429(限流)或 5xx(服务端错误),RetryTemplate 会自动重试,不用你手动写 try-catch 循环。

ChatResponse 的结构

ChatResponse 中提取文本需要三层嵌套:

java 复制代码
ChatResponse response = chatModel.call(prompt);
String text = response.getResult()      // Generation------一次生成结果
                      .getOutput()      // AssistantMessage------AI 的回复
                      .getText();       // 文本内容

// 获取 token 用量
Usage usage = response.getMetadata().getUsage();
long inputTokens = usage.getPromptTokens();      // 输入消耗
long outputTokens = usage.getCompletionTokens(); // 输出消耗

为什么 getResult() 返回的是 Generation 而不是直接返回文本?因为一次调用理论上可以返回多个结果 (通过 n 参数控制)。虽然实际开发中 99% 的情况只用一个结果,但 API 设计要考虑通用性。

2.4 痛点浮现------为什么还需要 ChatClient

ChatModel 能用,但写了几个接口之后就会觉得烦。我把第一章的代码和这一章对比了一下:

java 复制代码
// 每个 Controller 都要写这些重复代码
SystemMessage sys = new SystemMessage("你是机票分析师「票小蜜」");
UserMessage user = new UserMessage(question);
Prompt prompt = new Prompt(List.of(sys, user),
    ChatOptions.builder().model("qwen-plus").temperature(0.3).build());
ChatResponse response = chatModel.call(prompt);
return response.getResult().getOutput().getText();

三个痛点:

  1. 手动组装------每个接口都要 new Message、new Prompt,System Prompt 复制粘贴
  2. 无法拦截------想给所有调用加日志?加记忆?只能在每个 Controller 里硬编码
  3. 手动解析 ------response.getResult().getOutput().getText() 这串调用链太长了

如果是你来封装,会怎么做? 你可能会想:搞一个 Builder 模式把消息组装、参数配置、调用串成链式 API。再搞一个拦截器机制把日志、记忆这些横切关注点抽出去。

Spring AI 的 ChatClient 就是这么做的。

2.5 第四层:ChatClient------把五行代码变成一行

翻开 DefaultChatClient 源码,架构变得清晰了:

bash 复制代码
DefaultChatClient
  └── DefaultChatClientRequestSpec  ← 持有默认配置(system/options/advisors)
        └── ChatModel               ← 真正发 HTTP 请求的
              └── DashScopeChatModel ← 通义千问的实现
对比项 ChatModel ChatClient
抽象级别 低级,需手动组装 Prompt 高级,链式 API
拦截器 内置 Advisor 链
Memory 需手动管理消息列表 一行配置
结构化输出 手动解析 JSON .entity(Class) 自动映射
Java 类比 JDBC / DataSource JdbcTemplate / WebClient

前面 ChatModel 写了五行的事,ChatClient 一行搞定:

java 复制代码
// ChatModel 方式(繁琐)
SystemMessage sys = new SystemMessage("你是机票分析师");
UserMessage user = new UserMessage(question);
Prompt prompt = new Prompt(List.of(sys, user));
ChatResponse response = chatModel.call(prompt);
return response.getResult().getOutput().getText();

// ChatClient 方式(简洁)
return chatClient.prompt().system("你是机票分析师").user(question).call().content();
ChatClient 内部怎么工作------翻源码

当你写 chatClient.prompt("你好").call().content() 时,内部发生了什么?我跟着源码走了一遍:

第 1 步:prompt("你好")------复制默认配置 + 设置用户消息

java 复制代码
// DefaultChatClient.prompt() 源码
public ChatClientRequestSpec prompt(String content) {
    // 用 copy constructor 复制默认配置(system/options/advisors 全部复制一份)
    var spec = new DefaultChatClientRequestSpec(this.defaultChatClientRequest);
    spec.user(content);  // 设置用户消息
    return spec;
}

关键:每次调用 prompt() 都会复制一份默认配置 。这意味着 defaultSystemdefaultOptionsdefaultAdvisors 都会被带过来,但你在这次调用中做的修改(比如 .system("新角色"))不会影响下次调用。

这个设计跟 WebClient 一模一样------webClient.get().uri("/api") 每次都是新的请求实例。

第 2 步:.call()------构建 Advisor 链

java 复制代码
// DefaultChatClientRequestSpec.call() 源码简化
public CallResponseSpec call() {
    // 核心:构建 Advisor 链
    BaseAdvisorChain advisorChain = buildAdvisorChain();
    return new DefaultCallResponseSpec(request, advisorChain, ...);
}

private BaseAdvisorChain buildAdvisorChain() {
    // 在所有 Advisor 的末尾,追加 ChatModelCallAdvisor
    // 它负责真正调用 ChatModel.call()
    this.advisors.add(ChatModelCallAdvisor.builder()
        .chatModel(this.chatModel).build());

    // 构建链式结构(按 order 排序)
    return DefaultAroundAdvisorChain.builder()
        .pushAll(this.advisors)  // 按 Order 排序,压入栈
        .build();
}

画重点ChatModelCallAdvisorgetOrder() 返回 Ordered.LOWEST_PRECEDENCEInteger.MAX_VALUE),所以它一定排在最后。你的自定义 Advisor 不管 order 设多大,都在它前面。

第 3 步:.content()------执行 Advisor 链,提取文本

java 复制代码
// 执行链的核心------DefaultAroundAdvisorChain.nextCall()
public ChatClientResponse nextCall(ChatClientRequest request) {
    var advisor = this.callAdvisors.pop();  // 从栈顶弹出一个 Advisor
    return advisor.adviseCall(request, this);
    // advisor 内部会调用 chain.nextCall() 传给下一个
    // 最后一个是 ChatModelCallAdvisor,它调用 chatModel.call() 然后停止
}

链的执行方式是栈弹出 ------每次 nextCall() 弹出一个 Advisor 执行。Advisor 内部调用 chain.nextCall() 传给下一个,直到 ChatModelCallAdvisor 真正调用 chatModel.call() 返回结果,然后逐层回退执行 after()

💡 开发建议:日常开发直接用 ChatClient。ChatModel 只在需要手动控制 Prompt 的每个字节、或者做框架级封装时才用。


三、Advisor 拦截器链------ChatClient 的扩展机制

ChatModel 没有拦截机制。ChatClient 通过 Advisor 链 解决了这个问题。

你用过 Servlet Filter 或 Spring MVC HandlerInterceptor 吗?Advisor 就是同一个思路------在请求到达 LLM 之前和响应返回之后,插入横切逻辑。

sequenceDiagram participant Code as 你的代码 participant CC as ChatClient participant MA as MemoryAdvisor participant LA as LoggerAdvisor participant CM as ChatModel participant LLM as 通义千问 Code->>CC: prompt().user("北京到上海") CC->>MA: before(request) Note over MA: 从 ChatMemory 取历史
塞进消息列表 MA->>LA: before(request) Note over LA: 打印请求日志 LA->>CM: call(prompt) CM->>LLM: HTTP POST LLM-->>CM: JSON 响应 CM-->>LA: ChatResponse Note over LA: 打印响应日志 LA-->>MA: after(response) Note over MA: 存本轮对话到 ChatMemory MA-->>CC: ChatClientResponse CC-->>Code: .content() 提取文本
3.1 BaseAdvisor 接口------before/after 如何串起来

每个 Advisor 都实现 BaseAdvisor

java 复制代码
public interface BaseAdvisor extends CallAdvisor, StreamAdvisor {
    ChatClientRequest before(ChatClientRequest request, AdvisorChain chain);
    ChatClientResponse after(ChatClientResponse response, AdvisorChain chain);
    int getOrder();
}

你只需要实现 before()after() 两个方法。但链式调用是怎么串起来的?秘密在 BaseAdvisordefault 方法里:

java 复制代码
// BaseAdvisor 的 default 方法------自动串联 before → chain → after(源码简化)
default ChatClientResponse adviseCall(ChatClientRequest request, CallAdvisorChain chain) {
    // 1. 执行自己的 before
    ChatClientRequest processed = before(request, chain);
    // 2. 调用链的下一个 Advisor
    ChatClientResponse response = chain.nextCall(processed);
    // 3. 执行自己的 after
    return after(response, chain);
}

你不需要手动调用 chain.nextCall() ------BaseAdvisor 的 default 方法已经帮你做了。你只管写 before/after 的逻辑。

流式调用略有不同------after() 只在流结束时(finish_reason 不为空)才执行:

java 复制代码
// 流式的 adviseStream() default 方法(源码简化)
default Flux<ChatClientResponse> adviseStream(ChatClientRequest request, StreamAdvisorChain chain) {
    return Mono.just(request)
        .map(req -> this.before(req, chain))     // before 只执行一次
        .flatMapMany(chain::nextStream)          // 流式传给下一个 Advisor
        .map(response -> {
            if (onFinishReason().test(response)) {
                return after(response, chain);   // 只在流结束时执行 after
            }
            return response;
        });
}

这解释了为什么 MessageChatMemoryAdvisor 在流式模式下也能正确存储对话------它不是每个 chunk 都存一次,而是等最后一个 chunk(finish_reason=stop)到达时才执行 after() 保存。

3.2 翻一下 MessageChatMemoryAdvisor 的源码

MessageChatMemoryAdvisor 为例,看看一个真实的 Advisor 是怎么实现的:

java 复制代码
// MessageChatMemoryAdvisor.before() 核心逻辑(源码简化)
public ChatClientRequest before(ChatClientRequest request, AdvisorChain chain) {
    String conversationId = getConversationId(request.context());

    // 1. 从 ChatMemory 中取出历史消息
    List<Message> history = this.chatMemory.get(conversationId);

    // 2. 把历史消息插到当前消息列表前面
    List<Message> allMessages = new ArrayList<>(history);
    allMessages.addAll(request.prompt().getInstructions());

    // 3. 用新的消息列表替换原来的
    return request.mutate()
        .prompt(request.prompt().mutate().messages(allMessages).build())
        .build();
}

Advisor 本质上就是在改 Prompt 的消息列表。 历史消息放在前面,用户最新的问题放在后面------因为 LLM 的注意力机制对最后出现的内容更敏感(Recency Bias),用户最新的问题应该离模型的输出位置最"近"。

3.3 内置 Advisor 一览
Advisor before() after()
MessageChatMemoryAdvisor 从 ChatMemory 取历史消息塞进 Prompt 存本轮对话到 ChatMemory
PromptChatMemoryAdvisor 把历史对话拼成文本注入 System Prompt 同上
VectorStoreChatMemoryAdvisor 语义搜索相关历史(第 5 章详解) 同上
QuestionAnswerAdvisor 从 VectorStore 检索文档注入 Prompt
SafeGuardAdvisor 检查用户输入是否有敏感词 检查 LLM 输出是否安全
SimpleLoggerAdvisor 打印请求日志 打印响应日志

Advisor 执行顺序getOrder() 决定,跟 Spring 的 @Order 一样:数字越小越先执行 before()、越后执行 after()。想象一个洋葱------order=0 是最外层皮,before 先执行、after 最后执行。

3.4 Advisor 链构建的完整过程

把上面 ChatClient 和 Advisor 的源码串起来,完整的调用链路是这样的:

scss 复制代码
chatClient.prompt("你好").call().content()

① prompt("你好")
   → copy constructor 复制默认配置(defaultSystem/defaultOptions/defaultAdvisors)
   → spec.user("你好") 设置用户消息

② .call()
   → buildAdvisorChain():
     收集 [默认Advisors... + 本次Advisors... + ChatModelCallAdvisor(order=MAX)]
     按 order 排序,压入 Deque 栈
   → toChatClientRequest(spec):
     把 systemText → SystemMessage
     把 userText → UserMessage
     合并成 Prompt

③ .content()
   → chain.nextCall(request)
     → 弹出 Advisor1 (order=0, 如 TokenUsageAdvisor)
       → before(): 记录开始时间
       → chain.nextCall(request)
         → 弹出 Advisor2 (order=100, 如 MemoryAdvisor)
           → before(): 取历史消息塞进 Prompt
           → chain.nextCall(request)
             → 弹出 ChatModelCallAdvisor (order=MAX, 最后一个)
               → chatModel.call(prompt)
                 → DashScopeChatModel.buildRequestPrompt()  合并参数
                 → DashScopeChatModel.createRequest()       构建 JSON
                 → DashScopeChatModel.internalCall()        发 HTTP POST
               ← ChatClientResponse
           ← after(): 存本轮对话
         ← response
       ← after(): 统计 token、记录耗时
     ← response
   → 从 ChatResponse 中提取文本

四、整体架构回顾

从 HTTP 请求到 ChatClient 的四层抽象理清楚了:

graph TB HTTP["HTTP POST
curl + JSON"] --> Message["Message
Java 对象化"] Message --> Prompt["Prompt
消息 + 参数打包"] Prompt --> ChatModel["ChatModel
发 HTTP + 工具调用循环"] ChatModel --> ChatClient["ChatClient
链式 API + Advisor 栈"] style HTTP fill:#f9f9f9,stroke:#999 style Message fill:#e8f4fd,stroke:#4a9eda style Prompt fill:#e8f4fd,stroke:#4a9eda style ChatModel fill:#d4edda,stroke:#28a745 style ChatClient fill:#fff3cd,stroke:#ffc107
层级 解决什么问题 内部做了什么
HTTP 最原始的调用方式 手写 JSON,curl 发请求
Message + Prompt 类型安全,不用手拼 JSON Java 对象映射 HTTP 请求体
ChatModel 封装 HTTP 通信 + 参数合并 + 重试 buildRequestPrompt() 合并参数 → createRequest() 序列化 → internalCall() 发请求 + 工具调用递归
ChatClient + Advisor 链式 API、拦截器栈、自动解析 copy constructor 复制配置 → buildAdvisorChain() 组装栈 → Deque 弹出执行

每一层都是对上一层痛点的解决方案。 理解了这个,后面遇到问题就知道该在哪一层排查:

  • 参数不生效?→ 查 buildRequestPrompt() 的合并优先级
  • Advisor 没执行?→ 查 getOrder()buildAdvisorChain() 的排序
  • 工具调用没触发?→ 查 internalCall()isToolExecutionRequired 判断

实战篇

五、动手编码

5.1 ChatClient 基本用法
java 复制代码
package com.ai.course.chatclient.controller;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.prompt.ChatOptions;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux;

@RestController
@RequestMapping("/api/v2/chat")
public class ChatClientController {

    // 注入 ChatClientConfig 中配置好的 ChatClient(已带 Advisor 链)
    private final ChatClient chatClient;

    public ChatClientController(ChatClient chatClient) {
        this.chatClient = chatClient;
    }

    /**
     * 最简调用------一行代码完成对话
     * GET /api/v2/chat/simple?q=北京到上海的机票
     */
    @GetMapping("/simple")
    public String simple(@RequestParam("q") String q) {
        return chatClient.prompt(q).call().content();
    }

    /**
     * 动态覆盖 System Prompt
     * GET /api/v2/chat/custom?q=写首诗&role=你是一个诗人
     */
    @GetMapping("/custom")
    public String custom(@RequestParam("q") String q, @RequestParam("role") String role) {
        return chatClient.prompt()
            .system(role)          // 覆盖默认 System Prompt
            .user(q)              // 用户问题
            .call()
            .content();
    }

    /**
     * 流式输出
     * GET /api/v2/chat/stream?q=介绍北京到上海的航线
     */
    @GetMapping(value = "/stream", produces = "text/html;charset=UTF-8")
    public Flux<String> stream(@RequestParam("q") String q) {
        return chatClient.prompt(q)
            .stream()
            .content();           // 直接返回 Flux<String>
    }

    /**
     * Per-Request 动态配置
     * 精确查询用低 temperature,推荐类问题用高 temperature
     * GET /api/v2/chat/dynamic?q=推荐去哪旅游&temp=0.8
     */
    @GetMapping("/dynamic")
    public String dynamicConfig(@RequestParam("q") String q,
                                @RequestParam(value = "temp", defaultValue = "0.3") double temp) {
        return chatClient.prompt(q)
            .options(ChatOptions.builder()
                .temperature(temp)
                .model(temp > 0.5 ? "qwen-max" : "qwen-plus")  // 按需切换模型
                .build())
            .call()
            .content();
    }
}

启动后测试:

bash 复制代码
# 最简调用
curl "http://localhost:8081/api/v2/chat/simple?q=北京到上海的机票"

# 动态切换角色
curl "http://localhost:8081/api/v2/chat/custom?q=写首诗&role=你是李白"

# 流式输出(打字机效果)
curl "http://localhost:8081/api/v2/chat/stream?q=介绍北京到上海的航线"
5.2 ChatClient 全局配置------Config + Advisor

前面 Controller 的构造函数里 builder.build() 是最简写法。实际项目中,System Prompt、模型参数、Advisor 链应该统一放到 @Configuration 里管理,Controller 直接注入:

java 复制代码
package com.ai.course.chatclient.config;

import com.ai.course.chatclient.advisor.TokenUsageAdvisor;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor;
import org.springframework.ai.chat.prompt.ChatOptions;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class ChatClientConfig {

    @Bean
    public ChatClient chatClient(ChatClient.Builder builder) {
        return builder
            // 全局 System Prompt ------ 所有对话都生效
            .defaultSystem("你是一个专业机票分析师「票小蜜」," +
                "只回答机票、航班、旅行相关问题。" +
                "回答时提供航班号、时间、价格信息。")
            // 全局模型参数
            .defaultOptions(ChatOptions.builder()
                .model("qwen-plus")
                .temperature(0.3)
                .build())
            // Advisor 链------Token 监控 + 日志
            .defaultAdvisors(
                new TokenUsageAdvisor(),
                new SimpleLoggerAdvisor()
            )
            .build();
    }
}

这样 ChatClientController 的构造函数就很干净------直接注入配置好的 ChatClient

java 复制代码
public ChatClientController(ChatClient chatClient) {
    this.chatClient = chatClient;
}

defaultAdvisors() 支持传入多个 Advisor,框架按 getOrder() 排序后构建调用链。这里 TokenUsageAdvisor(order=0)在最外层,SimpleLoggerAdvisor 在内层。每次调用都会自动打印 Token 消耗和请求日志。

💡 开发建议 :多轮对话记忆(MessageChatMemoryAdvisor)也是通过 defaultAdvisors() 挂上去的,原理完全一样------第 4 章会详细实现。

5.3 自定义 Advisor------Token 用量监控

内置 Advisor 不够用时,自己实现 BaseAdvisor。比如监控每次调用的 Token 消耗和耗时:

java 复制代码
package com.ai.course.chatclient.advisor;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.client.advisor.api.AdvisorChain;
import org.springframework.ai.chat.client.advisor.api.BaseAdvisor;
import org.springframework.ai.chat.client.ChatClientRequest;
import org.springframework.ai.chat.client.ChatClientResponse;
import org.springframework.ai.chat.metadata.Usage;

/**
 * Token 消耗统计 Advisor
 *
 * 这个 Advisor 放在最外层(order=0),before 记录开始时间,after 统计 token。
 * 因为是最外层,after 最后执行,所以耗时包含了所有内层 Advisor 的耗时。
 */
public class TokenUsageAdvisor implements BaseAdvisor {

    private static final Logger log = LoggerFactory.getLogger(TokenUsageAdvisor.class);
    private final ThreadLocal<Long> startTime = new ThreadLocal<>();

    @Override
    public int getOrder() {
        return 0;  // 最外层------before 最先执行,after 最后执行
    }

    @Override
    public ChatClientRequest before(ChatClientRequest request, AdvisorChain chain) {
        startTime.set(System.currentTimeMillis());
        return request;  // 不修改请求,直接放行
    }

    @Override
    public ChatClientResponse after(ChatClientResponse response, AdvisorChain chain) {
        long elapsed = System.currentTimeMillis() - startTime.get();
        startTime.remove();

        Usage usage = response.chatResponse().getMetadata().getUsage();
        log.info("Token 消耗 | 输入: {} | 输出: {} | 总计: {} | 耗时: {}ms",
            usage.getPromptTokens(),
            usage.getCompletionTokens(),
            usage.getTotalTokens(),
            elapsed);

        return response;
    }
}

上面 5.2 的 ChatClientConfig 已经注册了这个 Advisor。启动后调用任意接口,控制台输出:

复制代码
Token 消耗 | 输入: 45 | 输出: 128 | 总计: 173 | 耗时: 2340ms

有了这个数据,可以估算成本:qwen-plus 的价格是输入 ¥4/百万 token,输出 ¥12/百万 token。173 个 token 的成本大约是 ¥0.002------一次对话不到一分钱。

5.4 Advisor 顺序设计
Advisor 类型 建议 order 为什么
监控/计时 0 最外层,记录完整耗时(包含所有内层 Advisor)
Memory 100 需要在日志之后拦截,往 Prompt 里塞历史消息(第 4 章实现)
业务逻辑 200 参数校验、内容过滤等
ChatModelCallAdvisor MAX_VALUE 最内层,真正调用 ChatModel(框架自动添加,你不用管)

不要用 Integer.MIN_VALUEInteger.MAX_VALUE,留出扩展空间。

5.5 参数对比实验------眼见为实

理论篇提到 temperaturetopP 不建议同时调。我写了个实验接口来验证:

java 复制代码
/**
 * 参数对比实验------观察不同参数组合的效果
 * GET /api/v2/chat/param-test?q=用一句话形容北京
 */
@GetMapping("/param-test")
public Map<String, List<String>> paramTest(@RequestParam("q") String q) {
    // 三组参数,各调用 3 次
    Map<String, ChatOptions> configs = Map.of(
        "temp=0", ChatOptions.builder().temperature(0.0).build(),
        "temp=1", ChatOptions.builder().temperature(1.0).build(),
        "temp=0+topP=0.1", ChatOptions.builder().temperature(0.0).topP(0.1).build()
    );

    Map<String, List<String>> results = new LinkedHashMap<>();
    configs.forEach((name, options) -> {
        List<String> responses = new ArrayList<>();
        for (int i = 0; i < 3; i++) {
            responses.add(chatClient.prompt(q).options(options).call().content());
        }
        results.put(name, responses);
    });
    return results;
}

实际运行结果:

json 复制代码
{
  "temp=0": [
    "北京是一座古老与现代交融的城市。",
    "北京是一座古老与现代交融的城市。",
    "北京是一座古老与现代交融的城市。"
  ],
  "temp=1": [
    "北京是一座承载千年文明的雄伟古都。",
    "帝都风华,古今交汇。",
    "北京是一座让人既敬畏又亲切的城市。"
  ],
  "temp=0+topP=0.1": [
    "北京是一座古老与现代交融的城市。",
    "北京是一座古老与现代交融的城市。",
    "北京是一座古老与现代交融的城市。"
  ]
}

结论:

  • temperature=0:三次完全相同,输出确定性
  • temperature=1:每次都不一样,充满创意
  • temperature=0 + topP=0.1:和单独 temperature=0 的结果一样------当 temperature 已经是 0 时,topP 没有额外作用。但如果 temperature=0.7 + topP=0.1,两个参数会互相影响,结果反而不可预测

💡 开发建议 :调参数时,调一个就够了 。Agent 场景用 temperature(0~0.3),创意场景用 temperature(0.7~1.0)topP 保持默认。


六、与机票比价 Agent 的集成

这一章建立的基础设施,后面每章都会用到:

  • ChatClient 配置 ------后面所有模块的入口,统一通过 ChatClient.Builder 构建
  • Advisor 链------Memory(第 4 章)、RAG(第 5 章)都通过 Advisor 挂上去,跟本章的日志 Advisor 无缝组合
  • defaultSystem + defaultOptions------全局配置一次,每个 Controller 不用重复设置
  • 参数合并机制------不同场景用不同 temperature,per-request options 覆盖默认值

七、FAQ 与踩坑记录

Q1:ChatClient.Builder 注入失败,提示找不到 Bean?

三个排查方向:

  1. 确认 pom.xml 里有 spring-ai-alibaba-starter-dashscope
  2. 确认环境变量 AI_DASHSCOPE_API_KEY 已设置且值正确
  3. 如果自定义了 @Configuration 手动创建 ChatClient Bean,可能和自动配置冲突。建议在 @Configuration 中注入 ChatClient.Builder 参数而不是自己 new

Q2:自定义 Advisor 没被执行?

两个常见原因:

  1. 忘记注册 ------Advisor 必须通过 .defaultAdvisors().advisors() 添加到 ChatClient,不是加了 @Component 就行
  2. order 冲突 ------如果两个 Advisor 的 order 相同,执行顺序不确定。翻源码看到排序用的是 AnnotationAwareOrderComparator,同 order 时按添加顺序

Q3:ChatClientChatModel 能混用吗?

可以。ChatClient 内部就是调用 ChatModel。你甚至可以从同一个 ChatModel 构建多个不同配置的 ChatClient(比如一个带 Memory,一个不带)。但同一个请求链路中不建议混用------要么全用 ChatClient,要么全用 ChatModel。

Q4:流式接口返回乱码或一次性返回?

三个排查方向:

  1. produces 必须设 "text/html;charset=UTF-8""text/event-stream;charset=UTF-8"
  2. 如果有 Nginx 反代,加 proxy_buffering off;------否则 Nginx 会缓冲所有 chunk 再一次性返回
  3. 浏览器直接访问 SSE 显示可能不友好,用 curl -N 或前端 EventSource 测试

Q5:Prompt 里设了 temperature 但没生效?

回顾理论篇的参数合并逻辑:Prompt options > ChatModel defaults > yml config。如果 ChatClient 的 defaultOptions 里设了 temperature,Prompt 里的 options 会覆盖它。但如果你用的是 chatClient.prompt(q).call()(没显式设 options),就会用 defaultOptions 的值。

SimpleLoggerAdvisor 打印请求日志,可以看到实际发给模型的参数------这是最快的排查手段。


本章小结

理论篇(搞懂原理) 实战篇(动手验证)
HTTP 裸调 + SSE 流式原理 ChatClient 链式调用
Message 四种类型 + 消息顺序 自定义 TokenUsageAdvisor 开发
Prompt = 消息 + 参数 Advisor 顺序设计
ChatModel 源码三件事:合并参数 → 序列化 → 发请求 + 工具递归 参数对比实验
ChatClient 源码:copy constructor → buildAdvisorChain → 栈弹出执行
Advisor 链:BaseAdvisor default 方法自动串联 before/after

下一章:Function Calling------让 LLM 长出"手脚"。LLM 的回答全是"编"的,怎么让它调用你的 Java 方法去查真实数据?下一篇从 HTTP 裸调讲到 Spring AI 封装,实现机票查询和比价两个工具函数。


本文代码GitHub - chat-client 模块

如果这篇文章对你有帮助,欢迎点赞收藏。有问题欢迎评论区交流。

相关推荐
RuiBo_Qiu2 小时前
【LLM进阶-后训练&部署】2. 常见的全参数微调SFT方法
人工智能·深度学习·机器学习·ai-native
2501_933329552 小时前
媒介宣发技术中台架构实践:基于AI多模态的舆情处置与智能分发系统设计
人工智能·架构·系统架构
_遥远的救世主_2 小时前
OpenCode vs OpenClaw 企业级 AI 平台二开选型深度拆解
人工智能
安全菜鸟2 小时前
OpenClaw-CN 完整安装教程与避坑指南(国内镜像加速版)
人工智能·openclaw
小慧教你用AI2 小时前
OpenClaw的多Agent架构设计,揭示其实现原理
人工智能
FluxMelodySun2 小时前
机器学习(二十三) 密度聚类与层次聚类
人工智能·机器学习·聚类
奋斗中的小猩猩2 小时前
Test Case Generator / AI 测试用例生成器(多Agent组合,效果可观)
人工智能·测试用例
总有刁民想爱朕ha2 小时前
OpenCV 图像操作入门:从零开始玩转计算机视觉
人工智能·opencv·计算机视觉
前进的李工2 小时前
LangChain使用之Model IO(提示词模版之PromptTemplate)
开发语言·人工智能·python·langchain