分享一个小程序内使用wx.request
的enableChunked
模式做AI对话的经历.最后会把用来解析分段响应的库分享给大家. 在此之前, 我在小程序中还没有找到任何一个可以把onChunkReceived
内容正常解析的库.
希望快速解决小程序AI对话的伙伴, 直接跳到代码库分享即可, 如果想看看具体的实现, 可以慢慢浏览
背景
前端要实现AI对话, 主流有两种方案, 一个是使用websocket
, 另外一个是使用http
的transfer-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的原因, 其实这不是微信开发者的锅, 我试过在浏览器时使用fetch
的Chunked
模式, 返回的结果一样一样的. 微信只是没有对Chunked
模式内容做进一步的封装, 把这个问题处理好, 但如果在浏览器上
, 就可以找到很多可用的第三方库处理好这些问题.
填坑
- 目标一: 解决方式自然是把
ArrayBuffer
转为string
; 这里有两个小目标:ArrayBuffer
转Uint8Array
:Uint8Array
转string
首先判断是ArrayBuffer
时, 先把它转为Uint8Array
,这一步没有问题;
然后重点来了: 是Uint8Array
转string
, 这里就会遇到上面提到的面包问题了,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:
, 这里的每一段相当于面包问题的每一袋
所以详细描述下目标二: 在收到散装面包时(怎么区分是不是散装还是完整的一袋呢), 缓存起来, 直到可以组成一袋, 在一次收到多袋时, 拆分一下.
真实处理是这样的,有点不一样的地方: 首先第一段是不处理的(因为无法判断是不是完整的), 直接缓存. 接下来,每一段数据返回时, 判断数据的最开始部分是不是分隔符data:
,如果是, 说明前面的内容是完整的(可能是一段,也可能多段,但是能袋装的),把数据拆分后返回(return
)出去, 最后整个http
的seccuss
回调在把最后一段缓存返回出去.
上代码
/**
* 分段返回开始
*/
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)
已经在公司内的项目中正常使用中, 在小程序上使用是很简单的
// 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
});