Fetch Post SSE 通信请求实现 Ai 聊天, SSE实践+深度原理解析

2,389 阅读5分钟

e45e49bc-7d52-4e60-a2a7-a72cd724952c_1722936448568971818~tplv-a9rns2rl98-web-image.jpeg

什么是 SSE

SSE,即 Server-Sent Events,是一种基于 HTTP 的服务器向客户端推送消息的方式。它的特点是一种单向连接,因此无法将事件从客户端发送到服务器。

因为 Ai 生成的时候,生成的句子都是由一个一个的语句逐渐生成拼接起来的,所以为了减少用户在客户端的等待消息的时间,将生成的语句通过 SSE 马上就推送到客户端,而不是等待完全生成完成之后再返回消息,这样用户在客户端就可以实时的看到生成的结果,减少等待的时间。

特点是:

  • 简单设置: SSE 易于实现,只需要标准HTTP连接
  • 单向通信: SSE 允许服务器向客户端推送数据,而不需要客户端的任何操作
  • 基于文本: SSE 以纯文本形式发送数据,使其易于理解和使用

豆包 Ai 使用 SSE 接收消息的例子

20240813_112743.gif

梳理一下豆包从提问到回答的全过程

  1. 输入消息,点击回车后,豆包发送了一个 POST 请求,其中Header Content-Type 的值为 application/json,表明请求体是一个 JSON ,同时在请求体中携带了当前经过编码的消息 msToken

    请求体内容

    image.png

  2. 发送消息之后,豆包返回了 Header 的 Content-Typetext/event-stream 的 response ,这个 MIME 为 text/event-stream 类型,非常重要,服务端通过此字段告诉浏览器返回的是一个数据流,数据流会通过不断的接收数据返回过来。

    image.png

  3. 事件流发送完毕,返回 event:done 表示结束

    image.png

    image.png

让我们来总结一下

  1. 浏览器是如何区分这是一个 SSE

    通过返回 Header Content-Typetext/event-stream 来表明自己返回的是一个数据流。

  2. SSE 返回的数据流本质上是一个文本

    如豆包返回的数据流

    ...
    
    id:678
    event:pb
    data:CAEScgoPMTIyOTI5NzUwMTU0MjQyEg8xMjI5Mjk3NTAxNTQ0OTgaDzEyMzAyNTEzNzgzNzgyNjoPMTIzMDI1MTM3ODM3MzE0UAFYAmIOeyJzdWdnZXN0IjpbXX1yDwoLaW5wdXRfc2tpbGwSAHgBgAGmBYgBATAB
    
    event:done
    data:
    

    引用自 Server-Sent Events 教程

    服务端每一次发送的信息,由若干个 `message` 组成,每个`message`之间用`\n\n`分隔。每个`message`内部由若干行组成,每一行都是如下格式。
    
    [field]: value\n
    

    上面的field可以取四个值。

    data
    event
    id
    retry
    
    各个字段的解释

    event

    标识所述事件类型的字符串。如果指定了该字符串,则将在浏览器上向指定事件名称的侦听器发送事件;网站源代码应使用来addEventListener()侦听命名事件。onmessage如果未为消息指定事件名称,则调用处理程序。

    data

    消息的数据字段。当EventSource收到以 开头的多个连续行时data:它会将它们连接起来,并在每行之间插入换行符。结尾的换行符会被删除。

    id

    用于设置对象的最后一个事件 ID 值的事件 ID 。

    retry

    重新连接时间。如果与服务器的连接丢失,浏览器将等待指定的时间,然后再尝试重新连接。这必须是一个整数,以毫秒为单位指定重新连接时间。如果指定非整数值,则忽略该字段。

    特别的,在前面只加上冒号,代表一段注释

    : this is a test stream
    

使用 Fetch Post 请求处理 SSE

背景:实现一个大模型的聊天请求

前端封装了一个使用 fetch 发送 post ,并且处理消息的 sendMessage 请求

export async function sendMessage(
  // aiId
  botId: string,
  // 发送消息
  message: string,
  // 聊天历史
  history: {
    content: string;
  }[],
) {
  const response = await fetch(`//xxx/send-message`, {
    method: 'POST', // 使用 post 方法
    headers: {
      Authorization: getAuthorization(),                // 根据情况,带上登录态
      Accept: 'text/event-stream',                      // 可写可不写,写上明确表明需要返回数据流
      'Content-Type': 'application/json;charset=UTF-8', // 默认是 text/plain 必须设置为 application/json,不然后端无法解析出 body 内容
    },
    // body 是一个字符串,指定 Content-Type 以表明内容格式,
    body: JSON.stringify({
      botId,
      msg: message,
      history,
    }),
  });

  const reader = response.body.getReader();
  const decoder = new TextDecoder('utf-8');

  let done: boolean;
  do {
    const { done: currentDone, value } = await reader.read();
    done = currentDone;
    if (done) return;

    const text = decoder.decode(value);
    console.log(text);
    // 自行解析
  } while (!done);
}
  • 代码解释

    通过 featch 方法发送了一个 POST 请求,并且设置 Header 的 Content-Typeapplication/json,在 body 中携带了必传的参数。

    接着,featch 完成之后得到 responseresponse.body 是一个 ReadableStream 对象,ReadableStream 对象提供了便捷的工具去读取数据流,如通过 await response.body.getReader().read() 可以获取到文本内容。

一般,在实际使用时,sendMessage 函数需要加上一个接收消息的参数,用来显示在页面上

function receiver(message: ChatResponse) {
    const element = getElementById('id1');
    element.innerHtml = element.innerHtml + message.content;
}

async function sendMessage(
  botId: string,
  message: string,
  history: {
    content: string;
  }[],
  receiver: (chatMessage: ChatResponse[]) => void,
) {
  const response = await fetch(`//xxx/send-message`, {
    method: 'POST',
    headers: {
      Authorization: getAuthorization(), // 根据需要,带上登录态
      Accept: 'text/event-stream',
      'Content-Type': 'application/json;charset=UTF-8',
    },
    body: JSON.stringify({
      botId,
      msg: message,
      history,
    }),
  });

  const reader = response.body.getReader();
  const decoder = new TextDecoder('utf-8');

  let done: boolean;
  do {
    const { done: currentDone, value } = await reader.read();
    done = currentDone;
    if (done) return;

    let text = decoder.decode(value);

    // text中可能有多条 data
    // 总感觉应该有更规范的方法,解析内容
    if (text.includes('data: [DONE]')) {
      text = text.replace(/data: [DONE]/g, '');
      if (!text) return;
    }

    const jsonText = `[${text.replace(/data: /g, ',')}]`.replace(/^[,/, '[');

    receiver(JSON.parse(jsonText));
    console.log(JSON.parse(jsonText));
  } while (!done);
}

sendMessage('bot1', '你是谁', [{history: '你好'}], receiver);

使用原生 EventSource 请求

当然,也可以通过原生的 EventSource 来发送消息,这样就可以直接通过订阅消息 onMeeage 来处理消息,缺点是扩展性比较差,比如无法发起 POST 请求等。

const source = new EventSource(url, { withCredentials: true });
source.onmessage = function (event) {
  const data = event.data;
  ...
};

...

source.close();

That's all.欢迎指正~

84f364a5-baa3-4749-bed3-53131c19dfc3_1722936448232173942~tplv-a9rns2rl98-web-thumb.jpeg