【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 的人 ------ 小特同学敬你是条汉子 🫡

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

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

相关推荐
传说故事2 小时前
【论文阅读】RoboCodeX: Multimodal Code Generation for Robotic Behavior Synthesis
论文阅读·人工智能·具身智能
桌面运维家2 小时前
IDV云桌面vDisk机房建设方案如何查看分组使用统计
大数据·人工智能
前端摸鱼匠2 小时前
【AI大模型春招面试题25】掩码自注意力(Masked Self-Attention)与普通自注意力的区别?适用场景?
人工智能·ai·面试·大模型·求职招聘
我是大聪明.2 小时前
RAG检索增强生成技术深度解析
人工智能
沫儿笙2 小时前
FANUC发那科机器人新能源车焊接节气装置
人工智能·机器人
2401_832298102 小时前
OpenClaw云服务器优化技巧:降本50%,性能提升3倍
人工智能
王莎莎-MinerU2 小时前
MinerU + LangChain 实战:从 PDF 解析到 AI 问答全流程
人工智能·langchain·pdf·开源·产品运营·团队开发·个人开发
赋创小助手2 小时前
RTX PRO 6000 vs RTX 5090:从一组230B模型测试数据谈企业级推理选型
服务器·人工智能·科技·深度学习·自然语言处理
不才小强2 小时前
深度学习模型部署实战指南
人工智能·深度学习