SSE(server sent events)流式数据传递

在对接 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 是一个单向的连接,所以你不能从客户端发送事件到服务器。

下面是 SSEWebSockets 的一些区别:

特性 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:为消息设置唯一标识,主要用于断线重连时恢复。客户端在断开后重连时,会在请求头中自动携带上次收到的最后一个 idLast-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 不同于 XMLHttpRequestFatch 没有直接提供连接中断的方法,但是我们可以采用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 单向数据流传输。

从代码层面上看来 SSEWebSockets 存在相同之处,但是他们背后的原理各位不同。不过在处理流式数据传递时,两个方案皆适用。

  • SSE:单向连接、在轻量级的应用下且不需要与服务端做互动时更为合适
  • WebSockets:双向连接、在处理高吞掉量、高并发时更为合适

而作为传统实现 SSEEventSource API 中、我们会遇到两大缺席:只能采用 GET 请求、不能自定义 Header。这就了导致我们不能满足一些常规需求。

而使用 Fetch 来模拟 SSE 传输数据的方式就完美的填充了 EventSource 不能支持的缺陷、虽然 Fetch 没有直接中止传输的 API、但是我们可以采用 AbortController 来中止。

处理 SSE 数据片段时、可能存在片段数据不完整、所以在客户端时、你要小心处理。你可以按照 SSE 的数据格式 \n\n 来作为结束一条消息的标识、按照这个思路、去处理你自己的业务数据。

相关推荐
小溪彼岸4 小时前
初识Google Colab
google·aigc
小溪彼岸4 小时前
【Hugging Face】Hugging Face模型的基本使用
aigc
LinXunFeng5 小时前
AI - Gemini CLI 摆脱终端限制
openai·ai编程·gemini
墨风如雪7 小时前
会“偷懒”的大模型来了:快手开源KAT-V1,终结AI“过度思考”
aigc
EdisonZhou8 小时前
多Agent协作入门:群聊编排模式
llm·aigc·.net core
奇舞精选12 小时前
prompt的参数调优入门指南 - 小白也能轻松掌握
人工智能·aigc
DisonTangor12 小时前
商汤InternLM发布最先进的开源多模态推理模型——Intern-S1
人工智能·深度学习·开源·aigc
软件测试君12 小时前
【Rag实用分享】小白也能看懂的文档解析和分割教程
aigc·openai·ai编程
redreamSo16 小时前
AI Daily | AI日报:Meta百亿抢人,AI数据标注产业升级; 百度全栈自研,AI应用大放异彩; Hinton访华:多模态大模型已有「意识」
程序员·aigc·资讯
DisonTangor17 小时前
Mistral AI开源 Magistral-Small-2507
人工智能·语言模型·开源·aigc