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 来作为结束一条消息的标识、按照这个思路、去处理你自己的业务数据。

相关推荐
鬼鬼鬼7 小时前
从软件1.0到3.0:在这场AI浪潮中,我们如何面对?
aigc·ai编程·cursor
墨风如雪7 小时前
AI界又炸了!会“卡壳”、会“改作业”的Dhanishtha-2.0来了!
aigc
临界点oc9 天前
SpringAI + DeepSeek大模型应用开发 - 进阶篇(上)
openai·springai·阿里百炼
墨风如雪9 天前
告别插件时代!OmniGen2:一个模型,通吃所有AIGC神操作
aigc
伊泽瑞尔9 天前
打造极致聊天体验:uz-chat——全端AI聊天组件来了!
后端·chatgpt·openai
阿维同学10 天前
媒体AI关键技术研究
人工智能·ai·aigc·媒体
量子位10 天前
OpenAI 硬件陷 “抄袭门”,商标 / 设计极其相似,官方火速删帖
openai
量子位10 天前
史上最高种子轮花落 AI:20 亿美元断档领先,苹果 Meta 抢着都投不进,扎克伯格转头挖联创也遭拒
aigc
PetterHillWater10 天前
基于大模型SSE的HTTP API接口测试与评估
aigc
新智元10 天前
任务太难,连 ChatGPT 都弃了!最强 AI 神器一键拆解,首测来袭
人工智能·openai