文章目录
- 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,几件事就清楚了:
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:
| 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 会:
这个循环可能执行多次(模型可能连续调用多个工具)。第 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();
三个痛点:
如果是你来封装,会怎么做? 你可能会想:搞一个 Builder 模式把消息组装、参数配置、调用串成链式 API。再搞一个拦截器机制把日志、记忆这些横切关注点抽出去。
Spring AI 的 ChatClient 就是这么做的。
2.5 第四层:ChatClient——把五行代码变成一行
翻开 DefaultChatClient 源码,架构变得清晰了:
DefaultChatClient
└── DefaultChatClientRequestSpec ← 持有默认配置(system/options/advisors)
└── ChatModel ← 真正发 HTTP 请求的
└── DashScopeChatModel ← 通义千问的实现
| 抽象级别 | 低级,需手动组装 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 一览
| 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 顺序设计
| 监控/计时 | 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?
三个排查方向:
Q2:自定义 Advisor 没被执行?
两个常见原因:
Q3:ChatClient 和 ChatModel 能混用吗?
可以。ChatClient 内部就是调用 ChatModel。你甚至可以从同一个 ChatModel 构建多个不同配置的 ChatClient(比如一个带 Memory,一个不带)。但同一个请求链路中不建议混用——要么全用 ChatClient,要么全用 ChatModel。
Q4:流式接口返回乱码或一次性返回?
三个排查方向:
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 模块
如果这篇文章对你有帮助,欢迎点赞收藏。有问题欢迎评论区交流。



