@[TOC]
Java 架构师的 AI 工程笔记(二):从一个 HTTP 请求开始,搞懂 ChatClient 的每一层
这是 Spring AI Alibaba 系列的第二篇,聊聊 ChatClient 到底帮你做了什么。 上一篇跑通了 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 ← 通义千问的实现
| 对比项 | ChatModel | ChatClient |
|---|---|---|
| 抽象级别 | 低级,需手动组装 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 之前和响应返回之后,插入横切逻辑。
sequenceDiagram
participant Code as 你的代码
participant CC as ChatClient
participant MA as MemoryAdvisor
participant LA as LoggerAdvisor
participant CM as ChatModel
participant LLM as 通义千问
Code->>CC: prompt().user("北京到上海")
CC->>MA: before(request)
Note over MA: 从 ChatMemory 取历史<br/>塞进消息列表
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:
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 一览
| 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 的源码串起来,完整的调用链路是这样的:
chatClient.prompt("你好").call().content()
① prompt("你好")
→ copy constructor 复制默认配置(defaultSystem/defaultOptions/defaultAdvisors)
→ spec.user("你好") 设置用户消息
② .call()
→ buildAdvisorChain():
收集 [默认Advisors... + 本次Advisors... + ChatModelCallAdvisor(order=MAX)]
按 order 排序,压入 Deque 栈
→ toChatClientRequest(spec):
把 systemText → SystemMessage
把 userText → UserMessage
合并成 Prompt
③ .content()
→ chain.nextCall(request)
→ 弹出 Advisor1 (order=0, 如 TokenUsageAdvisor)
→ before(): 记录开始时间
→ chain.nextCall(request)
→ 弹出 Advisor2 (order=100, 如 MemoryAdvisor)
→ before(): 取历史消息塞进 Prompt
→ chain.nextCall(request)
→ 弹出 ChatModelCallAdvisor (order=MAX, 最后一个)
→ chatModel.call(prompt)
→ DashScopeChatModel.buildRequestPrompt() 合并参数
→ DashScopeChatModel.createRequest() 构建 JSON
→ DashScopeChatModel.internalCall() 发 HTTP POST
← ChatClientResponse
← after(): 存本轮对话
← response
← after(): 统计 token、记录耗时
← response
→ 从 ChatResponse 中提取文本
四、整体架构回顾
从 HTTP 请求到 ChatClient 的四层抽象理清楚了:
graph TB
HTTP["HTTP POST<br/>curl + JSON"] --> Message["Message<br/>Java 对象化"]
Message --> Prompt["Prompt<br/>消息 + 参数打包"]
Prompt --> ChatModel["ChatModel<br/>发 HTTP + 工具调用循环"]
ChatModel --> ChatClient["ChatClient<br/>链式 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 基本用法
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。启动后调用任意接口,控制台输出:
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 不建议同时调。我写了个实验接口来验证:
/**
* 参数对比实验——观察不同参数组合的效果
* 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手动创建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 封装,实现机票查询和比价两个工具函数。
如果这篇文章对你有帮助,欢迎点赞收藏。有问题欢迎评论区交流。