核心定位:从HTTP到SSE/WebSocket,理解AI场景的通信选型逻辑
关键产出:SSE流式通信组件 + 协议选型决策树
1.1 SSE vs WebSocket:AI场景为什么选择了SSE
开篇:一个被忽视的架构决策
打开ChatGPT,打开DevTools的Network面板,切换到EventStream类型------你会看到一个持续不断的请求流。
不是WebSocket,不是长轮询,而是SSE(Server-Sent Events)。
这不是一个随意的选型。当我们把视角拉到整个AI产品的技术栈,这个决策背后隐藏着对协议本质、流量模型、基础设施兼容性的深层理解。
理解这一点,是从"会用API"到"能做架构决策"的第一步。
一、SSE协议规范:不只是"服务端推送"那么简单
1.1 SSE的本质
SSE并不是一个新协议,它基于HTTP/1.1,在2004年由W3C提出,2006年成为WHATWG标准。它的核心思想极其简洁:
服务器通过HTTP长连接,以text/event-stream格式持续向客户端推送事件。
一个最简的SSE响应:
yaml
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
data: Hello
data: World
注意几个关键细节:
- 每条消息以
data:开头 - 每条消息以两个换行符结尾(一个换行结束data,一个换行结束整个事件)
- 连接保持长存,服务器可以持续写入
1.2 EventSource API
浏览器原生提供了EventSource接口来消费SSE:
ini
const evtSource = new EventSource('/api/chat/stream');
evtSource.onmessage = (event) => {
console.log(event.data); // "Hello", "World"...
};
evtSource.onerror = (event) => {
console.log('连接异常,自动重连中...');
// EventSource会自动重连!
};
EventSource的核心特性:

1.3 SSE的消息格式详解
SSE的文本协议比大多数人想象的更丰富:
vbnet
id: evt-001
event: token
data: {"content": "你", "index": 0}
id: evt-002
event: token
data: {"content": "好", "index": 1}
id: evt-003
event: done
data: [DONE]
id::事件ID,用于断线续传event::事件类型,不指定则默认为messagedata::消息体,可以有多行(多行data会自动用\n连接)::注释行,用于保持连接活跃(心跳)
心跳机制------这是SSE一个精妙的设计:
kotlin
: this is a comment
以冒号开头的行是注释,客户端会忽略它,但它能保持连接不被代理服务器因超时而断开。许多SSE实现每15-30秒发送一个注释行作为心跳。
二、WebSocket:全双工的代价
2.1 WebSocket协议回顾
WebSocket在2011年成为RFC 6455标准,核心价值是在单个TCP连接上提供全双工通信。
握手阶段通过HTTP Upgrade完成:
makefile
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
服务端响应101:
makefile
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
此后,连接从HTTP协议切换为WebSocket协议,双方可以随时发送数据帧。
2.2 WebSocket的能力与代价

三、AI聊天场景的流量模型分析
这是选型的核心。不要先看协议,先看流量。
AI聊天场景的消息流:
css
用户 ──发送一条消息──→ 服务器
用户 ←──持续接收token── 服务器
用户 ←──持续接收token── 服务器
用户 ←──持续接收token── 服务器
用户 ←──[DONE]──────── 服务器
这是一个典型的单向上行、批量下行的流量模型:
- 上行:用户偶尔发一条消息,频率极低(几十秒甚至几分钟一次)
- 下行:服务器持续输出token,频率极高(每秒几十次)
- 时间不对称:用户发送是瞬时的,接收是持续的
WebSocket的全双工能力在这里是过度设计。你不需要在接收token的同时发送数据,双工带来的复杂度全是负担。
四、AI场景选择SSE的深层原因
4.1 单向推送完美匹配
SSE就是为"服务器向客户端推送"这个场景设计的。AI聊天的流量模型恰好是SSE的最佳适用场景。
4.2 自动重连是刚需
AI生成可能持续数十秒,网络抖动导致的断连是高频事件。SSE内置的自动重连 + Last-Event-ID机制让断线续传几乎零成本实现。
WebSocket需要你自己实现重连逻辑、状态同步、消息补发------每一个都是容易踩坑的边界case。
4.3 HTTP原生 = 基础设施友好
这是最容易被低估但最关键的原因:

一句话:SSE走得通的地方,WebSocket不一定走得通;WebSocket走得通的地方,SSE一定走得通。
4.4 实际产品验证

主流AI产品几乎一致选择SSE,这不是巧合,而是架构共识。
五、何时需要WebSocket或混合方案
SSE不是万能的,以下场景需要WebSocket:

混合方案:许多产品采用SSE + HTTP POST的组合------SSE负责接收流式输出,HTTP POST负责发送用户消息。这是最务实的选择。
arduino
用户发送消息 ──HTTP POST──→ 服务器
用户接收输出 ←──SSE Stream── 服务器
六、协议选型决策树
markdown
AI聊天场景
├── 是否需要客户端高频发送数据?
│ ├── 否 → SSE ✅
│ └── 是
│ ├── 是否需要传输二进制数据?
│ │ ├── 否 → SSE + HTTP POST ✅
│ │ └── 是 → WebSocket ✅
│ └── 是否需要毫秒级双向延迟?
│ ├── 否 → SSE + HTTP POST ✅
│ └── 是 → WebSocket ✅
└── 是否有复杂的网络环境(企业网/CDN)?
├── 是 → SSE ✅(基础设施友好)
└── 否 → 均可,按需选择
默认选SSE,有明确的双向需求时才选WebSocket。
实践任务
任务:用原生EventSource实现一个SSE客户端,支持断线重连与事件类型分流。
要求:
- 连接到SSE端点,按事件类型(
token/done/error)分发处理 - 断线后自动重连,重连时携带Last-Event-ID
- 记录接收到的消息数和重连次数
- 提供手动关闭连接的方法
参考框架:
kotlin
class SimpleSSEClient {
private eventSource: EventSource | null = null;
private messageCount = 0;
private reconnectCount = 0;
constructor(private url: string) {}
connect(): void {
this.eventSource = new EventSource(this.url);
this.eventSource.addEventListener('token', (e: MessageEvent) => {
this.messageCount++;
this.onToken(JSON.parse(e.data));
});
this.eventSource.addEventListener('done', () => {
this.onDone();
this.disconnect();
});
this.eventSource.addEventListener('error', (e: MessageEvent) => {
// TODO: 实现错误处理与重连计数
});
}
disconnect(): void {
this.eventSource?.close();
this.eventSource = null;
}
// 子类或回调实现
protected onToken(data: any): void {}
protected onDone(): void {}
}
面试题解析
Q:SSE和WebSocket的核心区别是什么?为什么AI聊天场景通常选择SSE?
答题结构:
- 协议层区别(30秒):SSE基于HTTP单向推送,WebSocket是独立协议全双工通信
- AI场景流量模型(30秒):AI聊天是"单向上行、批量下行",SSE天然匹配
- 基础设施兼容性(30秒):SSE是HTTP原生,穿透CDN/代理/网关无障碍;WebSocket的Upgrade握手容易被拦截
- 重连机制(15秒):SSE内置自动重连+Last-Event-ID,WebSocket需要自己实现
- 结论(15秒):默认选SSE,有明确双向实时需求时才选WebSocket
避坑:不要只说"SSE是单向的,WebSocket是双向的"------这只是表面区别,面试官想听的是你理解流量模型和基础设施层面的选型逻辑。
1.2 SSE流式通信组件封装与企业级增强
开篇:EventSource的局限
上一期我们理解了SSE的协议规范和选型逻辑。但在实际项目中,原生EventSource有几个致命限制:
sql
❌ 只支持GET请求------无法在请求体中发送消息
❌ 不能自定义Header------无法携带Authorization
❌ 不支持二进制数据------只能传文本
❌ 重连策略不可控------固定3秒间隔,无指数退避
❌ 无连接状态管理------不知道当前是connecting/open/closed/reconnecting
这意味着:生产级的SSE客户端,必须基于fetch实现。
一、Fetch-based SSE:从ReadableStream中解析事件
1.1 核心原理
fetch返回的Response.body是一个ReadableStream,我们可以从中逐块读取数据,手动解析SSE事件格式。
javascript
async function fetchSSE(url: string, options: RequestInit) {
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const reader = response.body!.getReader();
const decoder = new TextDecoder();
let buffer = ''; // 不完整行的缓冲区
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// 按双换行符分割事件
const events = buffer.split('\n\n');
buffer = events.pop()!; // 最后一段可能不完整,保留在缓冲区
for (const eventText of events) {
if (eventText.trim()) {
parseSSEEvent(eventText);
}
}
}
}
1.2 SSE事件解析器
ini
interface SSEEvent {
id?: string;
event?: string;
data: string;
retry?: number;
}
function parseSSEEvent(text: string): SSEEvent {
const result: SSEEvent = { data: '' };
const lines = text.split('\n');
for (const line of lines) {
if (line.startsWith(':')) continue; // 注释行,忽略
const colonIndex = line.indexOf(':');
if (colonIndex === -1) continue;
const field = line.slice(0, colonIndex);
let value = line.slice(colonIndex + 1);
if (value.startsWith(' ')) value = value.slice(1); // 去除前导空格
switch (field) {
case 'id':
result.id = value;
break;
case 'event':
result.event = value;
break;
case 'data':
result.data = result.data ? result.data + '\n' + value : value;
break;
case 'retry':
result.retry = parseInt(value, 10);
break;
}
}
return result;
}
关键细节:
data字段可以出现多次,多行data用\n连接- 缓冲区机制确保跨chunk的行不会被截断
- 注释行(心跳)被正确忽略
二、生产级SSE客户端封装
2.1 架构设计
scss
┌─────────────────────────────────────────┐
│ SSEClient (公共接口) │
├─────────────────────────────────────────┤
│ 连接管理层 │
│ ├─ connect() / disconnect() │
│ ├─ 连接状态机: idle→connecting→open │
│ │ →reconnecting→closed │
│ └─ 重连策略管理 │
├─────────────────────────────────────────┤
│ 事件解析层 │
│ ├─ SSE协议解析器 │
│ ├─ 事件类型分发 │
│ └─ 缓冲区管理 │
├─────────────────────────────────────────┤
│ 中间件管道 │
│ ├─ 日志中间件 │
│ ├─ 鉴权中间件 │
│ ├─ 重试中间件 │
│ └─ 自定义中间件... │
└─────────────────────────────────────────┘
2.2 连接状态机
css
enum SSEState {
IDLE = 'idle',
CONNECTING = 'connecting',
OPEN = 'open',
RECONNECTING = 'reconnecting',
CLOSED = 'closed',
}
type StateTransition =
| { from: SSEState.IDLE; to: SSEState.CONNECTING }
| { from: SSEState.CONNECTING; to: SSEState.OPEN }
| { from: SSEState.CONNECTING; to: SSEState.RECONNECTING }
| { from: SSEState.OPEN; to: SSEState.RECONNECTING }
| { from: SSEState.RECONNECTING; to: SSEState.OPEN }
| { from: SSEState.RECONNECTING; to: SSEState.CLOSED }
| { from: SSEState.IDLE; to: SSEState.CLOSED }
| { from: SSEState.OPEN; to: SSEState.CLOSED };
状态机的价值:防止非法状态转换,让连接生命周期可预测、可调试。
2.3 指数退避重连策略
typescript
interface ReconnectStrategy {
maxRetries: number; // 最大重试次数
baseDelay: number; // 基础延迟(ms)
maxDelay: number; // 最大延迟(ms)
jitter: boolean; // 是否添加随机抖动
}
function calculateDelay(
retryCount: number,
strategy: ReconnectStrategy
): number {
// 指数退避:baseDelay * 2^retryCount
const exponentialDelay = strategy.baseDelay * Math.pow(2, retryCount);
// 限制最大延迟
const cappedDelay = Math.min(exponentialDelay, strategy.maxDelay);
// 添加随机抖动,防止重连风暴
if (strategy.jitter) {
return cappedDelay * (0.5 + Math.random() * 0.5);
}
return cappedDelay;
}
// 示例:baseDelay=1000, maxDelay=30000
// 重试1: 1000ms → 1s
// 重试2: 2000ms → 2s
// 重试3: 4000ms → 4s
// 重试4: 8000ms → 8s
// 重试5: 16000ms → 16s
// 重试6+: 30000ms → 30s(封顶)
为什么需要抖动(Jitter)? 如果成千上万个客户端同时断线,没有抖动意味着它们会在同一时刻同时重连,形成"重连风暴",可能再次压垮服务端。抖动将重连时间分散开,是分布式系统的基本修养。
三、完整实现
kotlin
type SSEEventHandler = (event: SSEEvent) => void;
type StateChangeHandler = (state: SSEState) => void;
interface SSEClientOptions {
url: string;
method?: string;
headers?: Record<string, string>;
body?: string;
reconnect?: Partial<ReconnectStrategy>;
withCredentials?: boolean;
}
class SSEClient {
private state: SSEState = SSEState.IDLE;
private retryCount = 0;
private lastEventId: string | null = null;
private abortController: AbortController | null = null;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private eventHandlers = new Map<string, Set<SSEEventHandler>>();
private stateHandlers = new Set<StateChangeHandler>();
private middlewares: SSEMiddleware[] = [];
private reconnectStrategy: ReconnectStrategy = {
maxRetries: 5,
baseDelay: 1000,
maxDelay: 30000,
jitter: true,
...this.options.reconnect,
};
constructor(private options: SSEClientOptions) {}
// === 连接管理 ===
async connect(): Promise<void> {
if (this.state === SSEState.OPEN || this.state === SSEState.CONNECTING) {
return; // 防止重复连接
}
this.transition(SSEState.CONNECTING);
this.abortController = new AbortController();
try {
const headers: Record<string, string> = {
Accept: 'text/event-stream',
...this.options.headers,
};
if (this.lastEventId) {
headers['Last-Event-ID'] = this.lastEventId;
}
const response = await fetch(this.options.url, {
method: this.options.method ?? 'GET',
headers,
body: this.options.body,
signal: this.abortController.signal,
credentials: this.options.withCredentials ? 'include' : 'same-origin',
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
this.retryCount = 0;
this.transition(SSEState.OPEN);
await this.consumeStream(response);
} catch (error: any) {
if (error.name === 'AbortError') {
// 主动断开,不需要重连
return;
}
this.handleError(error);
}
}
disconnect(): void {
this.clearReconnectTimer();
this.abortController?.abort();
this.abortController = null;
this.transition(SSEState.CLOSED);
}
// === 流消费 ===
private async consumeStream(response: Response): Promise<void> {
const reader = response.body!.getReader();
const decoder = new TextDecoder();
let buffer = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
// 正常结束,触发重连
this.tryReconnect(new Error('Stream ended unexpectedly'));
break;
}
buffer += decoder.decode(value, { stream: true });
const events = buffer.split('\n\n');
buffer = events.pop()!;
for (const eventText of events) {
if (eventText.trim()) {
const sseEvent = parseSSEEvent(eventText);
this.lastEventId = sseEvent.id ?? this.lastEventId;
this.dispatchEvent(sseEvent);
}
}
}
} catch (error: any) {
if (error.name !== 'AbortError') {
this.tryReconnect(error);
}
}
}
// === 重连逻辑 ===
private tryReconnect(error: Error): void {
if (this.retryCount >= this.reconnectStrategy.maxRetries) {
this.transition(SSEState.CLOSED);
return;
}
this.transition(SSEState.RECONNECTING);
const delay = calculateDelay(this.retryCount, this.reconnectStrategy);
this.retryCount++;
console.warn(
`[SSE] 连接断开,${delay}ms 后进行第 ${this.retryCount} 次重连...`,
error.message
);
this.reconnectTimer = setTimeout(() => {
this.connect();
}, delay);
}
private clearReconnectTimer(): void {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
}
// === 事件系统 ===
on(event: string, handler: SSEEventHandler): this {
if (!this.eventHandlers.has(event)) {
this.eventHandlers.set(event, new Set());
}
this.eventHandlers.get(event)!.add(handler);
return this;
}
off(event: string, handler: SSEEventHandler): this {
this.eventHandlers.get(event)?.delete(handler);
return this;
}
onStateChange(handler: StateChangeHandler): this {
this.stateHandlers.add(handler);
return this;
}
private dispatchEvent(event: SSEEvent): void {
// 执行中间件管道
let processedEvent = event;
for (const middleware of this.middlewares) {
processedEvent = middleware.onEvent(processedEvent);
if (!processedEvent) return; // 中间件可以吞掉事件
}
const eventType = processedEvent.event ?? 'message';
const handlers = this.eventHandlers.get(eventType);
if (handlers) {
for (const handler of handlers) {
try {
handler(processedEvent);
} catch (e) {
console.error('[SSE] 事件处理器异常:', e);
}
}
}
}
// === 状态管理 ===
private transition(newState: SSEState): void {
const oldState = this.state;
this.state = newState;
if (oldState !== newState) {
for (const handler of this.stateHandlers) {
handler(newState);
}
}
}
getState(): SSEState {
return this.state;
}
// === 中间件 ===
use(middleware: SSEMiddleware): this {
this.middlewares.push(middleware);
return this;
}
}
// 中间件接口
interface SSEMiddleware {
onEvent(event: SSEEvent): SSEEvent | null;
}
// 日志中间件
class LoggingMiddleware implements SSEMiddleware {
onEvent(event: SSEEvent): SSEEvent {
console.log(`[SSE Event] type=${event.event ?? 'message'} data=${event.data.slice(0, 100)}`);
return event;
}
}
// 鉴权中间件(检查事件中的token过期信号)
class AuthMiddleware implements SSEMiddleware {
constructor(private onAuthExpired: () => void) {}
onEvent(event: SSEEvent): SSEEvent | null {
if (event.event === 'error' && event.data.includes('token_expired')) {
this.onAuthExpired();
return null; // 吞掉事件,不再传播
}
return event;
}
}
四、错误分类:不同错误需要不同策略
kotlin
private handleError(error: Error): void {
const status = this.extractHttpStatus(error);
if (status) {
switch (true) {
// 4xx错误:客户端问题,重连无意义
case status === 401:
// 鉴权过期,需要刷新token
this.transition(SSEState.CLOSED);
this.emit('auth_error', error);
break;
case status === 403:
// 禁止访问,彻底放弃
this.transition(SSEState.CLOSED);
this.emit('forbidden', error);
break;
case status === 429:
// 限流,等更长时间再重试
this.retryCount += 2; // 跳过一些重试次数以增加延迟
this.tryReconnect(error);
break;
case status >= 400 && status < 500:
// 其他4xx,不重连
this.transition(SSEState.CLOSED);
this.emit('client_error', error);
break;
// 5xx错误:服务端问题,可以重连
case status >= 500:
this.tryReconnect(error);
break;
}
} else {
// 网络错误(无HTTP状态码),适合重连
this.tryReconnect(error);
}
}
五、使用示例
javascript
// 创建SSE客户端
const client = new SSEClient({
url: '/api/chat/stream',
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'gpt-4',
messages: [{ role: 'user', content: '介绍一下SSE' }],
}),
reconnect: {
maxRetries: 10,
baseDelay: 500,
maxDelay: 60000,
jitter: true,
},
});
// 注册中间件
client.use(new LoggingMiddleware());
client.use(new AuthMiddleware(() => {
console.log('Token过期,跳转登录页');
window.location.href = '/login';
}));
// 监听事件
client
.on('token', (event) => {
const data = JSON.parse(event.data);
appendToUI(data.content);
})
.on('done', () => {
console.log('生成完成');
})
.on('message', (event) => {
// 默认消息类型的兜底处理
appendToUI(event.data);
});
// 监听连接状态
client.onStateChange((state) => {
updateConnectionIndicator(state);
});
// 建立连接
client.connect();
// 用户关闭页面时主动断开
window.addEventListener('beforeunload', () => {
client.disconnect();
});
六、EventSource vs Fetch-based SSE对比

结论:生产环境一律使用Fetch-based SSE,EventSource只适合简单场景和快速原型。
实践任务
任务:封装一个TypeScript版SSE客户端类,支持插件化中间件(日志/鉴权/重试),并编写单元测试。
测试用例要求:
- 正常连接与消息接收
- 断线重连与指数退避验证
- 4xx错误不重连 / 5xx错误触发重连
- 中间件管道执行顺序验证
- 主动disconnect后不触发重连
- 并发connect只建立一个连接
测试框架建议:使用Vitest + MSW(Mock Service Worker)模拟SSE服务端。
面试题解析
Q:EventSource有哪些限制?如何突破?
答题要点:
- 三大限制:仅GET请求、不能自定义Header、重连策略不可控
- 突破方案:使用fetch API + ReadableStream手动解析SSE协议
- 核心实现:TextDecoder逐块解码 → 缓冲区拼合 → 按双换行分割事件 → 解析field/value
- 额外收益:fetch方案还获得了AbortController超时控制、任意HTTP方法、请求体等能力
Q:如何实现SSE的断线重连?需要注意哪些边界情况?
答题要点:
- 指数退避 + 随机抖动防止重连风暴
- Last-Event-ID实现断点续传
- 错误分类:4xx不重连、5xx可重连、网络错误可重连
- 最大重试次数上限防止无限重连
- 主动断开(disconnect)与异常断开的区分
- 重连期间如果用户再次操作(如发新消息),需要取消旧的重连定时器
下期预告:前端AI工程化(二) : LLM流式输出与前端渲染,我们将从"怎么接收数据"进入"怎么渲染数据",直面AI长文本流的性能攻坚战。