欢迎光临
我们一直在努力

Spring AI Alibaba 实战(二):从一个 HTTP 请求开始,搞懂 ChatClient 的每一层

文章目录

  • Spring AI Alibaba 实战(二):从一个 HTTP 请求开始,搞懂 ChatClient 的每一层
    • 理论篇
      • 一、先忘掉框架——直接用 HTTP 调一下通义千问
        • 1.1 流式调用长什么样?
      • 二、从 HTTP 到 Java 对象——逐层拆解
        • 2.1 第一层:Message——把 JSON 里的 message 变成 Java 对象
        • 2.2 第二层:Prompt——消息 + 模型参数的打包容器
        • 2.3 第三层:ChatModel——真正发 HTTP 请求的执行者
          • 第一件事:参数合并——buildRequestPrompt()
          • 第二件事:构建 API 请求——createRequest()
          • 第三件事:发请求 + 工具调用循环——internalCall()
          • ChatResponse 的结构
        • 2.4 痛点浮现——为什么还需要 ChatClient
        • 2.5 第四层:ChatClient——把五行代码变成一行
          • ChatClient 内部怎么工作——翻源码
      • 三、Advisor 拦截器链——ChatClient 的扩展机制
        • 3.1 BaseAdvisor 接口——before/after 如何串起来
        • 3.2 翻一下 MessageChatMemoryAdvisor 的源码
        • 3.3 内置 Advisor 一览
        • 3.4 Advisor 链构建的完整过程
      • 四、整体架构回顾
    • 实战篇
      • 五、动手编码
        • 5.1 ChatClient 基本用法
        • 5.2 ChatClient 全局配置——Config + Advisor
        • 5.3 自定义 Advisor——Token 用量监控
        • 5.4 Advisor 顺序设计
        • 5.5 参数对比实验——眼见为实
      • 六、与机票比价 Agent 的集成
      • 七、FAQ 与踩坑记录
      • 本章小结

Spring AI Alibaba 实战(二):从一个 HTTP 请求开始,搞懂 ChatClient 的每一层

这是我学习 Spring AI Alibaba 的第二篇记录。 上一篇跑通了 Hello World 对话,但 chatClient.prompt(q).call().content() 这一行代码背后到底发生了什么?这一篇我要把它拆开来看。


理论篇

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

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

试一下就知道了。通义千问的 API 就是一个标准的 HTTP 接口,用 curl 就能调:

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 长这样(删掉了一些不重要的字段):

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

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

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

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

    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: 行:

    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 接口来表示它:

    // 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:

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

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

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

    // 多模态消息——传图片让 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 就是把这两部分打包到一起:

    // 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 语句 + 参数绑定。

    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 遇到指定字符串就停 结构化输出时防止多余内容

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

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

    // 通用 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:

    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.yml、ChatModel 默认配置、Prompt 运行时传入。谁的优先级高?

    翻 DashScopeChatModel.buildRequestPrompt() 的源码:

    // 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)。

    所以参数优先级是:

    Prompt 运行时传入 > ChatModel 默认配置 > application.yml
    ↑ 最高 ↑ 最低

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

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

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

    // 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 的递归调用:

    // 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 会:

  • 执行工具函数,拿到结果
  • 把工具结果作为 ToolResponseMessage 加入对话历史
  • 再次调用 internalCall()——让模型基于工具结果生成最终回答
  • 这个循环可能执行多次(模型可能连续调用多个工具)。第 3 章讲 Function Calling 时会详细展开。

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

    ChatResponse 的结构

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

    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 能用,但写了几个接口之后就会觉得烦。我把第一章的代码和这一章对比了一下:

    // 每个 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();

    三个痛点:

  • 手动组装——每个接口都要 new Message、new Prompt,System Prompt 复制粘贴
  • 无法拦截——想给所有调用加日志?加记忆?只能在每个 Controller 里硬编码
  • 手动解析——response.getResult().getOutput().getText() 这串调用链太长了
  • 如果是你来封装,会怎么做? 你可能会想:搞一个 Builder 模式把消息组装、参数配置、调用串成链式 API。再搞一个拦截器机制把日志、记忆这些横切关注点抽出去。

    Spring AI 的 ChatClient 就是这么做的。

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

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

    DefaultChatClient
    └── DefaultChatClientRequestSpec ← 持有默认配置(system/options/advisors)
    └── ChatModel ← 真正发 HTTP 请求的
    └── DashScopeChatModel ← 通义千问的实现

    对比项ChatModelChatClient
    抽象级别 低级,需手动组装 Prompt 高级,链式 API
    拦截器 无 内置 Advisor 链
    Memory 需手动管理消息列表 一行配置
    结构化输出 手动解析 JSON .entity(Class) 自动映射
    Java 类比 JDBC / DataSource JdbcTemplate / WebClient

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

    // 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("你好")——复制默认配置 + 设置用户消息

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

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

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

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

    // 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();
    }

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

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

    // 执行链的核心——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 之前和响应返回之后,插入横切逻辑。

    通义千问

    ChatModel

    LoggerAdvisor

    MemoryAdvisor

    ChatClient

    你的代码

    通义千问

    ChatModel

    LoggerAdvisor

    MemoryAdvisor

    ChatClient

    你的代码

    #mermaid-svg-ERnfQqNKnT2nhqjp{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-ERnfQqNKnT2nhqjp .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ERnfQqNKnT2nhqjp .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ERnfQqNKnT2nhqjp .error-icon{fill:#552222;}#mermaid-svg-ERnfQqNKnT2nhqjp .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ERnfQqNKnT2nhqjp .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ERnfQqNKnT2nhqjp .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ERnfQqNKnT2nhqjp .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ERnfQqNKnT2nhqjp .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ERnfQqNKnT2nhqjp .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ERnfQqNKnT2nhqjp .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ERnfQqNKnT2nhqjp .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ERnfQqNKnT2nhqjp .marker.cross{stroke:#333333;}#mermaid-svg-ERnfQqNKnT2nhqjp svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ERnfQqNKnT2nhqjp p{margin:0;}#mermaid-svg-ERnfQqNKnT2nhqjp .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-ERnfQqNKnT2nhqjp text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-ERnfQqNKnT2nhqjp .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-ERnfQqNKnT2nhqjp .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-ERnfQqNKnT2nhqjp .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-ERnfQqNKnT2nhqjp .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-ERnfQqNKnT2nhqjp #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-ERnfQqNKnT2nhqjp .sequenceNumber{fill:white;}#mermaid-svg-ERnfQqNKnT2nhqjp #sequencenumber{fill:#333;}#mermaid-svg-ERnfQqNKnT2nhqjp #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-ERnfQqNKnT2nhqjp .messageText{fill:#333;stroke:none;}#mermaid-svg-ERnfQqNKnT2nhqjp .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-ERnfQqNKnT2nhqjp .labelText,#mermaid-svg-ERnfQqNKnT2nhqjp .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-ERnfQqNKnT2nhqjp .loopText,#mermaid-svg-ERnfQqNKnT2nhqjp .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-ERnfQqNKnT2nhqjp .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-ERnfQqNKnT2nhqjp .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-ERnfQqNKnT2nhqjp .noteText,#mermaid-svg-ERnfQqNKnT2nhqjp .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-ERnfQqNKnT2nhqjp .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-ERnfQqNKnT2nhqjp .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-ERnfQqNKnT2nhqjp .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-ERnfQqNKnT2nhqjp .actorPopupMenu{position:absolute;}#mermaid-svg-ERnfQqNKnT2nhqjp .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-ERnfQqNKnT2nhqjp .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-ERnfQqNKnT2nhqjp .actor-man circle,#mermaid-svg-ERnfQqNKnT2nhqjp line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-ERnfQqNKnT2nhqjp :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

    从 ChatMemory 取历史

    塞进消息列表

    打印请求日志

    打印响应日志

    存本轮对话到 ChatMemory

    prompt().user("北京到上海")

    before(request)

    before(request)

    call(prompt)

    HTTP POST

    JSON 响应

    ChatResponse

    after(response)

    ChatClientResponse

    .content() 提取文本

    3.1 BaseAdvisor 接口——before/after 如何串起来

    每个 Advisor 都实现 BaseAdvisor:

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

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

    // 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 不为空)才执行:

    // 流式的 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 是怎么实现的:

    // 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 一览
    Advisorbefore()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 的源码串起来,完整的调用链路是这样的:

    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 的四层抽象理清楚了:

    #mermaid-svg-tl7oZaJS6QH9CsJK{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-tl7oZaJS6QH9CsJK .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-tl7oZaJS6QH9CsJK .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-tl7oZaJS6QH9CsJK .error-icon{fill:#552222;}#mermaid-svg-tl7oZaJS6QH9CsJK .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-tl7oZaJS6QH9CsJK .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-tl7oZaJS6QH9CsJK .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-tl7oZaJS6QH9CsJK .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-tl7oZaJS6QH9CsJK .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-tl7oZaJS6QH9CsJK .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-tl7oZaJS6QH9CsJK .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-tl7oZaJS6QH9CsJK .marker{fill:#333333;stroke:#333333;}#mermaid-svg-tl7oZaJS6QH9CsJK .marker.cross{stroke:#333333;}#mermaid-svg-tl7oZaJS6QH9CsJK svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-tl7oZaJS6QH9CsJK p{margin:0;}#mermaid-svg-tl7oZaJS6QH9CsJK .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-tl7oZaJS6QH9CsJK .cluster-label text{fill:#333;}#mermaid-svg-tl7oZaJS6QH9CsJK .cluster-label span{color:#333;}#mermaid-svg-tl7oZaJS6QH9CsJK .cluster-label span p{background-color:transparent;}#mermaid-svg-tl7oZaJS6QH9CsJK .label text,#mermaid-svg-tl7oZaJS6QH9CsJK span{fill:#333;color:#333;}#mermaid-svg-tl7oZaJS6QH9CsJK .node rect,#mermaid-svg-tl7oZaJS6QH9CsJK .node circle,#mermaid-svg-tl7oZaJS6QH9CsJK .node ellipse,#mermaid-svg-tl7oZaJS6QH9CsJK .node polygon,#mermaid-svg-tl7oZaJS6QH9CsJK .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-tl7oZaJS6QH9CsJK .rough-node .label text,#mermaid-svg-tl7oZaJS6QH9CsJK .node .label text,#mermaid-svg-tl7oZaJS6QH9CsJK .image-shape .label,#mermaid-svg-tl7oZaJS6QH9CsJK .icon-shape .label{text-anchor:middle;}#mermaid-svg-tl7oZaJS6QH9CsJK .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-tl7oZaJS6QH9CsJK .rough-node .label,#mermaid-svg-tl7oZaJS6QH9CsJK .node .label,#mermaid-svg-tl7oZaJS6QH9CsJK .image-shape .label,#mermaid-svg-tl7oZaJS6QH9CsJK .icon-shape .label{text-align:center;}#mermaid-svg-tl7oZaJS6QH9CsJK .node.clickable{cursor:pointer;}#mermaid-svg-tl7oZaJS6QH9CsJK .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-tl7oZaJS6QH9CsJK .arrowheadPath{fill:#333333;}#mermaid-svg-tl7oZaJS6QH9CsJK .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-tl7oZaJS6QH9CsJK .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-tl7oZaJS6QH9CsJK .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-tl7oZaJS6QH9CsJK .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-tl7oZaJS6QH9CsJK .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-tl7oZaJS6QH9CsJK .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-tl7oZaJS6QH9CsJK .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-tl7oZaJS6QH9CsJK .cluster text{fill:#333;}#mermaid-svg-tl7oZaJS6QH9CsJK .cluster span{color:#333;}#mermaid-svg-tl7oZaJS6QH9CsJK div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-tl7oZaJS6QH9CsJK .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-tl7oZaJS6QH9CsJK rect.text{fill:none;stroke-width:0;}#mermaid-svg-tl7oZaJS6QH9CsJK .icon-shape,#mermaid-svg-tl7oZaJS6QH9CsJK .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-tl7oZaJS6QH9CsJK .icon-shape p,#mermaid-svg-tl7oZaJS6QH9CsJK .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-tl7oZaJS6QH9CsJK .icon-shape rect,#mermaid-svg-tl7oZaJS6QH9CsJK .image-shape rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-tl7oZaJS6QH9CsJK .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-tl7oZaJS6QH9CsJK .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-tl7oZaJS6QH9CsJK :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

    HTTP POSTcurl + JSON

    MessageJava 对象化

    Prompt消息 + 参数打包

    ChatModel发 HTTP + 工具调用循环

    ChatClient链式 API + Advisor 栈

    层级解决什么问题内部做了什么
    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 基本用法

    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();
    }
    }

    启动后测试:

    # 最简调用
    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 直接注入:

    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:

    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 消耗和耗时:

    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。启动后调用任意接口,控制台输出:

    2026-03-17T13:40:33.974+08:00 INFO 20760 — [chat-client] [nio-8080-exec-1] c.a.c.c.advisor.TokenUsageAdvisor : Token 消耗 | 输入: 18 | 输出: 262 | 总计: 280 | 耗时: 7327ms
    2026-03-17T13:40:34.000+08:00 DEBUG 20760 — [chat-client] [nio-8080-exec-1] o.s.a.c.c.advisor.SimpleLoggerAdvisor : response: {
    "result" : {
    "output" : {
    "messageType" : "ASSISTANT",
    "metadata" : {
    "finishReason" : "STOP",
    "role" : "ASSISTANT",
    "id" : "b556f997-e1aa-44a6-bbd5-38c192987975",
    "messageType" : "ASSISTANT",
    "reasoningContent" : ""
    },
    "toolCalls" : [ ],
    "media" : [ ],
    "text" : "*仰头饮尽一杯酒,袖袍一挥,目光如电*\\n\\n哈哈,好!且让李白为你吟一首《醉后观星》——\\n\\n《醉后观星》 \\n玉山倾倒北斗斜,银河倒泻入吾槎。 \\n醉眼摩挲千峰雪,狂歌踏碎万重霞。 \\n忽见青鸾衔月来,云中欲唤谢自然。 \\n笑问姮娥可赊酒?不须桂树自生花!\\n\\n*将酒樽轻轻搁在青石上,抬手指向夜空* \\n你看那北斗西斜,星汉西流,岂非天公邀我共饮?方才我醉卧松岗,竟见青鸾衔着半轮明月飞来——这可不是寻常光景!若你信得过,咱们明日携剑登太白峰,我教你辨认星斗、听松涛作曲,再寻一处飞瀑,以石为鼓,以风为弦,痛饮三百杯!\\n\\n*忽然眨眨眼,笑意狡黠* \\n不过嘛……你得先告诉我,想听豪放的,还是带点仙气的?或者——要不要我即兴写首赠你的诗?"
    },
    "metadata" : {
    "finishReason" : "STOP",
    "contentFilters" : [ ],
    "empty" : true
    }
    },
    "metadata" : {
    "id" : "b556f997-e1aa-44a6-bbd5-38c192987975",
    "model" : "",
    "rateLimit" : {
    "requestsLimit" : 0,
    "tokensRemaining" : 0,
    "requestsReset" : 0.0,
    "tokensLimit" : 0,
    "tokensReset" : 0.0,
    "requestsRemaining" : 0
    },
    "usage" : {
    "promptTokens" : 18,
    "completionTokens" : 262,
    "totalTokens" : 280,
    "nativeUsage" : {
    "output_tokens" : 262,
    "input_tokens" : 18,
    "total_tokens" : 280
    }
    },
    "promptMetadata" : [ ],
    "empty" : true
    },
    "results" : [ {
    "output" : {
    "messageType" : "ASSISTANT",
    "metadata" : {
    "finishReason" : "STOP",
    "role" : "ASSISTANT",
    "id" : "b556f997-e1aa-44a6-bbd5-38c192987975",
    "messageType" : "ASSISTANT",
    "reasoningContent" : ""
    },
    "toolCalls" : [ ],
    "media" : [ ],
    "text" : "*仰头饮尽一杯酒,袖袍一挥,目光如电*\\n\\n哈哈,好!且让李白为你吟一首《醉后观星》——\\n\\n《醉后观星》 \\n玉山倾倒北斗斜,银河倒泻入吾槎。 \\n醉眼摩挲千峰雪,狂歌踏碎万重霞。 \\n忽见青鸾衔月来,云中欲唤谢自然。 \\n笑问姮娥可赊酒?不须桂树自生花!\\n\\n*将酒樽轻轻搁在青石上,抬手指向夜空* \\n你看那北斗西斜,星汉西流,岂非天公邀我共饮?方才我醉卧松岗,竟见青鸾衔着半轮明月飞来——这可不是寻常光景!若你信得过,咱们明日携剑登太白峰,我教你辨认星斗、听松涛作曲,再寻一处飞瀑,以石为鼓,以风为弦,痛饮三百杯!\\n\\n*忽然眨眨眼,笑意狡黠* \\n不过嘛……你得先告诉我,想听豪放的,还是带点仙气的?或者——要不要我即兴写首赠你的诗?"
    },
    "metadata" : {
    "finishReason" : "STOP",
    "contentFilters" : [ ],
    "empty" : true
    }
    } ]
    }

    有了这个数据,可以估算成本: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_VALUE 或 Integer.MAX_VALUE,留出扩展空间。

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

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

    /**
    * 参数对比实验——观察不同参数组合的效果
    * 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;
    }

    实际运行结果:

    {
    "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?

    三个排查方向:

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

    两个常见原因:

  • 忘记注册——Advisor 必须通过 .defaultAdvisors() 或 .advisors() 添加到 ChatClient,不是加了 @Component 就行
  • order 冲突——如果两个 Advisor 的 order 相同,执行顺序不确定。翻源码看到排序用的是 AnnotationAwareOrderComparator,同 order 时按添加顺序
  • Q3:ChatClient 和 ChatModel 能混用吗?

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

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

    三个排查方向:

  • produces 必须设 "text/html;charset=UTF-8" 或 "text/event-stream;charset=UTF-8"
  • 如果有 Nginx 反代,加 proxy_buffering off;——否则 Nginx 会缓冲所有 chunk 再一次性返回
  • 浏览器直接访问 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

    下一章:Prompt 工程与结构化输出。怎么写好 Prompt、怎么让 LLM 的输出直接变成 Java 对象——PromptTemplate 模板机制、Few-shot 技术、BeanOutputConverter 源码解析。


    本文代码:GitHub – chat-client 模块

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

    赞(0)
    未经允许不得转载:171主机测评 » Spring AI Alibaba 实战(二):从一个 HTTP 请求开始,搞懂 ChatClient 的每一层
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址