@[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,几件事就清楚了:
- 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) 协议:
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 |
遇到指定字符串就停 | 结构化输出时防止多余内容 |
⚠️ 注意 :
temperature和topP同时设的话效果可能冲突。通义千问的建议是调其中一个,另一个保持默认 。我后面踩过这个坑------同时设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.yml、ChatModel 默认配置、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 会:
- 执行工具函数,拿到结果
- 把工具结果作为
ToolResponseMessage加入对话历史 - 再次调用 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();
三个痛点:
- 手动组装------每个接口都要 new Message、new Prompt,System Prompt 复制粘贴
- 无法拦截------想给所有调用加日志?加记忆?只能在每个 Controller 里硬编码
- 手动解析 ------
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() 都会复制一份默认配置 。这意味着 defaultSystem、defaultOptions、defaultAdvisors 都会被带过来,但你在这次调用中做的修改(比如 .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();
}
画重点 :ChatModelCallAdvisor 的 getOrder() 返回 Ordered.LOWEST_PRECEDENCE(Integer.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 之前和响应返回之后,插入横切逻辑。
塞进消息列表 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() 两个方法。但链式调用是怎么串起来的?秘密在 BaseAdvisor 的 default 方法里:
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 的四层抽象理清楚了:
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_VALUE 或 Integer.MAX_VALUE,留出扩展空间。
5.5 参数对比实验------眼见为实
理论篇提到 temperature 和 topP 不建议同时调。我写了个实验接口来验证:
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?
三个排查方向:
- 确认 pom.xml 里有
spring-ai-alibaba-starter-dashscope - 确认环境变量
AI_DASHSCOPE_API_KEY已设置且值正确 - 如果自定义了
@Configuration手动创建ChatClientBean,可能和自动配置冲突。建议在@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 |
下一章:Function Calling------让 LLM 长出"手脚"。LLM 的回答全是"编"的,怎么让它调用你的 Java 方法去查真实数据?下一篇从 HTTP 裸调讲到 Spring AI 封装,实现机票查询和比价两个工具函数。
本文代码 :GitHub - chat-client 模块
如果这篇文章对你有帮助,欢迎点赞收藏。有问题欢迎评论区交流。