前端 SSE 流式传输技术 🌊 对话大模型最佳拍档!

一、前言

当我们在与 ChatGPTDeepSeekKimi 这样的大模型应用进行对话时,我们可能会注意到它们的对话输出并非一次性将所有内容完整呈现,而是像老式打字机那样逐字逐句地展开。这是由于大模型收到输入后并不是一次性生成最终结果,而是逐步地生成中间结果,最终结果由中间结果拼接而成。这种流式输出方式与传统请求-响应模式形成鲜明对比,自然引出一个技术疑问:这种连续的、动态的流式传输是如何实现的呢,是通过轮询还是 WebSocket?答案是 SSE(Server-Sent Events)技术。通过 SSE 实现的流式 API 调用,大模型能够实时推送中间生成结果,这种机制带来双重优势:既缩短用户等待内容呈现的时间,又有效规避了请求超时的风险。本文将深入探索 SSE 技术,揭开流式交互背后技术的神秘面纱。

二、SSE 技术

Server-Sent Events (SSE) 是一种允许服务器向客户端发送事件的技术。与 WebSockets 相比,SSE 只支持服务器到客户端的单向通信,适用于服务器需要向客户端推送信息的场景。同时,SSE 基于 HTTP 协议,使用标准 HTTP 请求和响应,每次消息发送完毕后连接会被关闭,然后客户端需要重新连接。ChatGPT 在进行对话时就是采用了 SSE 技术来高效地接收来自服务端的流式数据,通过这种方式,ChatGPT 能够以一种类似于打字机的效果,逐字逐句地展示对话结果,为用户提供了一种流畅且动态的交互体验。SSE 传输的每条消息通常由多个字段组成,每个字段的格式为[field]: [value]\n,每条消息之间用两个换行符\n\n分隔,常见字段包括:

  • data: 消息内容
  • event: 自定义事件类型(可选)
  • id: 事件的唯一标识符(可选)
  • retry: 重连时间(可选)
javascript 复制代码
// 发送一条简单的消息
res.write('data: Hello, SSE!\n\n');

// 发送多行消息
res.write('data: This is a message with multiple lines.\n');
res.write('data: Line 2 of the message.\n\n');

// 发送自定义事件
res.write('event: customEvent\n');
res.write('data: This is a custom event message.\n\n');

2.1 基础使用 - EventSource

发送简单消息

javascript 复制代码
/**
 * 服务端
 */
app.get("/sse", (req, res) => {
  // 设置 HTTP 头部以允许 SSE
  res.writeHead(200, {
    "Content-Type": "text/event-stream",
    "Cache-Control": "no-cache",
    Connection: "keep-alive",
  });

  setInterval(() => {
    res.write(`data: ${JSON.stringify({type: "date", content: new Date().toLocaleString()})}\n\n`);
  }, 1000)
});
javascript 复制代码
/**
 * 客户端
 */
const eventSource = new EventSource("http://localhost:3000/sse");
eventSource.onmessage = function (event) {
  const data = JSON.parse(event.data);
  console.log(data.content)
};

发送自定义事件

javascript 复制代码
/**
 * 服务端
 */
app.get("/sse", (req, res) => {
  // 设置 HTTP 头部以允许 SSE
  res.writeHead(200, {
    "Content-Type": "text/event-stream",
    "Cache-Control": "no-cache",
    Connection: "keep-alive",
  });

  setInterval(() => {
    res.write('event: customEvent\n');
    res.write('data: This is a custom event message.\n\n');
  }, 1000)
});
javascript 复制代码
/**
 * 客户端
 */
const eventSource = new EventSource("http://localhost:3000/sse");
eventSource.addEventListener("customEvent", function (event) {
  console.log("customEvent", event);
});

结束请求

javascript 复制代码
/**
 * 服务端
 */
app.get("/sse", (req, res) => { 
  // 服务端进行终止
  res.end();

  // 监听客户端关闭连接事件
  req.on("close", () => {
    console.log("Client disconnected");
    res.end(); // 结束响应
  });
});
javascript 复制代码
/**
 * 客户端
 */
const eventSource = new EventSource("http://localhost:3000/sse");

// 客户端进行终止
eventSource.close();

// 监听服务端终止
eventSource.onerror = function (event) {
  console.error("EventSource failed:", event);
  eventSource.close();
};

在 Vue 场景下,可以使用 vueuse/useEventSource 来进行 SSE 处理。通过 EventSource 实现 SSE 仅支持通过 GET 请求来建立连接,SSE 的设计初衷是让服务器能够主动向客户端推送消息,而客户端则通过一个持久的 HTTP GET 请求来接收这些消息。这种机制要求连接是单向的,即从服务器到客户端。当使用 GET 请求携带参数的话非常容易,在 URL 中携带即可:

javascript 复制代码
const eventSource = new EventSource("http://localhost:3000/sse?param1=value1&param2=value2");

2.2 进阶使用 - Post 请求

通过 GET 请求的参数是明文的,远不如 POST 请求安全,且参数携带的数据量也有限,那么如何通过 POST 请求的方式来使用 SSE 呢?

方案1: 通过 Post 请求方式获取 SSE 连接所需的 URL 地址或 token,再基于这个 URL 地址或 token 进行 SSE 连接。

javascript 复制代码
async function postSSEWithToken(url, data) {
  // 1. 先通过 POST 请求获取 SSE 连接所需的 UR L或 token
  const response = await fetch(url, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify(data),
  });
  const { sseUrl } = await response.json();

  // 2. 使用 GET 请求建立 SSE 连接
  const eventSource = new EventSource(sseUrl);
  // .....
}

方案2:fetch 请求方式

javascript 复制代码
async function postSSEWithFetch(url, data, { onmessage, onopen, onclose }) {
  const response = await fetch(url, {
    method: "POST",
    cache: "no-cache",
    keepalive: true,
    headers: {
      "Content-Type": "application/json",
      Accept: "text/event-stream",
    },

    body: JSON.stringify(data),
  });

  onopen && onopen();

  if (response.status !== 200) return;

  const reader = response.body?.getReader();

  while (true) {
    const { value, done } = await reader.read();
    if (done) {
      onclose && onclose();
      break;
    }
    const data = new TextDecoder().decode(value);
    onmessage && onmessage(data);
  }
}

也可以使用 @microsoft/fetch-event-source 库,本质上就使用了 fetch 请求:

javascript 复制代码
/**
 * 服务端
 */
app.post("/sse", (req, res) => {
  // 设置 HTTP 头部以允许 SSE
  res.writeHead(200, {
    "Content-Type": "text/event-stream", // SSE 规定推送类型需为事件流 text/event-stream。
    "Cache-Control": "no-cache", // 必须关闭缓存,以确保浏览器可以实时获取服务端发送的数据
    Connection: "keep-alive", // 保证当前链接持久化开启
  });
  const data = req.body;
  console.log(data);
  let count = 0;
  const interval = setInterval(() => {
    if (count === 3) {
      clearInterval(interval);
      res.end();
    }
    count++;
    res.write(`data: ${count}\n\n`);
  }, 1000);
});

/**
 * 客户端
 */
import { fetchEventSource } from "@microsoft/fetch-event-source";

fetchEventSource("http://localhost:3000/sse", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ foo: "bar" }),
  onmessage(msg) {
    console.log("msg", msg);
  },
});

三、总结

本文源码参考:streaming-learn

实时数据传输技术在当前数字化时代展现出广泛的应用价值,其典型应用场景包括:大模型对话系统中的流式响应输出、金融领域的实时股价更新、即时通讯软件的聊天消息同步,以及长耗时任务的进度实时反馈等。随着Web技术的持续革新,实时通信领域也在不断演进。其中,SSE 技术以其高效的服务器推送能力,在需要持续数据流的应用场景中展现出独特优势,通过建立持久连接实现数据的低延迟推送,显著提升了用户交互的实时性和流畅体验。


One More Thing

除了 SSE 技术,轮询和 WebSocket 也能实现或近似实现流失传输的效果,每种技术方案都有其特定的应用场景和潜在限制,选择技术方案需要综合考量具体的应用需求和运行环境。

轮询请求

javascript 复制代码
/**
 * 短轮询
 */
setInterval(() => {
  // ...发起请求
}, 1000);

/**
 * 长轮询
 */
// ...发起请求,等待后端响应
if(没有结束) {
  // 继续发起请求
}

轮询,通常指的是短轮询 ,就是其中浏览器按照设定的时间间隔周期性地向服务器发起请求以获取最新数据。这个过程本质上是浏览器发送请求、服务器响应的循环,通过缩短发送间隔来模拟实时数据传输的效果。这种方法因其简单性和易于实现而广受欢迎,但它也存在一些不容忽视的缺陷。首先,轮询可能会引发不必要的网络流量和增加服务器的负载。这是因为即使在没有新数据更新的情况下,客户端也会按照既定的时间间隔不断地发送请求。其次,轮询方式存在延迟的问题,客户端只有在下一个请求周期才能接收到服务器的最新数据,这可能导致信息传递的延迟。为了应对短轮询带来的不必要开销,我们可以采用长轮询作为优化方案。长轮询的工作机制是,客户端向服务器发送请求后,服务器不会立即响应,而是将请求暂时挂起。服务器会监测数据是否有更新,一旦检测到更新,便会立即响应客户端;如果数据长时间没有更新,服务器则会在达到预设的时间限制后返回响应。客户端在处理完服务器返回的信息后,会自动重新发起请求,建立新的连接。这种方式减少了在数据未更新时不断进行HTTP请求的资源浪费,从而节约了资源。

WebSocket

WebSocket 提供了一个全双工通信通道,允许服务器和客户端之间进行实时、双向的通信。一旦 WebSocket 连接建立,服务器可以在任何时候向客户端推送数据,而无需客户端不断发起新的请求。WebSocket 主要是用于解决无法实现服务器主动推送信息的问题。在 WebSocket 中,一旦建立连接,服务器可以主动向客户端推送数据,而客户端也可以向服务器发送数据。这种方式极大地减少了延迟,提高了通信效率。示例如下:

服务端(nodejs 需要使用 ws 库)

javascript 复制代码
const WebSocket = require("ws");

const wss = new WebSocket.Server({ port: 4000 });

wss.on("connection", (ws) => {
  console.log("Client connected");

  ws.on("message", (message) => {
    console.log(`Server receive message: ${message}`);
    const messageObj = JSON.parse(message);
    if (messageObj.type === "init") {
      count = 1;
      // 定时主动推送至客户端
      const interval = setInterval(() => {
        if (count < 5) {
          ws.send(`Hello init: ${Date.now()}`);
          count++;
        } else {
          clearInterval(interval);
        }
      }, 1000);
    } else if (messageObj.type === "click") {
      ws.send(`Hello Click: ${Date.now()}`);
    } else if (messageObj.type === "close") {
      ws.close();
    }
    // Broadcast to all clients
    // wss.clients.forEach((client) => {
    //   if (client.readyState === WebSocket.OPEN) {
    //     client.send(`Hello Client: ${Date.now()}`);
    //   }
    // });
  });

  ws.on("close", () => {
    console.log("Client disconnected");
  });
});

console.log("Server started on ws://localhost:4000");

客户端

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>WebSocket Demo</title>
  </head>
  <body>
    <button id="click-btn">click</button>
    <button id="close-btn">close</button>
  </body>
  <script>
    const ws = new WebSocket("ws://localhost:4000");

    ws.onopen = () => {
      console.log("服务端连接成功");
      ws.send(JSON.stringify({ type: "init", msg: "Hello Server" }));
    };
    ws.onmessage = (e) => {
      console.log("收到消息", e.data);
    };
    ws.onclose = () => {
      console.log("连接关闭");
    };
    ws.onerror = (e) => {
      console.log("发生错误", e);
    };
    document.getElementById("click-btn").onclick = () => {
      ws.send(JSON.stringify({ type: "click", msg: `Hello Server: ${Date.now()}` }));
    };
    document.getElementById("close-btn").onclick = () => {
      ws.close();
    };
  </script>
</html>
相关推荐
1024小神26 分钟前
在GitHub action中使用添加项目中配置文件的值为环境变量
前端·javascript
齐尹秦35 分钟前
CSS 列表样式学习笔记
前端
Mnxj39 分钟前
渐变边框设计
前端
用户76787977373242 分钟前
由Umi升级到Next方案
前端·next.js
快乐的小前端43 分钟前
TypeScript基础一
前端
北凉温华44 分钟前
UniApp项目中的多服务环境配置与跨域代理实现
前端
源柒1 小时前
Vue3与Vite构建高性能记账应用 - LedgerX架构解析
前端
Danny_FD1 小时前
常用 Git 命令详解
前端·github
stanny1 小时前
MCP(上)——function call 是什么
前端·mcp
1024小神1 小时前
GitHub action中的 jq 是什么? 常用方法有哪些
前端·javascript