关于解决 ChatGPT 流数据处理的bug

前言

我们废话不多说,今天主要和大家分享下我昨天修复的关于 chatGPT 流数据处理的一个bug,这个 bug 其实是一个偶现的 bug,因此一开始我就没管,(又不是不能用对吧?),但是后面发现这个 bug 出现的频率还比较高,所以还是决定解决一下,下面是解决过程。

复现 bug

下面是这个 bug 的截图。首先做下知识铺垫,使用 SseEmitter 给浏览器发送消息时,浏览器拿到的数据是会带着 data: 你发的内容,比如你发送的内容是 'hello',那么浏览器收到的消息就是 'data:hello'.

因为为了实现打字机的效果,后端是一个字一个字返回的,前端也是一个字一个字处理,下面是具体的代码。

后端逐字发送代码如下:

java 复制代码
private boolean printMessage(String reply) {
    try {
        char[] messages = reply.toCharArray();
        for (char message : messages) {
            TimeUnit.MILLISECONDS.sleep(20);
            if (!sendData2Client(String.valueOf(message))){
                return false;
            }
        }
        return true;
    } catch (InterruptedException e) {
        log.error("ResponseBody读取错误");
        e.printStackTrace();
        return false;
    }
}

具体使用 SseEmitter 的发送代码如下,这个代码后面有用,会细讲,大家先混个眼熟。

java 复制代码
private boolean sendData2Client(String data) {
    try {
        String text = "{" + data + "}";
        emitter.send(SseEmitter.event().data(text));
        return true;
    } catch (IOException e) {
        log.error("向客户端发送消息时出现异常");
        e.printStackTrace();
    }
    return false;
}

前端逐字处理代码:

js 复制代码
export async function requestChatStream(  
    req: ChatRequest,  
    options?: {  
        onMessage: (message: string, done: boolean) => void;  
        onError: (error: Error, statusCode?: number) => void;  
    }  
) {  
    try {  
        const res = await fetch(`${baseURL}/chat/${req.type}/legacy`, {  
            method: 'POST',  
            headers: {  
                'Content-Type': 'application/json',  
            },  
            body: JSON.stringify({  
                userId: `${Cookies.get('openbytecode-chat-id')}`,  
            }),  
        });  

        let responseText = '';  

        if (res.ok) {  
            const reader = res.body?.getReader();  
            const decoder = new TextDecoder();  

            while (true) {  
                const content = await reader?.read();  
                if (!content || !content.value) {  
                    break;  
                }  

                const text = decoder.decode(content.value, { stream: true });  
                console.log('sssssssssssssss', text);
                const value = extractData(text);
                responseText += value;  
                const done = content.done;  
                options?.onMessage(responseText, false);  
                if (done) {  
                    break;  
                }  
            }   
        } else if (res.status === 401) {  
            console.error('Unauthorized');  
            options?.onError(new Error('Unauthorized'), res.status);  
        } else {  
            console.error('Stream Error', res.body);  
            options?.onError(new Error('Stream Error'), res.status);  
        }  
    } catch (err) {  
        console.error('NetWork Error', err);  
        options?.onError(err as Error);  
    }  
}

下面是抽取 data 的代码,对应后端的 String text = "{" + data + "}";,至于为什么后端要在返回数据中加入 {},现在你只需要记住这个 {} 有大用处,我们一会再说。

js 复制代码
/**  
* 抽取 data  
* @param content  
*/  
export const extractData = (content: string) => {  
    let index1 = content.indexOf('{');  
    let index2 = content.lastIndexOf('}');  
    return content.substring(index1 + 1, index2);  
};

所以接下来就是复现这个的 bug,通过日志来找出问题。下面是通过打印日志得到的截图。

分析 bug

根据我们输出的日志,可以明显发现,decoder.decode(content.value, { stream: true }) 这玩意得到的内容并不是我们想的那样每次解析只有一个字,而是有时会出现多个字一起返回,这不就是出现了粘包嘛,由于后端数据的粘包,导致前端在处理时只截取了开头和结尾的 {}, 所以就出现了这个 bug,好了,问题找到了,下面我们就考虑如何解决。

解决 bug

其实,这个是后端粘包的问题,但是,我要说但是了,我们前端也能处理(因为后端还不知道该如何处理,头大),前端处理其实也比较简单,因为我们知道后端有时会出现粘包,那我们就将解码之后将内容拆成数组,代码如下。

js 复制代码
export async function requestChatStream(  
    req: ChatRequest,  
    options?: {  
        onMessage: (message: string, done: boolean) => void;  
        onError: (error: Error, statusCode?: number) => void;  
    }  
) {  
    try {  
        const res = await fetch(`${baseURL}/chat/${req.type}/legacy`, {  
            method: 'POST',  
            headers: {  
                'Content-Type': 'application/json',  
            },  
            body: JSON.stringify({  
                userId: `${Cookies.get('openbytecode-chat-id')}`,  
            }),  
        });  

        let responseText = '';  

        if (res.ok) {  
            const reader = res.body?.getReader();  
            const decoder = new TextDecoder();  

            while (true) {  
                const content = await reader?.read();  
                if (!content || !content.value) {  
                    break;  
                }  

                const text = decoder.decode(content.value, { stream: true });  
                const values = text.split('data:').filter((v) => v);  
  
                for (let i = 0; i < values.length; i++) {  
                    const data = extractData(values[i]);  
                    responseText += data;  
                }

                const done = content.done;  
                options?.onMessage(responseText, false);  
                if (done) {  
                    break;  
                }  
            }   
        } else if (res.status === 401) {  
            console.error('Unauthorized');  
            options?.onError(new Error('Unauthorized'), res.status);  
        } else {  
            console.error('Stream Error', res.body);  
            options?.onError(new Error('Stream Error'), res.status);  
        }  
    } catch (err) {  
        console.error('NetWork Error', err);  
        options?.onError(err as Error);  
    }  
}

好了,最后来说下为什么要加 {},其实就是为了方便处理返回数据的空格与换行问题,作用很大,如果仅仅是将数据处理拆成数据,那么最后你可能看到就是一竖排内容。最后再说下为什么不直接使用 fetch-event-source,因为通过查看 fetch-event-source 的源码,他底层使用的也是 fetch,索性我们也想到了解决办法,所以就先不换了,还有人可能会问,为什么不用 EventSource,因为 EventSource 不支持 post 请求😂。

最后

如果喜欢我的文章,欢迎关注我,同时也欢迎关注我的个人网站,里面有多个我一直迭代的项目, 还有我的 b 站,我会定期在 b 站分享一些你意想不到的知识。

相关推荐
让开,我要吃人了2 小时前
HarmonyOS开发实战(5.0)实现二楼上划进入首页效果详解
前端·华为·程序员·移动开发·harmonyos·鸿蒙·鸿蒙系统
everyStudy3 小时前
前端五种排序
前端·算法·排序算法
甜兒.4 小时前
鸿蒙小技巧
前端·华为·typescript·harmonyos
Jiaberrr8 小时前
前端实战:使用JS和Canvas实现运算图形验证码(uniapp、微信小程序同样可用)
前端·javascript·vue.js·微信小程序·uni-app
everyStudy8 小时前
JS中判断字符串中是否包含指定字符
开发语言·前端·javascript
城南云小白8 小时前
web基础+http协议+httpd详细配置
前端·网络协议·http
前端小趴菜、8 小时前
Web Worker 简单使用
前端
web_learning_3218 小时前
信息收集常用指令
前端·搜索引擎
tabzzz8 小时前
Webpack 概念速通:从入门到掌握构建工具的精髓
前端·webpack
200不是二百8 小时前
Vuex详解
前端·javascript·vue.js