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