大模型流式输出实战:从SSE到Fetch API与ReadableStream的完整解析
1. 从“等待”到“流淌”:为什么我们需要流式输出?
如果你最近在捣鼓大语言模型(LLM)的应用,比如自己部署一个类似ChatGPT的聊天界面,一定会遇到一个核心体验问题: 等待感 。传统的API调用,比如一个普通的HTTP请求,是“一问一答”的模式。你发送问题,服务器端的大模型吭哧吭哧开始推理,生成全部文本,直到最后一个字写完,才一次性打包成完整的响应体,通过HTTP返回给你。这个过程,用户面对的是一个空白的输入框或一个旋转的加载图标,除了等待,别无他法。对于生成长文本的场景,这种等待可能是几十秒甚至几分钟,体验极其糟糕。
流式输出(Streaming Output)就是为了解决这个“等待”问题而生的。它的核心思想是“边生成,边发送,边渲染”。服务器不再等大模型生成完整的回答,而是模型每生成一个词元(token),服务器就立刻把这个词元发送给客户端。客户端在收到第一个数据块时,就可以立即开始渲染,让用户看到文字是逐字“流淌”出来的。这种即时反馈极大地提升了交互的流畅度和沉浸感,是当前AI应用前端体验的基石。
那么,这个“流淌”的过程在技术上是如何实现的呢?这背后是一套从前端到后端的完整技术链。在前端,我们主要和三个核心概念打交道:
SSE(Server-Sent Events)
、
Fetch API 与 ReadableStream
,以及用于处理二进制数据的
Uint8Array
。很多人可能用过类似
EventSource
的库来对接SSE,但在现代前端框架如 Vue 3 的生态下,尤其是在追求更精细控制和更好性能时,直接使用 Fetch API 来处理流式响应正成为更主流和灵活的选择。本文将从一个实践者的角度,拆解从建立连接到最终渲染的完整流程,并分享在 Vue 3 + Vite 项目中调试这些流式数据时,我踩过的坑和总结的技巧。
2. 协议层基石:理解 SSE 的本质与局限
当我们谈论大模型的流式输出时,SSE 是无法绕开的一个协议。很多人对它有个误解,认为它是一种全新的、复杂的黑科技。其实不然,SSE 本质上是一个轻量级的、基于 HTTP 的协议标准。它的设计非常简洁:在客户端和服务器之间建立一个 单向的、长久的 HTTP 连接 ,服务器可以通过这个连接,持续地向客户端推送数据。
SSE 的工作原理 可以这样理解:
- 客户端发起请求 :浏览器或前端应用向一个特定的服务器端点发送一个普通的 HTTP GET 请求。
-
服务器保持连接
:服务器收到请求后,并不立即关闭连接,而是将响应的
Content-Type设置为text/event-stream,并保持 TCP 连接打开。 -
服务器推送事件
:每当有新的数据需要发送时,服务器就向这个连接写入遵循特定格式的文本数据块。每个数据块称为一个“事件”(Event),格式大致如下:
注意,每个事件以两个换行符event: message data: {"content": "这是", "finish_reason": null} data: {"content": "一段", "finish_reason": null} event: message data: {"content": "流式", "finish_reason": null} data: {"content": "文本。", "finish_reason": "stop"}\n\n结束。data:行可以有多行,最终会拼接成一个完整的字符串。 -
客户端监听处理
:客户端通过
EventSourceAPI 或类似库监听这个连接。每当收到一个完整的事件块,就会触发一个message事件,开发者可以在事件回调中获取并处理data字段的内容。
为什么大模型场景常用 SSE?
-
简单易用
:对于标准的文本流推送,浏览器原生
EventSource使用起来非常简单。 -
自动重连
:
EventSource内置了连接断开后的自动重连机制。 - 文本友好 :SSE 设计上就是传输文本的,与大模型返回的 JSON 或纯文本数据天然契合。
然而,SSE 的局限性也很明显,这促使我们寻找更底层的方案:
-
仅支持 GET 请求
:
EventSource只能发起 GET 请求。而大模型的 API 调用,往往需要携带复杂的提示词(prompt)和参数,这些数据放在 GET 请求的 URL 查询字符串中非常笨重且有长度限制,更适合通过 POST 请求的 Body 发送。 -
协议与实现耦合
:
EventSource对响应格式有严格要求(text/event-stream),并且隐藏了底层连接细节。当我们需要更精细的控制(如自定义请求头、处理非标准格式、手动管理连接生命周期)时,它就力不从心了。 - 单向通信 :SSE 是服务器向客户端的单向通信。虽然对于单纯的输出流这没问题,但在一些需要双向交互验证的复杂场景下显得不足。
正是这些限制,让我们把目光投向了更基础、更强大的 Web API——Fetch。
3. 核心武器库:解剖 Fetch API、ReadableStream 与 Uint8Array
要突破 SSE 的限制,实现一个功能完备、可控性强的流式请求,我们需要深入下一层:使用 Fetch API 直接处理流式响应体。这是现代前端处理流数据的核心手段。
3.1 Fetch API:不仅仅是“获取”
Fetch API 提供了一个名为
fetch()
的全局方法,用于发起网络请求。它返回一个 Promise,该 Promise 在收到 HTTP 响应头后就会解析(resolve),解析的值是一个
Response
对象。关键在于,这个
Response
对象的
body
属性本身就是一个
ReadableStream
。
这意味着,我们不必等待整个响应体下载完毕,就可以开始读取数据。这对于大模型动辄几十KB甚至几MB的流式响应来说,是至关重要的性能优化。
一个发起流式 POST 请求的示例:
const response = await fetch('/api/chat/stream', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'gpt-3.5-turbo',
messages: [{ role: 'user', content: '你好,请介绍一下你自己。' }],
stream: true // 关键:告诉后端需要流式响应
})
});
// 此时,连接已建立,响应头已收到,但响应体(body)还在传输中
const readableStream = response.body;
// 接下来就可以处理这个流了
3.2 ReadableStream:数据流的抽象
ReadableStream
是 Web Streams API 的一部分,它代表了一个可读的二进制数据流。你可以把它想象成一根水管,数据像水一样从服务器端流过来。我们的任务是安装一个“水龙头”(reader)来接水。
处理
ReadableStream
的基本模式是:
-
获取一个读取器(
reader)。 -
循环调用
reader.read()方法。这个方法返回一个 Promise,当流中有数据可读时,Promise 解析并返回一个对象,包含两个属性:value(本次读取到的数据块,是一个Uint8Array) 和done(流是否已结束)。 -
处理
value,然后继续读取,直到done为true。
const reader = readableStream.getReader();
const decoder = new TextDecoder('utf-8'); // 用于将二进制数据解码成字符串
let accumulatedText = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
console.log('流式传输结束');
break;
}
// value 是一个 Uint8Array,需要解码
const chunk = decoder.decode(value, { stream: true });
accumulatedText += chunk;
// 这里可以更新UI,例如将 accumulatedText 设置到 Vue 的响应式变量中
console.log('收到数据块:', chunk);
}
} catch (error) {
console.error('读取流失败:', error);
} finally {
reader.releaseLock(); // 重要:释放读取器锁
}
注意
decoder.decode(value, { stream: true })
中的
{ stream: true }
选项。这告诉解码器,当前解码的数据可能是一个多字节字符(如中文)的一部分,不要抛出错误,等待后续数据到来再一起解码。这是处理流式文本时的一个关键细节,否则你可能会在控制台看到乱码或解码错误。
3.3 Uint8Array:二进制数据的容器
为什么
reader.read()
返回的
value
是
Uint8Array
,而不是直接的字符串?因为网络传输的本质是二进制字节流。
Uint8Array
是一个表示 8 位无符号整数数组的类型化数组,它是 JavaScript 中处理原始二进制数据的高效方式。
服务器发送的每一个数据块,在 TCP 层面都是一串字节。Fetch API 将这些字节原封不动地包装成
Uint8Array
交给我们。这样做的好处是:
- 保真 :保留了数据的原始格式,无论是纯文本、JSON 片段还是其他二进制数据(如图片碎片),前端都可以按需处理。
-
高效
:避免了不必要的字符串编码/解码开销,直到我们需要使用文本内容时,才用
TextDecoder进行解码。
一个常见的坑:数据块的分割与拼接
服务器端在写入流时,并没有义务保证每次写入的数据块恰好是一个完整的逻辑单元(比如一个完整的 JSON 对象或一句话)。它可能因为网络缓冲区、服务器端框架的实现等原因,将一次逻辑上完整的数据拆分成多个
Uint8Array
发送,也可能将多次逻辑上小的数据合并成一个大的
Uint8Array
发送。
例如,后端可能发送了这样的数据:
数据块1: `{"content": "Hello`
数据块2: `, world!", "finish": false}\n\n`
如果你对每个数据块都尝试
JSON.parse()
,那么在数据块1处就会抛出错误,因为它不是一个合法的 JSON。
解决方案是使用“缓冲区”和“分隔符”
。大模型流式接口通常使用
\n\n
或
\n
作为数据块之间的分隔符(即所谓的 “data: ...\n\n” 格式,或简化的 “{...}\n” 格式)。我们的前端逻辑需要将收到的二进制数据解码成字符串后,缓存在一个变量里,然后根据分隔符来分割出完整的逻辑数据块。
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = ''; // 文本缓冲区
while (true) {
const { done, value } = await reader.read();
if (done) break;
// 将二进制数据块解码并追加到缓冲区
buffer += decoder.decode(value, { stream: true });
// 按行分割缓冲区
const lines = buffer.split('\n');
// 最后一行可能是不完整的,保留在缓冲区中
buffer = lines.pop() || '';
for (const line of lines) {
const trimmedLine = line.trim();
if (!trimmedLine || trimmedLine === 'data: [DONE]') continue; // 忽略空行和结束标记
if (trimmedLine.startsWith('data: ')) {
const jsonStr = trimmedLine.substring(6); // 去掉 "data: " 前缀
try {
const data = JSON.parse(jsonStr);
// 处理真正的数据,如 data.choices[0].delta.content
console.log('解析后的数据:', data);
} catch (e) {
console.warn('解析JSON失败,可能是不完整数据:', jsonStr);
}
}
}
}
// 循环结束后,记得处理缓冲区中可能残留的最后一小段数据
if (buffer.trim()) {
console.warn('流结束,缓冲区仍有未处理数据:', buffer);
}
这段代码展示了一个健壮的流式数据解析器核心逻辑: 解码 -> 缓冲 -> 按分隔符分割 -> 逐条解析 。这是手动处理大模型流式响应时最需要理解和实现的部分。
4. 在 Vue 3 项目中构建流式对话组件
理解了底层原理,我们就可以在 Vue 3 项目中,构建一个真实的、可复用的流式对话组件。我们将使用
<script setup>
语法和 Composition API,并搭配 Vite 构建工具。
4.1 组件结构与状态设计
首先,我们设计组件的状态。一个典型的聊天界面需要:消息列表、当前用户输入、加载状态。
<!-- ChatStream.vue -->
<script setup>
import { ref, reactive } from 'vue';
// 消息列表,每个消息包含角色和内容
const messages = ref([
{ role: 'assistant', content: '你好!我是AI助手,有什么可以帮您?' }
]);
// 用户输入
const userInput = ref('');
// 是否正在流式接收中
const isLoading = ref(false);
// 当前助手消息的索引(用于追加流式内容)
const currentAssistantMessageIndex = ref(-1);
</script>
<template>
<div class="chat-container">
<div class="messages">
<div v-for="(msg, index) in messages" :key="index" :class="['message', msg.role]">
<strong>{{ msg.role === 'user' ? '你' : '助手' }}:</strong> {{ msg.content }}
</div>
<div v-if="isLoading" class="loading-indicator">思考中...</div>
</div>
<div class="input-area">
<textarea v-model="userInput" @keydown.enter.prevent="sendMessage" :disabled="isLoading"></textarea>
<button @click="sendMessage" :disabled="isLoading || !userInput.trim()">发送</button>
</div>
</div>
</template>
4.2 核心流式请求函数
接下来是核心的
sendMessage
函数。我们将使用 Fetch API 来处理流式响应。
<script setup>
// ... 其他状态定义
const sendMessage = async () => {
if (!userInput.value.trim() || isLoading.value) return;
const userMessage = userInput.value.trim();
userInput.value = ''; // 清空输入框
// 将用户消息添加到列表
messages.value.push({ role: 'user', content: userMessage });
// 为助手创建一个初始为空的消息占位
messages.value.push({ role: 'assistant', content: '' });
currentAssistantMessageIndex.value = messages.value.length - 1;
isLoading.value = true;
try {
const response = await fetch('http://your-backend-api/chat/stream', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
// 如果需要认证,可以在这里添加 Authorization 头
// 'Authorization': `Bearer ${yourToken}`
},
body: JSON.stringify({
model: 'gpt-3.5-turbo',
messages: [
...messages.value.slice(0, -1).map(m => ({ role: m.role, content: m.content })),
{ role: 'user', content: userMessage }
],
stream: true,
temperature: 0.7,
})
});
if (!response.ok || !response.body) {
throw new Error(`网络请求失败: ${response.status}`);
}
await handleStreamResponse(response);
} catch (error) {
console.error('请求出错:', error);
// 更新最后一条助手消息为错误信息
messages.value[currentAssistantMessageIndex.value].content = `抱歉,出错了: ${error.message}`;
} finally {
isLoading.value = false;
currentAssistantMessageIndex.value = -1;
}
};
const handleStreamResponse = async (response) => {
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
// 流结束,处理缓冲区可能残留的数据(如果有)
processBuffer(buffer);
break;
}
// 解码并追加到缓冲区
buffer += decoder.decode(value, { stream: true });
// 处理缓冲区中的完整行
buffer = processBuffer(buffer);
}
} catch (error) {
console.error('读取流时出错:', error);
throw error;
} finally {
reader.releaseLock();
}
};
const processBuffer = (buffer) => {
const lines = buffer.split('\n');
// 保留最后一行(可能不完整)作为新的缓冲区
const newBuffer = lines.pop() || '';
for (const line of lines) {
const trimmedLine = line.trim();
// 忽略空行和特殊事件行
if (!trimmedLine || trimmedLine.startsWith('event:')) continue;
if (trimmedLine === 'data: [DONE]') {
// 流式传输结束信号
return newBuffer;
}
if (trimmedLine.startsWith('data: ')) {
const jsonStr = trimmedLine.substring(6); // 移除 "data: " 前缀
try {
const parsed = JSON.parse(jsonStr);
// 假设后端返回格式类似 OpenAI API: { choices: [{ delta: { content: "..." } }] }
const contentDelta = parsed.choices?.[0]?.delta?.content;
if (contentDelta) {
// 关键:更新 Vue 响应式数据,触发视图更新
messages.value[currentAssistantMessageIndex.value].content += contentDelta;
}
// 也可以处理 finish_reason 等字段
if (parsed.choices?.[0]?.finish_reason) {
console.log('生成结束原因:', parsed.choices[0].finish_reason);
}
} catch (e) {
// 忽略解析错误,可能是不完整的 JSON 片段
console.warn('解析数据行失败,可能数据不完整:', trimmedLine);
}
}
}
return newBuffer;
};
</script>
这个实现包含了几个关键点:
- 错误处理 :对网络请求失败和流读取失败都进行了捕获。
-
缓冲区管理
:
processBuffer函数负责按\n分割并解析数据,正确处理了数据块可能被拆分或合并的情况。 -
响应式更新
:通过直接修改
messages.value[currentAssistantMessageIndex.value].content来追加内容,Vue 的响应式系统会自动检测到变化并更新 DOM,实现文字的逐字打印效果。 -
资源清理
:在
finally块中调用reader.releaseLock(),确保读取器被正确释放。
4.3 用户体验优化:添加打字机效果与中断控制
基本的流式展示已经完成,但我们可以做得更好。
打字机效果
:直接追加内容可能太快,可以添加一个简单的动画,模拟逐字打印。我们可以使用
setTimeout
或
requestAnimationFrame
来缓冲。
// 在 processBuffer 函数中,替换直接的内容追加
if (contentDelta) {
// 改为调用一个打字机函数
typewriterEffect(contentDelta);
}
const typewriterEffect = (text) => {
let i = 0;
const speed = 20; // 每个字符的间隔(毫秒)
const targetIndex = currentAssistantMessageIndex.value;
const type = () => {
if (i < text.length && targetIndex === currentAssistantMessageIndex.value) {
messages.value[targetIndex].content += text.charAt(i);
i++;
setTimeout(type, speed);
}
};
type();
};
注意:这个简单实现有个问题,如果流式数据到达很快,会创建很多定时器。更优的方案是将所有到达的
contentDelta先缓存在一个队列里,由一个统一的定时器消费。
中断控制 :用户可能在生成过程中希望停止。我们需要提供一个“停止”按钮,并能够中止网络请求。
<script setup>
// 新增一个 AbortController 用于中断请求
let abortController = null;
const sendMessage = async () => {
if (!userInput.value.trim() || isLoading.value) return;
// 创建新的 AbortController
abortController = new AbortController();
isLoading.value = true;
// ... 其他初始化代码
try {
const response = await fetch('http://your-backend-api/chat/stream', {
// ... 其他配置
signal: abortController.signal // 传入中断信号
});
// ... 处理响应
} catch (error) {
// 如果是主动中断,错误名称为 'AbortError'
if (error.name === 'AbortError') {
console.log('请求被用户中断');
messages.value[currentAssistantMessageIndex.value].content += '\n\n[已中断]';
} else {
// ... 其他错误处理
}
} finally {
// ... 清理工作
abortController = null;
}
};
// 停止生成函数
const stopGenerating = () => {
if (abortController) {
abortController.abort(); // 触发 AbortError
}
};
</script>
<template>
<!-- 在按钮区域添加一个停止按钮 -->
<div class="input-area">
<button @click="stopGenerating" v-if="isLoading">停止</button>
<!-- ... 其他按钮 -->
</div>
</template>
通过
AbortController
,我们实现了对正在进行的 Fetch 请求的控制,提升了应用的交互性。
5. Vue 3 + Vite 项目中的流式调试实战与避坑指南
在开发过程中,流式相关的 bug 往往比较隐蔽,因为涉及异步数据的分块到达和解析。下面分享我在 Vue 3 + Vite 项目中调试流式应用时总结的一套方法和遇到的典型问题。
5.1 调试工具与技巧
-
浏览器开发者工具 - 网络面板 :
-
查看请求
:确认你的 POST 请求是否成功发出,
Content-Type和body是否正确。 -
查看响应
:这是
最重要的调试步骤
。找到你的流式请求,点击它,在“响应”(Response)标签页里,你看到的不是最终结果,而是服务器持续推送过来的原始数据流。你可以在这里直观地看到数据格式是否是
data: {...}\n\n,是否有不该出现的字符,数据块是否完整。 - 复制响应体 :你可以将看到的原始响应数据复制出来,用于离线分析和单元测试。
-
查看请求
:确认你的 POST 请求是否成功发出,
-
浏览器开发者工具 - 控制台 :
-
在你的
handleStreamResponse和processBuffer函数中大量使用console.log。打印出原始的value(Uint8Array),打印出解码后的buffer,打印出分割后的每一行line,打印出尝试解析的jsonStr和解析结果parsed。通过日志流水线,你可以精准定位问题发生在哪个环节:是数据没收到?解码出错?分割不对?还是 JSON 格式不符?
-
在你的
-
构建一个模拟后端 : 在开发初期,或者后端接口不稳定时,在本地模拟一个流式接口极其有用。你可以用 Node.js 的
http模块或者 Express 快速搭建一个。// mock-server.js (使用 Express) import express from 'express'; const app = express(); app.use(express.json()); app.post('/api/mock-stream', (req, res) => { res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); const message = "这是一段模拟的流式响应。"; let index = 0; const intervalId = setInterval(() => { if (index < message.length) { const chunk = message.substring(index, index + 1); // 每次发送一个字 const data = JSON.stringify({ choices: [{ delta: { content: chunk } }] }); res.write(`data: ${data}\n\n`); index++; } else { res.write('data: [DONE]\n\n'); clearInterval(intervalId); res.end(); } }, 50); // 每50毫秒发送一个字 }); app.listen(3001, () => console.log('Mock server running on port 3001'));这样,你就可以完全控制返回的数据格式和速度,前端代码可以独立于真实后端进行开发和调试。
5.2 常见问题与解决方案
问题一:控制台报错 “Unexpected token < in JSON at position 0”
-
现象
:在
JSON.parse(jsonStr)时抛出错误,提示 JSON 不合法。 -
排查
:
-
检查网络面板的响应体。很可能服务器返回的不是你期望的 JSON 流,而是一个 HTML 页面(比如 404 或 500 错误页)。响应体的开头是
<html>或<!DOCTYPE>。 -
检查请求 URL 和代理配置(Vite 的
server.proxy)是否正确,确保请求真正到达了后端流式接口,而不是静态文件服务器。
-
检查网络面板的响应体。很可能服务器返回的不是你期望的 JSON 流,而是一个 HTML 页面(比如 404 或 500 错误页)。响应体的开头是
-
解决
:修正 API 地址或代理配置。确保后端接口正确设置了
Content-Type: text/event-stream并返回了正确的流式数据。
问题二:中文显示乱码或出现“�”字符
- 现象 :接收到的文本中,中文变成了乱码或问号方块。
-
排查
:
-
检查
TextDecoder的编码是否设置为'utf-8'。这是目前 Web 标准的通用编码。 -
关键
:确认
decoder.decode(value, { stream: true })中传入了{ stream: true }选项。没有这个选项,当Uint8Array恰好在一个多字节字符(如一个中文字符占3个字节)的中间被截断时,解码会失败并输出替换字符(�)。 - 检查服务器端编码是否也是 UTF-8。
-
检查
-
解决
:确保正确使用
TextDecoder和stream: true选项。
问题三:数据接收不完整或拼接错误
- 现象 :UI 上显示的文字缺失、重复,或者 JSON 解析频繁失败。
-
排查
:
-
在
processBuffer函数中打印每一行trimmedLine,观察数据格式是否严格符合data: {...}。有时服务器可能多发了空格、少了换行,或者使用了不同的前缀(如"data: "和"data:")。 -
检查你的缓冲区逻辑。确保
lines.pop()正确地保留了不完整的最后一行。在流结束后的finally块或done为true时,检查buffer变量是否还有未处理的数据。 -
服务器可能使用了
\r\n作为换行符。使用buffer.split(/\r?\n/)可以同时兼容\n和\r\n。
-
在
-
解决
:根据服务器实际返回的格式,调整数据分割和前缀剥离的逻辑。增强解析器的容错性,比如尝试匹配
trimmedLine.match(/^data:\s*(.+)/)来提取数据部分。
问题四:Vue 响应式更新不及时或性能问题
- 现象 :文字不是逐字出现,而是卡顿一下然后大段出现;或者界面在流式接收时变得很卡。
-
排查
:
-
更新频率过高
:如果每个 token(可能是一个字或一个词)都触发一次 Vue 的响应式更新和 DOM 渲染,对于长文本来说频率太高了。可以尝试“节流”,累积一定数量的字符(比如10个)或固定时间间隔(比如50毫秒)再更新一次
message.content。 -
使用了深度监视或计算属性
:如果组件中对
messages数组使用了watch且设置了deep: true,或者有依赖它的复杂计算属性,每次内容追加都会触发昂贵的计算。
-
更新频率过高
:如果每个 token(可能是一个字或一个词)都触发一次 Vue 的响应式更新和 DOM 渲染,对于长文本来说频率太高了。可以尝试“节流”,累积一定数量的字符(比如10个)或固定时间间隔(比如50毫秒)再更新一次
-
解决
:
- 实现一个简单的节流更新:
let updateBuffer = ''; let updateTimer = null; const scheduleUpdate = (delta) => { updateBuffer += delta; if (!updateTimer) { updateTimer = setTimeout(() => { messages.value[currentAssistantMessageIndex.value].content += updateBuffer; updateBuffer = ''; updateTimer = null; }, 50); // 每50毫秒批量更新一次 } }; // 在 processBuffer 中,用 scheduleUpdate(contentDelta) 替换直接的内容追加-
审查组件中的
watch和computed,避免不必要的深度监听。
问题五:在开发热重载(HMR)时流连接异常
- 现象 :使用 Vite 开发时,每次保存文件触发热更新后,之前的流式连接可能不会自动关闭,导致内存泄漏或错误。
-
排查
:Vite 的 HMR 会重新执行模块代码,但不会自动清理之前组件实例中创建的
AbortController或持有的ReadableStreamreader。 -
解决
:在组件的
onUnmounted生命周期钩子中执行清理操作。<script setup> import { onUnmounted } from 'vue'; // ... 其他代码 onUnmounted(() => { if (abortController) { abortController.abort(); } // 如果有 reader,也需要考虑释放锁,但通常 reader 会在请求结束时释放。 // 更稳妥的做法是在发送新请求前,检查并中断旧请求。 }); </script>
流式输出的实现,是一个融合了网络协议、数据流处理和前端框架响应的综合课题。从理解 SSE 的局限,到掌握 Fetch + ReadableStream 的底层操作,再到在 Vue 3 中构建出稳定、流畅的用户界面,每一步都需要对细节的精准把控。调试的过程,就是与不稳定的网络、非常规的数据分块和框架的响应式机制不断博弈的过程。希望本文拆解的原理、提供的代码范例和总结的避坑经验,能帮助你顺利地将“流淌”的 AI 智慧,优雅地呈现在你的下一个应用之中。
更多推荐
所有评论(0)