为什么很多 AI 流式输出选择 SSE + EventSource?

167 阅读5分钟

很多人在做 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:本质区别

可以理解成:

方案定位
EventSourceSSE 高级封装 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 开始推送

用户体验会好很多。