我彻底搞懂了 SSE,原来流式响应效果还能这么玩的?(附 JS/Dart 双端实战)

前言

大家好,我是【小林】

说起来有点意思,最近我在做 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 后:

  1. 追加到 buffer
  2. 按 \n\n 分割出完整消息
  3. 剩下的部分留在 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

掘金文章 | github

Flutter 图片编辑器

掘金文章 | pub.dev | github

Flutter 全链路监控 SDK

掘金文章 | pub.dev | github

Flutter 全场景弹框组件

掘金文章 | pub.dev | github


关于作者

大家好,我是【小林】,一名 Flutter 开发工程师。近期在研究 AI Agent 和流式传输技术,欢迎关注我的掘金账号,获取更多技术分享。

相关推荐
GoAI20 分钟前
DeepSeek Harness Memory (一)主流记忆插件总结
后端·算法·面试
卸任39 分钟前
Vibe coding工具使用心得
前端·ai编程
brien91 小时前
为什么模型标称 1M,Google Antigravity 聊到 256K 却突然失忆?我们给它做了个原生行内监控圆环
前端
修炼的dance2 小时前
录屏关键帧间隔多少合适?六档实测,2秒到10秒小了75.6%
前端
Lank_M2 小时前
关键帧从1秒拉到10秒,录屏小87.3%,摄像头小10.8%
前端
补码缩写2 小时前
用 ffprobe 拆录屏,八成字节在 29 个关键帧上
前端
百思不得小李2 小时前
一个复杂的动态表单是如何实现的
前端·设计模式
陈随易2 小时前
Cmder 停更 17 个月,2.0 支持 Windows Terminal
前端·后端·程序员
风骏时光牛马2 小时前
前沿视界
前端
Zan2 小时前
SQLite + SQLx 实战:为什么已经发布的数据库迁移文件,绝对不能再修改?
数据库·后端·面试