前端流式输出大模型回答时为什么使用长连接,具体怎么实现流式输出markdown文本

效果 

为什么用长连接?

实时交互需求

用户与大模型交互时,希望能实时看到回答的生成过程,长连接可让后端将生成的内容逐段实时传给前端,实现类似 “边生成边显示” 的效果,提升交互体验。

应对大数据量传输

大模型回答可能包含大量数据,长连接能在一次连接中持续传输,避免短连接因频繁建立和断开导致的性能损耗,保证数据传输的稳定性和完整性。     

这里使用的是通过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:提供了内置的错误处理和重连机制。当连接出现错误或断开时,会触发相应的事件(如 onerroronclose),开发者可以监听这些事件并实现重连逻辑。

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.总结

  1. 接收事件数据,对其预处理,替换特殊字符。
  2. 将处理后的数据添加到完整内容存储处和缓冲区。
  3. 用防抖机制,若 100ms 无新数据则渲染,含特定标记时强制渲染。
  4. 渲染 Markdown 为 HTML,更新 DOM 并清空缓冲区,滚动页面到底部。
  5. 组件销毁时清除定时器防内存泄漏。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值