前后端流式交互的几种方式

最近在用AI做练手项目,做的最多的就属聊天助手了,是的,用聊天助手做聊天助手,我做了个很多个版本,有基于electron的,有基于pyqt5的,还有基于web的,web的有用flask框架的,还有用fastapi的,尝试下来,发现做聊天助手要解决的无非下面几个问题,1. 流式交互。2. markdown格式解析。markdown解析有现成的库,我看代码都差不多,无非是把AI返回的回答用markdown解析模块转换一下变成html,再把html显示出来。当然本地客户端的写法和web可能不太一样,因为像pyqt5的控件本身就支持markdown格式,所以压根就不用转,只不过显示的样式没有web的好看,所以,想要好看的话,最好是基于html的界面。然后流式交互我发现就有点意思了,这里面涉及到很多方式,下面对这些方式做一下总结。

  1. electron。electron前端是html+css+js这些,然后,后端是node,其实也是js,但是像这种封装度比较高的框架,前后端的交互往往都是写好的,基本就是发消息,动态交互的话,依赖已经写好的包,electron一般有三个主要文件,一个main.js,主要是后端的逻辑,然后有个preload.js,负责前后端的绑定,还有一个是render.js,主要负责前端的逻辑,然后electron的流式交互主要是通过event实现的,每当大模型返回一点内容,就使用event对象向一个消息接收函数发送,具体代码如下
javascript 复制代码
ipcMain.handle('api-query', async (event, prompt) => {
  try {
    const stream = await client.chat.completions.create(
      {
        messages: [{ role: 'user', content: prompt }],
        model: 'deepseek-ai/DeepSeek-V3-0324',
        stream: true,
      }
    )
    let fullResponse = '';
    for await (const chunk of stream) {
      var content = chunk.choices[0]?.delta?.content || '';
      //content = content.replace(/\s/g, '');
      fullResponse += content;
      event.sender.send('stream-chunk', content);
      console.log(content);
    }
    return fullResponse;
  } catch (error) {
    console.error('API Error:', error);
    throw { message: `API req failed: ${error.message}` };
  }
});

这里是main.js里的一段代码,是负责响应页面的发送消息按钮的消息的,也就是说,页面点击发送,后台就会调用这个函数,我们可以看到,当用户点击发送自己的问题,那么后台就开始接收用户的问题,prompt,同时进来的还有个event参数,后面当遍历模型的回复时,每当收到一个回复,就调用event.sender.send(),像界面的一个函数,发送一条内容,然后再看下绑定代码preload.js

javascript 复制代码
contextBridge.exposeInMainWorld('electronAPI', {
  marked: marked,
  sendQuery: (prompt) => ipcRenderer.invoke('api-query', prompt),
  receiveStreamChunk: (callback) => ipcRenderer.on('stream-chunk', (event, chunk) => callback(chunk))
});

event对象是向stream-chunk函数发送的消息,这里看起来比较绕,首先,receiveStreamChunk是前端的一个函数,然后这个函数的参数还是个函数,当收到名字为stream-chunk的消息时,调用callback函数处理这个消息。再看下前端的receiveStreamChunk函数

javascript 复制代码
window.electronAPI.receiveStreamChunk((chunk) => {
        if(currentResponseDiv) {
          buffer += chunk || '';
          content = marked.parse(buffer)
          currentResponseDiv.innerHTML = content;
          chatContainer.scrollTop = chatContainer.scrollHeight;
        }
      });

当然这里面没什么特别的,只是用了匿名函数的写法,其实把函数单独摘出来也是一样的道理,这里可能是为了方便,所以js里面由于好多参数都是回调函数,所以会比较绕,用法也基本是固定的了,只是需要一定的适应,至于说为什么非要再套一层,这个就是js的特点了,js里面全是异步,只要涉及到异步,就要套个回调函数,而不是直接传参。

  1. pyqt5的桌面程序。其实桌面程序大体的编程逻辑都有些相似,上面说了electron,其实pyqt5也是差不多的,pyqt5主要通过信号和槽的机制在不同线程中传递消息,信号其实就是给消息起个名字,防止混乱,槽就是接收消息的函数,因为桌面程序界面本来就占一个线程,而调用大模型时比较耗时,如果直接在界面类里写就会阻塞界面导致界面一卡一卡的,所以,一般情况下,比较耗时的操作需要单独开一个线程,然后通过信号和槽和界面通信,这样就不会阻塞界面,或者在每次收到回复的后面加一句QApplication.processEvents() # 更新UI,这个方法也行,但是不如用线程优雅,或者如果代码较多的话,还是建议单独写一个线程。

用pyqt5的话除了使用现成的控件以外,还有一种是使用web控件,就是界面直接用html+css+js编写,这种方式就跟electron几乎是一致的,只不过用的后端语言不同,前后端的通信模式也很类似,只不过python不强调异步,所以感觉上理解上要比js好懂。看下代码

python 复制代码
class Worker(QThread):
    def __init__(self, api_client,config,bridge,user_input):
        super().__init__()
        self.api_client = api_client
        self.config = config
        self.user_input = user_input
        self.bridge = bridge
        self.conversation = []
    
    def run(self):
        full_response = ""
        self.conversation.append({"role": "user", "content": self.user_input})
        for chunk in self.api_client.chat_completion_stream(
            self.conversation,
            model=self.config["model"],
        ):
            # 收集流式内容
            self.bridge.sendMessage(chunk)

Worker类主要负责调用大模型api,获取回答,然后发送给界面,api_client是调用大模型的api,config主要是调用大模型的一些参数配置,比如模型名称,温度,最大token数等,唯一需要注意就是bridge了,这个bridge就是python和前端js通信的桥梁,其实和electron的preload.js做的事差不多,就是绑定前端和后端的函数和消息,看下bridge的代码

python 复制代码
class Bridge(QObject):
    messageReceived = pyqtSignal(str)
    messageSent = pyqtSignal(str)
    @pyqtSlot(str)
    def handleMessage(self, message):
        print("message",  message)

        #self.messageSent.emit(f"{1}")
        self.messageReceived.emit(message)
    
    @pyqtSlot(str)
    def sendMessage(self, message):
        self.messageSent.emit(message)

它是一个类,这个类是必须的,只要前端用浏览器控件,你想要和前端js通信,都要写这么一个类,类的名字其实无所谓,叫什么都行,最主要的是里面的信号和函数,负责接收和发送消息,跟前端进行通信,xxx.emit(xx)就是发送消息。再看下前端怎么接收消息的

javascript 复制代码
window.pyObj.messageSent.connect(function(message) {
                if (current_msg_div) {
                    all_content += message || '';
                    html_content = marked.parse(all_content);
                    if (all_content){
                        current_msg_div.innerHTML = html_content;
                        hljs.highlightAll();
                        window.scrollTo(0, document.body.scrollHeight);
                    }
                    

                }
            });

熟悉pyqt5的小伙伴都知道,在pyqt5使用通过connect函数来绑定消息和消息处理函数的,在js里,因为习惯用匿名函数,所以写法上保持了js的特点,直接就在参数那写函数逻辑了,其实单独写个函数,然后在连接这写函数名也是可以的。然后这里的window.pyobj,其实就是刚刚的Bridge类的实例对象,前面有个绑定的过程

javascript 复制代码
window.pyObj = channel.objects.pyObj;

然后pyqt5官方提供了一个叫qwebchannel.js的文件,它就是负责js和python通信的,细节咱就不研究了,凡是前端需要导入这个js代码。这个就是用pyqt5+webengineview实现的桌面版聊天应用,因为用到webengineview,它其实相当于给软件集成了浏览器,所以打包出来非常大,好处是可以用html写界面,可用的库是非常多的。

  1. 还有一种就是web了,前端还是html+css+js,网站由于是和服务器通信,这块的流式交互就有很多方法,我看ai实现的主要由两种,一种是用fetch+reader的方式(kimi k2),一种是用eventsource方式,这两种都是js内部实现的,不需要第三方模块,还有一个是fetcheventsource,微软的一个实现,这个和eventsource不同的是它可以用post方式请求,eventsource只支持get方式看下fetch+reader的方式代码
javascript 复制代码
async function sendMessage(message) {
            if (!message.trim()) return;

            // 添加用户消息到界面
            addMessage(message, true);
            messages.push({ role: 'user', content: message });
            
            // 清空输入框并禁用发送按钮
            chatInput.value = '';
            sendButton.disabled = true;
            
            showTyping();

            try {
                const response = await fetch('http://localhost:8000/api/chat', {
                    method: 'POST',
                    headers: {
                        'Content-Type': 'application/json',
                    },
                    body: JSON.stringify({
                        messages: messages,
                        model: modelSelect.value,
                        temperature: 0.7,
                        max_tokens: 65536
                    })
                });

                if (!response.ok) {
                    throw new Error('服务器响应错误');
                }

                const reader = response.body.getReader();
                const decoder = new TextDecoder();
                let assistantMessage = '';
                const assistantContentDiv = addMessage('');

                while (true) {
                    const { done, value } = await reader.read();
                    if (done) break;

                    const chunk = decoder.decode(value);
                    const lines = chunk.split('\n');

                    for (const line of lines) {
                        if (line.startsWith('data: ')) {
                            const data = line.slice(6);
                            if (data === '[DONE]') {
                                messages.push({ role: 'assistant', content: assistantMessage });
                                hideTyping();
                                sendButton.disabled = false;
                                return;
                            }

                            try {
                                const parsed = JSON.parse(data);
                                if (parsed.content) {
                                    assistantMessage += parsed.content;
                                    htmlContent = marked.parse(assistantMessage);
                                    //assistantContentDiv.textContent = assistantMessage;
                                    assistantContentDiv.innerHTML = htmlContent;
                                    hljs.highlightAll();
                                    chatMessages.scrollTop = chatMessages.scrollHeight;
                                }
                            } catch (e) {
                                console.error('解析响应数据失败:', e);
                            }
                        }
                    }
                }
            } catch (error) {
                console.error('发送消息失败:', error);
                hideTyping();
                showError('发送消息失败,请检查网络连接或稍后再试。');
                sendButton.disabled = false;
            }
        }

但是不知道什么情况,我做的这个项目,在多次问答后,前端就莫名的卡死,不知道为什么,不知道是不是这种方式有bug,后端我打印,都是没问题的,回答已经很快的返回,但是前端就是不显示,用console.log打印,也是卡。eventsource因为只能用get方式请求,需要改后端代码,就没测,fetcheventsource需要安装第三方包,所以也没测,我太懒了。。。

好的,上面就是近期AI项目总结的几种前后端流式交互的总结,写的比较乱,有问题的小伙伴可以留言。

相关推荐
葡萄城技术团队2 分钟前
Claude Code:AI编程的深度体验与实践
ai编程
Jackson_Mseven5 分钟前
Instruction Tuning & RLHF:大侠成长之路的“武功秘籍”
人工智能·chatgpt·llm
人工智能训练师8 分钟前
Fay数字人如何使用GPT-SOVITS进行TTS转换以及遇到的一些问题
人工智能·gpt·语音识别
eric-sjq9 分钟前
0.08B参数以小博大:用小模型生成媲美GPT-4o的古典诗词
人工智能
数据知道12 分钟前
百度翻译详解:包括PaddleNLP、百度AI开放平台、接口逆向(包括完整代码)
人工智能·百度·语言模型·自然语言处理·机器翻译
舒一笑35 分钟前
企业AI落地不顺问题可能出在你没搞懂知识库
人工智能
涛思数据(TDengine)1 小时前
可信数据库大会现场,TDengine 时序数据库展示核电场景下的高性能与 AI 创新
大数据·运维·数据库·人工智能·时序数据库·tdengine·涛思数据
不剪发的Tony老师1 小时前
NeoBase:一款开源、基于AI的数据库管理助手
数据库·人工智能·neobase
一碗白开水一1 小时前
【YOLO系列】YOLOv12详解:模型结构、损失函数、训练方法及代码实现
人工智能·深度学习·yolo·计算机视觉