前后端流式交互的几种方式
最近在用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对象向一个消息接收函数发送,具体代码如下
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
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函数
window.electronAPI.receiveStreamChunk((chunk) => {if(currentResponseDiv) {buffer += chunk || '';content = marked.parse(buffer)currentResponseDiv.innerHTML = content;chatContainer.scrollTop = chatContainer.scrollHeight;}});
当然这里面没什么特别的,只是用了匿名函数的写法,其实把函数单独摘出来也是一样的道理,这里可能是为了方便,所以js里面由于好多参数都是回调函数,所以会比较绕,用法也基本是固定的了,只是需要一定的适应,至于说为什么非要再套一层,这个就是js的特点了,js里面全是异步,只要涉及到异步,就要套个回调函数,而不是直接传参。
2. pyqt5的桌面程序。其实桌面程序大体的编程逻辑都有些相似,上面说了electron,其实pyqt5也是差不多的,pyqt5主要通过信号和槽的机制在不同线程中传递消息,信号其实就是给消息起个名字,防止混乱,槽就是接收消息的函数,因为桌面程序界面本来就占一个线程,而调用大模型时比较耗时,如果直接在界面类里写就会阻塞界面导致界面一卡一卡的,所以,一般情况下,比较耗时的操作需要单独开一个线程,然后通过信号和槽和界面通信,这样就不会阻塞界面,或者在每次收到回复的后面加一句QApplication.processEvents() # 更新UI,这个方法也行,但是不如用线程优雅,或者如果代码较多的话,还是建议单独写一个线程。
用pyqt5的话除了使用现成的控件以外,还有一种是使用web控件,就是界面直接用html+css+js编写,这种方式就跟electron几乎是一致的,只不过用的后端语言不同,前后端的通信模式也很类似,只不过python不强调异步,所以感觉上理解上要比js好懂。看下代码
class Worker(QThread):def __init__(self, api_client,config,bridge,user_input):super().__init__()self.api_client = api_clientself.config = configself.user_input = user_inputself.bridge = bridgeself.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的代码
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)就是发送消息。再看下前端怎么接收消息的
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类的实例对象,前面有个绑定的过程
window.pyObj = channel.objects.pyObj;
然后pyqt5官方提供了一个叫qwebchannel.js的文件,它就是负责js和python通信的,细节咱就不研究了,凡是前端需要导入这个js代码。这个就是用pyqt5+webengineview实现的桌面版聊天应用,因为用到webengineview,它其实相当于给软件集成了浏览器,所以打包出来非常大,好处是可以用html写界面,可用的库是非常多的。
3. 还有一种就是web了,前端还是html+css+js,网站由于是和服务器通信,这块的流式交互就有很多方法,我看ai实现的主要由两种,一种是用fetch+reader的方式(kimi k2),一种是用eventsource方式,这两种都是js内部实现的,不需要第三方模块,还有一个是fetcheventsource,微软的一个实现,这个和eventsource不同的是它可以用post方式请求,eventsource只支持get方式看下fetch+reader的方式代码
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项目总结的几种前后端流式交互的总结,写的比较乱,有问题的小伙伴可以留言。