Server-Sent Events (SSE) 是 AI 大模型流式输出的事实标准。但许多开发者在落地时,往往忽略了从"协议理解"到"工程实现"之间的巨大鸿沟。本文基于完整的工程实践对话,将 SSE 前后端实现拆解为三个循序渐进的阶段:先跑通最小 Demo 建立直觉,再引入心跳机制解决网络假死,最后对接 AI 流式输出完成生产级改造。
阶段一:最小可运行 Demo ------ 建立协议直觉
1. 核心认知纠偏
SSE 连接永远由前端(客户端)主动发起,后端只负责"保持响应不关闭"。
- 建立阶段 :浏览器通过
new EventSource(url)或fetch()向服务器发送一个标准的 HTTP GET 请求。 - 维持阶段 :服务器返回
200 OK和Content-Type: text/event-stream后,这个 HTTP 响应体保持打开状态。 - 推送阶段:服务器在这个已建立的、未关闭的 HTTP 响应通道中,持续写入数据块。
SSE 本质是一个 "永远不结束的 HTTP 响应"。后端没有能力主动"敲开"客户端的门,只是获得了在已建立的长响应中随时追加内容的权利。这也是 SSE 能穿透防火墙和代理的根本原因------它就是普通 HTTP 流量。
2. 后端实现
javascript
const express = require('express');
const app = express();
app.get('/stream', (req, res) => {
// ✅ 1. 设置 SSE 必需的响应头
res.setHeader('Content-Type', 'text/event-stream'); // 声明 SSE 协议类型
res.setHeader('Cache-Control', 'no-cache'); // 禁止任何中间层缓存
res.setHeader('Connection', 'keep-alive'); // 显式声明长连接
res.flushHeaders(); // 🔑 立即发送头部,否则 Express 会缓冲导致前端收不到首字节
let count = 0;
const timer = setInterval(() => {
// ✅ 2. SSE 格式:data: + JSON字符串 + 双换行符(\n\n)
// ⚠️ 注意:JSON.stringify 返回的是字符串,不是对象
res.write(`data: ${JSON.stringify({ chunk: count++ })}\n\n`);
}, 8000);
// ✅ 3. 客户端断开时清理资源
// ⚠️ 必须用 req.on('close'),而非 res.on('finish')
req.on('close', () => {
clearInterval(timer);
console.log('客户端断开,定时器已清理');
});
});
app.listen(3000);
3. 前端实现
javascript
// ✅ 创建 SSE 连接,URL 需与后端 /stream 路由一致
const es = new EventSource('http://localhost:3000/stream');
// ✅ 接收 data: 字段的消息
es.onmessage = (event) => {
// event.data 始终是字符串,需要手动解析
try {
const parsed = JSON.parse(event.data);
console.log('收到数据块:', parsed);
} catch (err) {
// ⚠️ 网络截断可能导致不完整数据,必须容错
console.error('JSON 解析失败:', err);
}
};
es.onopen = () => console.log('SSE 连接已建立');
es.onerror = (err) => console.error('SSE 连接异常:', err);
4. 阶段一注意事项清单
1. 为什么是 data: ... 格式?(SSE 协议规范)
SSE 协议规定,服务器推送的每条消息必须由 字段名 + 冒号 + 空格 + 值 组成。data 是承载实际载荷的标准字段名。
- ✅
data: {"chunk":0}\n\n→ 客户端EventSource.onmessage能正确解析出{"chunk":0} - ❌
{"chunk":0}\n\n→ 缺少data:前缀,客户端会将其视为非法消息直接丢弃 - ❌
Data: ...\n\n→ 字段名区分大小写,必须是全小写data
💡 SSE 还有其他字段如
event:(自定义事件类型)、id:(断线重连ID)、retry:(重连间隔),但data:是最基础且必须的。
2. 为什么是两个 \n\n?(消息边界分隔符)
这是 SSE 协议中最容易被忽视但最关键的细节:
| 换行符 | 含义 |
|---|---|
第一个\n |
结束当前data:这一行 |
第二个\n |
表示整条消息结束(空行 = 消息终止符) |
text
data: {"chunk":0}\n ← 第一个\n:结束 data 行
\n ← 第二个\n:空行,触发客户端 onmessage
data: {"chunk":1}\n
\n
- 如果只写一个
\n:客户端会认为消息还没结束,继续等待下一行,导致消息永远不会被派发。 - 如果写了三个
\n:多出的空行会被当作一条"空消息"触发一次额外的onmessage(e.data === "")。
⚠️ 这也是为什么心跳用
: heartbeat\n\n同样需要双换行------即使是注释,也必须以空行结尾才算一个完整的 SSE 帧。
3. 为什么要 JSON.stringify()?(数据类型约束)
res.write() 接受字符串或 Buffer,而 SSE 是纯文本协议:
- ❌
res.write({ chunk: count++ })→ Node.js 会对对象调用.toString(),结果是[object Object] - ✅
res.write(JSON.stringify({ chunk: count++ }))→ 输出合法的 JSON 字符串{"chunk":0}
客户端收到后可以通过 JSON.parse(e.data) 还原为对象。当然,如果你的业务只需要纯文本,也可以直接写 res.write('data: hello\n\n'),JSON.stringify 并非 SSE 强制要求,只是结构化数据传输的最佳实践。
4. 为什么必须调用 res.flushHeaders()?(Express 缓冲机制)
Express/Node.js 默认启用响应缓冲,即不会立即将 setHeader 的内容发送到客户端,而是等到第一次 res.write() 或 res.end() 时才一并发出。
- ❌ 不调用
flushHeaders():前端EventSource发出请求后,可能等待数秒才收到 HTTP 200 响应头和首个数据字节,表现为"连接已建立但长时间无数据"。 - ✅ 调用
flushHeaders():强制立即发送响应头,前端瞬间收到 200 并进入监听状态。
⚠️ 原生
http模块无此问题,此注意事项仅针对 Express/Koa 等带缓冲层的框架。
5. 为什么用 req.on('close') 而非 res.on('finish')?(断开检测可靠性)
两者触发条件完全不同:
| 事件 | 触发时机 | 覆盖场景 |
|---|---|---|
res.on('finish') |
服务端主动调用res.end()且数据全部发出后 |
仅正常结束 |
req.on('close') |
底层 TCP 连接关闭时 | 正常结束 + 客户端刷新 + 网络中断 + 标签页关闭 + 超时断开 |
- ❌ 只用
res.on('finish'):用户刷新页面或网络断开时,定时器/数据库连接/LLM 调用不会被清理,造成资源泄漏。 - ✅ 只用
req.on('close'):覆盖所有断开场景,是唯一可靠的清理钩子。
6. 为什么 event.data 必须手动 JSON.parse 且包裹 try-catch?(前端数据安全性)
SSE 是纯文本协议,EventSource.onmessage 回调中的 event.data永远是字符串,即使后端发送的是合法 JSON。
- ❌ 直接使用
event.data.chunk→undefined,因为字符串没有chunk属性 - ❌
JSON.parse(event.data)不加 try-catch → 网络分包导致某次收到的数据是{"chu(不完整),JSON.parse抛出异常,后续所有消息的消费逻辑被中断 - ✅
try { JSON.parse(event.data) } catch(e) { console.error(e) }→ 单条坏消息被安全跳过,流继续消费
7. 跨域携带 Cookie 时的 CORS 陷阱(credentials 互斥规则)
当 SSE 接口需要携带 Cookie(如鉴权 token)时:
- 前端必须设置
new EventSource(url, { withCredentials: true }) - 后端 不能 使用
Access-Control-Allow-Origin: *,必须指定具体域名 - 后端必须添加
Access-Control-Allow-Credentials: true
❌ * + withCredentials: true → 浏览器直接拒绝请求,报错 "The value of the 'Access-Control-Allow-Origin' header must not be the wildcard '*' when the request's credentials mode is 'include'."
⚠️ 本阶段局限:此 Demo 仅验证了"数据能从服务器推到浏览器",但在真实网络环境中,中间代理通常有 60s 空闲超时策略。若两次消息间隔超过阈值,连接会被静默断开,前端表现为"假死"。这就需要进入阶段二。
阶段二:心跳保活 ------ 解决网络假死
1. 为什么需要心跳?
AI 推理的首 Token 延迟可能长达数十秒,两次有效消息之间的空窗期极易触发中间代理的空闲超时。心跳的作用是在无业务数据时,持续向 TCP 通道注入微量数据,欺骗中间件"连接仍活跃"。
2. 后端实现(新增心跳)
javascript
app.get('/stream', (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders();
let count = 0;
// 业务数据定时器(模拟慢速生成)
const timer = setInterval(() => {
res.write(`data: ${JSON.stringify({ chunk: count++ })}\n\n`);
}, 8000);
// ✅ 新增:心跳保活(SSE 注释格式)
// ⚠️ 必须以冒号开头,这是 SSE 规范的注释语法
// ⚠️ 前端 onmessage 不会触发,但 TCP 层有数据传输
const heartbeat = setInterval(() => {
res.write(': heartbeat\n\n');
}, 3000); // 间隔必须小于中间代理超时(通常60s)
req.on('close', () => {
clearInterval(timer);
clearInterval(heartbeat); // 🔑 心跳定时器也必须清理!
console.log('客户端断开,所有资源已释放');
});
});
3. 前端实现(新增静默超时检测)
javascript
class ResilientSSE {
constructor(url, options = {}) {
this.url = url;
this.maxSilence = options.maxSilence || 10000; // 静默超时阈值
this.reconnectDelay = options.reconnectDelay || 3000;
this.es = null;
this.silenceTimer = null;
this.isManualClose = false; // 🔑 区分手动关闭与异常断开
this.connect();
}
connect() {
this.isManualClose = false;
this.es = new EventSource(this.url);
// ✅ 重置静默计时器的统一方法
const resetSilence = () => {
clearTimeout(this.silenceTimer);
this.silenceTimer = setTimeout(() => {
console.warn(`⚠️ ${this.maxSilence}ms 无消息,判定为假死,主动重连`);
this.reconnect();
}, this.maxSilence);
};
this.es.onopen = () => {
console.log('✅ SSE 连接已建立');
resetSilence(); // 连接建立时启动计时器
};
this.es.onmessage = (event) => {
resetSilence(); // 🔑 核心:每收到一条 data 消息就重置超时
if (event.data === '[DONE]') {
this.close();
return;
}
try {
const parsed = JSON.parse(event.data);
console.log('📦 数据块:', parsed);
} catch (err) {
console.error('❌ JSON 解析失败:', err);
}
};
this.es.onerror = () => {
// 🔑 只有非手动关闭时才触发重连
if (!this.isManualClose) {
console.error('❌ SSE 异常,准备重连...');
this.reconnect();
}
};
}
reconnect() {
this.cleanup();
setTimeout(() => this.connect(), this.reconnectDelay);
}
close() {
this.isManualClose = true; // 🔑 标记为手动关闭,阻止 onerror 触发重连
this.cleanup();
console.log('🔒 SSE 连接已手动关闭');
}
cleanup() {
clearTimeout(this.silenceTimer);
if (this.es) {
this.es.close();
this.es = null;
}
}
}
// 🚀 使用示例
const sse = new ResilientSSE('http://localhost:3000/stream', {
maxSilence: 10000, // 10s 无消息判定假死
reconnectDelay: 3000 // 3s 后重连
});
// ⚠️ SPA 路由切换或标签页隐藏时必须显式关闭
window.addEventListener('beforeunload', () => sse.close());
4. 阶段二注意事项清单
1. 为什么心跳必须用 : xxx\n\n 注释格式?(SSE 注释语法)
SSE 规范定义以冒号 : 开头的行为注释,客户端 EventSource 会静默忽略,不触发 onmessage。
- ✅
: heartbeat\n\n→ TCP 层有数据传输,保活生效;前端无感知 - ❌
data: heartbeat\n\n→ 前端onmessage被触发,JSON.parse("heartbeat")抛异常,污染业务逻辑 - ❌
heartbeat\n\n→ 无字段名前缀,被视为非法消息丢弃,TCP 层虽有数据但语义不规范
⚠️ 注释同样必须以 \n\n 结尾,否则不构成完整 SSE 帧,中间代理可能不认为这是一次有效传输。
2. 为什么心跳间隔必须小于中间代理超时?(保活有效性)
企业 Nginx、云 LB、CDN 通常有 60s 空闲超时策略。心跳的核心目的是在超时窗口内制造流量。
- ✅ 心跳 15-30s < 代理超时 60s → 每次超时窗口内至少有 2-4 次心跳,连接稳定
- ❌ 心跳 65s > 代理超时 60s → 心跳到达时连接已被中间件切断,完全失效
- ❌ 心跳 = 代理超时 → 边界竞态,网络抖动时仍可能被切断
💡 建议心跳间隔设为代理超时的 1/3 ~ 1/2,留出足够的安全裕量。
3. 为什么心跳定时器必须在 req.on('close') 中清理?(资源泄漏防护)
setInterval 创建的定时器持有对 res 对象的闭包引用。若客户端断开后不清理:
- 定时器继续执行
res.write()→ 触发Error: write after end未捕获异常 res对象无法被 GC 回收 → 每个断开连接泄漏一个定时器 + 一个 socket 引用- 高并发下内存持续增长 → 最终 OOM
⚠️ 阶段一中只清理了业务定时器,阶段二新增了心跳定时器,两者都必须清理,遗漏任何一个都是泄漏。
4. 为什么心跳和业务数据必须是两个独立的 setInterval?(节奏解耦)
业务数据的发送节奏由模型推理速度决定,完全不均匀;心跳的节奏必须恒定才能可靠保活。
- ✅ 两个独立定时器 → 心跳不受业务消息间隔影响,即使业务暂停 30s,心跳仍每 3s 发送
- ❌ 心跳嵌入业务定时器 → 业务消息间隔变为 30s 时,心跳也变成 30s,可能超过代理超时
5. 为什么前端不能用 onmessage 判断连接存活?(心跳不可见性)
心跳是 SSE 注释,EventSource 设计上就不派发事件。这意味着:
- ❌ 在
onmessage中重置超时计时器 → 心跳期间计时器不会被重置,误判为假死 - ✅ 只在
onmessage中重置 → 仅业务数据触发重置;心跳靠后端保证 TCP 层不断开,前端靠静默超时检测兜底
💡 这是一个前后端分工:后端心跳防代理超时,前端静默超时防 TCP 假死。两者互补,不可替代。
6. 为什么 maxSilence 必须大于最大消息间隔?(误判防护)
静默超时阈值 = 业务最大消息间隔 + 心跳间隔 + 安全裕量。
- 业务间隔 8s + 心跳 3s →
maxSilence应设为 ≥ 10s - ❌
maxSilence = 5s→ 业务消息间隔 8s 的正常空窗期内,前端在第 5s 就判定假死并断开重连,形成"连接→5s超时→重连→5s超时"的死循环 - ✅
maxSilence = 10s→ 正常空窗期内不会误判,仅在真正断连(心跳也丢失)超过 10s 时才触发重连
7. 为什么必须有 isManualClose 标记?(防止重连死循环)
原生 EventSource 调用 close() 后,浏览器仍可能异步触发 onerror 事件。
- ❌ 无标记 →
close()→onerror触发 →reconnect()→ 新连接 → 再次close()→ 无限循环 - ✅ 有标记 →
close()设置isManualClose = true→onerror检查标记 → 跳过重连 → 循环终止
⚠️ 这是 ResilientSSE 封装中最容易遗漏的细节,也是生产环境连接泄漏的常见根因。
8. 为什么必须在 beforeunload / 组件卸载时显式调用 close()?(SPA 生命周期管理)
EventSource 是浏览器级 API,不感知 SPA 路由切换、React/Vue 组件卸载或标签页隐藏。
- ❌ 不管理 → 用户从
/chat切换到/settings,旧连接的EventSource仍在后台运行,持续消耗服务端资源和客户端内存 - ✅ 在路由守卫 /
useEffectcleanup /beforeunload中调用sse.close()→ 连接随页面/组件生命周期正确释放
阶段三:AI 流式输出 ------ 生产级改造
1. AI 场景为何全选 SSE 而非 WebSocket?
| 维度 | SSE | WebSocket | AI 选择 SSE 的原因 |
|---|---|---|---|
| 通信模式 | 单向 Server→Client | 全双工 | AI 推理是典型"一问一答"单向生成 |
| 协议基础 | HTTP/1.1+ | 独立 TCP 协议 | 复用现有 HTTP 基础设施,零适配成本 |
| 断线重连 | 浏览器内置 + Last-Event-ID | 需手动实现 | AI 生成耗时长,原生重连降低前端复杂度 |
| CDN/代理 | 完美兼容 | 部分 WAF 拦截 WS | AI API 需全球分发 |
| 服务端开销 | 普通 HTTP Handler | 独立 WS 连接池 | GPU 密集型服务避免额外内存开销 |
| 调试体验 | curl/Network 面板可读 | 需专用工具 | AI 开发迭代快,可观测性优先 |
2. 核心概念:Stream 迭代器的本质
javascript
const stream = await openai.chat.completions.create({
model: "gpt-4", messages, stream: true
});
这个 stream 不是数组 ,它是绑定在 HTTP Response Body 上的惰性异步拉取管道:
- 没有
.length:LLM 自回归生成,模型自己都不知道总长度 - 不可重复消费:异步迭代器单次消费,第二次遍历为空
- 每个 chunk 是 delta 增量:仅含新生成的 token,前端负责累积拼接
- 永远不要
Array.from(stream):这会阻塞至流结束并撑爆内存
3. 后端生产级完整实现
javascript
app.post('/api/chat/stream', async (req, res) => {
// === 阶段一 & 二的 SSE 基础 ===
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders();
const abortController = new AbortController();
let tokensUsed = 0;
// === 阶段三新增:全链路资源回收 ===
req.on('close', () => {
abortController.abort(); // 🔑 中止 LLM API 调用(节省GPU/Token费用)
contextCache.delete(req.id); // 🔑 释放请求专属上下文缓存
usageTracker.record(req.id, tokensUsed); // 🔑 记录实际 token 消耗用于计费
console.log(`[SSE] 客户端断开,已中止推理,消耗 ${tokensUsed} tokens`);
});
try {
const stream = await openai.chat.completions.create({
model: req.body.model || 'gpt-4',
messages: req.body.messages,
stream: true,
signal: abortController.signal // ✅ 传播取消信号到 SDK
});
// === 阶段三新增:带背压的流式转发 ===
for await (const chunk of stream) {
tokensUsed += estimateTokens(chunk);
// 🔑 背压控制:检查写缓冲区是否已满
const ok = res.write(`data: ${JSON.stringify(chunk)}\n\n`);
if (!ok) {
// TCP 内核缓冲区满,暂停上游生成,等待 drain 事件
await new Promise(resolve => res.once('drain', resolve));
}
}
// ✅ 路径A:迭代器返回 {done:true} 后循环自动退出
// [DONE] 是上游 LLM 发出的信号经 SDK 翻译后的结果,此处仅为转发
res.write('data: [DONE]\n\n');
} catch (err) {
if (err.name === 'AbortError') {
console.log('[SSE] 推理被客户端取消');
} else {
// ❌ 路径B:模型报错/网络中断 → 抛异常 → 绝不发 [DONE]
// 🔑 使用 event: error 结构化事件,前端可精确区分错误类型
res.write(`event: error\ndata: ${JSON.stringify({
code: err.code || 'unknown',
message: err.message
})}\n\n`);
}
} finally {
res.end(); // 🔑 确保响应一定被关闭
}
});
4. 前端 AI 流式消费实现
javascript
// ✅ AI 场景推荐使用 fetch + ReadableStream(支持 POST、自定义 Header)
async function consumeAIStream(messages) {
const response = await fetch('/api/chat/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages })
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = ''; // 🔑 处理跨 chunk 的不完整 SSE 消息
let fullContent = ''; // 🔑 累积拼接 delta.content
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n\n'); // 按双换行分割消息
buffer = lines.pop(); // 🔑 最后一段可能不完整,留到下次处理
for (const line of lines) {
if (line.startsWith('event: error')) {
// 🔑 处理结构化错误事件
const errorData = line.replace(/^event: error\ndata: /, '');
console.error('AI 生成错误:', JSON.parse(errorData));
return;
}
if (line.startsWith('data: ')) {
const data = line.slice(6);
if (data === '[DONE]') {
console.log('✅ 生成完成,完整内容:', fullContent);
return;
}
try {
const chunk = JSON.parse(data);
const delta = chunk.choices?.[0]?.delta?.content || '';
fullContent += delta; // 🔑 增量拼接
process.stdout.write(delta); // 实时输出到终端/UI
} catch (err) {
console.error('chunk 解析失败:', err);
}
}
// : heartbeat 注释行自动忽略,无需处理
}
}
}
5. 阶段三注意事项清单(Demo vs 生产级差异)
1. 为什么 Stream 迭代器禁止 Array.from() 和重复消费?(惰性管道语义)
LLM 流式 API 返回的 stream 是绑定在未关闭 HTTP Response Body 上的异步迭代器,不是内存中的集合。
- ❌
Array.from(stream)→ 阻塞当前 async 函数直到整个流结束(可能数十秒),期间所有 token 堆积在内存中,高并发下直接 OOM - ❌ 第二次
for await (const chunk of stream)→ 迭代器已耗尽,循环体永不执行 - ✅
for await逐条消费 → 每收到一个 token 立即转发给客户端,内存占用恒定
💡 把 Stream 想象成水龙头而非水桶:你只能接水,不能把水龙头"装进"一个容器里。
2. 为什么 [DONE] 是"等"出来的而非"算"出来的?(完成信号来源)
你的服务端代码是 SSE 代理/中继,不是生成完成的判断者。
- 真正的
data: [DONE]由 LLM 提供商服务器 在生成完毕后发出 - SDK 内部读到该信号后,将迭代器的下一次
next()返回值设为{done: true} for await检测到{done: true}自动退出循环- 循环后的
res.write('data: [DONE]\n\n')仅是向前端转发/重发该信号
⚠️ 如果模型中途报错,for await 会抛出异常而非正常退出。绝不能在 catch 中发 [DONE],否则前端会误认为生成成功完成。
3. 为什么必须检查 res.write() 返回值实施背压?(服务端 OOM 防护)
AI 生成速度可能远超慢速客户端的消费能力。Node.js res.write() 返回 false 表示内核 TCP 写缓冲区已满。
- ❌ 无视返回值持续写入 → 数据堆积在 Node.js 用户态内存 → 每秒数百个并发流 → 内存指数增长 → OOM
- ✅ 返回
false时await new Promise(r => res.once('drain', r))→ 暂停上游 LLM 拉取,等待内核缓冲区排空后再继续 → 内存占用受控
💡 背压是生产级流式服务的生死线,Demo 可以忽略,生产环境必须实现。
4. 为什么异常时必须发 event: error 而非断开连接?(结构化错误传递)
前端需要区分"生成正常结束"、"模型报错"、"网络中断"、"鉴权失败"等不同情况。
- ❌ 异常时直接
res.end()→ 前端只看到连接断开,无法知道原因,用户体验差 - ❌ 异常时发
data: [DONE]\n\n→ 前端误认为生成成功,展示不完整内容 - ✅ 异常时发
event: error\ndata: {...}\n\n→ 前端通过addEventListener('error', ...)或解析event:行精确获取错误码和消息,展示友好提示
⚠️ event: error 是 SSE 协议的自定义事件机制,与 data: 平级,前端需单独监听或在 fetch 流解析中按行匹配。
5. 为什么必须在 req.on('close') 中调用 abortController.abort()?(成本控制)
这是 AI 流式输出与 Demo 最根本的区别。用户中途关闭页面时:
- ❌ 不清理 → LLM API 继续生成剩余 token → GPU 算力白烧 + Token 费用白花 → 每月可能浪费数千美元
- ✅
abortController.abort()→ SDK 向上游发送取消请求 → LLM 停止生成 → 仅计费已消耗的 token
⚠️ 必须将 abortController.signal 传入 SDK 的 create() 调用,否则 abort() 无效。同时需在 catch 中识别 AbortError 并静默处理,避免将其当作模型错误上报。
6. 为什么 AI 场景前端推荐 fetch + ReadableStream 而非 EventSource?(协议限制)
EventSource 仅支持 GET 请求,无法携带自定义 Header(如 Authorization Bearer Token),无法发送 POST Body。
- ❌
new EventSource('/api/chat/stream')→ 无法传 messages、无法鉴权 - ✅
fetch('/api/chat/stream', {method:'POST', headers:{...}, body:...})+response.body.getReader()→ 完整支持 RESTful API 设计
⚠️
fetch流式读取需要手动按\n\n分割 SSE 消息并处理跨 chunk 残留(见上方前端代码中的buffer逻辑),这是EventSource自动帮你做的事。代价是需要更多代码,换来的是协议灵活性。
7. 为什么前端解析 SSE 流必须有 buffer 残留处理?(网络分包)
TCP 是流式协议,不保证消息边界。一次 reader.read() 返回的 chunk 可能在任意位置被截断:
text
// 第一次 read() 返回:
data: {"choices":[{"delta":{"content":"你"}}]}\n\nda
// 第二次 read() 返回:
ta: {"choices":[{"delta":{"content":"好"}}]}\n\n
- ❌ 直接对每次 read() 的结果
split('\n\n')→ 第一条消息完整,第二条da被当作独立行解析失败 - ✅ 维护
buffer变量,每次 read() 追加到 buffer,split('\n\n')后用lines.pop()保留最后一段不完整数据 → 下次 read() 拼接后自然还原完整消息
💡 这是
fetch流式消费 SSE 最容易踩的坑,EventSource内部已处理此问题,但fetch需要你手动实现。
全文总结
SSE 前后端实现的难度不在协议本身,而在分阶段构建正确的工程心智模型:
- 阶段一 让你理解 SSE 是"不关闭的 HTTP 响应",掌握
flushHeaders、双换行符、req.close、event.data字符串解析等协议级要点; - 阶段二 让你理解真实网络的脆弱性,掌握心跳注释格式、静默超时检测、
isManualClose防死循环、生命周期管理等保活体系; - 阶段三让你理解 AI 流式输出的本质是"异步管道中继",掌握迭代器语义、背压控制、AbortController 全链路取消、buffer 分包处理、结构化错误事件和 token 计量。
Demo 验证了"数据能推过去",心跳保证了"连接不会假死",而生产级改造确保了:推得稳(背压)、断得干净(AbortController)、错得明白(结构化错误事件)、钱花得清楚(token 计量)。三个阶段缺一不可,跳过任何一个都会在上线后付出代价。