关于解决 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 站分享一些你意想不到的知识。

相关推荐
鱼雀AIGC3 分钟前
如何仅用AI开发完整的小程序<6>—让AI对视觉效果进行升级
前端·人工智能·游戏·小程序·aigc·ai编程
duanyuehuan32 分钟前
Vue 组件定义方式的区别
前端·javascript·vue.js
veminhe36 分钟前
HTML5简介
前端·html·html5
洪洪呀36 分钟前
css上下滚动文字
前端·css
搏博1 小时前
基于Vue.js的图书管理系统前端界面设计
前端·javascript·vue.js·前端框架·数据可视化
掘金安东尼2 小时前
前端周刊第419期(2025年6月16日–6月22日)
前端·javascript·面试
bemyrunningdog2 小时前
AntDesignPro前后端权限按钮系统实现
前端
重阳微噪2 小时前
Data Config Admin - 优雅的管理配置文件
前端
Hilaku2 小时前
20MB 的字体文件太大了,我们把 Icon Font 压成了 10KB
前端·javascript·css
fs哆哆2 小时前
在VB.net中,文本插入的几个自定义函数
服务器·前端·javascript·html·.net