臭名昭著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激活

一、💡 一句话理解

第 7 天核心结论

SSE 流式响应就是:后端不要等大模型完整回答后再一次性返回,而是把模型生成的文本片段通过一个保持连接的 HTTP 响应,持续推送给客户端。

本篇对应 0-学习路线 第 7 天:SSE 流式响应。

今天完成后,你应该能做到:在现有 Spring Boot + Spring AI 项目中,接收一个问题,调用 ChatClient.stream(),通过 SSE 接口把模型回答逐段返回。

客户端发送问题
    ↓
Java Controller 接收请求
    ↓
ChatClient.stream() 调用模型
    ↓
Flux<String> 持续产生文本片段
    ↓
HTTP SSE 持续推送给客户端
    ↓
客户端边接收边显示

本篇只完成“模型调用 → 流式返回”这条链路,不提前实现第 8 天的会话 ID、聊天记录和 Redis,也不加入重试、限流、降级、Tool Calling、RAG 或 Agent 工作流。

二、🧭 理论:SSE 是什么

2.1 SSE 的含义

SSE 是 Server-Sent Events 的缩写,中文可以理解为“服务器推送事件”。它建立在普通 HTTP 连接之上:客户端发起请求后,服务器保持连接,并在有新数据时不断写入事件。

SSE 的关键特点是:

特点白话理解
基于 HTTP不需要另外设计一套通信协议
服务器到客户端服务器可以连续推送,客户端不能通过同一条 SSE 连接反向推送
长连接响应不会马上结束,而是持续发送多个事件
文本事件数据通常使用 text/event-stream 和文本格式传输
适合实时展示例如 AI 输出、进度通知、日志推送

可以把普通响应想成“等厨师把整道菜做好后一次端上来”,把 SSE 想成“菜做好一部分就先端一部分”。对于大模型回答,用户可以更早看到内容,等待感会明显降低。

这个类比只用于理解体验差异。真实实现中,模型生成的是文本片段,Spring AI 用 Flux 表示这些片段,Spring Web 的 SSE 编码器再把它们写成 SSE 事件。

2.2 普通响应和流式响应的区别

普通同步响应的流程是:

发送请求
  → 等待模型生成完整回答
  → 后端拿到完整 String
  → 一次性返回 HTTP 响应

流式响应的流程是:

发送请求
  → 模型生成第一个文本片段
  → 后端立即推送第一个片段
  → 模型继续生成
  → 后端继续推送后续片段
  → 模型结束,关闭响应
对比项普通响应SSE 流式响应
返回时机完整结果生成后返回生成片段后立即返回
后端结果String 或完整对象Flux<String> 或多个事件
用户体验等待一段时间后看到全部内容边生成边看到内容
解析方式通常解析一个完整 JSON持续读取多个事件
适合场景短回答、结构化接口、后台任务AI 聊天、生成长文本、实时进度

2.3 SSE 和 WebSocket 的区别

SSE 和 WebSocket 都可以做实时通信,但当前学习清单只要求先掌握 SSE。

对比项SSEWebSocket
通信方向服务器到客户端客户端和服务器双向
基础协议HTTPWebSocket 协议
数据形式主要是文本事件可传文本和二进制数据
使用复杂度相对简单通常需要管理更多连接状态
AI 文本输出很适合也可以,但不是本篇重点

AI 聊天中,用户的问题仍然可以通过普通 HTTP 请求发送给后端;SSE 负责把模型回答从后端持续推回客户端。

官方事实

MDN 将 SSE 描述为服务器向网页推送数据的机制,并明确指出它是单向连接。Spring Framework 的 SseEmitter 也用于按照 SSE 规范发送事件。本篇使用 Spring AI 返回的 Flux,由响应层直接输出 text/event-stream。

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

3.1 SSE 事件长什么样

SSE 响应不是把多段普通文本直接拼在一起,而是使用事件格式。最常见的字段是 data:

data: 第一段文本
 
data: 第二段文本
 
data: 最后一段文本
 

每个事件通常以空行结束。客户端读到一个完整事件后,就可以把 data 内容追加到聊天窗口。

常见的 SSE 响应头是:

Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive

本篇的 Controller 使用:

produces = MediaType.TEXT_EVENT_STREAM_VALUE

它对应的就是 Content-Type: text/event-stream。

3.2 Flux<String> 是什么

Flux 是 Reactor 提供的响应式数据流类型。这里先不把它理解成复杂的并发框架,只记住:Flux<String> 表示“未来会陆续产生多个 String”。

普通调用返回一个字符串:

String answer = chatClient
        .prompt()
        .user("请解释 SSE")
        .call()
        .content();

流式调用返回一组会陆续产生的字符串:

Flux<String> answerStream = chatClient
        .prompt()
        .user("请解释 SSE")
        .stream()
        .content();

两段代码的主要区别是:

调用结果含义
.call().content()String等完整回答生成后取出文本
.stream().content()Flux<String>持续取得模型生成的文本片段

Spring AI 官方文档说明,ChatClient 同时支持同步和流式编程模型;流式调用的 content() 返回 Flux<String>。

3.3 从 ChatClient 到 SSE 的完整链路

这条链路中,每一层的职责不同:

  1. 模型服务负责逐步生成回答。
  2. Spring AI 把模型的流式结果转换成 Flux。
  3. Controller 声明响应类型为 text/event-stream。
  4. Spring Web 将 Flux 中的每个字符串编码并写入 HTTP 响应。
  5. 客户端收到事件后,把文本片段追加到页面或终端。

3.4 为什么本篇使用响应式返回,而不是手动 SseEmitter

Spring MVC 提供了 SseEmitter,可以手动创建事件并调用 send();Spring AI 的 ChatClient.stream() 则天然返回 Flux。

本篇选择直接返回 Flux<String>,原因是它和 Spring AI 的输出类型一致,代码更短,能够直接体现“模型片段 → SSE 事件”的关系。

SseEmitter 不是错误方案,但它需要自行管理:

  • 后台任务如何订阅模型流;
  • 每个片段如何调用 send();
  • 连接断开后如何处理;
  • 完成和异常时如何关闭 emitter。

当前学习清单的目标是先跑通流式对话,因此先掌握 ChatClient.stream() + Flux。后续遇到需要手动控制事件名称、心跳或多个异步来源合并时,再学习 SseEmitter 或 ServerSentEvent。

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

4.1 前置准备

4.1.1 沿用前几天的 Spring Boot 项目

本篇沿用 3.Spring AI ChatClient 中已经可以访问 /test1 的项目,不重新创建工程。

开始前确认:

  • Java、Maven 和 Spring Boot 项目已经可以启动。
  • ChatClient.Builder 可以被 Spring 注入。
  • 模型 API Key 通过环境变量提供。
  • 第 4 天的普通调用接口已经能够返回模型回答。

4.1.2 添加响应式 Web 依赖

Spring AI 官方文档说明,流式 ChatClient 需要 Reactive stack。pom.xml 中确认存在以下依赖:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webflux</artifactId>
</dependency>

模型依赖继续沿用前几天的配置:

<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>

如果项目已经存在 spring-boot-starter-web,先不要盲目同时修改多个 Web 组件。以当前项目的启动日志和 Spring Boot 配置为准,确保本次接口能够以响应式方式持续输出;实际项目中应统一 Web 技术栈,避免 MVC 和 WebFlux 配置互相影响。

4.1.3 沿用模型配置和 API Key

沿用前几天的 application.properties:

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

这里的模型名和地址必须以你当前使用的模型供应商文档及项目配置为准。真实 API Key 继续放在 IntelliJ IDEA 运行配置的环境变量中,不要写入 Java 源码、配置文件或 Git 仓库。

4.1.4 启动项目

使用前几天相同的方式启动 AiApplication。如果项目端口仍为 8080,本篇验证地址就是:

http://localhost:8080/ai/chat/stream

4.2 可以拿来干什么

4.2.1 实现 AI 聊天窗口的逐字输出

用途:用户发送问题后,不必等待完整答案生成,前端可以在收到每个 SSE 事件时立即追加文本。

前置:Controller 中已经注入 ChatClient,并且项目已添加 spring-boot-starter-webflux。核心对象来源如下:

private final ChatClient chatClient;
 
public AiController(ChatClient.Builder builder) {
    this.chatClient = builder.build();
}

核心流式调用:

Flux<String> contentStream = chatClient
        .prompt()
        .system("你是面向 Java 后端开发者的学习助手,请使用简洁中文回答。")
        .user("请解释 SSE 流式响应。")
        .stream()
        .content();

输入是一个用户问题;处理过程是 ChatClient 调用模型并持续产生文本片段;输出是 Flux<String>,由 HTTP 层继续编码成 SSE 事件。

适用场景:AI 聊天、代码解释、长文本生成、文档总结和需要展示实时进度的接口。

4.2.2 让后端统一控制返回协议

用途:后端不把模型供应商的原始流式格式直接暴露给前端,而是统一返回 SSE。将来更换模型供应商时,前端仍然只依赖自己的业务接口。

前置:Controller 方法需要声明 text/event-stream:

@PostMapping(
        value = "/chat/stream",
        produces = MediaType.TEXT_EVENT_STREAM_VALUE
)
public Flux<String> chatStream(@RequestBody ChatRequest request) {
    return chatClient
            .prompt()
            .user(request.message())
            .stream()
            .content();
}

输入是业务请求对象,不是模型供应商的原始请求 JSON;输出是项目自己的 SSE 接口。这样可以把 Prompt 组装、供应商协议和前端展示协议隔离开。

4.2.3 后续接入会话记录

用途:第 8~10 天可以在流式回答完成后,保存用户问题和完整回答,形成会话记录。

本篇只说明边界,不实现 Redis:当前 Flux<String> 中的片段需要先聚合成完整回答,才能保存一条完整记录。第 8 天学习会话 ID、聊天记录和 Redis 时,再设计会话表、缓存结构和保存时机。

第 7 天:模型流 → SSE 返回
第 8 天:会话 ID → 聊天记录 → Redis
第 10 天:流式对话 + 会话记录的完整验收

不要在本篇中为了“看起来完整”而把 Redis、历史消息和重试逻辑混进来,否则会打乱学习清单的前后依赖。

4.3 完整实践:实现 /ai/chat/stream

4.3.1 定义请求对象和流式接口

下面的代码解决一个完整问题:接收用户问题,调用模型,并通过 SSE 持续返回回答。

文件位置:

src/main/java/com/afyke/ai/controller/AiController.java

如果项目中已经存在 AiController,请在原 Controller 中合并下面的字段、构造方法、请求对象和接口方法,不要再创建一个同名 Controller。

package com.afyke.ai.controller;
 
import org.springframework.ai.chat.client.ChatClient;
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 reactor.core.publisher.Flux;
 
@RestController
@RequestMapping("/ai")
public class AiController {
 
    private final ChatClient chatClient;
 
    public AiController(ChatClient.Builder builder) {
        this.chatClient = builder.build();
    }
 
    @PostMapping(
            value = "/chat/stream",
            produces = MediaType.TEXT_EVENT_STREAM_VALUE
    )
    public Flux<String> chatStream(@RequestBody ChatRequest request) {
        return chatClient
                .prompt()
                .system("你是面向 Java 后端开发者的学习助手,请使用简洁中文回答。")
                .user(request.message())
                .stream()
                .content();
    }
 
    public record ChatRequest(String message) {
    }
}

代码中的关键部分:

代码作用
@RequestMapping("/ai")给接口增加统一路径前缀
@RequestBody ChatRequest接收客户端传来的问题
MediaType.TEXT_EVENT_STREAM_VALUE声明响应使用 SSE 类型
.stream()告诉 Spring AI 使用流式调用
.content()取得 Flux<String> 文本片段
Flux<String>让 Web 层可以持续输出多个结果

输入 JSON:

{
  "message": "请用大白话解释 SSE 流式响应。"
}

处理过程:

读取 request.message()
  → 组装 system message 和 user message
  → 调用 ChatClient.stream()
  → 取得 Flux<String>
  → Spring Web 按 SSE 事件持续写出

输出不是一个立即完成的普通 JSON,而是一串类似下面的事件:

data: SSE 是服务器向客户端持续推送数据的机制。
 
data: 它适合 AI 聊天中的边生成边显示。
 

实际分片数量和每个分片的文字长度由模型服务和客户端实现决定,不能把一次分片固定理解成一个汉字或一个完整句子。

4.3.2 启动项目

确认环境变量中存在真实 API Key 后启动:

./mvnw spring-boot:run

如果项目没有 Maven Wrapper,也可以使用 IntelliJ IDEA 启动 AiApplication。本篇沿用你前几天已经验证过的启动方式。

启动成功后,保持后端进程运行,再执行下面的请求。

4.3.3 使用 curl 验证流式输出

在项目根目录执行:

curl -N \
  -X POST 'http://localhost:8080/ai/chat/stream' \
  -H 'Content-Type: application/json' \
  -d '{
    "message": "请用大白话解释 SSE 流式响应,并说明它和普通响应的区别。"
  }'

-N 的作用是关闭 curl 的输出缓冲,让终端尽量及时显示服务器推送的内容。不要只执行不带 -N 的请求后,再根据终端一次性打印的效果判断接口没有流式输出。

预期现象:终端会在模型生成过程中陆续出现多段 data: 内容,而不是等很久后只出现一整段结果。每次分片的边界不固定,这是正常现象。

4.3.4 使用 HTTP Client 验证

如果你使用 IntelliJ IDEA 的 HTTP Client,可以在项目根目录的 request.http 中添加:

### 第 7 天:SSE 流式聊天
POST http://localhost:8080/ai/chat/stream
Content-Type: application/json
 
{
  "message": "请解释 SSE 为什么适合 AI 聊天接口。"
}

点击请求左侧的运行按钮后,观察响应窗口是否持续收到事件。不同 IDE 版本对 SSE 的展示方式可能不同;如果 IDE 把响应集中显示,也可以使用上一节的 curl -N 复核。

4.3.5 验收标准

第 7 天只需要完成以下验收:

  • 项目能够启动。
  • POST /ai/chat/stream 能接收问题。
  • 接口返回 text/event-stream 类型。
  • 模型回答能够分片返回。
  • 客户端不需要等待完整回答就能看到前面的内容。
  • 没有把 API Key 写入 Java 源码或提交到 Git。

今天暂不要求:

  • 保存会话 ID。
  • 把聊天记录写入 Redis。
  • 支持多轮历史消息。
  • 实现超时、重试、限流和降级。
  • 接入 Tool Calling、RAG 或 Agent 工作流。

4.3.6 常见报错排查

4.3.6.1 Flux 找不到或无法返回流

检查 pom.xml 是否添加:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webflux</artifactId>
</dependency>

然后重新加载 Maven 依赖并重启项目。还要确认 Spring AI 版本和项目实际使用的 Spring Boot 版本匹配。

4.3.6.2 接口返回后一次性出现全部文本

按顺序检查:

  1. 代码是否误用了 .call().content(),而不是 .stream().content()。
  2. Controller 返回值是否是 Flux<String>。
  3. @PostMapping 是否声明了 produces = MediaType.TEXT_EVENT_STREAM_VALUE。
  4. curl 是否使用了 -N。
  5. 反向代理或网关是否开启了响应缓冲。
4.3.6.3 返回 401、403 或模型调用失败

检查:

  • 环境变量名是否与 application.properties 中的 ${DEEPSEEK_API_KEY} 一致。
  • IDEA 的运行配置是否真的加载了环境变量。
  • API Key 是否有效,是否有余额或调用权限。
  • base-url、模型名和模型供应商是否匹配。

不要把真实 API Key 直接粘贴到代码、日志或聊天记录中。

4.3.6.4 返回 415 Unsupported Media Type

通常是请求头或请求体格式不正确。确认请求包含:

Content-Type: application/json

并且请求体是合法 JSON:

{
  "message": "你好"
}
4.3.6.5 浏览器看不到流式效果

先用 curl -N 确认后端确实在分片输出。如果 curl 正常而浏览器没有效果,重点检查前端读取方式和代理缓冲。

另外,浏览器原生 EventSource 通常发起 GET 请求,不能像本篇的 POST 一样直接携带 JSON 请求体。真实聊天页面可以使用支持流式读取的 fetch,或者把接口设计成适合 EventSource 的 GET 形式;这属于前端接入问题,不改变后端 SSE 的核心原理。

五、📌 总结

  • 快速回顾:SSE 是建立在 HTTP 上的服务器到客户端单向推送机制。
  • 快速回顾:普通调用使用 .call().content() 返回 String,流式调用使用 .stream().content() 返回 Flux<String>。
  • 快速回顾:Controller 需要声明 MediaType.TEXT_EVENT_STREAM_VALUE,让响应按 SSE 格式输出。
  • 快速回顾:本篇只完成模型流式输出,不实现会话 ID、Redis、重试、限流和 Agent 工作流。
  • 快速回顾:验证时使用 curl -N,观察文本是否在模型生成过程中陆续出现。

记忆句:stream() 产生 Flux,text/event-stream 负责传输,客户端收到片段后立即展示。

第 7 天之后的学习顺序:

第 7 天:SSE 流式响应
  → 第 8 天:会话 ID、聊天记录、Redis
  → 第 9 天:超时、重试、限流、降级
  → 第 10 天:完成可运行聊天接口

官方资料: