流式输出完全指南:让 AI 对话像打字机一样流畅
摘要 :为什么 ChatGPT 能逐字显示回答,而其他 AI 要等半天才蹦出结果?流式输出(Streaming)是 LLM 产品体验的关键技术。本文从 Vue 3 前端项目出发,拆解
ReadableStream、getReader()、TextDecoder、SSE 数据格式和 Buffer 缓冲机制,带你掌握 AI 对话"逐字显示"的完整实现。
📑 目录
- 流式输出是什么?AI 对话的"打字机"效果
- Vue 3 基础:组件化与数据绑定
- 流式输出的约定:stream: true
- ReadableStream:数据流的"水管"模型
- reader.read():数据驱动的循环节拍器
- TextDecoder:二进制转文本的解码器
- SSE 格式与 DONE 结束标志
- Buffer 缓冲:解决数据块被切割的问题
- 完整流式读取流程
- 一点总结
- 互动讨论
流式输出是什么?AI 对话的"打字机"效果
使用 ChatGPT 或 DeepSeek 时,你会发现回答是逐字逐句 显示出来的,像打字机一样。这就是流式输出(Streaming) 。
| 对比维度 | 非流式(stream: false) |
流式(stream: true) |
|---|---|---|
| 返回方式 | LLM 生成完整结果后,一次性返回 | LLM 生成一点,就传输一点 |
| 用户体验 | 等待完整响应,然后突然显示 | 逐字显示,体验流畅 |
| 网络传输 | 单次大块传输 | 多次小块传输(分块) |
| 前端处理 | response.json() 直接解析 |
response.body 流式读取 + 解码 |
同步处理时,耗时主要来自 Transformer 推理和问题复杂度。如果让用户一直等待,体验会很差。流式输出的优化思路很简单:不用一次性给出,而是逐个 token 推理生成,实时展示。
就像在 LLM 和客户端之间接了一根"水管",生成的 token 像水流一样不断流向客户端。前端工程师需要做的,就是在这根"水管"上安装读取器,把水接住并展示出来。
Vue 3 基础:组件化与数据绑定
在开始流式输出之前,先理解 Vue 3 的基础概念。Vue 3 是前端第二流行的框架(React 是第一),它们都具备三个核心思想:组件化、数据绑定、响应式。
组件化:页面的最小工作单元
传统开发以 HTML 标签为最小单位,但标签太小、太多,难以维护。Vue 把 .vue 文件作为组件------一个组件封装了 HTML、CSS、JS 三部分,形成一个可复用、好维护的业务单元。
.vue 文件的三部分
html
xml
<template>
<!-- 模板:动态 HTML,支持数据绑定 -->
</template>
<script setup>
// 逻辑:数据和行为
</script>
<style>
/* 样式:作用于当前组件 */
</style>
数据绑定与响应式
Vue 的核心是数据驱动------你只需要修改数据,页面自动更新。三种数据绑定语法:
| 语法 | 用途 | 数据流向 |
|---|---|---|
{{ }} |
内容区显示数据 | 单向:JS → 页面 |
: / v-bind |
标签属性绑定 | 单向:JS → 属性 |
v-model |
表单双向绑定 | 双向:JS ↔ 页面 |
v-model 是 Vue 中最常用的指令之一。它让表单输入和 JS 数据保持同步------用户在输入框中打字,数据自动更新;数据被修改,输入框自动刷新。这就是"数据驱动"的核心:我们只需要关注数据,界面会自动跟随。
html
ini
<input v-model="question" />
在 <script setup> 中,使用 ref 声明响应式数据:
javascript
csharp
import { ref } from 'vue'
const question = ref('讲一个关于龙的故事')
const content = ref('')
const stream = ref(true)
ref 把普通值包装成响应式对象,修改时用 .value,模板中会自动解包。
流式输出的约定:stream: true
调用 LLM API 时,客户端和服务端通过一个约定来开启流式输出:
javascript
php
const response = await fetch(endpoint, {
method: 'POST',
headers,
body: JSON.stringify({
model: 'deepseek-v4-flash',
messages: [{ role: 'user', content: question.value }],
stream: stream.value // ← 约定字段:true 开启流式,false 关闭
})
});
- 客户端约定 :发送
stream: true,表示期望接收流式响应 - 服务端约定 :接受
stream: true,token 一生成就输出,而不是等全部生成完
当 stream: true 时,fetch 返回的 response.body 是一个 ReadableStream 对象,而不是完整的 JSON。
ReadableStream:数据流的"水管"模型
当 stream: true 时,服务器不一次性传完所有数据,而是通过 response.body 这个可读流(ReadableStream) 逐块发送。
javascript
vbscript
console.log(response.body);
// 输出:ReadableStream { locked: false, ... }
ReadableStream 就像一个从服务器通向浏览器的水管 ,数据以分块(chunk) 的形式不断流过来。要读取这些数据,需要在水管上安装一个"水龙头":
javascript
ini
const reader = response.body?.getReader();
console.log(reader);
// 输出:ReadableStreamDefaultReader { ... }
getReader() 创建一个 ReadableStreamDefaultReader 对象,它提供了 read() 方法来逐块读取数据。
reader.read():数据驱动的循环节拍器
reader.read() 是流式读取的核心 API:
javascript
csharp
const { value, done } = await reader.read();
关键认知 :reader.read() 返回一个 Promise,而 await 让循环的每一次迭代都由"数据块的到达"来驱动,而不是由 CPU 时钟驱动。
| 场景 | 行为 |
|---|---|
| 数据还没到 | await reader.read() 暂停循环,让出主线程,浏览器可以继续响应用户操作、渲染页面 |
| 数据块到达 | 浏览器底层网络进程唤醒 JS 引擎,await 恢复,循环继续执行 |
| 数据处理慢 | ReadableStream 内部队列积压数据,触发背压机制,自动降低网络传输速度 |
一句话总结:
await让"数据到达"成为循环的"节拍器"。
javascript
ini
let done = false;
while (!done) {
const { value, done: doneReading } = await reader.read();
done = doneReading;
// 处理 value...
}
value 是一个 Uint8Array ------ 二进制数据的数组,每个元素是 0~255 之间的整数。
TextDecoder:二进制转文本的解码器
网络传输的数据是二进制(Uint8Array),需要转换为字符串才能使用。
javascript
ini
const decoder = new TextDecoder();
const chunk = decoder.decode(value, { stream: true });
TextDecoder 的原理可以用一个简单的示例说明:
javascript
ini
const encoder = new TextEncoder();
const bytes = encoder.encode("你好");
console.log(bytes); // Uint8Array(6)
const decoder = new TextDecoder();
const str = decoder.decode(bytes);
console.log(str); // "你好"
关键参数 { stream: true } :表示当前块可能是不完整的,解码器会保留状态,跨块处理多字节字符(如中文 UTF-8 字符被切成两半时)。如果省略这个参数,跨块的中文字符可能解码失败,出现乱码。
SSE 格式与 DONE 结束标志
LLM 的流式响应使用 SSE(Server-Sent Events) 格式:
text
css
data: {"choices":[{"delta":{"content":"你"}}]}
data: {"choices":[{"delta":{"content":"好"}}]}
data: [DONE]
每一行以 data: 开头,后面跟着 JSON 字符串。delta.content 字段包含本次新增的 token 内容。最后以 data: [DONE] 表示结束。
解析流程:
javascript
ini
const payload = line.slice(6); // 去掉 "data: " 前缀
if (payload === '[DONE]') {
done = true;
break;
}
const json = JSON.parse(payload);
const delta = json.choices[0].delta?.content || '';
if (delta) {
content.value += delta; // 逐字拼接到页面绑定的变量
}
Buffer 缓冲:解决数据块被切割的问题
为什么需要 buffer?
网络数据块(chunk)是任意切割的,可能把一个完整的 SSE 消息切成两半:
text
swift
块1: "data: {"choices":[{"delta":{"content":"你"}}]}\n\ndata: {"choices":[{"delta":{"content":"好"
块2: ""}}]}\n\ndata: [DONE]\n\n"
如果直接按 \n 切分当前块,块1的最后一行 data: {"choices":[{"delta":{"content":"好" 是不完整的,无法直接解析。
buffer 的运作方式
javascript
ini
let buffer = '';
while (!done) {
const { value, done: doneReading } = await reader.read();
done = doneReading;
if (value) {
const chunk = decoder.decode(value, { stream: true });
buffer += chunk; // 拼接到缓冲区
const lines = buffer.split('\n'); // 按行切分
buffer = lines.pop() || ''; // 最后一行可能不完整,暂存到下一次
for (const line of lines) {
// 处理完整行
if (line.startsWith('data: ')) {
// 解析...
}
}
}
}
| 步骤 | 作用 |
|---|---|
buffer += chunk |
把新数据拼到缓冲区 |
split('\n') |
切分成完整行 + 最后一行 |
lines.pop() |
把最后一行(可能不完整)存回 buffer,等下一个 chunk 再拼 |
for 循环 |
处理已经完整的所有行 |
buffer同时解决了两个问题: ① 暂存跨块数据;② 确保消息不会被切割截断。
完整流式读取流程
将以上所有概念整合,得到完整的流式读取流程:
javascript
ini
if (stream.value) {
content.value = ''; // 清空,准备逐字拼接
const reader = response.body?.getReader();
const decoder = new TextDecoder();
let done = false;
let buffer = '';
while (!done) {
const { value, done: doneReading } = await reader.read();
done = doneReading;
if (value) {
// 1. 二进制 → 文本
const chunk = decoder.decode(value, { stream: true });
buffer += chunk;
// 2. 按行切分,处理完整行
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('data: ')) {
const payload = line.slice(6);
// 3. 检测结束标志
if (payload === '[DONE]') {
done = true;
break;
}
// 4. 解析 JSON,提取 delta
try {
const json = JSON.parse(payload);
const delta = json.choices[0].delta?.content || '';
if (delta) {
content.value += delta; // 5. 逐字拼接到页面
}
} catch (e) {
// 忽略解析错误
}
}
}
}
}
}
两个层面的"结束"
| 结束来源 | 表现 | 处理方式 |
|---|---|---|
| SSE 协议结束 | 收到 data: [DONE] |
主动跳出循环(业务层面的结束) |
| 流自身关闭 | reader.read() 返回 done: true |
被动退出循环(网络层面的结束) |
推荐做法: 优先检测 [DONE] 结束循环,done: true 作为兜底保护。
一点总结
流式输出 =
fetch+ReadableStream+reader.read()+TextDecoder+buffer拼接。
| 概念 | 一句话解释 |
|---|---|
stream: true |
告诉 LLM"请像水龙头一样,边生成边流出" |
response.body |
ReadableStream,服务器到浏览器的"水管" |
getReader() |
在水管上安装"水龙头",打开读取通道 |
reader.read() |
打开一次水龙头,取出一块数据 |
await reader.read() |
数据驱动的节拍器------有数据才继续,没数据就等待 |
TextDecoder |
二进制(Uint8Array)→ 文本(字符串)的解码器 |
buffer |
跨块数据拼接器,保证消息不被切割截断 |
data: [DONE] |
业务层面的结束标志 |
done: true |
网络层面的结束(兜底保护) |
互动讨论
- 为什么
TextDecoder解码时需要用{ stream: true }参数? 如果省略会怎样? buffer的lines.pop()把最后一行存回 buffer,如果最后一行是完整的,pop()会返回什么?- 如果 LLM 返回的数据块非常大,
split('\n')会不会导致内存问题? reader.read()的await在等待数据时,浏览器主线程会被阻塞吗? 为什么?data: [DONE]和done: true有什么区别? 为什么需要两者都检查?
📌 一点心得:流式输出是 AI 产品体验的"第一印象"。用户可能不懂技术,但"逐字显示"和"干等 10 秒"的体验差异是巨大的。作为前端工程师,掌握流式输出不仅是技术能力的体现,更是对用户体验的深刻理解。