流式输出通过设置stream:true参数,使服务器按token逐次生成并发送数据。浏览器端利用ReadableStream流式读取,配合TextDecoder解码,按SSE协议解析每个消息中的delta.content字段,并逐字渲染到页面,从而实现AI回复内容逐字出现的效果。
有没有想过:为什么在 ChatGPT 或 DeepSeek 的对话框里敲下一个问题,回答是一个字一个字“蹦”出来的,而不是像普通网页请求那样转半天然后“啪”一下全部出现?

长期稳定更新的攒劲资源: >>>点此立即查看<<<
这背后藏着的前端技术,在 AI 时代正变得越来越关键——流式输出(Streaming)。今天不堆砌概念,就从浏览器拿到数据的那一刻开始,一步步拆解“流”到底是怎么流起来的。
在理解流式输出之前,需要先明白大语言模型(LLM)的“慢”从哪来。
LLM 生成回答的过程,本质上是一个逐 token 的自回归过程。所谓 token,可以粗浅理解为“最小的文本单元”——可能是一个汉字、一个英文单词的一部分,甚至是一个标点符号。模型每生成一个 token,都要把它拼回已有的上下文,再预测下一个 token 是什么。这个过程涉及大量的矩阵运算,参数量越大、回答越长,耗时就越久。
如果等模型把全部内容生成完再一次性返回,用户等待的时间就是 N 个 token 的推理时间总和。而流式输出的思路极其简单:不用等全部生成完,生成一个 token 就给你一个 token。
这就是流式输出的核心逻辑。
流式输出能跑起来,靠的是前后端之间的一个约定。
客户端在请求中设置 stream: true,相当于告诉服务器:“我不要等你全部做完再回复,你边做边给我就行。”服务端收到这个参数后,不会等到全部内容生成完毕才写入响应体,而是每推理出一个 token 就立刻写入。
text
复制代码// 请求体中的关键参数
{
model: "deepseek-v4-flash",
messages: [{ role: "user", content: "讲一个中国龙的故事" }],
stream: true // ← 就是这个开关
}
在 HTTP 协议层面,这依赖于 分块传输编码(Chunked Transfer Encoding)。服务器不需要提前知道响应体的总长度,可以一边生成数据一边把数据块发给客户端。浏览器这边,则通过一套叫作 Web Streams API 的机制来接收这些陆续到达的数据块。
当用 fetch 发起一个 stream: true 的请求后,返回的 response 对象里藏着一个关键属性——response.body。
ja vascript
复制代码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 之后,要做两件事:
ja vascript
复制代码const reader = response.body.getReader(); // 拿到读取器
let done = false;
while (!done) {
const { value, done: doneReading } = await reader.read();
done = doneReading;
// value 就是这一口“嘬”到的数据
}
reader.read() 返回一个 Promise,resolve 之后得到一个对象,包含两个字段:
value:本次读取到的数据块,类型是 Uint8Arraydone:布尔值,true 表示数据已经全部读完这里有一个关键问题:网络传输的底层是字节流。reader.read() 拿到的 value 是一个 Uint8Array——也就是一堆 0 到 255 之间的数字组成的数组。
但需要显示的是中文、英文、标点符号,不是数字。所以需要一个解码器,把这些字节还原成字符串。
ja vascript
复制代码const decoder = new TextDecoder(); // 创建一个 UTF-8 解码器
// 在循环里:
const chunk = decoder.decode(value); // 把 Uint8Array 转成字符串
content += chunk; // 拼到已有的内容后面
TextDecoder 支持流式解码——也就是说,如果一个字符的字节被拆到了两个数据块里(比如一个中文汉字在 UTF-8 下可能占 3 个字节),TextDecoder 能正确处理这种跨块的情况,不会出现乱码。
DeepSeek API 的流式响应遵循 SSE(Server-Sent Events) 格式。具体来说,服务器每次发送一个数据块,格式是这样的:
text
复制代码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,再拼进去。
ja vascript
复制代码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 += deltaContent; // 逐字拼接到页面上
}
} catch (e) {
// 忽略解析错误(可能是不完整的 JSON)
}
}
}
这里有一个容易被忽视的细节:网络数据块不保证按“行”对齐。也就是说,一次 reader.read() 拿到的数据可能只包含半行,也可能包含好几行。所以用了一个 buffer 变量来缓存上一次没处理完的尾巴,等下一轮数据来了再拼起来一起解析。
有了上面的基础,将其集成到 Vue 3 组件里。
vue
复制代码
这里用了 Vue 3 的 ref 来定义响应式数据。content.value 每变化一次,页面上绑定了 {{ content }} 的地方就会自动更新——这就是 Vue 数据驱动视图的核心机制。
最后补充一个开发中的小技巧:热更新(Hot Reload)。
在开发阶段,使用的是 Vite 这样的构建工具。当修改了 App.vue 的代码,Vite 会检测到文件变化,然后只替换发生变化的那部分模块,而不是刷新整个页面。
这意味着什么?页面上输入的一段话、展开的一个菜单、滚动到的某个位置——这些页面状态不会因为代码修改而丢失。如果是传统的“保存→刷新页面”流程,所有状态都会重置,就得重新操作一遍才能回到刚才的调试位置。
Vue/React 的响应式数据状态往往“密密麻麻”地分布在各个组件里,热更新让开发者能保持当前的调试上下文,效率提升非常明显。
流式输出的完整链路可以概括为四步:
stream: true,告诉服务器“我要流式数据”response.body.getReader() 逐块读取数据TextDecoder 解码、按行解析 SSE、提取 delta.content 后逐字渲染这四个环节环环相扣,少一个都跑不起来。掌握了这条链路,就掌握了 AI 时代前端与 LLM 交互的核心基本功。
侠游戏发布此文仅为了传递信息,不代表侠游戏网站认同其观点或证实其描述