【Vue 3 实战】如何优雅地处理大模型 SSE 流式数据:从 ReadableStream 到逐 token 渲染
引言
随着 ChatGPT、DeepSeek 等大语言模型(LLM)的爆发,AI 对话类的前端需求激增。在开发这类 AI 聊天应用时,传统的 HTTP 请求-响应模式体验极差——用户需要傻等几十秒才能看到长篇大论的回复。
为了实现类似官方那样的“打字机”效果(逐字输出),我们需要前端能够处理流式数据(Streaming)。目前主流的方案是基于 SSE(Server-Sent Events)。
本文将结合 Vue 3 (Composition API) + TypeScript,带你从零手撕一个高性能的流式对话渲染模块,彻底搞懂 ReadableStream 与 TextDecoder 的底层玩法。
一、为什么不用 Axios?
很多初学者遇到流式请求,第一反应是去查 Axios 的文档。虽然 Axios 最新版本也支持了流式处理,但在浏览器环境下,原生 fetch API 搭配 ReadableStream 才是处理大模型 SSE 最轻量、最原生的“王道”解法。
传统的接口是等所有数据生成完毕后一次性返回,而大模型接口通常会在 Response Headers 中加上 Content-Type: text/event-stream,这意味着数据会分成多个 Chunk 持续不断地推给前端。
二、核心实现:从 Fetch 到逐 Token 解析
在 Vue 3 中,我们通过响应式变量 ref 来接收这些碎片的 token,界面的更新由 Vue 的响应式系统自动完成。
1. 基础代码结构
我们在 <script setup> 中编写核心逻辑。这里的关键点在于:不要一次性接收,而是用一个 while 循环不断读取数据流。
<template>
<div class="chat-container" ref="chatBoxRef">
<div class="message">
<span v-html="parsedContent"></span>
<span v-if="isGenerating" class="cursor">|</span>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, nextTick } from 'vue';
// 状态定义
const content = ref<string>('');
const isGenerating = ref<boolean>(false);
const chatBoxRef = ref<HTMLElement | null>(null);
// 如果需要解析 Markdown,可以在这里加计算属性
const parsedContent = computed(() => {
// 假设这里引入了 marked 库:return marked.parse(content.value);
return content.value;
});
const fetchAiResponse = async (prompt: string) => {
isGenerating.value = true;
content.value = ''; // 清空上一次的回复内容
try {
const response = await fetch('YOUR_AI_API_ENDPOINT', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
// 'Authorization': 'Bearer YOUR_TOKEN'
},
body: JSON.stringify({ messages: [{ role: 'user', content: prompt }] })
});
if (!response.ok || !response.body) {
throw new Error('网络请求失败或不支持 ReadableStream');
}
// 1. 获取 reader
const reader = response.body.getReader();
// 2. 实例化 TextDecoder,用于将 Uint8Array 转换为字符串
const decoder = new TextDecoder('utf-8');
// 3. 循环读取流数据
while (true) {
const { done, value } = await reader.read();
// 如果数据读取完毕,跳出循环
if (done) {
break;
}
// 4. 解码当前 chunk
const chunkText = decoder.decode(value, { stream: true });
// 5. 拼接到响应式变量中,触发视图更新
// 注意:实际业务中,大模型返回的 chunkText 可能是 JSON 字符串,需要根据具体 API 结构做 JSON.parse
content.value += chunkText;
// 6. 核心 UX 优化:自动滚动到底部
scrollToBottom();
}
} catch (error) {
console.error('流式请求出错了:', error);
content.value += '\n[网络异常,请重试]';
} finally {
isGenerating.value = false;
}
};
// 自动滚动逻辑
const scrollToBottom = async () => {
await nextTick(); // 确保 DOM 已经根据最新 content 渲染完毕
if (chatBoxRef.value) {
chatBoxRef.value.scrollTop = chatBoxRef.value.scrollHeight;
}
};
</script>
<style scoped>
.chat-container {
height: 400px;
overflow-y: auto;
padding: 20px;
background-color: #f5f5f5;
border-radius: 8px;
}
.cursor {
animation: blink 1s step-end infinite;
font-weight: bold;
}
@keyframes blink {
50% { opacity: 0; }
}
</style>
三、进阶踩坑与优化指南(必看)
虽然上面的代码能跑通,但在企业级项目中,你大概率会遇到以下几个坑:
坑点 1:流式数据被截断导致 JSON 解析报错
大模型通过 SSE 返回的数据格式通常是:data: {"id":"123","choices":[{"delta":{"content":"你好"}}]}\n\n。 因为网络传输原因,一个 Chunk 并不一定包含完整的一行 JSON。如果你直接 JSON.parse(chunkText),极大概率会报错。
解决方案: 需要维护一个 buffer(缓冲区),按换行符 \n 对字符串进行分割,确保每次丢给 JSON.parse 的都是一个完整的 JSON 对象。目前社区比较成熟的轻量级方案是引入 @microsoft/fetch-event-source 库,它已经帮你封装好了底层的断线重连和流拼接逻辑。
坑点 2:长文本生成的渲染性能问题
当模型一次性输出几千字,且前端使用了 marked.js + highlight.js 进行 Markdown 和代码块高亮解析时,如果每次接收到一个 token 就全量解析一次,会导致非常严重的性能开销(卡顿、发热)。
解决方案:
-
防抖渲染(节流):不需要每一个 token 都触发一次复杂的 Markdown 全量渲染,可以利用
requestAnimationFrame或者定时器控制渲染频率(如 50ms 一次)。 -
虚拟列表:如果你在一个页面中维持着几十轮对话,DOM 节点数量会爆炸。强烈建议引入
vue-virtual-scroller这类虚拟列表渲染方案,只渲染可视区域内的消息气泡。
坑点 3:中断生成机制
用户在等待模型输出一半时,可能觉得内容不对,想要立刻停止生成。 解决方案: 利用 AbortController。在 fetch 请求中传入 signal,当用户点击“停止生成”按钮时,调用 controller.abort() 即可切断数据流,避免浪费昂贵的 API token 额度。
四、总结
在 Vue 3 中处理大模型流式对话,本质上是对浏览器原生流 API (ReadableStream) 的理解与应用。结合 Vue 的 Composition API,我们可以非常优雅地将复杂的流处理逻辑抽离成可复用的 useChatStream Hook。
如果你正在开发 AI 相关的 Web 应用,熟练掌握流式渲染、DOM 自动滚动跟踪以及中断控制,将是你区别于普通前端的重要壁垒。
如果这篇文章对你有帮助,欢迎点赞收藏!有关于前端接入大模型 API 的问题,也欢迎在评论区与我交流探讨!
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)