Spring AI Alibaba ChatClient实战:流式输出与多轮对话管理

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 的设计哲学出发,覆盖了以下核心知识点:

  1. Fluent API 链式调用.prompt()→.user()→.call()/.stream() 的完整调用链;
  2. 流式输出text/event-stream + Flux 的 SSE 实现,告别用户等待感;
  3. ChatMemoryInMemoryChatMemory 快速上手,RedisChatMemory 生产落地;
  4. Advisor 机制:拦截器链的原理与自定义扩展;
  5. 异常治理:超时配置、指数退避重试、QPS 限流三道防线;
  6. 性能优化:连接池调优 + 异步非阻塞调用。

下一篇将进入多模态领域,探索如何用 Spring AI Alibaba 实现图文混合理解、视频分析和文生图能力。


参考资料

相关推荐
1941s1 小时前
Google Agent Development Kit (ADK) 指南 第四章:Agent 开发与编排
人工智能·python·langchain·agent·adk
nap-joker1 小时前
【生物年龄age gap】基于影像的器官特异性衰老时钟预测人类疾病和死亡率
人工智能·深度学习·影像·生物年龄·age gap
竹二木1 小时前
深入拆解 AI Coding Agent 的底层原理
人工智能
Figo_Cheung1 小时前
Figo神经网络几何优化:基于深度学习发现复杂参数空间中的最优测量几何
人工智能·深度学习·神经网络
小帅学编程1 小时前
英语学习笔记
java·笔记·学习
deephub1 小时前
Claude Code 命令体系解析:三种类型、七大分类、50+ 命令
人工智能·大语言模型·claude·claude code
shangjian0071 小时前
AI-大语言模型-模型训练-数据集2-数据规模要求
人工智能·机器学习·语言模型
redsea_HR1 小时前
红海eHR解决方案背后的底层能力
大数据·数据库·人工智能
qq_452396231 小时前
【Python × AI】LangChain 深度剖析:从组件解耦到 LCEL 的逻辑美学
人工智能·python·ai·langchain