臭名昭著aTrust隔离计划1.臭名昭著aTrust隔离计划2.VPN原理与aTrust隔离网络实践3.docker-easyconnect到底做了什么4.TUN(tunnel-隧道-虚拟网卡)模式
个人项目
personal(个人展示)GitHub Actions 自动部署个人展示项目
基于Quartz搭建的个人博客1.Quartz个人博客使用教程2.使用 rsync 增量部署 Quartz 博客3.使用 GitHub Actions 自动部署 Quartz 博客5.域名绑定6.CDN加速7.Google 搜索收录排查:为什么关键词搜得到别人,却搜不到我的网站?Obsidian、双向链接与知识图谱
架构设计
分布式分布式事务RPC原理
旅行
行程攻略青岛三日游行程攻略
前端
NginxNginx配置与反向代理入门
Nodenpm与npx的区别
数据库
mysql01.MySQL基础语法与约束02.MySQL数据库类型03.MySQL多表查询04.MySQL 索引05.MySQL 事务06.MySQL 隔离级别07.MySQL MVCC08.MySQL 锁09.EXPLAIN10.慢查询与 SQL 优化
mysql面试题01.索引问题02.事务与隔离级别03.锁机制04.存储引擎与性能优化05.用户权限、字符集与备份恢复
mysql实战01.EXPLAIN实战02.测试环境菜单误删排查与恢复03.MySQL IN 详解
网络
网络安全1.无线监听网卡苹果M系列虚拟机安装Kali Linux教程3.kali怎么使用RTL8812AU4.kali基础命令
网络基础进程之间怎样通过网络传递数据
网址常用网址
中间键
esElasticsearch 入门
mq
RabbitMQRabbitMQ高级RabbitMQ基础
1. MQ 基础2. MQ 核心角色与代码对应3. MQ Consumer Group 与消费模式4. SOFAMQ 账户变更代码实践5. MQ 可靠性问题6. 消息幂等
redis缓存击穿、穿透与雪崩Redis 分布式锁Redis 缓存问题Redis 数据结构
AI
使用说明
第三方插件&技能简介Archify使用与安装指南geekgeekRun使用OfferFlow使用指南Ponytail使用指南Skills Manager使用指南
CodexCodex CLI与IDE区别及使用指南Codex Hook单独配置与提交通知Codex MCP安装与使用指南Codex第三方插件安装与使用指南
Agent开发0-学习路线1.LLM、Token、上下文窗口与模型参数2.大模型 API、请求参数与响应结构3.Spring AI ChatClient4.Prompt、System Prompt、Prompt 模板6.结构化输出、JSON Schema7.SSE 流式响应8.会话 ID、聊天记录、Redis9.超时、重试、限流、降级10.*完成可运行聊天接口11.Function Calling、Tool Calling 原理12.用 Java 方法定义 Tool13.工具参数设计和校验14.实现订单查询工具15.实现物流查询工具16.实现库存查询工具17.多个工具自动选择18.工具异常、超时、重试
diy主机
主机零件学习1.CPU2.主板3.内存4.显卡5.硬盘6.散热7.风扇8.机箱热门型号价格表
捡垃圾diy-13600kf+5060diy-14600kf+5070
English高频词汇英文基础语法简单句子音标
GitGit常用命令Sublime Merge 使用指南
Java
工具库Caffeine本地缓存Reactor教程
工作实践如何使用@Async线程池使用与排查线上服务 CPU 使用率过高排查压测下RPC接口大量失败排查记录
基础1.HashMap2.ConcurrentHashMap3.HashSet4.final5.static6.volatile7.AtomicInteger8.String动态代理反射锁与线程同步线程注解
面试题1.HashMap 存储在方法内是线程安全的吗2.ArrayList参数传递与引用重新赋值3.== 和 equals 的区别、hashcode4.重载和重写的区别5.String、StringBuffer、StringBuilder区别及使用场景6.接口和抽象类7.ArrayList 与 LinkedList 的区别和扩容机制8.HashMap 和 Hashtable 的区别及底层实现9.Object 和 Objects 的区别10.ThreadLocal的原理和使用场景11.字节码是什么及作用12.Java类加载器有哪些13.双亲委派模型14.Java异常体系15.GC是什么16.GC如何判断对象可以被回收
面试总结0.HR面试模板1.面试一2.面试二3.面试三笔试
网络与 IO(黑马程序员-纯理论)01-网络编程基础02-IO模型与零拷贝03-BIO04-NIO基础与缓冲区05-NIO通道与选择器06-AIO
juc00-目录01-进程与线程02-线程同步与通信03-并发设计模式04-Java内存模型05-CAS与原子类06-ThreadLocal07-阻塞队列08-线程池使用与原理09-任务调度10-ForkJoin与享元模式11-AQS12-ReentrantLock13-读写锁14-并发协作工具15-ConcurrentHashMap16-CopyOnWrite与同步容器17-SkipListMap18-非阻塞队列
juc(工作常见)Java并发编程三个重点拆解
jvm1. JVM运行时数据区
Spring1.Spring IoC、DI与构造方法注入CGLIB、Byte Buddy 与 Spring AOPSpring 常用注解详解Spring Bean 生命周期
Spring MVC异常处理机制
linux常用命令
mac快捷键终端常用操作
python
基础01.Python是什么,为什么要安装Python02.Python项目中.venv是什么03.Python基础语法
基础实战sql替换
HTTPXHRMS员工详情接口调用(Python HTTPX)
windowswindows激活

一、💡 一句话理解

第 10 天核心结论

今天不是再学一个新框架,而是把前 1~9 天已经验证过的能力收口成一个接口:客户端发送问题,后端通过 SSE 持续返回模型文本,并按 conversationId 把本轮问答保存到 Redis 会话记忆中。

本篇严格对应 0-学习路线 第 10 天:10.*完成可运行聊天接口。它只整合已经学习过的 ChatClient、SSE、会话记忆和接口保护,不提前加入 Tool Calling、RAG 或 Agent 工作流。

学习建议

本篇会用到 Reactor 的 Flux、流式信号和订阅。如果你对这些概念还不熟悉,建议先阅读 Reactor教程,再继续完成本篇的聊天接口实践。

第 10 天的验收目标是:

POST /api/chat/stream
  → 返回 text/event-stream
  → 首次请求生成 conversationId
  → 后续请求携带同一个 ID
  → 模型能利用最近聊天上下文回答
  → Redis 保存短期会话记忆

二、🧭 理论:它是什么

2.1 什么叫“可运行聊天接口”

它不是只有“能调用模型”的一个 Controller 方法,而是一条可以被客户端稳定使用的完整链路。

能力前序学习内容第 10 天怎样组合
模型调用3.Spring AI ChatClientChatClient 负责组织 Prompt 并调用模型。
流式输出7.SSE 流式响应stream().content() 产生文本片段,WebMVC 以 SSE 推送。
连续对话8.会话 ID、聊天记录、Redis同一 conversationId 读取和写回最近消息窗口。
异常保护9.超时、重试、限流、降级重试配置、入口限流和流内错误事件避免把失败伪装成答案。

本篇中的“会话记录”

Redis 保存的是给模型提供上下文的短期会话记忆,不是长期审计档案。路线后续接入 PostgreSQL、权限和审计后,再单独设计可查询、可删除的完整聊天记录。

2.2 请求为什么要带会话 ID

大模型不会天然记住上一轮。conversationId 就是一段对话的档案编号:首次请求由后端生成,客户端保存后在下一次请求中带回。MessageChatMemoryAdvisor 按这个 ID 从 Redis 读取历史消息,并在本轮结束后写回用户消息和助手消息。

2.3 SSE 事件协议:为什么不直接裸返回字符串

直接推文本可以跑通,但客户端无法分辨“这是模型正文”“模型已经结束”还是“调用失败”。本篇统一定义三类事件:

事件名何时出现data 中的关键字段
delta模型产生一个文本片段conversationId、content
done模型正常结束conversationId
error超时、上游失败或连接异常conversationId、code、message

这样前端只需要订阅自己的 /api/chat/stream,无需理解不同模型供应商的原始流式协议。

三、⚙️ 理论:它是怎么工作的

3.1 Spring AI 调用链

Spring AI 官方的 ChatClient 同时支持同步调用和流式调用。此处必须使用 .stream().content(),它返回会陆续产生内容的 Flux<String>;误写成 .call().content() 就会退化为等待完整回答后一次性返回的普通接口。

// 正确:得到会持续产生文本片段的数据流。
Flux<String> contentStream = chatClient.prompt()
        .user(message)
        .stream()
        .content();

Spring MVC 可以处理响应式返回值,因此本学习路线既然明确选择 WebMVC,就不额外加入 spring-boot-starter-webflux。同一个项目混用两套 Web 技术栈容易让自动配置和排错范围变大。

3.2 本篇的保护边界

请求进入
  → 限流过滤器:过快则直接 429
  → Controller:校验 message、生成或复用 conversationId
  → Spring AI:按 spring.ai.retry 配置重试暂时性上游错误
  → 流式模型调用:最长等待 30 秒
  → SSE:持续发送 delta;正常发送 done;异常发送 error
  → Redis:由 Memory Advisor 保存短期上下文

这里的 30 秒 只限制本服务等待响应式数据流的最长时间;它不保证远端服务一定立即取消请求。错误事件不是模型答案,客户端收到后应停止本轮渲染并提示用户重试。

四、🚀 实践:从准备到验证

4.1 前置准备

4.1.1 Maven:严格沿用学习路线的基础配置

项目使用 Java 21、Spring Boot 4.1.1、Spring AI 2.0.1 与唯一的 WebMVC starter。不要同时添加 spring-boot-starter-webflux,也不要把 API Key 写入 pom.xml。

文件位置:pom.xml。

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
 
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>4.1.1</version>
        <relativePath/>
    </parent>
 
    <groupId>com.afyke</groupId>
    <artifactId>ai-agent-demo</artifactId>
    <version>0.0.1-SNAPSHOT</version>
 
    <properties>
        <java.version>21</java.version>
        <spring-ai.version>2.0.1</spring-ai.version>
    </properties>
 
    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.ai</groupId>
                <artifactId>spring-ai-bom</artifactId>
                <version>${spring-ai.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>
 
    <dependencies>
        <!-- 唯一的 Web 技术栈:Spring MVC。 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-webmvc</artifactId>
        </dependency>
        <!-- 校验请求中的 message,避免空问题消耗模型配额。 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>
        <!-- OpenAI 兼容模型(例如路线中的 DeepSeek 配置)。 -->
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-starter-model-openai</artifactId>
        </dependency>
        <!-- Redis Stack 聊天记忆仓库。 -->
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-starter-model-chat-memory-repository-redis</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>
 
    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

依赖更新后,在 IDEA 的 Maven 面板重新加载。路线指定的本机 Maven 路径是 /Volumes/data/develop/apache-maven-3.9.16;没有 Maven Wrapper 时,使用它执行后文的 Maven 命令。

4.1.2 高级配置:模型、Redis 和重试

文件位置:src/main/resources/application.properties。真实密钥只通过 IDEA Run Configuration 或终端环境变量 DEEPSEEK_API_KEY 提供。

# ===== 模型:沿用学习路线的 OpenAI 兼容配置 =====
spring.ai.openai.api-key=${DEEPSEEK_API_KEY}
spring.ai.openai.base-url=https://api.deepseek.com
spring.ai.openai.chat.model=deepseek-v4-flash
 
# ===== Redis Stack 会话记忆 =====
spring.ai.chat.memory.repository.redis.host=localhost
spring.ai.chat.memory.repository.redis.port=6379
# 如果 Redis 设置密码,取消下一行注释,并在环境变量中提供 REDIS_PASSWORD。
# spring.ai.chat.memory.repository.redis.password=${REDIS_PASSWORD}
spring.ai.chat.memory.repository.redis.key-prefix=chat-memory:
spring.ai.chat.memory.repository.redis.time-to-live=24h
# 本地学习时创建 Redis 搜索索引;生产环境应由部署流程统一管理。
spring.ai.chat.memory.repository.redis.initialize-schema=true
 
# ===== 第 9 天的模型调用重试配置 =====
spring.ai.retry.max-attempts=3
spring.ai.retry.backoff.initial-interval=1s
spring.ai.retry.backoff.multiplier=2
spring.ai.retry.backoff.max-interval=4s
spring.ai.retry.on-client-errors=false
spring.ai.retry.on-http-codes=429,500,502,503,504
spring.ai.retry.exclude-on-http-codes=400,401,403

429 在这里指模型供应商返回的限额错误;它和稍后本服务限流返回的 429 状态相同,但发生位置不同。认证、权限和参数错误不能重试。

4.1.3 启动 Redis Stack 并检查构建

RedisChatMemoryRepository 需要 Redis Stack,基础 Redis 镜像不够。Docker 可用时执行:

docker run -d --name ai-redis-stack -p 6379:6379 redis/redis-stack-server:latest
mvn clean compile

预期:Redis 容器正在运行,且 Maven 编译成功。若你已创建同名容器,不要再次执行 docker run;先使用 docker start ai-redis-stack 启动即可。

4.2 可以拿来干什么

4.2.1 实现连续流式对话

输入是 message 和可选 conversationId;输出是连续的 SSE 事件。第一次获得服务端返回的 ID 后,客户端必须保存它并用于下一次请求:

{"message":"我叫小王。"}

下一次:

{"conversationId":"demo-user:实际返回的UUID","message":"我叫什么?"}

预期:模型可以基于 Redis 中的最近消息回答“小王”。这只验证记忆链路,不代表模型回答百分之百可靠。

4.2.2 为后续 Agent 保留稳定入口

第 20 天加入 Tool Calling、第 30 天加入 RAG 后,客户端仍可以调用 /api/chat/stream;变化应留在 Controller 之后的业务层,而不是把供应商 URL、密钥和原始协议暴露给前端。

4.3 完整实践:代码、启动和验证

下面的代码实现完整的第 10 天链路。假设应用基础包为 com.afyke.ai;若你的实际包名不同,必须统一替换所有文件第一行的 package。

4.3.1 注册 Redis 会话记忆与 ChatClient

要解决的问题:让每一次模型调用能按会话 ID 自动读取和写回 Redis 中的最近消息。

文件位置:src/main/java/com/afyke/ai/config/ChatMemoryConfig.java。

package com.afyke.ai.config;
 
import java.time.Duration;
 
import redis.clients.jedis.RedisClient;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.memory.MessageWindowChatMemory;
import org.springframework.ai.chat.memory.repository.redis.RedisChatMemoryRepository;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
 
/**
 * 把 Redis Stack 里的消息存储、消息窗口和 ChatClient 串起来。
 * Controller 不需要手写“查历史、拼 Prompt、存回答”的重复逻辑。
 */
@Configuration
public class ChatMemoryConfig {
 
    @Value("${spring.ai.chat.memory.repository.redis.host:localhost}")
    private String redisHost;
 
    @Value("${spring.ai.chat.memory.repository.redis.port:6379}")
    private int redisPort;
 
    @Value("${spring.ai.chat.memory.repository.redis.key-prefix:chat-memory:}")
    private String redisKeyPrefix;
 
    @Value("${spring.ai.chat.memory.repository.redis.time-to-live:24h}")
    private Duration redisTimeToLive;
 
    @Bean
    RedisClient redisClient() {
        // 建立到 Redis Stack 的客户端;真实密码由 Starter 配置或安全部署配置处理。
        return RedisClient.builder().hostAndPort(redisHost, redisPort).build();
    }
 
    @Bean
    RedisChatMemoryRepository redisChatMemoryRepository(RedisClient redisClient) {
        // 指定键前缀和过期时间,避免短期测试会话永久占用 Redis。
        return RedisChatMemoryRepository.builder()
                .jedisClient(redisClient)
                .keyPrefix(redisKeyPrefix)
                .timeToLive(redisTimeToLive)
                .build();
    }
 
    @Bean
    ChatMemory chatMemory(RedisChatMemoryRepository repository) {
        // 每次模型调用最多带入最近 10 条消息,控制上下文长度和 Token 成本。
        return MessageWindowChatMemory.builder()
                .chatMemoryRepository(repository)
                .maxMessages(10)
                .build();
    }
 
    @Bean
    ChatClient chatClient(ChatClient.Builder builder, ChatMemory chatMemory) {
        // Advisor 在模型调用前读历史、在调用完成后写回本轮用户和助手消息。
        return builder
                .defaultAdvisors(MessageChatMemoryAdvisor.builder(chatMemory).build())
                .build();
    }
}

输入是 application.properties 中的 Redis 地址、端口、键前缀和 TTL;输出是已注册记忆 Advisor 的唯一 ChatClient Bean。后续 Controller 必须注入这个 ChatClient,不要再注入 ChatClient.Builder 后自行 build(),否则会绕开这里注册的记忆能力。

4.3.2 定义接口:SSE、会话 ID 与错误边界

要解决的问题:接收经过校验的消息,首次生成会话 ID,用 SseEmitter 依次发送 delta、done 或 error 事件。

文件位置:src/main/java/com/afyke/ai/controller/ChatController.java。

package com.afyke.ai.controller;  
  
import jakarta.validation.Valid;  
import jakarta.validation.constraints.NotBlank;  
import org.springframework.ai.chat.client.ChatClient;  
import org.springframework.ai.chat.memory.ChatMemory;  
import org.springframework.http.MediaType;  
import org.springframework.web.bind.annotation.PostMapping;  
import org.springframework.web.bind.annotation.RequestBody;  
import org.springframework.web.bind.annotation.RequestMapping;  
import org.springframework.web.bind.annotation.RestController;  
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;  
  
import java.io.IOException;  
import java.time.Duration;  
import java.util.UUID;  
  
/**  
 * 对外只暴露自己的聊天协议;前端不直接接触模型供应商地址和 API Key。  
 */  
  
/**  
 * 这是实践,本章最重要的知识点之一,不能删  
 */  
@RestController  
@RequestMapping("/api/chat")  
public class ChatController {  
  
    // SSE_TIMEOUT_MILLIS:SSE 异步请求最多保持 35 秒;35_000L 的单位是毫秒,L 表示 long 类型。  
    // 它控制的是 Spring MVC 的 HTTP 异步请求,与下面 Reactor 的 timeout 属于两套不同的超时机制。  
    private static final long SSE_TIMEOUT_MILLIS = 35_000L;  
  
    private final ChatClient chatClient;  
  
    public ChatController(ChatClient chatClient) {  
        // 注入 4.3.1 中已配置好聊天记忆 Advisor 的 Bean。  
        this.chatClient = chatClient;  
    }  
  
    @PostMapping(  
            value = "/stream",  
            consumes = MediaType.APPLICATION_JSON_VALUE,  
            produces = MediaType.TEXT_EVENT_STREAM_VALUE  
    )  
    /**  
     * Controller 返回 SseEmitter 后,Spring 发现这是一个流式响应,于是保持 HTTP 连接打开,同时释放当前处理请求的线程。  
     *  
     * 普通接口是这样的:  
     * 浏览器请求  
     * → Controller 返回一个对象  
     * → Spring 转成 JSON  
     * → HTTP 请求结束  
     /  
  
  
     /**     * SseEmitter 接口则是:  
     * 浏览器请求  
     * → Controller 返回 emitter  
     * → Spring 保持 HTTP 连接打开  
     * → 原来的请求线程被释放  
     * → 后续通过 emitter.send(...) 多次发送数据  
     * → emitter.complete() 后连接才结束  
     */  
  
    /**     * 只有把 Flux 直接作为 Controller 返回值交给 Spring 时,Spring 才会替你订阅;你现在返回的是 SseEmitter,Flux 藏在方法内部,Spring 看不到它,所以代码才手动调用 subscribe()  
     */    public SseEmitter chatStream(@Valid @RequestBody ChatRequest request) {  
        // 新对话由服务端生成 ID;真实项目应把 demo-user 改为登录态中的可信用户 ID。  
        String conversationId = createOrReuseConversationId(request.conversationId());  
  
        // SseEmitter:Spring MVC 用来维持 SSE 长连接的对象。Controller 返回它以后,这次 HTTP 请求不会立刻结束,  
        // 服务端可以在 AI 生成回答的过程中,多次调用 emitter.send(...) 向浏览器推送文本片段。  
        // new SseEmitter(timeout):创建连接并设置异步请求超时时间;这里是 35 秒,不是 AI 模型本身的超时时间。  
        SseEmitter emitter = new SseEmitter(SSE_TIMEOUT_MILLIS);  
  
        // prompt:开始组装一次 AI 请求,后面的 system、user、advisors 都是在配置这次请求。  
        chatClient.prompt()  
                // system:给 AI 设定身份、回答风格和长期规则,优先级通常高于本轮用户问题;业务规则不要由客户端传入。  
                .system("你是企业售后与知识库 Agent 的学习版助手,请使用简洁、诚实的中文回答。")  
                // user:放入用户这一轮真正提出的问题;这里只传本轮消息,历史消息由聊天记忆 Advisor 自动读取。  
                .user(request.message())  
                // advisors:类似 AI 调用的拦截器,可在请求发出前和响应回来后做额外处理;这里把会话 ID 交给聊天记忆 Advisor。  
                // 相同 conversationId 会继续读取同一段聊天记录,不同 conversationId 的聊天记录彼此隔离。  
                .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId))  
                // stream:使用流式方式调用 AI,让回答像打字一样分批返回,而不是等完整答案生成后一次返回。  
                .stream()  
                // content:只取出模型回答中的文字内容,得到会持续产生文本片段的 Flux<String>。  
                .content()  
                // timeout:订阅后 30 秒内没有首个信号,或相邻两个信号间隔超过 30 秒,就抛出超时错误。  
                // 它限制的是“等待下一个信号的时间”,不是整轮模型回答的总耗时;超时后进入 subscribe 的 onError。  
                .timeout(Duration.ofSeconds(30))  
                // subscribe:订阅并正式启动这条流;依次定义“收到数据、发生错误、正常完成”三种处理方式。  
                .subscribe(  
                        // onNext/delta:把新增文字作为 SSE 事件发送,例如 event: delta、data: {"content":"你"}(实际还包含会话 ID 等字段)。  
                        chunk -> send(emitter, "delta", new ChatEvent(conversationId, chunk, null, null)),  
                        // onError:AI 调用、网络或超时发生异常时执行,而且执行后不会再调用下面的 onComplete。  
                        error -> {  
                            // 失败时给客户端可识别的事件,绝不伪造一段模型回答。  
                            send(emitter, "error", new ChatEvent(  
                                    conversationId,  
                                    null,                                    "AI_STREAM_FAILED",  
                                    "AI 服务暂时不可用或响应超时,请稍后重试"  
                            ));  
                            emitter.complete();  
                        },  
                        // onComplete:模型所有文本片段都正常发送完毕后执行。  
                        () -> {  
                            // 模型正常结束;客户端收到 done 后结束本轮渲染。  
                            send(emitter, "done", new ChatEvent(conversationId, null, null, null));  
                            emitter.complete();  
                        }  
                );  
  
        // onTimeout:注册超时回调;SSE 请求达到 35 秒时调用 complete() 结束连接。  
        // emitter::complete 等价于 () -> emitter.complete()。  
        emitter.onTimeout(emitter::complete);  
  
        // onCompletion:注册请求结束后的回调,正常结束、超时或网络异常最终都会触发。  
        // 当前 Lambda 内容为空,所以这里只是占位,实际没有执行清理逻辑。  
        emitter.onCompletion(() -> { });  
        return emitter;  
    }  
  
    private String createOrReuseConversationId(String requestedConversationId) {  
        if (requestedConversationId == null || requestedConversationId.isBlank()) {  
            return "demo-user:" + UUID.randomUUID();  
        }  
        return requestedConversationId;  
    }  
  
    /**  
     * 向当前浏览器连接发送一条 SSE 事件。  
     *  
     * @param emitter 当前请求对应的 SSE 连接,用来把事件推送给客户端  
     * @param eventName SSE 事件名称,例如 delta、error 或 done,客户端可根据名称分别处理  
     * @param event 事件携带的业务数据,Spring 会把它序列化成 JSON  
     */    // private:这个发送方法只供当前 Controller 内部使用;void:方法只负责发送事件,不返回结果。  
    private void send(SseEmitter emitter, String eventName, ChatEvent event) {  
        try {  
            // send:把构建完成的 SSE 事件写入当前 HTTP 长连接并推送给客户端。  
            emitter.send(  
                    // event:创建一条 SSE 事件的构建器,后面继续设置事件名称和数据。  
                    SseEmitter.event()  
                            // name:设置 SSE 的 event: 字段,例如 event: delta,方便客户端区分事件类型。  
                            .name(eventName)  
                            // data:设置 SSE 的 data: 字段;ChatEvent 会由 Spring 自动序列化成 JSON。  
                            .data(event)  
            );  
        }  
        catch (IOException exception) {  
            // IOException:浏览器关闭页面、网络中断等情况可能导致事件无法写入客户端。  
            // completeWithError:携带当前异常结束这条 SSE 连接,后面不再继续向该连接发送事件。  
            emitter.completeWithError(exception);  
        }  
    }  
  
    /** 客户端请求:新对话时 conversationId 可不传,message 不能为空。 */  
    public record ChatRequest(String conversationId, @NotBlank String message) {  
    }  
    /** 每一条 SSE 的 JSON 数据结构。 */  
    public record ChatEvent(String conversationId, String content, String code, String message) {  
    }}

输入、过程和输出如下:

环节内容
输入JSON 中的 message,以及可选的 conversationId。
过程记忆 Advisor 读取 Redis 历史;模型持续产生文本;Controller 将每一段封装为 delta。
正常输出多条 event: delta,最后一条 event: done。
异常输出一条 event: error,包含固定错误码和可展示提示。

会话 ID 的安全边界

为了让 curl 直接验证,示例接受客户端回传的 ID。真实系统必须从 JWT 或登录态取得用户 ID,并校验会话归属;不能相信任意客户端传来的 demo-user:...。

4.3.3 添加单机入口限流

要解决的问题:同一用户在一分钟内最多开始 5 条新聊天请求,避免明显误操作或脚本耗尽模型配额。

文件位置:src/main/java/com/afyke/ai/ratelimit/FixedWindowRateLimiter.java。

package com.afyke.ai.ratelimit;
 
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
 
import org.springframework.stereotype.Component;
 
@Component
public class FixedWindowRateLimiter {
 
    private static final int LIMIT_PER_MINUTE = 5;
    private static final long WINDOW_MILLIS = 60_000L;
    private final ConcurrentHashMap<String, Counter> counters = new ConcurrentHashMap<>();
 
    public boolean allow(String key) {
        long now = System.currentTimeMillis();
        Counter counter = counters.compute(key, (ignored, old) -> {
            if (old == null || now - old.windowStartedAt() >= WINDOW_MILLIS) {
                // 新用户或旧窗口已过期:本次请求是新窗口的第 1 次。
                return new Counter(now, new AtomicInteger(1));
            }
            old.used().incrementAndGet();
            return old;
        });
        return counter.used().get() <= LIMIT_PER_MINUTE;
    }
 
    private record Counter(long windowStartedAt, AtomicInteger used) {
    }
}

文件位置:src/main/java/com/afyke/ai/ratelimit/AiRateLimitFilter.java。

package com.afyke.ai.ratelimit;
 
import java.io.IOException;
 
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
 
@Component
public class AiRateLimitFilter extends OncePerRequestFilter {
 
    private final FixedWindowRateLimiter rateLimiter;
 
    public AiRateLimitFilter(FixedWindowRateLimiter rateLimiter) {
        this.rateLimiter = rateLimiter;
    }
 
    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) {
        // 只限制本篇的聊天入口,不影响其他业务接口和健康检查。
        return !"/api/chat/stream".equals(request.getRequestURI());
    }
 
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {
        // 学习用请求头;生产环境必须从认证后的登录态读取可信用户 ID。
        String userId = request.getHeader("X-User-Id");
        String limitKey = (userId == null || userId.isBlank()) ? "anonymous" : userId;
 
        if (!rateLimiter.allow(limitKey)) {
            response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
            response.setContentType("application/json;charset=UTF-8");
            response.getWriter().write("{\"code\":\"RATE_LIMITED\",\"message\":\"请求过于频繁,请 1 分钟后再试\"}");
            return;
        }
        filterChain.doFilter(request, response);
    }
}

这是单实例内存限流:应用重启会清零,多台机器不能共享计数。它适合第 10 天验证入口保护;路线后续使用 Redis、网关和认证后,再替换为分布式方案。

4.3.4 启动与两轮验证

先在 IDEA 中配置环境变量 DEEPSEEK_API_KEY=真实值,再执行:

mvn clean compile
mvn spring-boot:run

第 1 次请求不传会话 ID。-N 禁用 curl 缓冲,才能看到边生成边输出的效果:

curl -N -X POST 'http://localhost:8080/api/chat/stream' \
  -H 'Content-Type: application/json' \
  -H 'X-User-Id: demo-user' \
  -d '{"message":"我叫小王,请记住这个名字。"}'

预期输出(文本分片边界由模型决定,以下仅示意):

event: delta
data: {"conversationId":"demo-user:...","content":"好的,"}
 
event: delta
data: {"conversationId":"demo-user:...","content":"我会记住你叫小王。"}
 
event: done
data: {"conversationId":"demo-user:...","content":null,"code":null,"message":null}

复制第一轮的 conversationId,第 2 次请求继续带入它:

curl -N -X POST 'http://localhost:8080/api/chat/stream' \
  -H 'Content-Type: application/json' \
  -H 'X-User-Id: demo-user' \
  -d '{"conversationId":"demo-user:替换为第一轮返回的值","message":"我叫什么?"}'

预期:返回的多个 delta 片段组合后含有“小王”。若省略或更换会话 ID,则不能把模型未记住名字视为故障,因为那是另一段对话。

4.3.5 验收与常见问题

验收清单:

  • mvn clean compile 成功。
  • Redis Stack 在 6379 端口运行。
  • POST /api/chat/stream 的响应类型为 text/event-stream。
  • 第 1 次请求能连续收到 delta,最后收到 done。
  • 第 2 次携带同一 conversationId 后可以利用前文上下文。
  • 模型错误或超过等待时间时收到 error,而不是虚假的业务回答。
  • 同一 X-User-Id 一分钟第 6 次请求返回 429。
  • API Key 未写入代码、application.properties 或 Git。
现象优先检查处理方式
RedisChatMemoryRepository 找不到 BeanRedis starter 是否在 pom.xml,Maven 是否重新加载保留 4.1.1 的依赖和 4.3.1 的显式 Bean。
启动时 Redis 连接失败是否启动了 Redis Stack、端口是否为 6379启动容器并核对 host、port、密码配置。
一次性收到所有内容curl 是否使用 -N;接口是否为 text/event-stream先用本文命令复核;若经过网关,关闭其响应缓冲。
第二轮不记得名字两次 conversationId 是否完全相同使用服务端第一轮返回的原值;不要自行拼写。
401 / 403环境变量名和模型配置是否匹配检查 IDEA Run Configuration 中的 DEEPSEEK_API_KEY。
第 6 次仍没有 429请求头 X-User-Id 是否相同在一分钟内使用相同请求头重试;该限流器按用户键计数。

五、📌 总结

  • ChatClient.stream().content() 是流式调用;.call().content() 是普通同步调用。
  • 本项目坚持 WebMVC,不新增 WebFlux starter。
  • conversationId 是会话边界;Redis 记忆窗口负责让模型获得最近上下文。
  • API Key 放环境变量,模型重试只针对暂时性上游错误。
  • 第 10 天的成品要让客户端能稳定识别正文、结束和失败,而不只是“偶尔能返回一段文字”。

六、🔗 官方资料