微信小程序AI对话 - 流式响应 - enableChunked - requestTask.onChunkReceived

3,584 阅读6分钟

分享一个小程序内使用wx.requestenableChunked模式做AI对话的经历.最后会把用来解析分段响应的库分享给大家. 在此之前, 我在小程序中还没有找到任何一个可以把onChunkReceived内容正常解析的库.

希望快速解决小程序AI对话的伙伴, 直接跳到代码库分享即可, 如果想看看具体的实现, 可以慢慢浏览

背景

前端要实现AI对话, 主流有两种方案, 一个是使用websocket, 另外一个是使用httptransfer-encoding chunked

WebSocket 技术

WebSocket提供了一种在单个TCP连接上进行全双工通信的方式。这种技术允许服务器与客户端之间进行实时、连续的数据交换,无需客户端频繁发送请求。在AI对话系统中,WebSocket能够实现快速响应和低延迟的交互体验。

HTTP Chunked Transfer Encoding技术

另一方面,HTTP的transfer-encoding chunked技术允许服务器在知道整个响应内容大小之前就开始发送数据。这种技术通过将数据分割成多个块来发送,使得服务器可以动态生成响应内容,适用于AI对话系统中的流式数据处理。

结论

使用websocket做AI对话, 有杀鸡牛刀的感觉, 开发和维护成本也更高. http Chunked方式相对就更适用.

但在小程序场景下, wx.request是被微信二次定制的基础库, 原本很多在前端可用的HTTP Chunked的库, 在微信小程序上是无法使用的. 这就导致小程序内的对话, 很多人使用的都是WebSocket.

那当我接手前面小伙伴留下的项目时, 使用的也是WebSocket,他们维护了10来种弱网状态(有业务相关代码参合). 对话时经常报错, 还找不到原因, 报错就说是网络不稳定(后面检查下来, 涉及到初始化等各种代码原因, 并不是网络问题). 在几次演示事故后, 老板发话, 说这个对话三天两头出问题,必须搞好! 所以开始了本次重构.

重构开始

基于对两种方案的基本认识, 最终决定使用HTTP Chunked. 然后入坑就开始了!

  • 坑1: 返回数据并不是直接是字符串而是ArrayBuffer
  • 坑2: 其实也不全是ArrayBuffer, 在开发者工具上是Uint8Array
  • 坑3: 前端收到的分段和服务发送的分段不是一一对应的,举一个例子: 把服务器比作一个面包厂家, 假设他每做好10个面包就打包, 然后发送给前端, 所以我们理所当然的以为:'每次收到一袋面包, 且每一袋有10个面包', 但其实不是这样的, 当我们收到面包时, 有时候一次可能直接给两袋; 有时候包装坏了, 一次只给了3个面包;有时候面包都不是完整的, 可能给了3.5个面包. 这也是微信开发者社区很多人说enableChunked模式有bug的原因, 其实这不是微信开发者的锅, 我试过在浏览器时使用fetchChunked模式, 返回的结果一样一样的. 微信只是没有对Chunked模式内容做进一步的封装, 把这个问题处理好, 但如果在浏览器上, 就可以找到很多可用的第三方库处理好这些问题.

填坑

  • 目标一: 解决方式自然是把ArrayBuffer转为string; 这里有两个小目标:
    • ArrayBufferUint8Array:
    • Uint8Arraystring

首先判断是ArrayBuffer时, 先把它转为Uint8Array,这一步没有问题;

然后重点来了: 是Uint8Arraystring, 这里就会遇到上面提到的面包问题了,string就是面包,有时候返回的数据可能是3.5个字符串, 这时候转string就会失败. 当找到这个原因后, 解决起来也不复杂:发现解析失败时, 把这一段Uint8Array缓存起来(千万别舍弃,不然下一段必然失败!), 当下一段数据来到时, 两段Uint8Array合起来再尝试解析成string(3.5+.5.5=9这时候就成功了,但如果你舍弃前面的3.5, 后面的5.5必然失败), 成功后清除缓存; 如此往复(原因很简单,http能保证所有数据按顺序接收到,这也是为什么不开enableChunked时总能正常返回的原因)!

上代码

  /**
   * 返回数据转文本
   * @param res
   * @returns
   */
  const getChunkText = (data: any) => {
    // let data = res.data;
    // console.log('getSeeResData:', data)
    // 兼容处理,真机返回的的是 ArrayBuffer
    if (data instanceof ArrayBuffer) {
      data = new Uint8Array(data)
    }
    let text = data
    // Uint8Array转码
    if (typeof data != 'string') {
      // 兼容处理  微信小程序不支持TextEncoder/TextDecoder
      try {
        console.log('lastData', lastData)
        text = decodeURIComponent(escape(String.fromCharCode(...lastData, ...data)))
        lastData = new Uint8Array()
      } catch (error) {
        text = ''
        console.log('解码异常', data)
        // Uint8Array 拼接
        let swap = new Uint8Array(lastData.length + data.length)
        swap.set(lastData, 0)
        swap.set(data, lastData.length)
        // lastData = lastData.concat(data)
        lastData = swap
      }
    }
    return text
  }
  • 目标二: string中抽取后端返回的结构化数据

HTTP Chunked在分段数据时; 会在原始数据上注入分段符data: , 这里的每一段相当于面包问题的每一袋

image.png

所以详细描述下目标二: 在收到散装面包时(怎么区分是不是散装还是完整的一袋呢), 缓存起来, 直到可以组成一袋, 在一次收到多袋时, 拆分一下.

真实处理是这样的,有点不一样的地方: 首先第一段是不处理的(因为无法判断是不是完整的), 直接缓存. 接下来,每一段数据返回时, 判断数据的最开始部分是不是分隔符data: ,如果是, 说明前面的内容是完整的(可能是一段,也可能多段,但是能袋装的),把数据拆分后返回(return)出去, 最后整个httpseccuss回调在把最后一段缓存返回出去.

上代码

 /**
   * 分段返回开始
   */
  const CHUNK_START = 'data:'
  /**
   * 分段返回中断
   */
  const SPLIT_WORD = '\ndata:'
  
    /**
   * 判断是否被拆分
   * @param text
   * @returns
   */
  const isStartString = (text: string) => {
    return text.substring(0, 5) == CHUNK_START
  }

  /**
   * 对被合并的多段请求拆分
   * @param text
   */
  const splitText = (text: string) => {
    return text
      .replaceAll(`\n\n${SPLIT_WORD}`, `\n${SPLIT_WORD}`)
      .replaceAll(`\n${SPLIT_WORD}`, `${SPLIT_WORD}`)
      .split(SPLIT_WORD)
      .filter((str) => !!str)
  }
  
    /**
   * 删除文本的开始的 data:
   * @param text
   * @returns
   */
  const removeStartText = (text: string) => {
    if (text.substring(0, CHUNK_START.length) == CHUNK_START) {
      return text.substring(CHUNK_START.length)
    }
    return text
  }
  
  /**
   * 返回数据集(返回数据)
   * @param res
   * @param onSuccess
   */
  const onChunkReceivedReturn = function (res: any) {
    let text = getChunkText(res)
    console.log('onChunkReceived', text)
    if (isStartString(text) && lastText) {
      // console.log("onSuccess", lastText);
      // onSuccess();
      let swap = lastText
      // 存储本次的数据
      lastText = text
      return splitText(removeStartText(swap))
    } else {
      lastText = lastText + text
    }
  }

至此: 对HTTP分段响应的解析就完成了, 填坑到此结束!

代码库分享

基于目前在社区还找不到在微信小程序上处理分段请求的库, 所以把这一块代码分享出来, 希望后续有伙伴在小程序内做AI对话需求时用得上!

luoanb/chunk-res: 微信小程序内实现http流式响应 (github.com)

chunk-res - npm (npmjs.com)

已经在公司内的项目中正常使用中, 在小程序上使用是很简单的

// Example:
const chunkRes = ChunkRes();
// can`t use ref() to save task; it will lost task info
const task = wx.request({
  //...other params
  enableChunked: true,
  success: (res) => {
    const lastResTexts: string[] | undefined = chunkRes.onComplateReturn();
    // dosomething
  },
});
task.onChunkReceived((res) => {
  const resTexts: string[] | undefined = chunkRes.onChunkReceivedReturn(
    res.data
  );
  // dosomething
});