大模型的流式响应实现

为什么需要流式响应

在与大模型交互时,用户通常希望立刻看到用户的输出,而不是等待整个响应生成完毕。例如,在与AI聊天时,你希望看到模型逐字逐句地回复,就像对面有人在打字一样。这要求服务器在生成内容的过程中,能够分块,实时地将数据发送给前端。

效果观赏:

前端在流式响应的方案

方案一:WebSocket

WebSocket 通过三个关键特性完美解决了大模型流式响应的需要:持久化的全双工连接,帧机制和消息实时推送:

  1. 持久化的全双工连接

    1. 工作方式:一旦 WebSocket 连接建立,它就会一直保持开放状态,直到被显示关闭,这个单一的 TCP 连接可以同时进行双向通信。
    2. 解决的问题:与 HTTP/1.1 每次请求都要重新建立连接不同,WebSocket 避免了反复握手和连接的开销,这对于需要长时间通信的流式应用至关重要。
  2. 帧机制:

    1. 工作方式:WebSocket 在应用层将数据分割成多个"帧"(Frames)。服务器在模型上生成一个词或者一个句子后,立刻将这个小块的数据打包成一个帧发送出去。
    2. 解决的问题:WebSocket 协议的低开销帧机制,使得前端不需要等待一个完整的JSON对象,而是可以接受一个又一个的文本或二进制数据。
  3. 消息推送:

    1. 工作方式:由于 WebSocket 是服务器主动推送的。当大模型生成新的内容时,服务器可以立即通过已建立的连接将这些内容发送给前端,而无需前端进行轮询。

    2. 解决的问题:消除了传统 HTTP 轮询模式带来的延迟和资源浪费,前端页面可以实时地接收数据并渲染数据,实现流畅的打字机效果。

具体的工作流程如下:

  1. 连接建立:前端页面通过 new WebSocket() 方法,向服务器发起一个请求(wss://)。

  2. 握手:服务器验证请求,如果通过,会返回一个特殊的响应,完成握手,连接正式建立。

  3. 请求发起:前端会向服务器发送一个请求。

  4. 服务器处理:服务器把收到的请求交给大模型处理。

  5. 流式响应:大模型开始生成输出。每生成一小段内容,服务器就会把其封装成一个 WebSocket 消息帧,通过已建立的连接立即发送给前端。

  6. 前端实时渲染:前端的 onmessage 监听器会不断地接收这些消息帧。每收到一个,前端就会把新的内容追加到页面上,实现实时渲染。

  7. 结束标志:当大模型生成完毕,服务器会发送一个特殊的"结束帧",告诉前端流式响应已完成。

  8. 连接保持:连接保持:整个过程结束后,WebSocket 连接依然保持开放,等待下一次通信。

方案二:SSE(Server-Sent Events,服务器发送事件)

在前端大模型应用中,SSE(Server-Sent Events,服务器发送事件)是另一种实现流式响应的有效方式。与 WebSocket 不同,SSE是一种基于 HTTP 的协议,专门用于服务器向客户端单向推送数据流。

SSE 的工作机制非常简洁:

  1. 基于HTTP的长连接:客户端发起一个普通的HTTP请求,但服务器不会立即关闭连接,而是保持它开放。

  2. MIME类型:服务器返回的 Content-Type 必须是 text/event-stream。

  3. 数据流:服务器不断地向这个开放的连接写入格式化的文本数据,每一条消息都已特定的格式(data:...)发送。

  4. 自动重连:如果这个连接中断,浏览器会自动尝试重新连接。

SSE如何实现 大模型 响应:

  1. 建立连接:前端会使用内置的 EventSource() 创建一个到服务器的连接

  2. 服务器处理:服务器接收到这个请求后,它不会向处理普通 HTTP 请求那样一次性返回所有数据,而是保持连接开放,并将大模型生成的内容逐块写入响应流。

  3. 数据格式:服务器发送的每一条消息都必须遵循 SSE 的特定格式,通常以data:开头,并以换行符结束。

  4. 前端监听:前端的 EventSource 对象会持续监听数据流。每当服务器发送一个完整的消息块,onmessage 事件就会被触发。

  5. 错误和重连:EventSource 内置了自动重连机制,大大简化了前端的异常处理。

方案三:fetch方法+ReadStream

适用场景

fetch 的流式输出特别适合那些只需要从服务器获取数据流 ,而不需要双向实时交互的场景。

  • 大模型 生成:客户端发起一个请求,接收大模型的完整输出,且在此过程中不需要向服务器发送其他指令。
  • 文件下载:当下载大文件时,可以分块接收数据并实时写入本地文件,避免一次性加载到内存。

总结fetch 提供了实现流式输出的能力,让你可以在不依赖第三方库 的情况下,实现高效的数据流处理。它的主要缺点是需要手动处理重试和缺乏双向通信能力,但对于许多大模型应用来说,这已经足够了。

下文会结合项目详细讲解

WebSocket 与 SSE 的区别

  1. WebSocket 与 SSE 对比如下:
特性 SSE WebSocket
通信模式 单向通信:服务器向客户端推送 全双工,双向通信
协议 基于 HTTP 基于 TCP 的独立协议
实现复杂性 简单,浏览器内置EventSource API 较复杂,需要专门的库
自动重连 内置,浏览器自动处理 需要手动实现
二进制数据 不支持,仅支持 UTF-8编码的文本 支持。文本/二进制均可传输
适用场景 实时股票行情,新闻推送等"只读流" 在线聊天,多人协作游戏等需要双向交互的场景
  1. 总结:

    1. 如果只需要从服务器接收大模型的流式响应,且无需再流传输过程中向服务器发送消息,那 SSE 是一个优雅且简单的选择。它利用了 HTTP 协议,内置了自动重连功能,降低开发复杂度。

    2. 如果你的应用需要更复杂的双向实时通信,比如聊天机器人等,那么 WebSocket 是更为灵活和复杂的方案。

项目场景实现

背景

在使用 Coze 智能体实现大模型流式响应时,HelloKitty 同学遇到了一个问题:Coze 的 API 需要通过请求头传输 Token 进行身份验证,但原生的 SSE (Server-Sent Events) 不支持自定义请求头。

为了解决这个矛盾,HelloKitty 同学选择了 fetch 结合 ReadableStream 的方案。这种方法不仅能够处理流式数据,还能通过 fetch 请求头灵活地发送 Token,完美解决了认证问题。

代码示例分析

javascript 复制代码
const response = await fetch(`https://api.coze.cn/v3/chat${params}`, {
    method: "post",
    headers: {
      Authorization: `Bearer ${cozeToken}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      stream: true,
      bot_id: botId,
      user_id: userName,
      additional_messages: additionalMessages,
    }),
  })
  if (!response.ok) {
    throw new Error("Network response was not ok")
  }

  if (!response.body) {
    throw new Error("Response body is null")
  }
  const reader = response.body.getReader() // 获取可读流
  const decoder = new TextDecoder("utf-8") // 解码流
  let done = false
  let buffer = "" // 用于存储流数据
  let answer = "" // 用于存储回复

  // 逐步读取流,处理流式数据
  while (!done) {
    const { value, done: isDone } = await reader.read()
    done = isDone

    if (value) {
      const decodeValue = decoder.decode(value, { stream: true })
      buffer += decodeValue
      // 可以打印 decodeValue 查看流数据
      // console.log(decodeValue);
      try {
        const parsedData = parseEventStream(decodeValue)

        if (parsedData) {
          const eventData = parsedData
          for (let i = 0; i < eventData.length; i++) {
            if (eventData[i].event === "conversation.message.delta") {
              answer += eventData[i].data.content
            }
            // 处理其他事件,有一些联想问题,可通过 event:conversation.message.completed 和 "type":"follow_up" 判断
          }
        }
      } catch (error) {
        // Continue accumulating buffer if parsing fails
        console.error("解析流数据时出错:", error)
      }
    }
    // 此answer为持续更新的回复
    console.log(answer)
  }

  return buffer // 返回流处理后的结果
}

结合以上代码,我们总结出如何如何实现的流式响应:

  1. 发起流式请求 :利用 Fetch 函数发起一个 POST 请求。与传统请求不同的是,它在请求体中设置了 stream:true,这是告诉 Coze API 服务器,希望以流的形式返回响应数据。同时,它在请求头中加入了 ``Authorization: `Bearer ${cozeToken}```,用于身份验证。
  2. 获取可读流(ReadableStream) :请求发送后,fetch返回的响应对象 response 上的 body 属性就是流式数据的源头,一个 ReadableStream 的对象。代码通过 response.body.getReader() 获取了一个 reader。这个 reader 提供了 read 的方法,用于从流中逐块读取数据。
  3. 逐块读取与解码数据:代码进入一个 while 循环,直至流被全部读取完毕(isDone:true)。每次循环中,await reader.read()会读取流中的下一块数据。

注:流数据是二进制格式,但是 SSE 不支持二进制格式的传输,因此代码中使用了 Web 内置的 API TextDecoder("utf-8"), 将这些字节块解码为字符串,并附加到 Buffer 变量中。

  1. 解析 SSE 数据:Coze API 返回的流数据遵循 Server-Sent Events(SSE) 格式。它会接受一个字符串作为输入,按行分割,并查找 event:和 data:标记。

  2. 实时处理和拼接回复:每当成功解析一块数据后,都会提取文本片段,逐步追加。

场景思考

思考一:大模型流式响应输出到一半终止了怎么办

如果大模型在流式响应输出到一半终止了,通常是由于网络连接,服务端或客户端的异常导致的。处理这种情况,需要做一套健壮的机制来完善,从而确保用户体验和数据完整性。我们遇到这种情况,需要通过"发现问题 ------> 定位问题 ------>解决问题 ------>完善后端------>前端提升体验"这个流程。

流程模型:

总结 :处理大模型流式响应中断的核心在于**客户端的容错机制服务器的上下文管理**。

  • 客户端:通过监听断开事件,并结合重连策略
  • 服务器:通过保存对话状态和支持续写请求,保证在连接恢复后能够继续生成

思考二:对接多个LLM时,数据块返回不完整怎么办

核心思路:利用 TransformStream 来搭建一个数据管理通道,它能够实时处理和转换从大模型 API 返回的流式数据。

步骤如下:

  1. 流式数据获取与解码:

    1. response.body.pipeThrough(...):通过管道 pipeThrough 将原始的 HTTP 响应流连接到一个自定义的转换流,并将二进制 chunk 转换为字符串。
  2. 缓冲区与数据累积:buffer+=textChunk缓存所有接收到的数据,即使数据时不完整的,也就可以被联系累积到这个字符串中

  3. 数据格式判断与处理:

    1. 完整 JSON 对象:如果返回一个完整 JSON 错误信息,这个逻辑就可以被捕获
    2. SSE 格式处理:开始标记通过寻找 data: 来识别一个事件的开始,结束标记通过 }\n\n 来判断完整JSON对象的结束。
  4. 适配与转换转换:

    1. adapter.transform2OpenAiResponse4Stream(...) 这是一个适配器模式,将不同 LLM 返回的原始数据格式转换为统一你想要的格式。转换后的数据会被重新编码,并重新推入管道形成统一格式的流

代码如下:

ini 复制代码
export function isValidJsonStr(str: string) {
  if (typeof str !== 'string') {
    return false;
  }

  try {
    const parsed = JSON.parse(str);
    return typeof parsed === 'object' && parsed !== null;
  } catch (e) {
    return false;
  }
}

const response: Response = await fetch(requestInit);

const decoder = new TextDecoder('utf-8');
const encode = new TextEncoder();

if (response.body) {

  let accumulatedData = ''; // 初始化累积的数据为空字符串
  let buffer = ""; // 初始化缓冲区为空字符串

  response.body
    .pipeThrough(
      new TransformStream({
        transform(chunk, controller) {
          const textChunk = decoder.decode(chunk, { stream: true }); // 解码数据块
          accumulatedData += textChunk; // 将解码后的数据块添加到累积的数据中

          // 查找完整的 JSON 对象
          while (true) {
            // 如果返回的是 json 字符串 一般都是大模型直接报错了
            if (isValidJsonStr(accumulatedData)) {
              const transformedChunk =
                adapter.transform2OpenAiResponse4Stream(accumulatedData); // 转换数据块 这里自己实现转换方法吧!
              const handledChunk = encode.encode(transformedChunk); // 编码转换后的数据块
              self.chunks.push(transformedChunk); // 将转换后的数据块添加到数据片段数组中
              controller.enqueue(handledChunk); // 将编码后的数据块推入控制器
              accumulatedData = ""; // 重置累积的数据为空字符串
              break; // 退出循环
            } else {
              const dataStartIndex = accumulatedData.indexOf("data: "); // 查找 'data: ' 开头的数据块
              if (dataStartIndex === -1) {
                // 如果没有找到 'data: ' 开头的数据块,保留当前累积的数据
                buffer = accumulatedData;
                accumulatedData = "";
                break;
              }

              const jsonStartIndex = dataStartIndex + 6; // 跳过 'data: '
              const jsonEndIndex = accumulatedData.indexOf(
                "}\n\n",
                jsonStartIndex
              ); // 查找完整的 JSON 对象结束位置  这里可以进一步优化,因为担心json中也会出现 "}\n\n"

              if (jsonEndIndex === -1) {
                // 如果没有找到完整的 JSON 对象,保留当前累积的数据
                buffer = accumulatedData;
                accumulatedData = "";
                break;
              }

              // 提取完整的 JSON 对象
              const jsonString = accumulatedData.slice(
                jsonStartIndex,
                jsonEndIndex + 1
              );
              try {
                // 处理完整的 JSON 对象
                const transformedChunk =
                  adapter.transform2OpenAiResponse4Stream(
                    "data: " + jsonString
                  ) + "\n\n"; // 转换数据块
                const handledChunk = encode.encode(transformedChunk); // 编码转换后的数据块
                self.chunks.push(transformedChunk); // 将转换后的数据块添加到数据片段数组中
                controller.enqueue(handledChunk); // 将编码后的数据块推入控制器
              } catch (error) {
                console.error("Error parsing JSON:", error); // 如果解析 JSON 失败,打印错误信息
                // 如果解析 JSON 失败,保留当前累积的数据
                buffer = accumulatedData;
                accumulatedData = "";
                break;
              }

              // 移除已处理的 JSON 对象
              accumulatedData = accumulatedData.slice(jsonEndIndex + 3);
            }
          }
        },
      })
    )
    .pipeTo(writable) // 将处理后的数据流管道连接到可写流
    .catch((error) => {
      console.error("Error:", error); // 捕获并打印错误
      throw new Error("Handle stream response error"); // 抛出错误
    });

  const processedResponse = new Response(readable, {
    status: response.status,
    statusText: response.statusText,
    headers: response.headers,
  });

  return processedResponse; // 构建并返回处理后的响应对象
}

总结展望

在实践中,尽管会遇到数据不完整、格式不统一等挑战,但通过缓冲区智能解析器适配器模式等技术,我们能够构建一个健壮的系统,确保无论是哪个大模型,都能提供流畅、可靠的流式响应体验。

最终,流式响应不仅仅是技术上的优化,更是用户体验上的一大飞跃,它让大模型的交互变得更加自然、高效,也为未来的 AI 应用开启了更多可能性。

相关推荐
小高00710 小时前
🎯v-for 先还是 v-if 先?Vue2/3 编译真相
前端·javascript·vue.js
zzywxc78710 小时前
如何利用AI IDE快速构建一个简易留言板系统
开发语言·前端·javascript·ide·vue.js·人工智能·前端框架
CaracalTiger10 小时前
网站漏洞早发现:cpolar+Web-Check安全扫描组合解决方案
java·开发语言·前端·python·安全·golang·wpf
TimelessHaze10 小时前
面试必备:深入理解与实现高效瀑布流布局
前端·面试·trae
linyi710 小时前
Rocket.Chat Video Call
前端·javascript
Jooolin10 小时前
大名鼎鼎的哈希表,真的好用吗?
数据结构·c++·ai编程
石小石Orz10 小时前
为什么有些依赖必须 import *引入使用?
前端·cursor·trae
玲小珑10 小时前
LangChain.js 完全开发手册(四)Callback 机制与事件驱动架构
前端·langchain·ai编程
前往悬崖下寻宝的神三算10 小时前
Vue Router 也能“强类型”?vite-plugin-vue-typed-router 上手体验
前端·vue.js