你有没有好奇过:为什么在 ChatGPT 或 DeepSeek 的对话框里敲下一个问题,回答是一个字一个字"蹦"出来的,而不是像普通网页请求那样转半天然后"啪"一下全部出现?
这背后藏着一个前端开发者在 AI 时代必须掌握的技术------流式输出(Streaming) 。今天我们不堆砌概念,而是从浏览器拿到数据的那一刻开始,一步步拆解"流"到底是怎么流起来的。
一、先搞懂一个问题:LLM 为什么"慢"
在理解流式输出之前,得先明白大语言模型(LLM)的"慢"从哪来。
LLM 生成回答的过程,本质上是一个逐 token 的自回归过程 。所谓 token,可以粗浅理解为"最小的文本单元"------可能是一个汉字、一个英文单词的一部分,甚至是一个标点符号。模型每生成一个 token,都要把它拼回已有的上下文,再预测下一个 token 是什么。这个过程涉及大量的矩阵运算,参数量越大、回答越长,耗时就越久。
如果等模型把全部内容生成完再一次性返回,用户等待的时间就是 N 个 token 的推理时间总和 。而流式输出的思路极其简单:不用等全部生成完,生成一个 token 就给你一个 token 。
这就是流式输出的核心逻辑。
二、HTTP 协议层到底发生了什么
客户端在请求体里带上 stream: true,相当于告诉服务器:"我不要等你全部做完再回复,你边做边给我就行。"服务端收到这个参数后,不会等到全部内容生成完毕才写入响应体,而是每推理出一个 token 就立刻写入。
text
arduino
// 请求体中的关键参数
{
model: "deepseek-v4-flash",
messages: [{ role: "user", content: "讲一个中国龙的故事" }],
stream: true // ← 就是这个开关
}
在 HTTP 协议层面,这依赖于 分块传输编码(Chunked Transfer Encoding) 。服务器不需要提前知道响应体的总长度,可以一边生成数据一边把数据块发给客户端。浏览器这边,则通过一套叫作 Web Streams API 的机制来接收这些陆续到达的数据块。
三、浏览器端的武器:ReadableStream
当你用 fetch 发起一个 stream: true 的请求后,返回的 response 对象里藏着一个关键属性------response.body。
javascript
php
const response = await fetch(endpoint, {
method: "POST",
headers: { /* ... */ },
body: JSON.stringify({
model: "deepseek-v4-flash",
messages: [{ role: "user", content: question.value }],
stream: true
})
});
console.log(response.body); // ReadableStream 对象
response.body 是一个 ReadableStream 对象 。你可以把它理解成一根"水管"------服务器那边源源不断地往管子里送水(数据),浏览器这边负责从管子的另一头接水。
拿到 ReadableStream 之后,我们要做两件事:
- 获取一个"读取器"(reader) ,用来从水管里一口一口"嘬"数据
- 循环读取,直到服务器说"数据发完了"
javascript
ini
const reader = response.body.getReader(); // 拿到读取器[reference:13]
let done = false;
while (!done) {
const { value, done: doneReading } = await reader.read();
done = doneReading;
// value 就是这一口"嘬"到的数据
}
reader.read() 返回一个 Promise,resolve 之后得到一个对象,包含两个字段:
value:本次读取到的数据块,类型是Uint8Arraydone:布尔值,true表示数据已经全部读完
四、Uint8Array 与 TextDecoder:二进制怎么变回文字
这里有一个关键问题:网络传输的底层是字节流 。reader.read() 拿到的 value 是一个 Uint8Array------也就是一堆 0 到 255 之间的数字组成的数组。
但我们要显示的是中文、英文、标点符号,不是数字。所以需要一个解码器,把这些字节还原成字符串。
javascript
scss
const decoder = new TextDecoder(); // 创建一个 UTF-8 解码器[reference:16]
// 在循环里:
const chunk = decoder.decode(value); // 把 Uint8Array 转成字符串
content.value += chunk; // 拼到已有的内容后面
TextDecoder 支持流式解码 ------也就是说,如果一个字符的字节被拆到了两个数据块里(比如一个中文汉字在 UTF-8 下可能占 3 个字节),TextDecoder 能正确处理这种跨块的情况,不会出现乱码。
五、SSE 数据格式:一行一行解析
DeepSeek API 的流式响应遵循 SSE(Server-Sent Events) 格式。简单说,服务器每次发送一个数据块,格式是这样的:
text
css
data: {"id":"chatcmpl-xxx","choices":[{"delta":{"content":"在"}}]}
data: {"id":"chatcmpl-xxx","choices":[{"delta":{"content":"遥远"}}]}
data: {"id":"chatcmpl-xxx","choices":[{"delta":{"content":"的"}}]}
data: [DONE]
每一行以 data: 开头,后面跟着一个 JSON 字符串。delta 字段里装着本次新增的内容(也就是新生成的那个 token)。最后一行是 data: [DONE],表示全部发送完毕。
所以在前端,我们不能直接把 decoder.decode(value) 的结果拼到页面上------需要先按行解析,提取出 delta.content,再拼进去。
javascript
ini
let buffer = ""; // 缓存上一次未处理完的数据
while (!done) {
const { value, done: doneReading } = await reader.read();
done = doneReading;
// 把新数据追加到缓冲区
const chunkValue = buffer + decoder.decode(value);
buffer = "";
// 按换行符拆分成多行
const lines = chunkValue.split("\n").filter(line => line.startsWith("data: "));
for (const line of lines) {
const jsonStr = line.replace("data: ", "");
if (jsonStr === "[DONE]") {
done = true;
break;
}
try {
const parsed = JSON.parse(jsonStr);
const deltaContent = parsed.choices?.[0]?.delta?.content;
if (deltaContent) {
content.value += deltaContent; // 逐字拼接到页面上
}
} catch (e) {
// 忽略解析错误(可能是不完整的 JSON)
}
}
}
这里有一个容易被忽视的细节:网络数据块不保证按"行"对齐 。也就是说,一次 reader.read() 拿到的数据可能只包含半行,也可能包含好几行。所以我们用了一个 buffer 变量来缓存上一次没处理完的尾巴,等下一轮数据来了再拼起来一起解析。
六、在 Vue 3 里串起来
有了上面的基础,我们把它放进 Vue 3 的组件里。
vue
ini
<script setup>
import { ref } from "vue";
const question = ref("讲一个中国龙的故事");
const content = ref("");
const useStream = ref(true);
const update = async () => {
if (!question.value) return;
content.value = "思考中...";
const response = await fetch("https://api.deepseek.com/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`
},
body: JSON.stringify({
model: "deepseek-v4-flash",
messages: [{ role: "user", content: question.value }],
stream: useStream.value
})
});
if (useStream.value) {
content.value = "";
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let done = false;
while (!done) {
const { value, done: doneReading } = await reader.read();
done = doneReading;
const chunkValue = buffer + decoder.decode(value);
buffer = "";
const lines = chunkValue.split("\n").filter(line => line.startsWith("data: "));
for (const line of lines) {
const jsonStr = line.replace("data: ", "");
if (jsonStr === "[DONE]") {
done = true;
break;
}
try {
const parsed = JSON.parse(jsonStr);
const delta = parsed.choices?.[0]?.delta?.content;
if (delta) content.value += delta;
} catch (_) {}
}
}
} else {
// 非流式模式:一次性拿到全部结果
const data = await response.json();
content.value = data.choices[0].message.content;
}
};
</script>
这里用了 Vue 3 的 ref 来定义响应式数据。content.value 每变化一次,页面上绑定了 {{ content }} 的地方就会自动更新------这就是 Vue 数据驱动视图 的核心机制。
七、热更新:开发阶段的"局部刷新"
文章最后顺带提一下笔记里提到的热更新(Hot Reload) 。
在开发阶段,我们用的是 Vite 这样的构建工具。当我们修改了 App.vue 的代码,Vite 会检测到文件变化,然后只替换发生变化的那部分模块,而不是刷新整个页面。
这意味着什么?你在页面上输入了一段话、展开了一个菜单、滚动到了某个位置------这些页面状态不会因为代码修改而丢失。如果是传统的"保存→刷新页面"流程,所有状态都会重置,你就得重新操作一遍才能回到刚才的调试位置。
Vue/React 的响应式数据状态往往"密密麻麻"地分布在各个组件里,热更新让开发者能保持当前的调试上下文,效率提升非常明显。
总结
流式输出的完整链路可以概括为四步:
- 客户端 在请求中设置
stream: true,告诉服务器"我要流式数据" - 服务端每生成一个 token 就通过 HTTP 分块传输发出来,格式为 SSE
- 浏览器 通过
response.body.getReader()逐块读取数据 - 前端 用
TextDecoder解码、按行解析 SSE、提取delta.content后逐字渲染
这四个环节环环相扣,少一个都跑不起来。掌握了这条链路,你就掌握了 AI 时代前端与 LLM 交互的核心基本功。