大模型流式数据请求与解析

2,629 阅读5分钟

在近期的项目中,对接了大模型,这个过程中踩了一些坑,和大家分享。
整体分为两块内容:SSE的简单介绍大模型流式数据请求与解析,大家可以重点关注请求与解析。 最终效果gif形式附在文章末尾,供参考。

一、SSE 简单介绍

流式输出,即服务器持续地将数据推送到客户端,而不是一次性全部发送完。
这种模式下,一旦连接建立,服务器可以实时发送数据更新,客户端接收后进行处理。
简单来说,服务器会将完整数据拆分为多个小块,分批次发送,客户端实时接收每个小批次数据。

image.png

这种流式传输形式在大模型交互中非常常见,且具有明显的优势:

  • 提升用户体验:因为大模型生成结果通常需要较长时间,流式传输像“打字机”一样逐步输出结果,能够实时展现部分生成内容,增强交互感和体验的实时性。
  • 减少等待时间:相比传统接口模式需要等待结果生成完毕才返回,流式传输减少了中间的等待时间,用户体验更流畅。

当下流式输出几乎已成为大模型交互中的标准做法。

二、大模型流式数据请求与解析

接下来分享踩的两个坑,重点是解决方案。

1. 数据请求

在流式输出的实现中,第一个遇到的问题就是网络请求,这个问题耗费了我小半天的时间。

目前项目中我们基本都在用 Axios 发起请求,Axios 默认使用的是 XMLHttpRequest (XHR) ,而 Axios 对 XHR 的封装中并没有处理流式数据。也就是说,默认情况下 Axios 并不能很好地支持 SSE 这种实时流式传输。

为了解决这个问题,我们有两个选择:

  • 直接使用 Fetch:Fetch 原生支持流式数据。
  • 在 Axios 中切换到 Fetch:在 Axios 1.7.0 版本中,增加了对 Fetch 的适配器支持(今年5月下旬发布)。因此,也可以通过切换 Axios 使用 Fetch 来支持流式数据。切换比较简单,代码如下:
const instance = axios.create({
  // other config 
  adapter: ['fetch' , 'xhr' , 'http']
});

// axios 的默认配置优先级是
// adapter: ['xhr' , 'http', 'fetch']

以上是切换了Axios的适配器优先级。
简单来说,Axios内部会自动适配node和browser环境。对于browser环境,有两种选择:xhr、fetch。默认情况下优先使用xhr。
具体可以参考以下Axios源码: github.com/axios/axios…
github.com/axios/axios…

2. 数据解析

在成功请求流式数据后,接下来就遇到了 流式数据解析 的问题。

为了更好地理解问题,我们可以在浏览器中查看流式数据的响应格式。通常流式响应会有一些特定的标志,比如 data: 字段标识每一块数据。在解析时,必须逐步读取这些数据块并即时处理,而不是等到所有数据接收完才处理。

这一步相当重要,因为解析不当可能导致数据的拼接出错或者数据延迟展示,影响整体效果。实际可以参考以下gif图片

未命名.gif

从上图中只能简易看出这种数据特点前两个特点:

  • 分段返回
  • 每段有多条类似json数据
  • 每段的最后一条json数据有可能不完整

针对这种数据解析,暂时没有找到成熟方案以下是我自己实现,供参考:

const chartRequest = async (messages: str) => {
  const response = await agentChat({
    type: 'CHAT',
    sessionId,
    query: messages
  }).catch(() => {
    return DefaultError();
  })

  if (!(response instanceof ReadableStream)) {
    return DefaultError();
  }

  const reader = response.getReader() as ReadableStreamDefaultReader;
  const decoder = new TextDecoder('utf-8');
  const encoder = new TextEncoder();
  let jsonBuffer = ''

  const readableStream = new ReadableStream({
    async start(controller) {
      function push() {
        reader
          .read()
          .then(({ done, value }) => {
            if (done) {
              controller.close();
              console.log('数据流解析-------- 连接关闭')
              return;
            }
            // 1、流返回的块数据
            const chunk = decoder.decode(value, { stream: true });
            console.log('数据流解析-------- 当前返回块', chunk);
            // 2、更新到缓存区
            jsonBuffer += chunk;
            // 3、尝试分片解析json
            let boundaryIndex = 0;
            // 当前片内容
            let result = '';
            while ((boundaryIndex = jsonBuffer.indexOf('\n')) >= 0) {
              // 3.1 数据块切片
              const jsonString = jsonBuffer.slice(0, boundaryIndex);
              // 3.2 更新缓存区
              jsonBuffer = jsonBuffer.slice(boundaryIndex + 2);
              console.log('数据流解析-------- 缓存区剩余数据', jsonBuffer)
              try {
                const jsonStr = jsonString.replace('data:', '');
                console.log('数据流解析-------- 将要解析的json字符串', jsonStr)
                const jsonObject = JSON.parse(jsonStr); // 解析 JSON
                console.log('数据流解析-------- json字符串转换为对象', jsonObject);

                // 处理可识别内容 - 伪代码,根据实际对象处理
                const content = jsonObject?.data?.content;
                controller.enqueue(encoder.encode(content));
                // 解析结束 - 我们业务是根据此字段标识,根据实际情况调整
                if (jsonObject?.data?.isEnd === true) {
                  console.log('数据流解析-------- 解析数据流结束');
                  // 清空缓存区
                  jsonBuffer = '';
                  break;
                }
              } catch (error) {
                console.log('数据流解析-------- json解析出错', error)
              }
            }

            // 处理缓冲区中剩余的数据(这里冗余设计,可以考虑去掉,只是为了观察每块数据的不完整json串)
            if (jsonBuffer) {
              console.log('数据流解析-------- 缓存区剩余内容', jsonBuffer)
              try {
                const jsonObject = JSON.parse(jsonBuffer);
                console.log('缓存区剩余内容:解析成功', jsonObject);
              } catch (error) {
                console.log('数据流解析-------- 处理缓存区剩余内容出错,可能需要等待下一块流数据,缓存区剩余数据', jsonBuffer);
              }
            }
            push();
          })
          .catch((err) => {
            console.log('数据流解析-------- 读取流中的数据时发生错误', err);
            controller.error(err);
          });
      }
      push();
    },
  });

  return new Response(readableStream);
}

注释较多,不再赘述。

最终效果.gif

总结

我们简单介绍了sse请求方式,一种长链接,服务端可以持续的向客户端推送数据。之后讨论了sse如何使用axios请求,以及如何解析这种数据。希望以上对您有所收获。我的微信:l592816909(备注掘金),欢迎探讨。