从 EventSource 到可复用 SSE Client:我如何实现多实例、双超时与自动重连
在 AI 对话和代码生成场景中,前端面对的并不是一次普通 HTTP 请求,而是一条可能持续几十秒的任务流。
本文记录我如何从直接使用
EventSource,逐步封装出一个支持多实例、连接/响应超时、自动重连、统一错误处理和生命周期回调的 SSE Client。
一、先认识 SSE
SSE 全称是 Server-Sent Events,即服务器发送事件。它允许服务端在建立 HTTP 连接后,不立即结束响应,而是持续向浏览器推送数据。
一次普通 HTTP 请求通常是:
txt
客户端发起请求 → 服务端返回完整响应 → 连接结束
SSE 则是:
txt
客户端发起请求 → 服务端保持响应 → 持续推送事件 → 主动或被动关闭
服务端返回的响应类型为:
http
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
事件流由一段段文本组成,一条标准 SSE 消息可以包含:
txt
id: 42
event: message
data: {"content":"你好"}
常见字段含义如下:
| 字段 | 作用 |
|---|---|
data |
当前事件携带的数据 |
event |
自定义事件名称 |
id |
事件 ID,可用于断线恢复 |
retry |
建议客户端采用的重连间隔 |
每条事件以空行结束。服务端只要不断写入新的事件,浏览器就可以不断消费数据,而不需要反复轮询接口。
没有声明 event 或使用 event: message 时,可以通过 onmessage 接收;如果服务端返回 event: answer、event: done 等自定义事件,则需要使用 addEventListener("answer", handler) 监听。
二、EventSource 是什么
浏览器提供了原生 EventSource API,用于建立和消费 SSE 连接。
最基础的用法是:
js
const source = new EventSource("/api/tasks/123/events");
source.onopen = () => console.log("连接已打开");
source.onmessage = (event) => {
const data = JSON.parse(event.data);
console.log(data);
};
source.onerror = () => console.log("连接异常");
EventSource 内部有三个连接状态:
| 状态 | 值 | 含义 |
|---|---|---|
CONNECTING |
0 |
正在连接或重连 |
OPEN |
1 |
连接已建立 |
CLOSED |
2 |
连接已经关闭 |
它的优点很直接:
- 基于标准 HTTP,服务端和代理层容易接入;
- 浏览器原生支持,不需要额外协议库;
- 天然适合服务端向客户端的单向流式推送;
- 支持命名事件、事件 ID 和原生重连语义;
- 文本流与 AI 模型的增量输出天然契合。
它也有明显限制:
- 原生 EventSource 主要使用 GET;
- 不能像
fetch一样自由设置请求头; - 通信方向主要是服务端到客户端;
onerror通常拿不到完整 HTTP 状态码和响应体;- 如果缺少业务协议,断线重连后可能重复消费消息。
三、为什么 AI 流式输出适合 SSE
AI 对话的典型链路是:
txt
用户提交一次问题
↓
服务端开始模型推理
↓
持续返回正文、推理摘要和工具状态
↓
服务端发送任务完成事件
提交问题是一次性的,后续数据主要从服务端流向客户端。这种"低频上行、持续下行"的模式非常适合 SSE。
和其他方案相比:
| 方案 | 通信方式 | 适合场景 |
|---|---|---|
| 短轮询 | 客户端反复请求 | 更新频率低、实时性要求不高 |
| 长轮询 | 请求挂起,返回后重新请求 | 需要兼容旧系统的准实时场景 |
| SSE | 服务端单向持续推送 | AI 输出、日志、进度和通知 |
| WebSocket | 全双工通信 | 即时聊天、游戏、多人协同 |
WebSocket 的能力更强,但能力更强不等于更适合。对于一次提交、持续返回的 AI 生成任务,SSE 的通信模型更简单,HTTP 语义也更清晰。
四、直接使用 EventSource 遇到了什么问题
最初,我在业务组件中直接创建 EventSource:
js
const source = new EventSource(url);
source.onmessage = (event) => {
const data = JSON.parse(event.data);
message.value += data.content;
};
随着业务完善,组件中很快混入了大量非 UI 逻辑:
- 创建和保存连接;
- 判断任务是否结束;
- JSON 消息解析;
- 连接超时处理;
- 网络异常重连;
- 重试次数维护;
- 页面卸载清理;
- 用户取消生成;
- 错误提示和状态恢复。
更麻烦的是,项目不只有一条流。AI 对话、代码生成和构建日志可能同时存在,各自都维护一套 EventSource、定时器和错误逻辑。
这说明问题已经不再是"如何接收一条消息",而是"如何管理一条流式任务的完整生命周期"。
因此,我开始把连接能力从业务组件中抽离出来。
五、我为客户端划定的职责边界
整个流式链路被拆成三层:
txt
SSE Client
负责连接、超时、重连、关闭和错误收敛
协议适配层
负责把服务端事件解析成正文、推理摘要和工具状态
业务层
负责更新会话、消息列表和页面状态
SSE Client 不应该知道当前消息属于哪个聊天气泡,也不应该直接修改 Vue 状态。它只负责稳定地接收数据,再通过回调将结果交给业务侧。
最终对外暴露的生命周期包括:
txt
onOpen 连接打开
onMessage 收到消息
onRetry 准备重连
onComplete 任务完成
onError 发生错误
onClose 连接最终关闭
客户端内部则维护:
txt
idle → connecting → open → retrying → closed
明确职责和状态以后,后续的多实例、超时与重连才不会互相缠绕。
六、多实例:每个任务拥有独立连接
我采用的是任务级 SSE,而不是整个页面永久共用一条连接:
txt
一次 AI 生成任务
↓
一个 SseClient 实例
↓
一条 EventSource 连接
↓
接收多个 chunk
↓
任务结束后释放
一次任务可能收到几百个 chunk,但只会创建一个客户端实例。下一轮任务会创建新的实例,因此每轮任务的状态、定时器和重试次数相互隔离。
为了统一管理活跃连接,我使用 Map 保存实例:
js
const connections = new Map();
function createSseConnection(url, options) {
const client = new SseClient(url, options);
connections.set(client.id, client);
client.connect();
return client;
}
function closeAllSseConnections() {
[...connections.values()].forEach((client) => client.close());
}
每个客户端最终关闭时,会将自己从 connections 中移除。这样既可以查询当前活跃连接数,也可以在退出工作台时统一释放全部连接。
多实例不等于无节制地建立连接。它表达的是"不同任务之间相互隔离",业务仍然可以限制同一会话只能运行一个任务。
七、双超时:连接超时和响应超时
只设置一个 timeout 无法准确描述 SSE 的异常状态。我把超时拆成两类。
1. 连接超时
连接超时关注的是:创建 EventSource 后,规定时间内有没有触发 onopen。
txt
new EventSource
↓
等待 onopen
↓ 超过 15 秒
connect timeout
它可以发现网络不可用、服务端没有响应或者代理连接失败。
2. 响应空闲超时
响应超时关注的是:连接已经打开,但是否长时间没有收到任何消息。
txt
连接已打开
↓
收到部分内容
↓ 长时间无新消息
idle timeout
它可以发现服务端任务卡住、代理中断或连接假死。
两个定时器的核心逻辑并不复杂:
js
startConnectTimer() {
this.connectTimer = setTimeout(() => {
if (this.state === "connecting") {
this.handleError(createTimeoutError("connect"));
}
}, this.options.connectTimeout);
}
refreshIdleTimer() {
clearTimeout(this.idleTimer);
this.idleTimer = setTimeout(() => {
this.handleError(createTimeoutError("idle"));
}, this.options.idleTimeout);
}
连接打开后清除连接定时器并启动空闲定时器;每收到一条消息,就重新计算空闲时间。
如果模型可能长时间没有正文输出,服务端需要发送心跳事件。心跳不需要展示,但应该刷新空闲定时器,证明连接仍然存活。
八、自动重连:先接管,再重建
EventSource 自带重连能力,但项目需要控制最大次数、重试间隔以及哪些错误可以重试,因此我选择在客户端中统一管理重连。
这里有一个容易忽略的问题:如果发生错误后不关闭旧 EventSource,浏览器可能进行原生重连;业务代码又创建一个新实例,就会同时存在两条连接,造成消息重复。
我的处理顺序是:
txt
发生连接错误
↓
关闭当前 EventSource
↓
清理连接与空闲定时器
↓
判断错误是否允许重试
↓
延迟创建新的 EventSource
重试使用指数退避,而不是固定间隔:
js
function getRetryDelay(retryCount, baseDelay, maxDelay) {
return Math.min(
baseDelay * 2 ** (retryCount - 1),
maxDelay,
);
}
例如基础间隔为一秒时,重试节奏是:
txt
第 1 次:1 秒
第 2 次:2 秒
第 3 次:4 秒
实际实现中我还加入了少量随机抖动,避免网络恢复时多个客户端在同一时刻集中重连。
重试策略通过配置控制:
js
retry: {
enabled: true,
maxRetries: 3,
baseDelay: 1000,
maxDelay: 8000,
},
shouldRetry: (error) => error.retryable,
用户取消、任务完成、解析错误和明确的业务错误不会自动重试;连接中断和超时可以按策略恢复。
九、错误不能只分成"成功"和"失败"
SSE 的错误可能来自完全不同的阶段:
| 类型 | 示例 | 默认策略 |
|---|---|---|
| 创建错误 | URL 不合法、构造失败 | 直接结束 |
| 连接错误 | 网络中断、服务端断开 | 尝试重连 |
| 连接超时 | 长时间没有 onopen |
尝试重连 |
| 响应超时 | 连接打开后长期无消息 | 尝试重连 |
| 解析错误 | 返回内容不是约定格式 | 上报并结束 |
| 业务错误 | 鉴权失败、参数错误、任务失败 | 直接结束 |
我给错误增加了 type 和 retryable:
js
class SseClientError extends Error {
constructor(type, message, retryable = false) {
super(message);
this.name = "SseClientError";
this.type = type;
this.retryable = retryable;
}
}
所有错误进入同一个处理入口,但并不执行完全相同的策略:
txt
清理当前连接
↓
触发 onError
↓
是否可重试?
├─ 是:触发 onRetry,延迟重连
└─ 否:关闭客户端,触发 onClose
统一错误入口减少了重复逻辑,而错误分类保留了不同故障的处理差异。
十、生命周期回调如何解耦业务
封装完成后,业务层不再直接操作 EventSource,只声明各阶段需要做什么:
js
const client = createSseConnection(`/api/tasks/${taskId}/events`, {
connectTimeout: 15_000,
idleTimeout: 90_000,
onOpen() {
taskStatus.value = "streaming";
},
onMessage(data) {
if (data.type === "answer") appendAnswer(data.content);
if (data.type === "reasoning") appendReasoning(data.content);
if (data.type === "tool_call") updateToolState(data);
},
onComplete() {
taskStatus.value = "done";
},
onRetry({ retryCount }) {
taskStatus.value = `第 ${retryCount} 次重连`;
},
onError(error) {
console.error(error.type, error.message);
},
onClose({ reason }) {
console.log("连接已关闭:", reason);
},
});
通信层只负责把解析后的数据交出去。业务层根据 type 将数据分发到正文、推理摘要和工具状态,从而避免所有内容都被拼接进同一个字符串。
这种结构还有一个好处:同一个 SSE Client 可以用于不同业务,区别只在 parseMessage、完成条件和生命周期回调。
十一、多轮对话如何更新正确的消息
每次用户发送消息时,业务层先通过普通请求创建任务并取得 taskId,随后创建一条空的 assistant 消息,再建立本轮 SSE:
js
function startStream(conversation, taskId) {
const conversationKey = conversation.key;
const assistantKey = crypto.randomUUID();
appendAssistantMessage(conversationKey, assistantKey);
return createSseConnection(`/api/tasks/${taskId}/events`, {
onMessage(data) {
appendContent(
conversationKey,
assistantKey,
data.content,
);
},
});
}
conversationKey 和 assistantKey 被保存在回调闭包中,因此本轮所有 chunk 都只会更新本轮 assistant 消息。
下一轮对话会重新执行 startStream,生成新的消息 ID 和客户端实例:
txt
第 1 轮:conversation-A + assistant-1 + SSE-1
第 2 轮:conversation-A + assistant-2 + SSE-2
固定本轮 assistantKey 不是限制,反而可以防止上一轮迟到的消息被错误地追加到下一轮回复。
如果一条全局 SSE 需要同时承载多个任务,服务端事件就必须携带 conversationId、taskId 和 messageId,前端再根据这些字段动态路由。
十二、关闭连接时要清理什么
SSE Client 的关闭逻辑必须是幂等的。任务完成、业务报错、用户取消和组件卸载可能同时触发关闭,但真正的资源回收只能执行一次。
最终关闭需要处理:
- 清除连接超时定时器;
- 清除响应空闲定时器;
- 清除待执行的重试定时器;
- 移除 EventSource 事件函数;
- 调用
eventSource.close(); - 从活跃连接 Map 中移除实例;
- 更新客户端状态;
- 触发一次
onClose。
js
close(reason = "manual") {
if (this.closed) return;
this.closed = true;
this.clearAllTimers();
this.destroyEventSource();
connections.delete(this.id);
this.options.onClose({ reason });
}
特别需要清理重试定时器。否则用户已经离开页面,之前安排的重连仍可能创建新的 EventSource,形成难以察觉的连接泄漏。
十三、EventSource 在项目中的两个协议问题
1. 不要把长 prompt 直接放进 URL
由于原生 EventSource 主要使用 GET,有些实现会把用户问题放进查询参数:
txt
/api/chat/stream?message=很长的用户输入
这会带来 URL 长度、日志暴露和复杂参数编码问题。
更合理的方式是先创建任务,再订阅任务流:
txt
POST /api/ai/tasks
body: { prompt, context }
response: { taskId }
GET /api/ai/tasks/{taskId}/events
response: text/event-stream
POST 负责提交复杂数据,SSE 只负责接收任务结果。
2. 重连要考虑消息重复
重新建立连接后,服务端可能重新发送已经消费过的内容。如果直接拼接,会出现重复文本。
更可靠的事件应该包含:
json
{
"taskId": "task-1",
"messageId": "message-1",
"seq": 12,
"type": "answer",
"content": "新的片段"
}
前端记录最后消费的序号:
js
if (data.seq <= lastSeq) return;
lastSeq = data.seq;
如果后端支持标准 SSE id 字段,还可以结合 Last-Event-ID 做断点恢复。
因此,自动重连只解决"重新连上",seq、消息去重和断点续传才解决"连上后数据仍然正确"。
十四、这次封装带来的变化
封装前,业务组件同时承担通信和 UI 职责:
txt
组件
├─ 创建 EventSource
├─ 解析数据
├─ 管理定时器
├─ 处理重连
├─ 判断任务结束
└─ 更新页面
封装后,职责被重新划分:
txt
SSE Client
负责连接可靠性与生命周期
协议适配层
负责将原始事件转换为业务数据
组件
只负责消费数据与展示状态
最终实现的核心能力包括:
- 每个任务对应独立客户端,支持多条连接并存;
- 区分连接超时和响应空闲超时;
- 使用可配置重试和退避策略恢复临时故障;
- 统一连接、超时、解析和业务错误;
- 通过生命周期回调隔离通信层与业务层;
- 在完成、失败、取消和卸载时统一释放资源。
回过头看,这次封装的重点并不是把 new EventSource 放进一个 class,而是为流式任务建立统一、可预测的生命周期。