Spring AI Alibaba ChatClient实战:流式输出与多轮对话管理
导读:本文聚焦于生产实战场景,深入剖析 ChatClient 的 Fluent API 设计哲学、Flux 响应式流式输出的前后端协作模式、基于 ChatMemory 的多轮对话状态管理,以及企业级必须面对的异常治理与性能优化方案。
一、ChatClient 的设计哲学:一切皆流式
在正式写代码之前,值得先理解 Spring AI 为什么要把 ChatClient 设计成链式 Fluent API,而不是传统的 Service 方法风格。
原因很简单:AI 调用天生是异步的、流式的。一次模型调用少则几百毫秒,多则数十秒,传统的同步 return response 模式会让线程白白阻塞,既浪费资源,用户体验也差。Spring AI 的 Fluent API 从设计上推动你往响应式方向走------先 .prompt() 描述请求,再在 .call() 或 .stream() 这两个出口处决定用阻塞还是响应式,其余所有配置都在这两个调用之前完成。
这个设计非常优雅,理解了它,你会发现后续很多 API 都是"水到渠成"的。
二、ChatClient Fluent API 全览
2.1 完整调用链结构
ChatClient.builder(chatModel) // 构建阶段
.defaultSystem("...") // 固化系统提示
.defaultOptions(options) // 全局参数
.defaultAdvisors(advisor1, ...) // 全局 Advisor
.build() // 得到 ChatClient 实例
|
v
chatClient.prompt() // 开始一次调用
.system("...") // 本次系统提示(覆盖 defaultSystem)
.user("...") // 用户消息
.messages(messageList) // 批量传入消息列表
.options(callOptions) // 本次参数(覆盖 defaultOptions)
.advisors(advisor -> ...) // Advisor 参数配置
.tools(toolCallbacks) // Function Calling 工具
|
+---> .call() // 同步阻塞,返回 CallResponse
| .content() // 提取文本
| .chatResponse() // 获取完整响应(含 metadata)
| .entity(Class<T>) // 结构化输出
|
+---> .stream() // 响应式流,返回 StreamResponse
.content() // Flux<String>
.chatResponse() // Flux<ChatResponse>
2.2 defaultSystem 的高级用法
defaultSystem 不仅支持静态字符串,还支持模板变量:
java
@Bean
public ChatClient chatClient(ChatClient.Builder builder) {
return builder
// 使用 {} 占位符,运行时通过 .system(s -> s.param("k","v")) 填充
.defaultSystem("""
你是 {company} 公司的智能助手,专注于 {domain} 领域。
当前时间:{datetime}
回答语言:{language}
""")
.build();
}
// 调用时填充参数
public String chat(String message, String company) {
return chatClient.prompt()
.system(s -> s
.param("company", company)
.param("domain", "技术支持")
.param("datetime", LocalDateTime.now().toString())
.param("language", "中文"))
.user(message)
.call()
.content();
}
三、流式输出深度实践
3.1 为什么要用流式
用一个具体数字说明问题:通义千问 qwen-max 生成 500 字的回答,通常需要 3-8 秒。如果用阻塞式接口,用户要等 3-8 秒才看到第一个字,转化率会急剧下降。流式接口让用户在 200ms 内就看到第一个 token 开始输出,体验上的差距是数量级的。
3.2 服务端 SSE 实现
java
package com.example.ai.controller;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Flux;
@Slf4j
@RestController
@RequestMapping("/api/chat")
@RequiredArgsConstructor
public class StreamChatController {
private final ChatClient chatClient;
/**
* 基础流式接口
* produces 必须指定为 text/event-stream,否则 Flux 会被当作普通 JSON
*/
@GetMapping(value = "/stream", produces = "text/event-stream;charset=UTF-8")
public Flux<String> streamChat(
@RequestParam String message,
@RequestParam(required = false, defaultValue = "default") String sessionId) {
log.info("[sessionId={}] 流式请求:{}", sessionId, message);
return chatClient.prompt()
.user(message)
.advisors(a -> a.param(
MessageChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY,
sessionId))
.stream()
.content()
// 流结束时记录日志(可选)
.doOnComplete(() -> log.info("[sessionId={}] 流式响应完成", sessionId))
.doOnError(e -> log.error("[sessionId={}] 流式响应异常:{}", sessionId, e.getMessage()));
}
/**
* 返回完整 ChatResponse 的流式接口
* 可以获取每个 chunk 的 metadata(如 finishReason)
*/
@GetMapping(value = "/stream/full", produces = "text/event-stream;charset=UTF-8")
public Flux<String> streamChatFull(@RequestParam String message) {
return chatClient.prompt()
.user(message)
.stream()
.chatResponse()
// 只取文本内容(过滤掉 null 的 chunk)
.filter(resp -> resp.getResult() != null
&& resp.getResult().getOutput() != null
&& resp.getResult().getOutput().getContent() != null)
.map(resp -> resp.getResult().getOutput().getContent());
}
}
3.3 前端 JavaScript 消费 SSE
javascript
/**
* 前端 SSE 消费示例(原生 JavaScript)
*/
function startStreamChat(message, sessionId) {
const url = `/api/chat/stream?message=${encodeURIComponent(message)}&sessionId=${sessionId}`;
// 创建 EventSource 连接
const eventSource = new EventSource(url);
const outputDiv = document.getElementById('chat-output');
outputDiv.textContent = ''; // 清空
// 接收每个 chunk
eventSource.onmessage = function(event) {
outputDiv.textContent += event.data;
};
// 处理错误(包括流结束时的正常关闭)
eventSource.onerror = function(event) {
eventSource.close();
console.log('流式输出完成');
};
}
踩坑记录 :Spring Boot 默认的
HttpMessageConverter可能会缓冲 Flux 输出,导致流式效果失效,实际变成等全部生成后一次性返回。解决方案是确保项目引入了spring-boot-starter-webflux,或者配置spring.mvc.async.request-timeout=-1。
3.4 流式输出的完整数据流
DashScope API Spring Controller 前端浏览器
| | |
| -- token chunk 1 ---------> | |
| | -- data: "Spring" -------> |
| -- token chunk 2 ---------> | |
| | -- data: " AI" ----------> |
| -- token chunk 3 ---------> | |
| | -- data: " 很强大" -------> |
| ... | ... |
| -- [DONE] ----------------> | |
| | -- event: close ---------> |
| | (流关闭) |
四、多轮对话管理:ChatMemory 深度解析
4.1 多轮对话的本质问题
大语言模型本身是无状态的------每次 API 调用都是独立的,模型不会"记住"上次你说了什么。实现多轮对话的方式是:将历史消息一起拼接到下一次请求里,让模型看到完整的对话上下文。
Spring AI 的 ChatMemory 接口封装了这个"历史消息管理"的逻辑,MessageChatMemoryAdvisor 负责在每次请求时自动注入历史消息。
4.2 InMemoryChatMemory:快速上手
java
@Configuration
public class ChatMemoryConfig {
/**
* 内存版 ChatMemory
* 适合:单机部署、开发测试、会话量不大的场景
* 缺点:应用重启后历史丢失,多实例部署时不同实例的记忆不共享
*/
@Bean
public ChatMemory inMemoryChatMemory() {
return new InMemoryChatMemory();
}
@Bean
public ChatClient chatClientWithMemory(
ChatClient.Builder builder,
ChatMemory chatMemory) {
return builder
.defaultSystem("你是一个有记忆能力的智能助手,能记住对话历史")
.defaultAdvisors(
// 记忆管理 Advisor
new MessageChatMemoryAdvisor(chatMemory),
// 日志 Advisor(开发阶段开启)
new SimpleLoggerAdvisor()
)
.build();
}
}
使用方式:
java
@GetMapping("/multi-turn")
public String multiTurnChat(
@RequestParam String message,
@RequestParam String sessionId) {
return chatClient.prompt()
.user(message)
// 通过 Advisor 参数传入会话 ID
// Advisor 会自动加载该 sessionId 的历史消息
.advisors(a -> a.param(
MessageChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY,
sessionId))
.call()
.content();
}
4.3 Redis 版 ChatMemory:生产推荐
内存版 ChatMemory 在生产环境有两个致命缺陷:重启丢失历史、多实例无法共享。Redis 版解决了这两个问题。
首先添加依赖:
xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
然后实现 Redis 版 ChatMemory:
java
package com.example.ai.memory;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.messages.Message;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
/**
* 基于 Redis 的 ChatMemory 实现
* 支持分布式部署,历史消息持久化存储
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class RedisChatMemory implements ChatMemory {
private final RedisTemplate<String, Object> redisTemplate;
// 历史消息默认保留 24 小时
private static final Duration TTL = Duration.ofHours(24);
// 每个会话最多保留最近 N 轮对话(避免 context 过长)
private static final int MAX_MESSAGES = 20;
private String buildKey(String conversationId) {
return "chat:memory:" + conversationId;
}
@Override
public void add(String conversationId, List<Message> messages) {
String key = buildKey(conversationId);
List<Object> existing = redisTemplate.opsForList().range(key, 0, -1);
if (existing == null) existing = new ArrayList<>();
// 序列化并追加新消息
for (Message message : messages) {
redisTemplate.opsForList().rightPush(key, serializeMessage(message));
}
// 超出限制时,裁剪旧消息(保留最近 MAX_MESSAGES 条)
long size = redisTemplate.opsForList().size(key);
if (size != null && size > MAX_MESSAGES) {
redisTemplate.opsForList().trim(key, size - MAX_MESSAGES, -1);
}
// 刷新 TTL
redisTemplate.expire(key, TTL);
log.debug("[conversationId={}] 新增 {} 条消息,当前共 {} 条",
conversationId, messages.size(),
redisTemplate.opsForList().size(key));
}
@Override
public List<Message> get(String conversationId, int lastN) {
String key = buildKey(conversationId);
List<Object> raw = redisTemplate.opsForList().range(key, -lastN, -1);
if (raw == null || raw.isEmpty()) {
return new ArrayList<>();
}
return raw.stream()
.map(o -> deserializeMessage((String) o))
.filter(m -> m != null)
.toList();
}
@Override
public void clear(String conversationId) {
redisTemplate.delete(buildKey(conversationId));
log.info("[conversationId={}] 会话历史已清除", conversationId);
}
// 序列化/反序列化方法(实际项目建议用 Jackson)
private String serializeMessage(Message message) {
return message.getMessageType().name() + "|" + message.getContent();
}
private Message deserializeMessage(String raw) {
// 实际实现需要完整的反序列化逻辑
// 这里简化处理,生产环境请使用 Jackson + 完整类型信息
return null;
}
}
4.4 Message 类型说明
Spring AI 中的消息类型对应大模型的 Role 概念:
消息类型 Role 用途
UserMessage → user → 用户输入
AssistantMessage → assistant → AI 回复
SystemMessage → system → 系统提示(行为约束)
ToolResponseMessage → tool → 函数调用结果(Function Calling 场景)
手动构建多轮对话消息列表:
java
public String chatWithHistory(String newMessage, List<String[]> history) {
List<Message> messages = new ArrayList<>();
// 加入历史对话
for (String[] turn : history) {
messages.add(new UserMessage(turn[0]));
messages.add(new AssistantMessage(turn[1]));
}
// 加入新消息
messages.add(new UserMessage(newMessage));
return chatClient.prompt()
.messages(messages)
.call()
.content();
}
五、Advisor 机制深度解析
5.1 Advisor 是什么
Advisor 是 Spring AI 的 AOP 机制,可以在 ChatClient 发出请求前后插入横切逻辑,类似 Spring AOP 的 @Around。常见用途:
- 注入会话记忆(
MessageChatMemoryAdvisor) - 注入 RAG 检索上下文(
QuestionAnswerAdvisor) - 请求/响应日志(
SimpleLoggerAdvisor) - 请求限流(自定义)
- 敏感词过滤(自定义)
5.2 自定义 Advisor 示例
java
package com.example.ai.advisor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.AdvisedRequest;
import org.springframework.ai.chat.client.RequestResponseAdvisor;
import org.springframework.ai.chat.model.ChatResponse;
import reactor.core.publisher.Flux;
/**
* 自定义 Token 计费 Advisor
* 记录每次调用的 Token 消耗,用于成本分析
*/
@Slf4j
public class TokenBillingAdvisor implements RequestResponseAdvisor {
@Override
public AdvisedRequest adviseRequest(AdvisedRequest request, Map<String, Object> context) {
// 请求前:记录开始时间
context.put("startTime", System.currentTimeMillis());
return request;
}
@Override
public ChatResponse adviseResponse(ChatResponse response, Map<String, Object> context) {
// 响应后:计算耗时和 Token 数
long duration = System.currentTimeMillis() - (long) context.get("startTime");
if (response.getMetadata() != null) {
var usage = response.getMetadata().getUsage();
if (usage != null) {
log.info("[Token Billing] 耗时={}ms, 输入Token={}, 输出Token={}, 总计={}",
duration,
usage.getPromptTokens(),
usage.getGenerationTokens(),
usage.getTotalTokens());
// 实际项目这里可以写入数据库或发送到计费系统
}
}
return response;
}
@Override
public Flux<ChatResponse> adviseResponse(
Flux<ChatResponse> fluxResponse, Map<String, Object> context) {
// 流式响应的处理
return fluxResponse.doOnComplete(() -> {
long duration = System.currentTimeMillis() - (long) context.get("startTime");
log.info("[Token Billing] 流式调用完成,耗时={}ms", duration);
});
}
@Override
public int getOrder() {
// 数值越小,优先级越高(越先执行 before,越后执行 after)
return 100;
}
}
注册自定义 Advisor:
java
@Bean
public ChatClient chatClient(ChatClient.Builder builder, ChatMemory chatMemory) {
return builder
.defaultAdvisors(
new MessageChatMemoryAdvisor(chatMemory),
new TokenBillingAdvisor(), // 加入计费 Advisor
new SimpleLoggerAdvisor()
)
.build();
}
六、异常治理:网络超时、降级与限流
6.1 超时配置
DashScope API 调用存在网络延迟,必须配置合理的超时时间:
yaml
spring:
ai:
dashscope:
# 连接超时(毫秒)
connect-timeout: 5000
# 读取超时(毫秒):生成较长内容时可能需要更长时间
read-timeout: 60000
6.2 重试策略
java
@Configuration
public class RetryConfig {
/**
* AI 调用重试模板
* 网络超时和服务暂时不可用时自动重试
*/
@Bean
public RetryTemplate aiRetryTemplate() {
return RetryTemplate.builder()
// 最多重试 3 次
.maxAttempts(3)
// 指数退避:首次等 1s,之后翻倍,最长等 10s
.exponentialBackoff(1000, 2, 10000)
// 只对特定异常重试
.retryOn(RuntimeException.class)
// 不对这些异常重试(业务逻辑错误不需要重试)
.notRetryOn(IllegalArgumentException.class)
.build();
}
}
@Service
@RequiredArgsConstructor
public class ResilientChatService {
private final ChatClient chatClient;
private final RetryTemplate retryTemplate;
public String reliableChat(String message) {
return retryTemplate.execute(ctx -> {
if (ctx.getRetryCount() > 0) {
log.warn("第 {} 次重试,原因:{}", ctx.getRetryCount(),
ctx.getLastThrowable().getMessage());
}
return chatClient.prompt(message).call().content();
}, ctx -> {
// 3 次重试全部失败后的降级处理
log.error("AI 调用失败,启用降级响应", ctx.getLastThrowable());
return "抱歉,AI 服务暂时不可用,请稍后再试。";
});
}
}
6.3 QPS 限流:防止费用暴增
java
@Component
public class RateLimitedChatClient {
private final ChatClient chatClient;
// 令牌桶:每秒最多 10 次请求
private final RateLimiter rateLimiter = RateLimiter.create(10.0);
public RateLimitedChatClient(ChatClient chatClient) {
this.chatClient = chatClient;
}
public String chat(String message) {
// 尝试获取令牌,等待最多 2 秒
if (!rateLimiter.tryAcquire(2, TimeUnit.SECONDS)) {
throw new TooManyRequestsException("请求过于频繁,请稍后再试");
}
return chatClient.prompt(message).call().content();
}
}
七、性能优化:连接池与异步非阻塞
7.1 HttpClient 5 连接池配置
Spring AI Alibaba 底层使用 Apache HttpClient 5 调用 DashScope API,合理配置连接池可以显著提升高并发下的吞吐量:
java
@Configuration
public class HttpClientConfig {
@Bean
public CloseableHttpClient httpClient() {
// 连接池配置
PoolingHttpClientConnectionManager connectionManager =
new PoolingHttpClientConnectionManager();
connectionManager.setMaxTotal(200); // 连接池总连接数
connectionManager.setDefaultMaxPerRoute(50); // 每个路由最大连接数
return HttpClients.custom()
.setConnectionManager(connectionManager)
// 连接闲置 30 秒后关闭
.evictIdleConnections(TimeValue.ofSeconds(30))
// 连接最长存活 5 分钟
.setConnectionTimeToLive(TimeValue.ofMinutes(5))
.build();
}
}
7.2 异步非阻塞调用
java
@Service
@RequiredArgsConstructor
public class AsyncChatService {
private final ChatClient chatClient;
/**
* 异步调用:立即返回 CompletableFuture,不阻塞调用线程
*/
@Async
public CompletableFuture<String> asyncChat(String message) {
String result = chatClient.prompt(message).call().content();
return CompletableFuture.completedFuture(result);
}
/**
* 并行调用多个模型,取最快返回的结果
*/
public String fastestResponse(String message) {
// 同时发起 qwen-turbo 和 qwen-plus 两个请求
CompletableFuture<String> future1 = asyncChat(message);
CompletableFuture<String> future2 = asyncChat(message);
// 取最先完成的那个(anyOf 不区分具体是哪个)
return (String) CompletableFuture
.anyOf(future1, future2)
.join();
}
}
八、完整多轮对话实战示例
java
@Slf4j
@RestController
@RequestMapping("/api/conversation")
@RequiredArgsConstructor
public class ConversationController {
private final ChatClient chatClient;
/**
* 多轮对话接口
* 前端每次请求携带 sessionId,服务端自动管理会话历史
*/
@PostMapping("/chat")
public ResponseEntity<Map<String, String>> chat(@RequestBody ChatRequest request) {
log.info("[session={}] 用户:{}", request.getSessionId(), request.getMessage());
String response = chatClient
.prompt()
.user(request.getMessage())
.advisors(a -> a.param(
MessageChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY,
request.getSessionId()))
.call()
.content();
log.info("[session={}] 助手:{}", request.getSessionId(), response);
return ResponseEntity.ok(Map.of(
"sessionId", request.getSessionId(),
"response", response
));
}
/**
* 清除会话历史
*/
@DeleteMapping("/session/{sessionId}")
public ResponseEntity<Void> clearSession(
@PathVariable String sessionId,
ChatMemory chatMemory) {
chatMemory.clear(sessionId);
log.info("[session={}] 会话历史已清除", sessionId);
return ResponseEntity.noContent().build();
}
/**
* 获取会话历史
*/
@GetMapping("/session/{sessionId}/history")
public ResponseEntity<List<Map<String, String>>> getHistory(
@PathVariable String sessionId,
ChatMemory chatMemory) {
List<Message> messages = chatMemory.get(sessionId, 20);
List<Map<String, String>> history = messages.stream()
.map(m -> Map.of(
"role", m.getMessageType().name().toLowerCase(),
"content", m.getContent()))
.toList();
return ResponseEntity.ok(history);
}
}
九、总结
本文从 ChatClient 的设计哲学出发,覆盖了以下核心知识点:
- Fluent API 链式调用 :
.prompt()→.user()→.call()/.stream()的完整调用链; - 流式输出 :
text/event-stream+ Flux 的 SSE 实现,告别用户等待感; - ChatMemory :
InMemoryChatMemory快速上手,RedisChatMemory生产落地; - Advisor 机制:拦截器链的原理与自定义扩展;
- 异常治理:超时配置、指数退避重试、QPS 限流三道防线;
- 性能优化:连接池调优 + 异步非阻塞调用。
下一篇将进入多模态领域,探索如何用 Spring AI Alibaba 实现图文混合理解、视频分析和文生图能力。
参考资料