效果
为什么用长连接?
实时交互需求
用户与大模型交互时,希望能实时看到回答的生成过程,长连接可让后端将生成的内容逐段实时传给前端,实现类似 “边生成边显示” 的效果,提升交互体验。
应对大数据量传输
大模型回答可能包含大量数据,长连接能在一次连接中持续传输,避免短连接因频繁建立和断开导致的性能损耗,保证数据传输的稳定性和完整性。
这里使用的是通过fetch结合ReadableStream实现长连接
不是websocket,区别如下:
1. 协议层面
fetch
+ ReadableStream
:本质上基于 HTTP 协议。HTTP 是一种无状态的协议,虽然可以通过流式传输数据来模拟长连接,但它仍然是基于请求 - 响应模型,客户端发起请求,服务器返回响应,只是响应数据可以分块逐步传输。
WebSocket:基于 WebSocket 协议,它是一种独立的、双向的网络协议,在 TCP 协议之上运行。WebSocket 连接一旦建立,就可以在客户端和服务器之间进行全双工通信,即双方可以同时发送和接收数据,无需像 HTTP 那样每次通信都要发起新的请求。
2. 连接建立过程
fetch
+ ReadableStream
:客户端通过 fetch
API 发起一个 HTTP POST 请求,服务器接收到请求后,开始返回响应数据。如果服务器支持流式传输,就会持续不断地发送数据块,客户端通过 ReadableStream
逐步读取这些数据。
WebSocket:客户端通过 WebSocket
构造函数创建一个 WebSocket 实例,向服务器发起一个特殊的 HTTP 升级请求(HTTP Upgrade request),请求将连接从 HTTP 协议升级到 WebSocket 协议。如果服务器同意升级,双方就会建立一个 WebSocket 连接。
3. 数据传输方式
fetch
+ ReadableStream
:数据传输是单向的,通常是客户端发起请求,服务器返回数据。数据以文本形式传输,通常使用特定的格式(如 Server-Sent Events 格式)来区分不同的事件和数据块。
WebSocket:支持双向数据传输,客户端和服务器可以随时向对方发送数据。数据可以是文本或二进制格式,开发者可以根据需要灵活选择。
4. 实时性和性能
fetch
+ ReadableStream
:由于基于 HTTP 协议,存在一定的延迟,特别是在处理大量数据或频繁交互时。每次请求都需要携带 HTTP 头部信息,增加了数据传输的开销。
WebSocket:实时性更好,因为它是全双工通信,没有请求 - 响应的延迟。而且 WebSocket 连接建立后,后续的数据传输只需要携带少量的协议头部信息,减少了数据传输的开销,性能更高。
5. 错误处理和重连机制
fetch
+ ReadableStream
:错误处理相对复杂,需要处理 HTTP 状态码、网络错误等多种情况。重连机制需要开发者手动实现,当连接中断时,需要重新发起 fetch
请求。
WebSocket:提供了内置的错误处理和重连机制。当连接出现错误或断开时,会触发相应的事件(如 onerror
、onclose
),开发者可以监听这些事件并实现重连逻辑。
6. 适用场景
fetch
+ ReadableStream
:适用于一次性请求,需要逐步接收大量数据的场景,如文件下载、服务器推送实时日志等。
WebSocket:适用于需要实时双向通信的场景,如聊天应用、实时游戏、实时数据监控等。
如何实现?
1. 数据接收与预处理
在 handleEvent
方法中,当接收到 answer
事件时,对事件数据进行预处理。使用正则表达式替换特殊字符,将 \\n
替换为 \n
,并把连续的换行符统一为 \n\n
。
2. 内容更新与缓冲
将处理后的数据添加到 completeMarkdown
中,该变量用于存储完整的 Markdown 内容。同时,将处理后的数据添加到 answerBuffer
中,answerBuffer
作为一个缓冲区,暂存待渲染的新内容。
3.防抖渲染机制
为避免频繁渲染影响性能,使用防抖技术。每次接收到新数据时,清除之前设置的定时器 this.renderTimer
,并重新设置一个 100ms 的定时器。如果在 100ms 内没有新数据到达,则调用 renderMarkdown
方法进行渲染。(但是随着时间间隔增大生成答案的效果会变得更卡顿,在意生成流畅度可以不适用定时器改用其他性能优化手段)
4. 强制渲染条件
通过 shouldForceRender
方法判断当前接收到的数据是否包含特定的强制渲染标记(如 ###
、##
、\n\n
、。
、!
、?
)。如果包含这些标记,则立即调用 renderMarkdown
方法进行渲染,确保在关键位置及时更新页面。
5. 渲染与更新 DOM
在 renderMarkdown
方法中,使用 this.md.render
方法将完整的 Markdown 内容渲染为 HTML。
使用 this.$nextTick
确保在 DOM 更新后执行后续操作。更新 answerMarkdown
变量,将渲染后的 HTML 内容赋值给它,从而更新页面显示。
清空 answerBuffer
缓冲区,为下一次渲染做准备。
调用 scrollToBottom
方法将页面滚动到底部,确保用户能看到最新的内容。
示例:
<div class="contentText" v-if="chat.type !== 'user'">
<div
class="thinking"
v-html=thinkingMarkdown
></div>
<div v-html=answerMarkdown></div>
</div>
export default {
data() {
return {
thinkingMarkdown: '',
answerMarkdown: '',
answerBuffer: '',
completeMarkdown: '', // 存储完整的 markdown
renderTimer: null,
md: null
}
},
methods: {
handleEvent(event) {
if (event.name === 'answer') {
// 1. 处理数据
const normalizedData = event.data
.replace(/\\n/g, '\n')
.replace(/\r?\n\r?\n/g, '\n\n');
// 2. 更新完整内容
this.completeMarkdown += normalizedData;
// 3. 添加到缓冲区
this.answerBuffer += normalizedData;
// 4. 使用防抖进行渲染
clearTimeout(this.renderTimer);
this.renderTimer = setTimeout(() => {
this.renderMarkdown();
}, 100); // 100ms 的防抖时间
// 5. 强制在特定标记处渲染
if (this.shouldForceRender(normalizedData)) {
this.renderMarkdown();
}
}
},
shouldForceRender(text) {
// 定义需要强制渲染的标记
const forceRenderMarkers = [
'###',
'##',
'\n\n',
'。',
'!',
'?'
];
return forceRenderMarkers.some(marker => text.includes(marker));
},
renderMarkdown() {
if (!this.answerBuffer) return;
try {
// 渲染新内容
const renderedContent = this.md.render(this.completeMarkdown);
this.$nextTick(() => {
// 更新 DOM
this.answerMarkdown = renderedContent;
// 清空缓冲区
this.answerBuffer = '';
// 滚动到底部(如果需要)
this.scrollToBottom();
});
} catch (error) {
console.error('Markdown 渲染错误:', error);
}
},
scrollToBottom() {
const element = this.$el.querySelector('.left');
if (element) {
element.scrollTop = element.scrollHeight;
}
}
},
// 组件销毁时清理
beforeDestroy() {
if (this.renderTimer) {
clearTimeout(this.renderTimer);
}
}
}
6.总结
- 接收事件数据,对其预处理,替换特殊字符。
- 将处理后的数据添加到完整内容存储处和缓冲区。
- 用防抖机制,若 100ms 无新数据则渲染,含特定标记时强制渲染。
- 渲染 Markdown 为 HTML,更新 DOM 并清空缓冲区,滚动页面到底部。
- 组件销毁时清除定时器防内存泄漏。