【AI辅助趣学SpringAI】03-聊天模型之SSE流式编程

写在最前

哈喽,各位友友们,我是小特同学 🤚

欢迎来到 AI 辅助趣味学习 SpringAI 专栏


下面说一下这个专栏的文章体系:
🤖 AI 的任务
文章由 AI 生成,但代码我敲、坑我踩。
AI 负责把抽象概念翻译成 人话 + 比喻 ,我负责学懂、跑通、记录。
👨‍💻 我的任务

  • ✅ 每篇学完
  • ✅ 代码跑通
  • ✅ 踩坑必写

📖 文章特色

AI 提效输出大白话 + 真实可运行代码


不装不水,诚实学习。看完就能敲,敲完就能用。

想快速上手 SpringAI 的友友们,看完保证不吃亏 💪


一、前言:为什么你点了个外卖,要等30秒才看到"已接单"?😤

兄弟们,你们有没有遇到过这种场景:打开一个AI聊天页面,问它"给我写一篇关于SSE的博客",然后你就在那儿干等,转圈圈转了半天,突然"啪"一下,整篇博客出来了......

是不是感觉像在等对象化妆? 💄😂

其实现在牛逼的AI都是一个字一个字往外蹦 的,就像我打游戏时队友的"救救我救救我救救我"刷屏一样,这叫流式输出

那这玩意是怎么实现的呢?SSE (Server-Sent Events)就是幕后大功臣!今天就带你把它扒个精光!
单词解释


二、SSE协议:HTTP也能"长舌妇"式推送?🤫

2.1 HTTP的"天生缺陷"

HTTP协议大家都很熟了,请求-响应模型,你问一句,服务器答一句,然后就挂电话了。服务器想说第二句?对不起,您拨打的用户已关机📞。

但是流式输出需要服务器主动哔哔,咋整?

SSE(Server-Sent Events) 说:放着我来!💪

SSE的本质:服务器跟客户端打个招呼------"兄弟,接下来我要给你发流消息了,你连接别关啊,等着收菜就行!"

2.2 SSE的四大金刚特性✨

特性 解释(人话版)
基于HTTP 不用搞WebSocket那种新端口,你家路由器不用重新配,省心!
单向通信 只能服务器给你发,你不能通过这个连接给服务器发(想发?再开个请求呗)
自动重连 网断了?浏览器自己会重连,不用你写一行代码,良心!
自定义消息类型 可以发普通消息,也可以发fooend等自定义事件,像发微信消息还能选"红包"一样

2.3 响应头长啥样?🤔

服务器想发SSE,必须设置下面这两个头,缺一不可:咱不用学原理,先用起来呗

bash 复制代码
Content-Type: text/event-stream;charset=utf-8
Connection: keep-alive

温馨提示⚠️:text/event-stream就是告诉浏览器"我要开始流式表演了",keep-alive是"别挂电话,我还有话说"。

2.4 SSE数据格式:不是随便发的!📝

SSE的消息格式非常讲究,每个消息由若干行组成,每个message之间用两个换行\n\n分隔

每行格式:[field]: value\n

field能取啥值?

field 是否必需 作用
data ✅ 必需 你要发的真实数据内容
event ❌ 可选 自定义事件类型(默认是message
id ❌ 可选 每条数据的编号,断线重连时可以告诉服务器从哪条继续
retry ❌ 可选 告诉浏览器如果断了,过多少毫秒重新连接

还有以冒号:开头的行,表示注释,浏览器会忽略它。

举个例子(你就当是发朋友圈的格式):

复制代码
event: foo
data: 这是一条foo事件的数据

data: 这是一条没有event的普通消息

event: end
data: 游戏结束啦

看到没?data是正文,event是类型,每个消息块用空行隔开。


三、原生Java手写SSE?太麻烦了些许,Spring封装的有!🚀

3.1 传统Servlet写法(了解即可)

java 复制代码
@RequestMapping("/data")
public void data(HttpServletResponse response) throws IOException, InterruptedException {
    // 设置响应头为 SSE(服务端推送)格式,编码UTF-8,告诉浏览器这是流式推送
    response.setContentType("text/event-stream;charset=utf-8");
    
    // 获取响应输出流:用于向浏览器发送文本数据
    PrintWriter writer = response.getWriter();
    
    // 循环20次:一共向浏览器推送20条消息
    for (int i = 0; i < 20; i++) {
        // 按照 SSE 格式推送数据:固定格式 data: 内容\n\n
        // 必须以 data: 开头,以两个换行结尾,浏览器才能识别
        writer.write("data: " + new Date() + "\n\n");
        
        // 刷新缓冲区:强制把数据立即发送到浏览器,不刷新会攒在一起发送
        writer.flush();
        
        // 线程休眠1秒:每隔1秒推送一次消息
        Thread.sleep(1000);
    }
}

前端JS用EventSource接收,so easy:

html 复制代码
<!-- 定义一个div容器,用来展示后端实时推送过来的数据 -->
<div id="sse"></div>

<style>
    /* 全局样式:让页面居中、好看一点 */
    body {
        margin: 0;
        padding: 30px;
        font-family: "Microsoft YaHei", Arial, sans-serif;
        background-color: #f5f7fa;
    }

    /* SSE 展示容器样式:居中、卡片感、阴影、圆角 */
    #sse {
        width: 600px;
        min-height: 100px;
        line-height: 100px;
        margin: 50px auto;
        padding: 0 20px;
        background-color: #fff;
        border-radius: 12px;
        box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
        text-align: center;
        font-size: 18px;
        color: #333;
        border: 1px solid #eee;
    }
</style>

<script>
    // 1. 创建SSE连接对象,请求后端接口路径 /sse/data
    // EventSource 是浏览器原生API,专门用来接收服务端SSE流式推送
    let eventSource = new EventSource("/sse/data");

    // 2. 监听默认message事件(后端未自定义事件名时,默认走这个)
    // 后端每发送一次 data: xxx\n\n 格式数据,就会触发此回调
    eventSource.onmessage = function(event){
        // 获取后端推送过来的数据 event.data,赋值到页面div中展示
        // innerHTML 会覆盖更新页面内容
        document.getElementById("sse").innerHTML = event.data;
    };

    // 监听连接错误(比如后端断开)
    eventSource.onerror = function() {
        document.getElementById("sse").innerHTML = "连接断开,正在重连...";
        document.getElementById("sse").style.color = "red";
    };
</script>

结果

图解流程🚀(一图胜言)

3.2 Spring 5 + WebFlux:这才是真正的优雅!👨‍🍳

上面那种写法太原始了,还得自己循环、sleep、flush......累不累啊?

Spring从4.2开始支持SSE,5以后配合WebFlux的Flux,简直爽到飞起!


四、Flux是什么?传送带懂不懂?📦

Flux是Project Reactor的核心API,你就把它想象成一个传送带

  • 异步传送:数据像快递包裹一样,来一个传一个,不用等所有包裹到齐
  • 灵活加工:可以在传送带上加过滤、转换的"机器",比如把"苹果"变成"APPLE"
  • 弹性控制:接收方能控制速度,不会把接收方撑死(背压机制)

4.1 Flux三板斧:创建→处理→订阅

java 复制代码
public class FluxDemoTest {
    public static void main(String[] args) throws InterruptedException {
        // 1. 创建Flux(把水果放上传送带)
        Flux<String> fruitFlux = Flux.just("Apple", "Banana", "Cherry")
                .delayElements(Duration.ofSeconds(1)); // 每隔1秒掉一个水果下来
        
        // 2. 处理数据 + 3. 订阅(加工并开始传送)
        fruitFlux.map(String::toUpperCase)   // 转大写
                 .subscribe(System.out::println); // 每来一个就打印
        
        Thread.sleep(4000); // 防止主线程结束,看不到效果
    }
}

输出:

复制代码
APPLE
BANANA
CHERRY

代码:

java 复制代码
    public static void main(String[] args) throws InterruptedException {
        // 1. 创建 Flux 数据流(包含3个字符串元素)
        Flux.just("hello", "hi", "haha")
                // 2. 每个元素之间延迟 1 秒发出(实现每隔1秒推送一个数据)
                .delayElements(Duration.ofSeconds(1))
                // 3. 数据转换:把每个字符串 转成 大写
                // String::toUpperCase = 方法引用语法
                // 等价于:s -> s.toUpperCase()
                // 作用:调用 String 类里的 toUpperCase() 方法,把字符串变大写
                .map(String::toUpperCase)
                // 4. 订阅数据流(订阅 = 开始执行!响应式编程:不订阅不执行)
                // System.out::println = 方法引用,等价于 s -> System.out.println(s)
                .subscribe(System.out::println);

        // 5. 主线程等待,防止程序直接退出
        // 因为 delayElements 是异步的,主线程不等待会直接结束
        Thread.sleep(4000);
    }

4.2 创建Flux的花式玩法🎨

java 复制代码
// 方式1:just - 直接塞几个元素
Flux<String> flux1 = Flux.just("A", "B", "C");

// 方式2:fromIterable - 从List/Set创建
Flux<Integer> flux2 = Flux.fromIterable(Arrays.asList(1, 2, 3));

// 方式3:range - 生成整数范围
Flux<Integer> flux3 = Flux.range(1, 5); // 1,2,3,4,5

// 方式4:interval - 定时发射(无限流,小心使用)
Flux<Long> flux4 = Flux.interval(Duration.ofSeconds(1));

4.3 操作符:传送带上的"加工机器"🔧

操作符 作用 代码示例
map() 一对一转换 .map(String::toUpperCase)
filter() 条件过滤 .filter(s -> s.length() > 5)
take() 只取前N个 .take(2)
merge() 合并多个Flux(不保证顺序) Flux.merge(fluxA, fluxB)
concat() 顺序拼接(保证A完了再B) Flux.concat(fluxA, fluxB)
delayElements() 延迟发射 .delayElements(Duration.ofSeconds(1))

举个栗子🌰:

java 复制代码
Flux.just("Apple", "Banana", "Avocado", "Cherry")
    .map(String::toUpperCase)           // 变大写
    .filter(s -> s.startsWith("A"))     // 只要A开头的
    .subscribe(System.out::println);
// 输出:APPLE, AVOCADO

4.4 重要!Flux是"懒汉" - 不订阅不干活💤

记住:Flux就像你女朋友问你"想吃什么",你不说"随便"然后选一个,她是不会行动的。 subscribe()就是那个"选一个"的动作,只有调用了它,数据才会开始流动。

java 复制代码
Flux<String> flux = Flux.just("A", "B", "C");
// 此时啥都没发生,flux只是个空壳子

flux.subscribe(System.out::println);
// 订阅后,ABC才打印出来

五、Spring中SSE流式响应:一行代码搞定!🎯

有了Flux,写一个每秒推送当前时间的接口,就跟喝水一样简单:

java 复制代码
    /**
     * SSE 流式推送接口
     * 访问路径:/sse/stream
     * produces = MediaType.TEXT_EVENT_STREAM_VALUE
     * 作用:设置响应头 Content-Type: text/event-stream;charset=UTF-8
     * 告诉浏览器这是 SSE 服务端推送格式
     */
    @GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<String> stream() {

        // Flux.interval(Duration.ofSeconds(1))
        // 作用:创建一个定时器数据流,每隔1秒发出一个数字(0,1,2,3,...无限递增)
        // 相当于:每隔1秒触发一次推送

        // .map(sequence -> new Date().toString())
        // map:把数据流里的元素进行转换
        // sequence:就是上面发出的 0,1,2,3...(这里我们用不到它,只是用来触发时间)
        // 每次触发,都获取【当前最新时间】并转成字符串返回
        return Flux.interval(Duration.ofSeconds(1))
                .map(sequence -> new Date().toString());
    }

注意! produces = MediaType.TEXT_EVENT_STREAM_VALUE 这个注解不能少,它告诉Spring返回的是SSE流,不是普通JSON【作用:设置响应头 Content-Type: text/event-stream;charset=UTF-8】。

前端代码:

html 复制代码
<div id="sse"></div>
<script>
    let eventSource = new EventSource("/sse/stream");
    eventSource.onmessage = function(event){
        document.getElementById("sse").innerHTML = event.data;
    }
</script>

打开浏览器,你会看到时间每秒自动刷新一次,不用你手动刷新页面,牛逼不? 🤯


六、小特给你总结一张图(脑补)🧠

复制代码
客户端                          服务器
  |                              |
  | -----  GET /stream --------> |
  |                              |
  | <---- data: 10:00:01 --------|
  | <---- data: 10:00:02 --------|
  | <---- data: 10:00:03 --------|
  | <---- data: 10:00:04 --------|
  |                              |
  | (连接一直开着,直到服务器主动关或者客户端离开)

七、补充SSE vs WebSocket 🥊

对比项 SSE WebSocket
通信方向 单向(服务器→客户端) 双向(全双工)
协议 HTTP/HTTPS WS/WSS(独立协议)
自动重连 ✅ 浏览器内置 ❌ 需要自己实现
二进制数据 ❌ 只支持文本 ✅ 支持
复杂度 简单 较复杂
适用场景 服务器推送通知、AI流式对话、股票行情 聊天室、在线游戏、协同编辑

建议:如果只是服务器往客户端推数据,别整WebSocket那个大炮打蚊子,SSE足矣!


八、踩坑💀

  1. Nginx代理SSE :默认会缓冲响应,要配置proxy_buffering off;,否则客户端等半天收不到数据。
  2. 浏览器限制:同一个域名下EventSource最多同时开6个连接(HTTP/1.1的限制)。
  3. 断线重连的retry :如果服务器想控制重连间隔,可以发一行retry: 3000\n\n,告诉浏览器3秒后重连。
  4. Flux别忘了subscribe:很多人写Flux不调用subscribe,结果毛都没有,debug到怀疑人生。

写在最后

好了,这篇就到这。

如果你跟着敲到了最后,说明你也是真想学 SpringAI 的人 ------ 小特同学敬你是条汉子 🫡

点赞、关注、收藏 来一波,不是因为我缺,是因为你点了之后算法会更勤快地把下篇推给你 😏

谢谢老铁阅读,踩坑继续,下期见!🤚

相关推荐
JJJennie7771 小时前
当AI开始学会欺骗
网络·人工智能
糖果店的幽灵1 小时前
大模型测评DeepEval快速入门-安全与通用指标详解
人工智能·安全·langgraph·大模型测评·deepeval
江畔柳前堤7 小时前
roLabelImg 详细安装教程
开发语言·人工智能·后端·云原生
阿里云大数据AI技术7 小时前
分链路差异化设计的DSP准实时数仓|钛动科技基于阿里云实时计算 Flink 版 + DLF Paimon + EMR Serverless StarRocks 的实践
人工智能·flink
陕西企来客7 小时前
2026年7月AI智能搜索曝光趋势研判
大数据·人工智能·机器学习·ai智能搜索曝光
阿里云大数据AI技术8 小时前
从算力到智能体,面向 Agentic AI 的基础设施演进
人工智能·agent
hangyuekejiGEO8 小时前
GEO技术服务选型指南
大数据·人工智能·python
阿里云大数据AI技术9 小时前
EMR Serverless Spark AI Function 的双维降本实践
人工智能·sql·spark
维基框架9 小时前
GitHub源码处理提速 一趟扫描反而更慢
人工智能·github
冬奇Lab9 小时前
代码库知识库系列(05):向量检索 vs 知识图谱——加了调用图并没有变更好
人工智能