很多人在做 AI 流式输出的时候,都会纠结几个问题:
- 为什么很多项目用 EventSource?
- Fetch +
Accept: text/event-stream不也能实现吗? - EventSource 为什么只能 GET?
- SSE 断线重连到底是怎么做的?
- AI 聊天中断之后,为什么还能继续生成?
这篇文章尝试把这些问题一次讲清楚。
1、为什么使用 EventSource?
因为:
EventSource 本身就是浏览器为 SSE(Server-Sent Events)设计的高级 API。
它帮你做好了:
- 自动重连
- SSE 协议解析
- 心跳维护
- 流式监听
- 浏览器兼容处理
所以绝大部分情况下:
你根本不需要自己处理流。
Fetch + text/event-stream 可以吗?
当然可以。
本质上:
Accept: text/event-stream
只是告诉服务端:
“我要接收 SSE 流式数据”。
所以:
const response = await fetch('/api/stream', {
headers: {
Accept: 'text/event-stream'
}
});
完全没问题。
但问题在于:
Fetch 只是给你“原始流”。
后面的事情全得你自己处理。
比如:
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { value, done } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
// 你需要自己解析:
// data: xxx\n\n
}
这时候你还需要处理:
- chunk 粘包
- 半包
- SSE 协议解析
- reconnect
- timeout
- retry
- 心跳检测
- 异常恢复
代码量会瞬间暴涨。
EventSource vs Fetch:本质区别
可以理解成:
| 方案 | 定位 |
|---|---|
| EventSource | SSE 高级封装 API |
| Fetch + Stream | 底层流控制 |
大多数情况下直接用 EventSource
因为它:
1、更简单
EventSource:
const es = new EventSource('/api/stream');
es.onmessage = (e) => {
console.log(e.data);
};
结束。
2、浏览器原生支持自动重连
EventSource 默认会:
- 自动重连
- 自动维护连接状态
- 自动处理断线
这些你都不用管。
3、更符合 SSE 的设计初衷
SSE 本来就是:
服务端不断推消息给客户端。
而 EventSource 就是浏览器官方给 SSE 配套的 API。
那什么时候用 Fetch + Stream?
当你需要更底层控制的时候
比如:
- POST 请求
- 自定义 timeout
- 自定义 retry
- 非标准协议
- 特殊鉴权
- 已经基于 Fetch 封装了一套网络层
尤其是大多数公司都会对 Fetch 做一层封装
除此之外,EventSource 最大的问题:只能 GET,这是很多人第一次用时最难受的地方,因为业务接口不可能就用get来实现,那需要传参数就需要通过拼接URL的方式来,但是如果参数复杂那就难搞了,只能通过一些特殊的方式,例如增加一个订阅接口来配合传参数了
为什么很多 AI 聊天能“断点续传”?
这里其实涉及:
SSE + 会话状态管理
很多人误以为:
前端断开了,后端是不是也停了?
实际上:
大多数 AI 系统不会停。
AI 任务通常在后端继续执行。
前端只是:
“暂时没在听”。
用一个“公司开会”的例子理解
为了方便理解。
我们把整个系统类比成一个公司。
公司组织架构
| 角色 | 对应技术 |
|---|---|
| 前端 | 浏览器 |
| 后端老大 | SessionManager |
| AI 工作线程 | 真正调用 AI 的服务 |
| 会议室 | ConversationSession |
| 门 | SSE 连接 |
| 任务号 | conversationId |
| 白板 | generatedChunks |
| 文件柜 | 最终缓存结果 |
第一章:新项目启动
前端:
“老大,我们有个新任务 A。”
后端老大检查:
任务 A 是否已有会议室?
发现没有。
于是:
创建会议室 A1
安排 AI 工作线程进入
AI 开始工作:
“开始生成内容...”
“继续生成...”
“正在思考...”
同时不断往会议室白板写内容。
第二章:前端掉线
突然。
前端网络断了。
门1关闭
但重点来了:
会议室并不会消失。
AI 还在继续生成。
白板还在继续记录内容。
所以:
前端掉线 ≠ AI 停止
第三章:重新连接
几分钟后。
前端回来了。
“老大,我想继续看任务 A。”
后端发现:
会议室 A1 还在
于是:
重新开门2
并把之前错过的内容全部同步给前端:
- 已完成第一段
- 已完成第二段
- 当前正在第三段
然后继续实时推送。
为什么需要 seq(序号)?
因为:
前端需要告诉后端:
“我已经收到哪里了。”
比如:
seq = 15
意思是:
前15条我已经收到了
从16开始继续给我
这样才能实现真正的:
断点续传
第四章:多人同时监听
甚至:
多个前端还能同时监听同一个任务。
比如:
标签页1 → 门1
标签页2 → 门2
手机端 → 门3
但:
AI 只生成一次。
所有人共享结果。
这也是很多 AI 系统的核心优化思路。
第五章:任务结束
AI 完成生成后:
任务状态 = completed
但会议室不会立刻删除。
通常会:
保留30分钟
因为:
用户可能还会回来查看结果。
超时清理机制
如果:
30分钟无人访问
系统会:
- 清理缓存
- 关闭会话
- 释放内存
- 删除 generatedChunks
否则:
长时间运行后内存会爆炸。
为什么选择这样设计?
因为这种模式有几个巨大优势。
1、职责分离
AI 服务:
只负责生成内容
SessionManager:
只负责管理会话
SSE:
只负责消息推送
系统会非常清晰。
2、前端断线不影响 AI
这是最关键的。
因为:
连接状态
≠
任务状态
两者是解耦的。
3、多人共享结果
AI 很贵。
所以:
一份结果最好复用。
而不是:
每个前端来一次。
后端重新调用一次 OpenAI。
4、支持断点续传
用户网络断了。
重新连接后:
继续从 seq=N 开始推送
用户体验会好很多。