添加链接
link之家
链接快照平台
  • 输入网页链接,自动生成快照
  • 标签化管理网页链接

基于Nextjs搭建的一个 ChatGPT 问答平台来训练体验算法模型,该平台使用的接口非openAI 官方接口,而是基于一些自研 or 开源模型新包装的接口。

在应用部署后及过程中遇到了以下问题:

  • 页面直接返回500错误,而非提示错误原因
  • 页面回复打字机效果消失,回答内容延迟到加载完毕一次性展示
  • 项目上线后,后续某一天升级后本地部署OK但发布容器环境部署失败
  • 阅读完这篇文章,你将会:

  • 理解 SSE 概念与实现方案
  • 实现一个基于 EventSource API 的 SSE demo
  • 理解 fetch 模拟 EventSource 实现 SSE 方案
  • 问题一:chatgpt返回500

    1.1现象

  • 这种问题存在如果只针对后端服务挂掉尚且能接受,但假如其他原因也导致500则不合理
  • 如果只要发生错误就统一返回500,没有错误原因,用户一头雾水
  • 正常情况下,应该返回相应的错误原因,正常情况如下:
  • 1.2问题定位

    针对接口返回问题一般我们找后端看接口返回逻辑,但在该next项目中,服务端提供接口代理转发到真正的服务,因此前端请求的接口会先达到该项目的服务接口。

    在向后端确认后端暴露的接口服务没有问题后就要看我们的接口代理做了哪些逻辑处理。

    直接看前端代码接口请求逻辑,可以看到接口请求被 fetchEventSource 包了一层, fetchEventSource 的回调函数 onopen(res) 中对接口请求返回数据进行处理:

  • 如果content-type类型值为text/plain,会以text格式直接return处理
  • 除此之外会以JSON格式处理返回
  • 那么 fetchEventSource 里面又做了什么额外处理,会不会影响接口数据?

    接下来进一步看 @microsoft/fetch-event-source 包中 fetchEventSource 涉及数据返回的逻辑:

  • 修改请求头 accept text/event-stream
  • fetch 请求返回的结果 response 作为 onopen() 的入参
  • 明显这里的 centent-type: text/plain 是一个关键点,但这里又不足以解释请求响应状态 500,但前端部分的处理逻辑就到这儿,再往后看就是fetch 到接口的处理逻辑了。

    next api 部分转发请求对响应做了相应处理,此处具体作者当时的设计逻辑不再追究,到浏览器network栏确认500错误返回的确是 centent-type: text/plain

    1.3解决方案

    经过排查后确定问题出在流量网关,经过域名映射到服务 IP 的过程中,会走内部流量网关自动为响应头加上了 centent-type: text/plain ,覆盖了正确的 content-type: text/stream 。将请求响应头恢复为正确的 content-type 类型即可。

    问题二:chatgpt返回答案逐字显示效果失效

    解决了问题一,我们再来看问题二,首先我们已经确认响应头 content-type: text/stream

    在浏览器网络请求返回时可以看到:此时网络请求能看到收到数据的流式效果,看来这个问题也是 content-type 错误类型导致的。

    那么很自然的会想到,页面回复逐字返回的效果是如何实现的?

    ChatGPT回答文字效果实现原理

    3.1 ChatGPT回答文字效果是如何实现的

    首先看下 ChatGPT 回答的文字效果:

    可以看到网络请求栏数据返回多了一栏 EventStream,并没有我们日常熟悉的 Preview 和 Response 及其展示返回相关 JSON 数据,EventStream 这就是返回的数据了,那么首先来了解下 EventStream。

    EventStream(事件流)为 UTF-8 格式编码的 文本 或使用 Base64 编码和 gzip 压缩的二进制消息。

    每条消息由一行或多行字段( event id retry data )组成,每个字段组成形式为: 字段名:字段值 。字段以行为单位,每行一个(即以 \n 结尾)。

    从网络请求的头部信息可以看到EventStream仍然是基于HTTP协议的,其特点是返回的 MIME Content-Type 为 text/event-stream

    一般来说,当浏览器 HTTP 请求一个资源时,浏览器会一直等待资源返回,期间页面是空白一片。

    而在响应头中 Content-Type: text/event-stream 时,返回为 EventStream 格式数据时可以看到,只要还有响应内容返回,浏览器就会渲持续染这个持久化连接的响应内容。一旦有新的数据被传输过来,浏览器就会将新的内容继续显示出来。

    3.2 Server Sent Event

    然而,不同于WebSocket支持服务端和客户端双向通信,EventStream 是单向的服务器推送到客户端的数据,这种通信就是SSE(Server Sent Event)。

    MDN 上对 Server Sent Event 的描述十分详细:

    使用服务器发送事件,服务器可以随时向我们的 Web 页面推送数据和信息。这些被推送进来的信息可以在这个页面上以 事件 * + 数据* 的形式来处理。

    SSE 通过接口 EventSource 定义了所有处理与服务器连接、接收事件/数据、处理错误、关闭连接等功能的特性,是 web 内容与服务器发送事件通信的接口。

    一个 EventSource 实例会对 HTTP 服务器开启一个持久化的连接,以 text/event-stream 格式发送,此连接会一直保持开启直到通过调用 EventSource.close() 关闭。

    一旦连接开启,来自服务端传入的消息会以事件的形式分发至你代码中。如果接收消息中有一个 event 字段,触发的事件与 event 字段的值相同。如果不存在 event 字段,则将触发通用的 message 事件。

    详细内容前往 MDN 查看。

    3.3 EventSource手动实现

    SSE本质是浏览器发起 http 请求,服务器在收到请求后,返回状态与数据。

    3.3.1服务器端实现

    本地起一个服务器配置 http header、消息格式、消息发送及连接关闭停止发送消息。

  • 推送事件流的 MIME 类型为 text/event-stream
  • 指定浏览器不缓存服务端发送的数据,以确保浏览器可以实时显示服务端发送的数据
  • SSE 是一个一直保持开启的 TCP 连接,http/1.1 默认长连接
  • 注意跨域问题,设置解决跨域 'Access-Control-Allow-Origin': '*'
  • 设置正确的消息返回格式
  • 设置动态返回数据
  • const http = require('http')
    const arr = ['我', '爱', '写', '代', '码', '爱', '吃', '小', '鱼', '干', '小', '鱼', '干', '小', '鱼', '干']
    const len = arr.length
    http.createServer((req, res) => {
      const url = req.url
      if (url.includes('/sse')) {
        // 如果请求 /sse 路径,建立 SSE 连接
        res.writeHead(200, {
          'Content-Type': 'text/event-stream',
          'Cache-Control': 'no-cache',
          'Access-Control-Allow-Origin': '*', // 允许跨域
        // 每隔 200ms 发送一条消息
        let id = 0
        const intervalId = setInterval(() => {
          res.write(`event: customEvent\n`)
          res.write(`id: ${id}\n`)
          res.write(`retry: 30000\n`)
          res.write(`data: ${JSON.stringify(arr[id])}\n\n`)
          console.log(id, len, 'len')
          if (id >= len) { // 消息发送完毕终止条件
            clearInterval(intervalId)
            res.end()
        }, 100)
        // 当客户端关闭连接时停止发送消息
        req.on('close', () => {
          clearInterval(intervalId)
          id = 0
          res.end()
      } else {
        res.writeHead(404)
        res.end()
    }).listen(8103)
    console.log('Server listening on port 8103')
    

    3.3.2客户端实现

  • 使用 JavaScript 的 EventSource API 创建 EventSource 对象监听服务器发送的事件建立连接
  • 监听 EventSource 对象的 onmessageonopenonerror 事件处理并展示返回数据
  • 浏览器主动关闭连接 eventSource.close()
  • <!DOCTYPE html>
    <html lang="en">
      <meta charset="UTF-8">
      <meta http-equiv="X-UA-Compatible" content="IE=edge">
      <meta name="viewport" content="width=device-width, initial-scale=1.0">
      <title>SSE 打字机</title>
    </head>
      <h1>SSE 打字机</h1>
      <button onclick="connectSSE()">建立 SSE 连接</button>  
      <button onclick="closeSSE()">断开 SSE 连接</button>
      <div id="message"></div>
      <script>
        const messageElement = document.getElementById('message')
        let eventSource
        // 建立 SSE 连接
        const connectSSE = () => {
          eventSource = new EventSource('http://127.0.0.1:8103/sse')
          // 监听消息事件
          eventSource.addEventListener('customEvent', (event) => {
            const data = JSON.parse(event.data)
            messageElement.innerHTML += `${data}`
          eventSource.onopen = () => {
            messageElement.innerHTML += `<br />SSE 连接成功,状态${eventSource.readyState}<br />`
          eventSource.onerror = () => {
            messageElement.innerHTML += `<br />SSE 连接错误,状态${eventSource.readyState}<br />`
        // 断开 SSE 连接
        const closeSSE = () => {
          eventSource.close()
          messageElement.innerHTML += `SSE 连接关闭,状态${eventSource.readyState}<br />`
      </script>
    </body>
    </html>
    

    最终实现效果:

    3.4 ChatGPT SSE 方案

    看到这里,应该就明白 SSE 工作原理了,但你也许会有新的疑问,我在问题一定位问题时查看源码并没有用到 EventSource API,而是 fetchEventSource 包装的fetch接口发起请求的呀。

    对了!正是fetchEventSource 这个第三方工具结合fetch() 模拟实现了与 EventSource 等价的 SSE。

    另外,较早接触openAI的同学应该知道其实在 openAI 更新对话接口为 /api/openai/v1/chat/completions之前,其实还有一个接口 /api/chat-stream,本质上是是结合第三方工具eventsource-parser 实现 SSE 的另一种实现方式。

    具体原理可以去看这两个工具包:fetchEventSourceeventsource-parser

    即时通信方案

    Instant Messaging (IM)实现方案

  • 短轮询 — 客户端设置定时器每隔一段时间通过ajax请求从后端获取更新
  • 长轮询 — 短轮询的改进,请求到服务顿啊被挂起,直到有新的消息才会返回响应,然后再重新发起请求
  • websocket — HTML5提供的在单个TCP连接上进行全双工通信的一种协议。它依赖 HTTP 协议进行一次握手,http请求升级websocket,服务器同意变更返回状态码101
  • Socket.io — 解决 websocket 的兼容性的一个解决方案,因为websocket出现的较新,所以一些老的浏览器兼容性不好,而 Socket.IO就是将websocket长轮询两种通信方式封装成了统一的通信接口进行降级兼容
  • SSE — 基于流的推送技术就是指 SSESSE是一个H5的属性,它只能由服务器向浏览器发送数据,所以协作式通过 http 发送消息,sse 接受消息
  • ChatGPT 选择方案 — SSE

    为什么选择 SSE?

  • ChatGPT 是一个基于深度学习的大型语言模型,处理自然语言需要大量的计算资源和时间,响应速度肯定比普通的读数据库要慢的多,如果等待请求响应内容回复后再展示,等待时间过长
  • 对于这种单项对话场景,ChagtGPT 将先计算出的数据“推送”给用户,边计算边返回,避免用户因为等待时间过长关闭页面
  • 采用 SSE 技术相较于其他方案更轻量,也比较适合 ChatGPT 这种不需要以消息形式将数据从客户端发送到服务器的场景
  • 问题三:项目上线后续发布容器环境部署失败

    现象是容器部署失败,项目运行报错。 排查方案是通过启动日志报错定位代码报错位置。

    # RequestInit: duplex option is required when sending a body
    

    问题原因是 node fetch API 更新了一个新特性要求 MIME 类型为 text/event-stream 的请求必须携带双工通信参数 duplex: "half" 具体问题过程及解决方案可以参考该 issue

  • developer.mozilla.org/zh-CN/docs/…
  • juejin.cn/post/722963…
  • github.com/nodejs/node…
  •