今天,笔者这个菜鸟程序员在尝试实现流式输出(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 断开之前,先完成"存盘"这个苦力活。
新的流程变成了这样:
- 持续吐字(Streaming):文字实时跳动。
- 吐字完成:文字停了,但连接不断。
- 后端存盘:后端在后台默默把 JSON/PDF 落地。
- 发送"哨声":发送一个 status: completed 的信号包,告诉前端"我写完了!"。
- 挂断电话:这时候再正式断开 SSE。
🌟 总结:不仅仅是"接个接口"
我本来只是想做一个前端接后端接口的工作,没想到衍生了这么多学习的东西。
作为一个"菜鸟切图仔",这次联调让我深刻体会到了:开发不仅仅是写代码,更是前后端的深度协作。
-
搞清楚数据的来龙去脉: 以前觉得数据是"跳"出来的,现在知道它是从二进制流(ReadableStream)里一桶桶接出来,再经过解码(TextDecoder)翻译出来的。
-
避开请求 API 的暗坑 : 从 CORS 保安的拦截,到 OPTIONS 预检请求的询问,再到 POST 方法下原生
EventSource的哑火......这些坑踩过一次,才算真的长了记性。 -
反思自己的小细节 : 我也犯了不少低级错误,比如 API 地址写重了(
/v1/stream写错位了)、路径对不齐等。但也正是这些小错,逼着我学会了看 F12 网络面板,去分析数据到底在哪里断了。
最深刻的感悟: 以前我只关心"页面好不好看",现在我开始关心"逻辑稳不稳"。
虽然最后代码很多是发给 AI 改的,但理解了数据是怎么流转的、知道了异步逻辑里的时序陷阱,这才是今天最大的收获。
既然逻辑已经闭环,后端也把"哨子"准备好了,我也要把那套凑合的轮询代码删了。
从"能跑就行"到"优雅运行",这一波不亏!
限于个人经验,文中若有疏漏,还请不吝赐教。
参考文献:
- 从零到一实现流式输出:SSE技术在前端应用中的魔法时刻本文深入解析流式输出SSE技术,从原理到实现全面讲解,助你掌握大厂 - 掘金
- Server-Sent Events 教程 - 阮一峰的网络日志
- EventSource - Web API | MDN
- 使用 Fetch - Web API | MDN
- 跨源资源共享(CORS) - HTTP | MDN
- 什么是 CORS?一文搞懂 CORS 原理!什么是 CORS ?CORS 的原理是什么?为什么需要 CORS?在这篇文章 - 掘金
- ReadableStream.getReader() - Web API | MDN
- ReadableStream - Web API | MDN
- Fetch API - Web API | MDN
- Window:fetch() 方法 - Web API | MDN
- XMLHttpRequest API - Web API | MDN
- XMLHttpRequest (XHR) - MDN Web 文档术语表:Web 相关术语的定义 | MDN
- 轮询(Polling) 是什么?(轮询的原理) - 数据库博客-OceanBase
- 使用可读流 - Web API | MDN
- Query Parameters - FastAPI