在对接 AI 接口时、用户输入传递 prompt 时、服务端以流式数据返回、起初认为是用的双工持久化连接、WebSockets 来作为数据传输。然后看了 deepseek 的网络请求,发现是基于 EventStream 的事件流。

基于 EventStream 的优点:在 AI 对话的过程中,由于大模型需要长时间的计算和响应时间,让用户等待较长时间显然是不适合的。而使用 EventStream 数据传输后、大模型可以一边推导、一边吐数据,让用户能感知到,大模型是在正常工作的。
你需要注意的是:
EventSource
仅支持 GET 请求,不支持 POST 或其他 HTTP 方法。
EventSource
不支持直接自定义 header
SSE 的简单概述
Server-sent Events
服务器发送的事件、简称 SSE
。服务器可以随时将新数据发送到网页。然后,网页会收到这些新消息,这些消息可以被视为包含数据的事件。
SSE
的使用由两部分组成:
- 创建一个
EventSource
实例 - 监听事件、接受服务端数据
ts
// 实例化 EventSource、建立连接
const evtSource = new EventSource("//api.example.com/ssedemo.php", {
withCredentials: true,
});
// 监听 message 事件、接受服务端返回的数据
evtSource.onmessage = (event) => {
const newElement = document.createElement("li");
const eventList = document.getElementById("list");
newElement.textContent = `message: ${event.data}`;
eventList.appendChild(newElement);
};
从客户端的代码来看,处理传入事件部分几乎与 websocket 相同。但是你需要注意的是:SSE
是一个单向的连接,所以你不能从客户端发送事件到服务器。
下面是 SSE
和 WebSockets
的一些区别:
特性 | WebSockets | Server-Sent Events (SSE) |
---|---|---|
协议类型 | 双向全双工通信(基于 TCP) | 单向通信(仅服务器→客户端) |
通信方向 | 客户端与服务器可双向实时交互 | 仅支持服务器主动推送数据到客户端 |
数据格式 | 支持二进制帧和文本帧 | 仅支持文本(通常为 UTF-8 编码) |
连接建立 | 通过 HTTP 升级握手(Upgrade: websocket ) |
标准 HTTP/HTTPS 连接(无握手升级) |
协议复杂度 | 高(需管理帧、心跳、状态等) | 低(基于简单 HTTP 流) |
断线重连 | 需手动实现重连逻辑 | 内置自动重连机制(通过 retry 字段) |
浏览器兼容性 | 所有现代浏览器(IE10+) | 所有现代浏览器(不支持 IE/Edge Legacy) |
传输效率 | 高效(轻量级帧头,节省带宽) | 较高(HTTP 头冗余,但支持压缩) |
适用场景 | 实时游戏、聊天、双向协作工具 | 实时通知、股票行情、新闻推送、日志流 |
安全性 | 支持 wss:// (加密) |
依赖 HTTPS 加密 |
开发复杂度 | 较高(需处理双向通信逻辑) | 较低(类似处理普通 HTTP 请求) |
API 接口 | WebSocket 对象(send() , onmessage() ) |
EventSource 对象(监听 message 事件) |
服务端的 SSE
响应头
SSE
是有客户端发起的 HTTP 请求、由服务端响应,并设置响应头:
text
headers:
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
数据格式
SSE
的数据格式、每条消息由字段组成,字段类型包括 event
, data
, id
, retry
等,以两个换行符 \n\n
结束一条消息。
例如:
text
event:message
data:{"v":"This is Message"}\n\n
字段类型
字段类型描述:
event
:定义消息的自定义事件名称,用于客户端区分不同类型的消息。data
:承载数据的实际内容,一条消息可以包含多个data
字段(会被连接成一个字符串,用换行符分隔)、通常是文本(JSON 字符串或纯文本),不支持二进制数据。id
:为消息设置唯一标识,主要用于断线重连时恢复。客户端在断开后重连时,会在请求头中自动携带上次收到的最后一个id
(Last-Event-ID
头)、服务端可根据该 ID 恢复后续推送。retry
:客户端断开连接后重新尝试连接的等待时间、通常以毫秒作为单位,服务端可在任何时候发送新的retry
值覆盖之前的设置。
字段 | 作用 | 格式 | 是否必需 | 示例 | 客户端处理方式 |
---|---|---|---|---|---|
data |
携带实际消息内容 | data: <内容>\n |
必需 | data: Hello\n |
通过 e.data 获取 |
event |
定义消息类型/分类 | event: <事件名>\n |
可选 | event: notification\n |
addEventListener('<事件名>', handler) |
id |
消息唯一标识(用于断线重连) | id: <ID值>\n |
可选 | id: 12345\n |
自动发送 Last-Event-ID 头 |
retry |
指定重连等待时间 | retry: <毫秒数>\n |
可选 | retry: 5000\n |
自动调整重连间隔 |
完整的例子,Node.js
+ Express
:
ts
const express = require('express');
const app = express();
const port = 3000;
// 静态文件服务(前端页面)
app.use(express.static('public'));
// SSE 路由
app.get('/sse', (req, res) => {
// 1. 设置 SSE 响应头
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'Access-Control-Allow-Origin': '*' // 跨域支持
});
// 2. 发送初始消息(可选)
res.write('event: connected\n');
res.write('data: SSE connection established\n\n');
// 3. 定时推送消息
let counter = 0;
const intervalId = setInterval(() => {
counter++;
const data = {
time: new Date().toISOString(),
value: counter
};
// 消息格式:
res.write('event: update\n'); // 事件名称
res.write(`data: ${JSON.stringify(data)}\n\n`); // 数据
}, 1000);
// 4. 客户端断开连接时清理
req.on('close', () => {
clearInterval(intervalId);
console.log('Client disconnected');
});
});
app.listen(port, () => {
console.log(`SSE server running at http://localhost:${port}`);
});
客户端的 SSE
在上面 [服务端的 SSE
](#服务端的 SSE
)中、我们已经知道他是由两个部分组成:实例化 EventSource
+ 监听事件
建立连接
EventSource
接受两个参数:URL、options
- URL:
EventSource
连接 HTTP 的来源 - options:一个可选参数,包含一个字段
withCredentials
、用来表示EventSource
对象是否使用跨源资源共享(CORS)凭据来实例化(true
),或者不使用(false
,即默认值)。
在实例化EventSource
后并建立连接时、他还存在一个只读属性readyState
(number
类型)、私有函数close()
。
readyState
:用于表示连接状态。close()
:用于关闭当前的连接,如果调用了此方法,则会将EventSource.readyState
这个属性值设置为 2
readyState 值 |
表示含义 |
---|---|
0 | 连接尚未打开 |
1 | 连接已打开并准备好进行通信 |
2 | 连接已关闭或无法打开 |
事件监听
EventSource
对象本身继承自 EventTarget
接口,因此可以使用 addEventListener()
方法来监听事件。EventSource
对象触发的事件主要包括以下三种:
open
:当成功连接到服务端时触发。message
:当接收到服务器发送的消息时触发。该事件对象的 data 属性包含了服务器发送的消息内容。error
:当发生错误时触发。该事件对象的 event 属性包含了错误信息。- 自定义监听事件:用于处理特殊的业务数据
ts
evtSource.addEventListener("open",(event) => {
console.log("建立连接")
})
evtSource.addEventListener("message",(event) => {
console.log("接收到服务端发送的数据",event.data)
})
evtSource.addEventListener("error",(event) => {
console.log("连接发生错误")
})
自定义事件
如果服务器发送的消息中定义了 event
字段,就会以 event
中给定的名称作为事件接收。例如:
ts
evtSource.addEventListener("ping", (event) => {
const newElement = document.createElement("li");
const eventList = document.getElementById("list");
const time = JSON.parse(event.data).time;
newElement.textContent = `ping at ${time}`;
eventList.appendChild(newElement);
});
每当服务器发送一条 event
字段设置为 ping
的消息时,这段代码就会被调用,然后接着解析 data
字段中的 JSON
并输出这些信息。
简单的代码示例
ts
// 创建 SSE 连接
const eventSource = new EventSource('/sse-endpoint');
// 连接成功建立时触发
evtSource.addEventListener("open",(event) => {
console.log("SSE 连接已建立")
})
// 收到服务器消息时触发
evtSource.addEventListener("message",(event) => {
console.log('收到消息:', event.data);
try {
// 尝试解析 JSON 数据
const data = JSON.parse(event.data);
// 处理数据
updateUI(data);
} catch (error) {
// 处理普通文本消息
displayMessage(event.data);
}
})
// 发生错误时触发
evtSource.addEventListener("error",(error) => {
console.error('SSE 错误:', error);
})
// 心跳检测、(服务端发送的注释消息)
// 自定义事件、event 存在 heartbeat 时触发
evtSource.addEventListener('heartbeat', () => {
console.log('连接保持活跃:', new Date().toLocaleTimeString());
});
// 示例辅助函数
function updateUI(data) {
// 根据数据更新 UI
console.log('更新界面:', data);
}
function displayMessage(message) {
// 显示文本消息
console.log('显示消息:', message);
}
// 关闭 SSE 连接
function closeConnection() {
eventSource.close();
console.log('SSE 连接已关闭');
}
Fatch
模拟 SSE
由于直接使用 EventSource
API 来作为流式传递数据存在两大缺陷,导致我们在与服务端对接时不能满足一些需求、并且在大多数浏览器中,URL 限制 2000个字符,所以我们需要采用其他的方式来实现 SSE
。
而 Fatch
API这是我们的不二之选、通常来说Fetch
API 本身不支持流式事件处理、但我们可以通过读取响应流来模拟 SSE 的行为。这里的关键是使用 ReadableStream
并逐块处理数据。
模拟实现
简单实现如下:
ts
// 使用 fetch 模拟 SSE 连接
async function fetchSSE(url, options = {}) {
// 创建控制器用于中断请求
const controller = new AbortController();
// 设置默认请求头
const headers = new Headers(
{
...(options.headers || {}),
"Accept":"text/event-stream"
}
);
// 发起 fetch 请求
const response = await fetch(url, {
...options,
method: options.method || 'POST', // 可以使用 POST 方法
headers,
signal: controller.signal
});
// 检查响应是否有效
if (!response.ok || !response.body) {
throw new Error(`SSE请求失败: ${response.status}`);
}
// 创建事件处理器
const eventHandlers = {
open: [],
message: [],
error: []
};
// 处理流式响应
const reader = response.body.getReader();
const decoder = new TextDecoder();
let eventBuffer = '';
let eventName = 'message'; // 默认事件类型
let dataBuffer = '';
let eventId = null;
// 触发事件函数
const triggerEvent = (type, data) => {
eventHandlers[type].forEach(handler => handler({
data,
lastEventId: eventId
}));
};
// 开始读取流
const readStream = async () => {
try {
while (true) {
const { value, done } = await reader.read();
if (done) {
triggerEvent('error', '连接已关闭');
return;
}
// 处理接收到的数据块
eventBuffer += decoder.decode(value, { stream: true });
// 处理完整事件(以 \n\n 分隔)
while (eventBuffer.includes('\n\n')) {
const eventEndIndex = eventBuffer.indexOf('\n\n');
const rawEvent = eventBuffer.substring(0, eventEndIndex);
eventBuffer = eventBuffer.substring(eventEndIndex + 2);
// 重置事件数据
dataBuffer = '';
eventName = 'message';
// 解析事件字段
rawEvent.split('\n').forEach(line => {
const colonIndex = line.indexOf(':');
if (colonIndex === -1) return;
const field = line.substring(0, colonIndex).trim();
let value = line.substring(colonIndex + 1).trim();
// 处理多行数据
if (value.startsWith(' ')) value = value.substring(1);
switch (field) {
case 'event':
eventName = value;
break;
case 'data':
dataBuffer += value + '\n';
break;
case 'id':
eventId = value;
break;
case 'retry':
// 可在此实现重连逻辑
break;
}
});
// 移除末尾换行符并触发事件
const finalData = dataBuffer.trim();
if (finalData) {
if (eventName === 'message') {
triggerEvent('message', finalData);
} else {
triggerEvent(eventName, finalData);
}
}
}
}
} catch (error) {
triggerEvent('error', `流处理错误: ${error.message}`);
}
};
// 启动流处理
readStream().catch(error => {
triggerEvent('error', `读取错误: ${error.message}`);
});
// 立即触发 open 事件
triggerEvent('open');
// 返回控制器和事件注册方法
return {
controller,
onopen: (handler) => eventHandlers.open.push(handler),
onmessage: (handler) => eventHandlers.message.push(handler),
onerror: (handler) => eventHandlers.error.push(handler),
close: () => controller.abort()
};
}
// 使用示例
const sseConnection = await fetchSSE('https://api.example.com/sse', {
method: 'POST',
headers: {
'Authorization': 'Bearer your_token',
'Content-Type': 'application/json'
},
body: JSON.stringify({
userId: '123',
topics: ['news', 'updates']
})
});
// 注册事件处理器
sseConnection.onopen(() => {
console.log('SSE 连接已建立');
});
sseConnection.onmessage((event) => {
console.log('收到消息:', event.data);
try {
const data = JSON.parse(event.data);
console.log('解析后的数据:', data);
} catch {
console.log('原始文本消息:', event.data);
}
});
sseConnection.onerror((event) => {
console.error('发生错误:', event.data);
});
// 需要时关闭连接
sseConnection.close();
中止传输
Fatch
不同于 XMLHttpRequest
、Fatch
没有直接提供连接中断的方法,但是我们可以采用AbortController
来中止 Fetch
请求:
ts
// 创建控制器用于中断请求
const controller = new AbortController();
// ...
// 终止传输
controller.abort();
当客户端使用 AbortController 终止请求时,服务端虽然也会收到连接中止的通知、但是接受中止是需要响应事件。如果你在当前的连接中正好在操作数据、建议同时实现客户端显式关闭通知和服务端自动清理机制,确保资源在任何断开情况下都能正确释放。
爬坑
在处理 SSE
返回过来的流式数据时、他可能是数据片段,例如:
text
// 第一个片段
event:message
data:
// 第二个片段
{"v":"This is Message"}\n\n
event:message
data:{"v":"第二条数据"}\n\n
所以你在处理数据时、需要当心,如果代码处理不恰当、会导致部分数据丢失或者整个程序异常。SSE
的数据格式通常都是以\n\n
来作为一串数据为结束的标识、你可以按照这个思路来处理。
总结
在我们看到的大部分 AI 对话的应用面前、他们背后的技术都是采用了 SSE
单向数据流传输。
从代码层面上看来 SSE
和 WebSockets
存在相同之处,但是他们背后的原理各位不同。不过在处理流式数据传递时,两个方案皆适用。
SSE
:单向连接、在轻量级的应用下且不需要与服务端做互动时更为合适WebSockets
:双向连接、在处理高吞掉量、高并发时更为合适
而作为传统实现 SSE
的 EventSource
API 中、我们会遇到两大缺席:只能采用 GET 请求、不能自定义 Header。这就了导致我们不能满足一些常规需求。
而使用 Fetch
来模拟 SSE
传输数据的方式就完美的填充了 EventSource
不能支持的缺陷、虽然 Fetch
没有直接中止传输的 API、但是我们可以采用 AbortController
来中止。
处理 SSE
数据片段时、可能存在片段数据不完整、所以在客户端时、你要小心处理。你可以按照 SSE
的数据格式 \n\n
来作为结束一条消息的标识、按照这个思路、去处理你自己的业务数据。