前言
大家好,我是【小林】
说起来有点意思,最近我在做 AI 项目的时候,突然对 "流式响应效果" 产生了浓厚兴趣也就是所谓的打字机效果。你知道那种感觉吧,AI 回答的时候,文字像被人敲出来一样,一个字一个字地蹦出来。
以前我以为是前端用 setTimeout 模拟的,直到有一次网络抖动,我发现它居然能从断开的地方继续输出,而不是重新开始。这就像看直播卡顿后,会自动从卡住的地方继续播放,而不是重播一遍。
这不就是断点续传吗?但 HTTP 请求不是无状态的吗?
带着这个疑问,我开始深入研究,才发现这背后藏着一套完整的流式传输协议------SSE(Server-Sent Events)。
更让我意外的是,我发现业界对于"AI 流式响应该用 SSE 还是 WebSocket"这个话题,争议还挺大。有人说 WebSocket 功能更强大,有人说 SSE 更简单。
到底该选哪个?
我干脆从零开始实现了一套完整的 Demo,包含后端服务、JavaScript 客户端、Dart 客户端,甚至还实现了断线重连、指数退避、粘包处理等生产级特性。
这篇文章,我就把这背后的原理、坑点、实战经验分享出来。
篇章一:为什么 AI 聊天首选 SSE 而非 WebSocket?
在讲代码之前,我们先搞清楚一个核心问题:为什么 ChatGPT、Claude 这些 AI 助手都选 SSE,而不是看起来更强大的 WebSocket?
1.1 场景分析:AI 对话的"一问多答"模式
我们先看 AI 对话的典型特征:
用户:如何学好 Flutter?
AI: 【开始一段一段地输出,持续十几秒甚至更长】
这就是典型的**"一问多答"模式**:
- 用户发送的 Prompt 通常很短(几个字到几百字)
- AI 的回复可能很长(几千字,甚至更长)
- 数据流向是单向的:Server → Client
1.2 SSE vs WebSocket 核心对比
我们用一个表格来看两者的差异:
| 特性 | SSE | WebSocket |
|---|---|---|
| 通信方向 | 单工(Server → Client) | 全双工(双向通信) |
| 协议基础 | HTTP 标准 | 自定义 WS 协议 |
| 连接方式 | 标准 HTTP 请求 | 需要握手升级 |
| 鉴权方式 | ✅ 自定义 Header(如 Authorization) | ❌ 只能在握手时带 Header |
| 断线重连 | ✅ 内置 Last-Event-ID 机制 | ❌ 需要手动实现 |
| 浏览器调试 | ✅ DevTools 直接查看 EventStream | ⚠️ 需要在 WS Frames 面板查看 |
| 服务端实现 | ✅ 简单,标准 HTTP 响应 | ⚠️ 需要维护连接状态 |
| AI 场景契合度 | ✅ 完美匹配"一问多答" | ❌ 过度设计 |
1.3 一个餐厅大厨的比喻
让我用一个好懂的比喻来解释:
SSE 就像"自助餐厅的传菜口":
- 你点完菜(发送 HTTP 请求)
- 厨师开始炒菜,炒好一道就传出来一道(Server 持续推送数据)
- 你坐在那里等,菜一道一道地上来(Client 接收流式数据)
- 如果突然停电了,来电后厨师会问你:"刚才上到第几道了?"然后继续上(断线重连)
WebSocket 就像"打电话订外卖":
- 你和骑手保持通话(双向通信通道)
- 骑手一边送一边向你汇报位置(实时双向交互)
- 如果电话断了,你得重新打过去,还得从头说(需要手动重连)
对于 AI 聊天这种"我点菜,你上菜"的场景,SSE 的传菜口模式显然更合适。WebSocket 更适合"我和骑手实时沟通位置"这种需要频繁交互的场景。
1.4 为什么不用原生 EventSource?
看到这里你可能会问:浏览器不是有原生 EventSource API 吗?为什么还要自己实现?
问题在于,原生 EventSource 有几个致命限制:
javascript
// 原生 EventSource 的问题
const eventSource = new EventSource('/stream'); // ❌ 只支持 GET
// ❌ 无法自定义 Header(比如 Authorization)
// ❌ 无法发送请求体(AI 场景的 Prompt 可能很长)
// ❌ 只能在 URL 里传参数,不安全也不优雅
在 AI 场景下,我们需要:
- 用 POST 请求发送长 Prompt
- 在 Header 里带 Authorization Token
- 支持自定义错误处理和重连策略
所以,我们需要基于 fetch + ReadableStream 自己实现一个 SSEManager。
篇章二:直击底层:SSE 协议原理剖析
2.1 SSE 协议格式
SSE 是基于 HTTP 的,协议格式非常简单:
vbnet
event: message
id: 1234567890
data: {"type": "content", "payload": "我"}
event: message
id: 1234567891
data: {"type": "content", "payload": "喜"}
event: close
data: [DONE]
协议要点:
- 每条消息由
event:、id:、data:三个字段组成 - 字段顺序不重要,但每条消息后必须有一个空行作为分隔符
event:表示事件类型(message、error、close 等)id:用于断线重连时恢复(客户端会记录 Last-Event-ID)data:是实际数据,通常是 JSON 字符串
2.2 核心挑战:粘包和半包问题
这是 SSE 实现中最容易踩的坑。
什么是粘包?
vbnet
服务器一次发送:
event: message\ndata: {"type":"content","payload":"我"}\n\nevent: message\ndata: {"type":"content","payload":"喜"}\n\n
客户端可能收到:
event: message
data: {"type":"content","payload":"我"}
event: message ← 两条消息粘在一起了
data: {"type":"content","payload":"喜"}
什么是半包?
csharp
服务器发送一条完整消息:
event: message\ndata: {"type":"content","payload":"我是中文"}\n\n
客户端可能分两次收到:
第一次:event: message\ndata: {"type":"content","payload": "我
第二次:是中文"}\n\n ← JSON 被截断了!
解决方案 : 维护一个 buffer 缓冲区,每次收到 chunk 后:
- 追加到 buffer
- 按
\n\n分割出完整消息 - 剩下的部分留在 buffer,等下次 chunk 到来
篇章三:实战实现
3.1 后端实现(Node.js + Express)
先看后端怎么实现 SSE 接口:
javascript
app.get('/stream-sse', async (req, res) => {
// 设置 SSE 必需的 HTTP Headers
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache, no-transform');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no'); // 禁用 Nginx 缓冲
const query = req.query.query || '默认问题';
const text = '这是 AI 的回复内容...';
const chars = text.split('');
// 逐字发送
for (let i = 0; i < chars.length; i++) {
const message = `event: message\nid: ${Date.now()}\ndata: ${JSON.stringify({
type: 'content',
payload: chars[i],
index: i,
total: chars.length
})}\n\n`;
res.write(message);
// 模拟 AI 生成延迟(打字机效果)
await new Promise(resolve => setTimeout(resolve, 100));
}
// 发送完成信号
res.write('event: close\ndata: [DONE]\n\n');
res.end();
});
关键点:
Content-Type: text/event-stream告诉浏览器这是 SSE 流Cache-Control: no-cache禁止缓存,确保实时性Connection: keep-alive保持长连接- 逐字符发送,模拟 AI 打字机效果
- 最后发送
[DONE]信号告诉客户端流结束了
3.2 JavaScript 客户端:手写 SSEManager
这是核心部分。我们基于 fetch + ReadableStream 实现一个完整的 SSEManager:
javascript
class SSEManager {
constructor(url, options = {}) {
this.url = url;
this.options = {
headers: {},
body: null,
maxRetries: 5,
initialRetryDelay: 1000,
enableRetry: true,
...options
};
this.onMessageCallback = null;
this.onErrorCallback = null;
this.onCompleteCallback = null;
this.abortController = null;
this.retryCount = 0;
this.lastEventId = null;
this.isConnecting = false;
}
async connect() {
if (this.isConnecting) return;
this.isConnecting = true;
this.abortController = new AbortController();
try {
const fetchOptions = {
method: this.options.body ? 'POST' : 'GET',
headers: {
'Content-Type': 'application/json',
...this.options.headers
},
signal: this.abortController.signal
};
if (this.options.body) {
fetchOptions.body = JSON.stringify(this.options.body);
}
// 如果有 Last-Event-ID,带上(用于断线重连)
if (this.lastEventId) {
fetchOptions.headers['Last-Event-ID'] = this.lastEventId;
}
const response = await fetch(this.url, fetchOptions);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
// 🔥 关键:消息缓冲区(处理粘包和半包)
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
buffer += chunk;
// 解析缓冲区中的完整消息
buffer = this._parseBuffer(buffer);
}
// 如果不是主动断开,尝试重连
if (this.options.enableRetry && !this.abortController.signal.aborted) {
this._scheduleRetry();
}
} catch (error) {
if (error.name === 'AbortError') return;
if (this.onErrorCallback) {
this.onErrorCallback(error);
}
if (this.options.enableRetry && this.retryCount < this.options.maxRetries) {
this._scheduleRetry();
}
} finally {
this.isConnecting = false;
}
}
// 🔥 核心方法:解析缓冲区中的 SSE 消息
_parseBuffer(buffer) {
const lines = buffer.split('\n');
let currentEvent = { event: null, id: null, data: null };
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// 空行表示一条消息结束
if (line === '') {
if (currentEvent.data !== null) {
this._handleEvent(currentEvent);
currentEvent = { event: null, id: null, data: null };
}
continue;
}
// 解析字段
if (line.startsWith('event:')) {
currentEvent.event = line.substring(7).trim();
} else if (line.startsWith('id:')) {
currentEvent.id = line.substring(4).trim();
this.lastEventId = currentEvent.id;
} else if (line.startsWith('data:')) {
currentEvent.data = line.substring(6).trim();
}
}
// 返回未处理的缓冲区(最后一条不完整的消息)
return lines[lines.length - 1] === '' ? '' : lines[lines.length - 1];
}
_handleEvent(event) {
if (event.event === 'close') {
if (this.onCompleteCallback) this.onCompleteCallback();
return;
}
if (event.event === 'error') {
if (this.onErrorCallback) {
this.onErrorCallback(new Error(event.data));
}
return;
}
if (this.onMessageCallback) {
try {
const data = JSON.parse(event.data);
this.onMessageCallback(data);
} catch (e) {
console.error('Failed to parse SSE data:', e);
}
}
}
// 🔥 指数退避重连算法
_scheduleRetry() {
this.retryCount++;
const delay = Math.min(
this.options.initialRetryDelay * Math.pow(2, this.retryCount - 1),
30000 // 最大 30 秒
);
console.log(`[SSE] Retry ${this.retryCount}/${this.options.maxRetries} after ${delay}ms`);
setTimeout(() => {
this.connect();
}, delay);
}
onMessage(callback) {
this.onMessageCallback = callback;
return this;
}
onError(callback) {
this.onErrorCallback = callback;
return this;
}
onComplete(callback) {
this.onCompleteCallback = callback;
return this;
}
disconnect() {
if (this.abortController) {
this.abortController.abort();
}
this.isConnecting = false;
}
}
使用示例:
javascript
const sse = new SSEManager('http://localhost:3000/stream-sse', {
body: { query: '如何学好 Flutter?' },
headers: { 'Authorization': 'Bearer token123' },
enableRetry: true,
maxRetries: 5
});
sse.onMessage((data) => {
console.log('收到数据:', data.payload);
// 逐字显示到界面上
})
.onError((error) => {
console.error('发生错误:', error);
})
.onComplete(() => {
console.log('传输完成');
})
.connect();
3.3 Dart 客户端:UTF-8 安全处理
Dart 端有个特殊问题:中文字符的 UTF-8 编码问题。
中文字符在 UTF-8 中占 3 个字节,如果流正好把一个字符的 3 个字节截断了,就会出现乱码。
解决方案 :使用 utf8.decoder + LineSplitter() 的流转换链:
dart
class SSEManager {
final String url;
final Map<String, String> headers;
final Map<String, dynamic> body;
int _retryCount = 0;
String? _lastEventId;
bool _isConnecting = false;
SSEManager({
required this.url,
this.headers = const {},
this.body = const {},
});
Future<void> connect() async {
if (_isConnecting) return;
_isConnecting = true;
try {
final client = HttpClient();
final request = await client.postUrl(Uri.parse(url));
// 设置 Headers
headers.forEach((key, value) {
request.headers.set(key, value);
});
if (_lastEventId != null) {
request.headers.set('Last-Event-ID', _lastEventId!);
}
// 设置 Body
if (body.isNotEmpty) {
request.add(utf8.encode(jsonEncode(body)));
}
final response = await request.close();
// 🔥 核心流转换链
response
.transform(utf8.decoder) // ByteStream → String
.transform(const LineSplitter()) // String → Lines
.listen(_parseLine);
} catch (e) {
_scheduleRetry();
} finally {
_isConnecting = false;
}
}
void _parseLine(String line) {
// 解析 SSE 协议...
// (类似 JS 版本的逻辑)
}
void _scheduleRetry() {
_retryCount++;
final delay = min(1000 * pow(2, _retryCount - 1), 30000).toInt();
Future.delayed(Duration(milliseconds: delay), () {
connect();
});
}
}
3.4 实际运行效果
让我们看看实际运行的效果:

传输中状态:
- AI 响应区域逐字显示
- 系统日志实时滚动
- 性能指标动态更新

连接错误:
- 当服务器未启动时,显示红色错误提示
- 自动触发重连机制

重试中状态:
- 显示当前重试次数和延迟时间
- 使用指数退避算法(1s → 2s → 4s → 8s...)

传输完成:
- 显示完整的输出内容
- 性能指标:总字数、总耗时、平均延迟

篇章四:踩坑总结
做这个 Demo 的过程中,我踩了不少坑。这里挑几个最经典的分享给你。
4.1 粘包/半包处理
坑 :一开始我直接用 split('\n\n') 分割消息,结果经常出现 JSON 解析错误。
原因:一个 chunk 可能包含半个 JSON,或者两条消息粘在一起。
解决:维护 buffer,每次解析后把剩余部分留给下次:
javascript
let buffer = '';
buffer += chunk; // 追加新数据
const messages = buffer.split('\n\n');
buffer = messages.pop(); // 保留最后一个(可能不完整)
// 处理前面的完整消息
messages.forEach(msg => parseMessage(msg));
4.2 UTF-8 字符截断
坑:Dart 端经常出现乱码,特别是中文字符。
原因:中文字符在 UTF-8 中占 3 字节,流可能把 3 字节截断。
解决 :使用 utf8.decoder 自动处理字节边界:
dart
response
.transform(utf8.decoder) // ✅ 自动处理 UTF-8 边界
.transform(const LineSplitter())
.listen(_parseLine);
4.3 重连时机判断
坑:服务器正常结束时也触发重连,导致死循环。
原因:没区分"正常结束"和"异常断开"。
解决 :检查 [DONE] 信号:
javascript
_handleEvent(event) {
if (event.event === 'close' && event.data === '[DONE]') {
// 正常结束,不重连
this.onCompleteCallback();
return;
}
// ... 其他处理
}
// 在流关闭时判断
while (true) {
const { done, value } = await reader.read();
if (done) {
// 如果收到了 [DONE],说明正常结束
if (receivedDoneSignal) return;
// 否则可能是异常断开,触发重连
this._scheduleRetry();
break;
}
}
4.4 Nginx 缓冲问题
坑:部署到生产环境后,SSE 流半天不输出。
原因:Nginx 默认会缓冲响应,等积累到一定大小才发送。
解决 :在响应头添加 X-Accel-Buffering: no:
javascript
res.setHeader('X-Accel-Buffering', 'no');
或者在 Nginx 配置中:
nginx
proxy_buffering off;
最终章:总结与展望
5.1 技术选型建议
什么时候用 SSE?
- ✅ AI 聊天助手(一问多答)
- ✅ 实时通知推送
- ✅ 股票/加密货币价格推送
- ✅ 服务器日志实时监控
什么时候用 WebSocket?
- ✅ 即时通讯(IM、聊天室)
- ✅ 在线协作(多人同时编辑)
- ✅ 游戏直播(需要高频双向交互)
- ✅ 远程桌面/控制
5.2 本项目的核心特性
我实现的这个 Demo,包含了以下生产级特性:
- ✅ 支持 POST 请求(可以发送长 Prompt)
- ✅ 自定义 Header(支持 Authorization)
- ✅ 粘包/半包处理(buffer 缓冲区)
- ✅ 指数退避重连(1s → 2s → 4s → 8s...)
- ✅ Last-Event-ID 机制(断线续传)
- ✅ UTF-8 安全处理(Dart 端)
- ✅ 错误处理和日志
5.3 开源地址
项目已完全开源,欢迎 Star 和 PR:
🔗 GitHub : github.com/xinqingaa/s...
包含:
- Node.js 后端(Express)
- JavaScript 客户端(原生 JS,无依赖)
- Dart 客户端(Flutter 友好)
- 交互式演示界面
5.4 写在最后
回看这一周的学习,我发现:
技术选型没有银弹。SSE 不是万能的,WebSocket 也不是过时的。关键是要理解你的场景需求。
对于 AI 聊天这种"一问多答"的单向流式场景,SSE 就像量身定做的一样:
- 简单(基于 HTTP)
- 可靠(内置重连)
- 高效(没有全双工的开销)
- 可调试(DevTools 直接看)
而 WebSocket 的强大在于双向实时交互,但这在 AI 聊天场景下是"杀鸡用牛刀"。
最后,如果这篇文章对你有帮助,点个赞吧~
(完)
往期文章回顾
LangGraph + React + Nest 全栈Agent
Flutter 图片编辑器
Flutter 全链路监控 SDK
Flutter 全场景弹框组件
关于作者
大家好,我是【小林】,一名 Flutter 开发工程师。近期在研究 AI Agent 和流式传输技术,欢迎关注我的掘金账号,获取更多技术分享。