消失的最后一秒:SSE 流式联调中的“时序竞争”

今天,笔者这个菜鸟程序员在尝试实现流式输出(SSE),原本以为只是调个接口,结果处处是卡点,记录这一场对我来说困难重重的 SSE 联调之旅。

1. 什么是 SSE (Server-Sent Events)?

我理解就是后端的消息流实时反馈到前端输出。

想象这样一个场景:你需要获取一串不断变化的数字。

SSE:就像一个转述人实时给你打电话,每接到一个新数字就立刻报给你

轮询(Polling):你每隔5分钟给转述人打电话,问他"现在有哪些数字?"

SSE 是一种服务器端向客户端推送实时消息的 API。与 WebSockets 不同,它是单向的,(也就是说,这通电话就像收听一个只能听不能对话的电台广播),且直接运行在 HTTP 协议之上。

2. 为什么要用 SSE?

我现在做的是一个 Agent 交互,大模型经常会有"正在思考中",这个思考的过程比直接蹦出结果更有交互体验。用户看着屏幕一字一字蹦出来(吐字效果),心理压力更小;而且有时候答案出了一半,用户觉得够了,还可以随时"挂断"打断生成。

3. "打电话"的那些事儿:建立连接与数据流

在前面有关打电话的比方中,要打电话,先得拨通连接。

建立连接其实是前后端的一场多重协议握手:

  • 第一重:小区保安(CORS 跨源资源共享) 域名、协议(http/https)、端口,这三样只要有一个对不上,浏览器这个"保安"就会把请求拦住。

    CORS 是浏览器出于安全考虑的限制,需要后端在响应头里显式加上 Access-Control-Allow-Origin 才能放行。

  • 第二重:进门前的询问(OPTIONS 预检请求) 因为我用了 POST 并在 Header 里塞了自定义信息,浏览器会先发一个 OPTIONS 请求问后端:"我待会儿要发个带 JSON 的 POST,你接不接受?"后端点头了,真正的通话(SSE)才能开始。

  • 第三重:身份通行证(Authorization) 原生 EventSource 默认不带任何证件(Header),导致后端不认。所以我才必须改用 fetch 手动塞入 Bearer Token

这一块涉及到 应用层协议 (SSE 是基于 HTTP 的长连接,运行在浏览器和服务器之间)和磁盘写入(Disk IO,类比一下就是转述人在记录数字)。

但我踩了个大坑:

原生的 EventSource 只支持 GET 请求 ,且默认不支持在请求头里带 Authorization。但后端偏偏要用 POST,这就像保安只让推着小车的人进,不让开车的人进,我们只能自己"兜底"实现。

那我只好绕路:改用 fetch + ReadableStream 实现。

【伪代码:用 Fetch 模拟 SSE 建立连接】
TypeScript 复制代码
const response = await fetch('/api/v1/stream', {
  method: 'POST', // 对齐后端 POST 要求
  headers: {
    'Content-Type': 'application/json',
    'Authorization': 'Bearer YOUR_TOKEN' // 手动带上保安要的证件
  },
  body: JSON.stringify({ meeting_id: '123' }) 
});

4. 拿到数据流:为什么配合 ReadableStream?

后端 SSE 返回的数据是字节流(通常是UTF-8编码的文本数据流) ,像水管里的水。普通请求可以直接 res.json(),但流式数据要求我们一点一点读。

ReadableStream 接口表示可读的字节数据流。配合 TextDecoder(文本解码器),可以将这些二进制"乱码"翻译成人类能看懂的文字。

【伪代码:手动接水管解析数据】
TypeScript 复制代码
const reader = response.body?.getReader(); // 获取流的读取器(接水管)
const decoder = new TextDecoder(); // 乱码翻译器

while (true) {
  const { done, value } = await reader.read(); // 每次接一桶水(Chunk)
  if (done) break; // 水流完了,挂电话
  const chunk = decoder.decode(value); // 二进制转文字
  console.log("接到报数:", chunk);
}

5. 踩坑总结:404 错误与"时序竞争"

终于对齐了以后,居然还是 404!!!!!

我 Debug 了半天,路径和逻辑都没问题。

真相是:任务完成 ≠ 文件可读。

目前 SSE 连接断开时,后端正在后台悄悄写文件。

这就是时序竞争(Race Condition):流式吐字结束了,前端以为"挂电话=活干完",立刻去拿文件,但后端还没存好(Disk IO 慢)。

6. 最后的补救:轮询

虽然用了流式,但最后一步还要靠轮询补救。(保持冷静,坚强的菜鸟...)

我用了一个 while 循环:不再死等,而是每 2 秒问一次后端"好了没"。

  • 变量锁定 :用了 currentActiveMeetingId 局部变量,解决了 React 状态更新太慢导致的 ID 传不准。

  • 友好提示 :用户会看到 (1/6) 这样的动态提示,知道我们在收尾。

7. 转机:推动后端实现"五步闭环"

在我用轮询"补丁"自救后,我也在反思:难道前端注定要一直敲门吗?

经过沟通,后端同学重构了逻辑,把原先那两条各跑各的"平行线"拧成了一股绳。

这就引出了:异步流程的同步化控制

简单说,就是让后端在 SSE 断开之前,先完成"存盘"这个苦力活。

新的流程变成了这样:

  1. 持续吐字(Streaming):文字实时跳动。
  2. 吐字完成:文字停了,但连接不断。
  3. 后端存盘:后端在后台默默把 JSON/PDF 落地。
  4. 发送"哨声":发送一个 status: completed 的信号包,告诉前端"我写完了!"。
  5. 挂断电话:这时候再正式断开 SSE。

🌟 总结:不仅仅是"接个接口"

我本来只是想做一个前端接后端接口的工作,没想到衍生了这么多学习的东西。

作为一个"菜鸟切图仔",这次联调让我深刻体会到了:开发不仅仅是写代码,更是前后端的深度协作。

  1. 搞清楚数据的来龙去脉: 以前觉得数据是"跳"出来的,现在知道它是从二进制流(ReadableStream)里一桶桶接出来,再经过解码(TextDecoder)翻译出来的。

  2. 避开请求 API 的暗坑 : 从 CORS 保安的拦截,到 OPTIONS 预检请求的询问,再到 POST 方法下原生 EventSource 的哑火......这些坑踩过一次,才算真的长了记性。

  3. 反思自己的小细节 : 我也犯了不少低级错误,比如 API 地址写重了(/v1/stream 写错位了)、路径对不齐等。但也正是这些小错,逼着我学会了看 F12 网络面板,去分析数据到底在哪里断了。

最深刻的感悟: 以前我只关心"页面好不好看",现在我开始关心"逻辑稳不稳"。

虽然最后代码很多是发给 AI 改的,但理解了数据是怎么流转的、知道了异步逻辑里的时序陷阱,这才是今天最大的收获。

既然逻辑已经闭环,后端也把"哨子"准备好了,我也要把那套凑合的轮询代码删了。

从"能跑就行"到"优雅运行",这一波不亏!

限于个人经验,文中若有疏漏,还请不吝赐教。

参考文献:

  1. 从零到一实现流式输出:SSE技术在前端应用中的魔法时刻本文深入解析流式输出SSE技术,从原理到实现全面讲解,助你掌握大厂 - 掘金
  2. Server-Sent Events 教程 - 阮一峰的网络日志
  3. EventSource - Web API | MDN
  4. 使用 Fetch - Web API | MDN
  5. 跨源资源共享(CORS) - HTTP | MDN
  6. 什么是 CORS?一文搞懂 CORS 原理!什么是 CORS ?CORS 的原理是什么?为什么需要 CORS?在这篇文章 - 掘金
  7. ReadableStream.getReader() - Web API | MDN
  8. ReadableStream - Web API | MDN
  9. Fetch API - Web API | MDN
  10. Window:fetch() 方法 - Web API | MDN
  11. XMLHttpRequest API - Web API | MDN
  12. XMLHttpRequest (XHR) - MDN Web 文档术语表:Web 相关术语的定义 | MDN
  13. 轮询(Polling) 是什么?(轮询的原理) - 数据库博客-OceanBase
  14. 使用可读流 - Web API | MDN
  15. Query Parameters - FastAPI
相关推荐
Engineer邓祥浩2 小时前
设计模式学习(25) 23-23 责任链模式
学习·设计模式·责任链模式
shanghaichutai2 小时前
Transforming Growth Factor α (human) (TGF α (1-50) (human))
笔记
Gain_chance2 小时前
24-学习笔记尚硅谷数仓搭建-DIM层的维度表建表思路及商品表维度表的具体建表解析
数据仓库·hive·笔记·学习·datagrip
求真求知的糖葫芦2 小时前
RF and Microwave Coupled-Line Circuits射频微波耦合线电路4.2 使用均匀耦合线的方向性耦合器学习笔记(自用)
笔记·学习·线性代数·射频工程
RFCEO2 小时前
前端编程 课程十、:CSS 系统学习学前知识/准备
前端·css·层叠样式表·动效设计·前端页面布局6大通用法则·黄金分割比例法则·设计美观的前端
雄狮少年2 小时前
简单react agent(没有抽象成基类、子类,直接用)--- 非workflow版 ------demo1
前端·react.js·前端框架
子夜江寒2 小时前
OpenCV 学习:从光流跟踪到艺术风格迁移
opencv·学习·计算机视觉
QiZhang | UESTC2 小时前
学习日记day71
学习
ddxu2 小时前
AI学习笔记
笔记·学习·ai