前端必知必会:AI 流式输出(Streaming)原理与 Vue 3 实战 🚀
为什么 ChatGPT、DeepSeek 能像打字机一样逐字蹦出回答?作为前端工程师,这恰恰是你的责任田。
一、先来感受一下:流式 vs 非流式
打开一个 AI 应用,问「帮我写一篇关于中国龙的故事」,点击发送:
- 非流式(一次性返回):界面空白,转圈 10 秒,然后「啪」一声——整篇文章突然出现。你心里发毛:是不是卡了?网断了?
- 流式(逐 token 返回):文字像打字机,一个字一个字往外蹦。你边看边判断:「嗯,开头不错,继续看。」
总耗时差不多,体验天差地别。 原因是——
用户看到字在往外冒 → "它在工作了" → 心理等待时间大幅缩短。
这就是流式输出的核心价值:不是缩短绝对时间,而是缩短用户的感知等待时间。 这也是为什么聊天机器人、AI 助手类产品几乎全部采用流式输出——它是 AI 产品的"第一印象"。
📌 知识点速记
| 对比维度 | 非流式输出 | 流式输出 |
|---|---|---|
| 响应方式 | 全部生成完一次性返回 | 每生成一个 token 立刻发送 |
| 用户体验 | 空白等待 → 突然出现 | 逐字显示,打字机既视感 |
| 首字时间(TTFB) | 等到全部完成 | 几乎即时 |
| 前端处理 | response.json() 一行搞定 | 需要 ReadableStream + TextDecoder |
| 适用场景 | 非实时场景 | AI 对话、实时推送 |
二、底层原理——接根管子 💧
2.1 水管比喻
LLM 服务器是一个大水库,聊天客户端是你的水龙头。中间接一根水管(网络连接)。
- 非流式 = 水库把水全部装进大桶,桶满了再整个运到你面前。你干等着,不知道水什么时候来。
- 流式 = 水管打开,水不停地流过来,你边接边用。每一滴水来了你都能立刻看到。
大语言模型(LLM)生成内容是逐 token(词元)推理的——每次只推理下一个最可能的 token,不是一次性「想出」整段话:
"我" → "我今" → "我今天" → "我今天很" → "我今天很开心"
每生成一个 token,就立刻通过「水管」送出去。整条流水线:
LLM 逐 token 推理 → 服务器即刻发送 → 网络传输 → 前端读取 chunk → 拼接到界面
2.2 协议约定:stream: true
流式输出是客户端和服务器之间的一项约定:
- 客户端在请求体里带上
stream: true,意思是「我用流式方式收数据」 - 服务器收到后,每生成一个 token 立刻发,不等全部生成完
body: JSON.stringify({
model: 'deepseek-v4-flash',
messages: [{ role: 'user', content: question.value }],
stream: stream.value // 🔑 就这一个参数,控制流式/非流式
})
2.3 数据格式:SSE(Server-Sent Events,服务器推送事件)
流式输出在 HTTP 层面用 SSE 协议,原始数据长这样:
data: {"choices":[{"delta":{"content":"我"}}]}
data: {"choices":[{"delta":{"content":"今"}}]}
data: {"choices":[{"delta":{"content":"天"}}]}
data: [DONE]
四条规则:
- 每行以
data:开头 - 每条之间用空行分隔
[DONE]是结束信号- 单向通信:服务器 → 客户端,客户端不需要再发请求
💡 打开 Chrome DevTools → Network → 找到这个请求 → 看 Response 标签,数据是一段一段「长出来的」,而不是一次性出现。
三、为什么要从 0 搭建?—— Agent 时代的项目初始化
在动手写代码之前,先聊聊思想层面的东西。
3.1 不要从 0 写 Vue 项目
进入 Agent 开发时代,我们没必要从空白文件开始手写 index.html、App.vue。应该:
- 用
npm create vite@latest一键生成项目脚手架 - 或者去 GitHub 拉一个模板项目
把项目初始化这种体力活交给工具,把精力留给业务逻辑。
3.2 热更新(Hot Reload)
Vite 开发服务器提供热更新能力:文件修改 → 自动局部刷新。改了 HelloWorld.vue,浏览器里立刻看到效果,不用手动刷新页面。这是现代前端开发的基础体验。
四、Vue 3 极速入门(新手友好)📗
在看核心代码前,用 3 分钟搞懂 Vue 3。
4.1 .vue 单文件组件(SFC)的三大块
一个 .vue 文件就像一块乐高积木,把 HTML、CSS、JS 封装在一起,形成一个可复用的组件(Component)。
┌────────────────────────────────┐
│ <template> 模板 / HTML │ ← 定义结构,可绑定数据 {{ }}
│ </template> │ 可绑定事件 @click
│ │
│ <script setup> 脚本 / JS │ ← 定义数据和业务逻辑
│ </script> │ ref() 创建响应式数据
│ │
│ <style> 样式 / CSS │ ← 定义外观
│ </style> │
└────────────────────────────────┘
组件是构成页面的最小工作单元——不再以 HTML 标签为粒度,而是以「一个有独立功能的积木块」为粒度。
4.2 数据驱动思想(Data-Driven)
古老做法(手动撸 DOM):
document.getElementById('result').innerHTML = '新内容'; // 手动改,累且易出错
Vue 做法:
<div>{{ content }}</div> <!-- 绑定数据。数据变了,界面自动变 -->
const content = ref(''); // 创建一个响应式数据
content.value = '新内容'; // 只改数据!不用管 DOM!Vue 帮你更新界面
这就是数据驱动视图——你只管数据,界面自动跟上。
4.3 响应式(Reactive)
ref() 包裹的数据是响应式的。ref(0) 返回一个 RefImpl 对象,实际值存在 .value 里。当 .value 改变时,页面上所有绑定了这个数据的地方都会自动局部更新。
跟 Excel 公式一个道理:改了源单元格,所有引用它的格子自动重算。
⚠️ 注意:在
<script>里访问数据要.value(如content.value),但在<template>里 Vue 会自动解包,直接用{{ content }}。
4.4 单向 vs 双向绑定
Vue 中大部分数据流是单向的:数据 → 界面。用 {{ }} 插值表达式:
<div>{{ content }}</div> <!-- 单向:数据变了,div 自动更新 -->
但表单元素是例外——用户需要输入内容,输入要传回数据层。v-model 就是干这个的:
<input v-model="question" />
<!-- 双向 →
<!-- 用户输入 "你好" → question.value 自动变成 "你好" -->
<!-- question.value 改成 "hi" → input 框立刻显示 "hi" -->
v-model 同时做了两件事:显示数据(数据 → 界面)+ 写回输入(界面 → 数据)。
五、项目实战——手写一个流式聊天 🔨
5.1 项目结构
stream-demo/
├── index.html # 入口 HTML,提供挂载点 #app
├── .env.local # 🔒 环境变量(API Key 放这里,不要提交!)
├── .gitignore # Git 忽略规则
├── package.json # Vue 3.5 + Vite 8
├── vite.config.js # Vite 配置(注册 Vue 插件)
├── readme.md # 笔记
└── src/
├── main.js # 应用入口:createApp(App).mount('#app')
├── App.vue # 根组件,包裹 HelloWorld
├── style.css # 全局样式(margin/padding 归零)
├── 1.js # 编解码演示:TextEncoder / TextDecoder
└── components/
└── HelloWorld.vue # 🔥 核心组件!流式输出的全部逻辑
5.2 环境变量:.env.local
VITE_DEEPSEEK_API_KEY=sk-xxxxxxxxxxxxxxxx
VITE_DEEPSEEK_API_BASE_URL=https://api.deepseek.com
VITE_DEEPSEEK_MODEL=deepseek-v4-flash
Vite 会自动读取以 VITE_ 开头的变量,代码中通过 import.meta.env.VITE_DEEPSEEK_API_KEY 访问。
⚠️
.env.local必须加入.gitignore!API Key 就像你家门钥匙,提交到 Git 等于把钥匙挂在门上。
5.3 核心代码逐行拆解
Step 1:定义三个响应式数据
import { ref } from 'vue';
const question = ref('讲一个中国龙的故事'); // 用户输入的问题
const content = ref(''); // AI 回复内容
const stream = ref(true); // 是否流式(默认开启,checkbox 绑定)
stream 默认为 true——因为这是流式 Demo,默认就展示流式体验。
Step 2:update() 函数——发送请求
const update = async () => {
if (!question.value) return; // 空值校验
content.value = '思考中...'; // 🔔 先给用户即时反馈,表示"我在工作了"
const endpoint = 'https://api.deepseek.com/chat/completions';
const headers = {
'Content-Type': 'application/json',
Authorization: `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`
};
const response = await fetch(endpoint, {
method: 'POST',
headers,
body: JSON.stringify({
model: 'deepseek-v4-flash',
messages: [{ role: 'user', content: question.value }],
stream: stream.value // 🔑 流式开关
})
});
// ...后续分流处理
}
几个关键知识点:
Bearer鉴权:Authorization: Bearer sk-xxx是 API 鉴权标准格式。"Bearer" 直译「持票人」——谁持有这个 token 谁就能访问,类似演唱会门票,认票不认人。- POST 而非 GET:POST 把数据放在加密的请求体里,安全且没有 URL 长度限制。适合传输长消息和敏感 Key。
content.value = '思考中...':这是一个页面状态的变化。在 LLM 接口调用开始前,先给用户一个即时反馈,表示「你的请求已收到,正在处理」。这是优秀 UX 的细节。
Step 3:非流式分支(简单)
if (!stream.value) {
const data = await response.json();
content.value = data.choices[0].message.content;
}
传统的 API 调用方式:fetch → response.json() → 取 choices[0].message.content → 赋值。一行搞定,缺点就是用户干等。
Step 4:流式分支(核心!)🔥
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;
// ⑤ 解码 + 拼接缓存
const chunkValue = buffer + decoder.decode(value);
buffer = ''; // 缓存已拼入 chunkValue,清空
// ⑥ 按行分割,过滤出 SSE 的 "data:" 行
const lines = chunkValue.split('\n')
.filter((line) => line.startsWith('data: '));
// ⑦ 解析每个 data 行,提取 delta.content → 追加到 content.value
}
}
这是整个 Demo 的灵魂。让我们一层一层剥开:
第 ① 层:response.body?.getReader() —— 拿到水龙头
response.body 是一个 ReadableStream(可读流)对象,就是前面说的那根「水管」。.getReader() 返回一个读取器(reader),相当于在水管上装了个水龙头——每次拧开(调用 .read())就流出一截数据。
?. 是可选链操作符(Optional Chaining):如果 response.body 是 null 或 undefined,不报错,直接返回 undefined。
第 ② 层:new TextDecoder() —— 翻译官
水管里流的是二进制数据(Uint8Array,8 位无符号整数数组,每个值 0-255)。机器能读懂,人眼看就是乱码。
TextDecoder 就是翻译官,把二进制字节翻译成人类可读的 UTF-8 文本。
项目中还有一个独立的演示文件 src/1.js 专门讲这个:
// src/1.js —— 编解码演示
const encoder = new TextEncoder();
const bytes = encoder.encode('你好'); // 字符串 → Uint8Array
console.log(bytes); // 如 [228, 189, 160, 229, 165, 189]
const decoder = new TextDecoder();
const str = decoder.decode(bytes); // Uint8Array → '你好'
console.log(str); // 你好
💡
TextEncoder和TextDecoder是一对——一个编码(字符串→二进制),一个解码(二进制→字符串)。流式场景只用解码。
第 ③ 层:done 开关变量
let done = false; // 初始 false,读到 [DONE] 后变 true,循环退出
这是一个状态开关(flag variable)——控制 while 循环什么时候停止。
第 ④ 层:buffer 缓存区
let buffer = '';
为什么要 buffer?因为网络传输是不定长的——一个 chunk 可能在任意位置被切断。比如 SSE 的一行 data: {"choices"...} 可能被切成两半,分别落在两个 chunk 里。buffer 就是用来暂存「不完整的半行」,等下一个 chunk 来了再拼起来。
第 ⑤ 层:解码 + 拼缓存
const chunkValue = buffer + decoder.decode(value);
buffer = ''; // 缓存已拼入 chunkValue,清空等待下一轮
先把上一轮残留的 buffer(如果有)拼在本次解码结果前面,组成一个完整片段。然后清空 buffer,因为已经用掉了。
第 ⑥ 层:按行分割 + 过滤 data: 行
const lines = chunkValue.split('\n')
.filter((line) => line.startsWith('data: '));
SSE 格式中,每行有用数据都以 data: 开头。split('\n') 按行分割,filter 只保留 data: 开头的行,其他空行和元数据行统统过滤掉。
第 ⑦ 层:解析 JSON,提取 token
for (const line of lines) {
const dataStr = line.slice(6); // 去掉 "data: " 前缀(6个字符)
if (dataStr === '[DONE]') continue; // 结束信号,跳过
try {
const json = JSON.parse(dataStr);
const token = json.choices[0]?.delta?.content || '';
content.value += token; // 🔥 每次追加一个 token,触发视图更新!
} catch (e) {
// JSON 解析失败,忽略这一行
}
}
data: 后面跟的是一段 JSON。我们:
- 切掉
"data: "前缀(6 个字符) - 遇到
[DONE]就跳过(这是结束信号) JSON.parse解析出对象- 沿着
choices[0].delta.content路径取出 token 文本 content.value += token—— 拼到响应式数据上
每次 content.value 变化,Vue 的响应式系统就自动触发界面更新——用户就看到文字一个字一个字地往外蹦。
5.4 模板部分
<template>
<div class="container">
<div>
<label>输入:</label>
<input class="input" v-model="question" /> <!-- 双向绑定输入框 -->
<button @click="update">提交</button> <!-- 点击触发 update -->
</div>
<div class="output">
<div>
<label>Streaming</label>
<input type="checkbox" v-model="stream"/> <!-- 双向绑定 checkbox -->
</div>
<div>{{ content }}</div> <!-- 单向绑定,展示 AI 回复 -->
</div>
</div>
</template>
三个绑定方式一目了然:
v-model="question"— 双向绑定,用户输入 ↔ 数据v-model="stream"— 双向绑定,checkbox 勾选 ↔ 数据{{ content }}— 单向绑定,数据 → 界面展示@click="update"— 事件绑定,点击触发函数
5.5 CSS 布局
.container {
display: flex;
flex-direction: column;
align-items: start;
justify-content: start;
height: 100vh;
font-size: 0.85rem; /* 移动端适配:相对于 html 根元素等比缩放 */
}
使用了 Flexbox 弹性布局。CSS 的文档流是页面布局的基础:默认从上到下、从左到右排列。display: flex 开启了一个新的格式化上下文(Formatting Context),让子元素按 flex-direction: column(纵向)排列。
font-size: 0.85rem 中的 rem 是相对单位——相对于 <html> 根元素的字体大小。这样做的好处是移动端适配:只需改根元素字体大小,整个页面字体等比缩放。
六、进阶:SSE vs WebSocket——面试高频题 🔥
| 对比维度 | SSE | WebSocket |
|---|---|---|
| 通信方向 | 单向(服务器 → 客户端) | 双向(全双工) |
| 协议 | 基于 HTTP/HTTPS,简单 | 独立协议 ws://,需握手升级 |
| 断线重连 | 浏览器内置自动重连 | 需手动实现 |
| 实现复杂度 | 低(EventSource 或手动 fetch) | 高 |
| 适用场景 | AI 流式、股票行情、通知 | 聊天室、协同编辑、在线游戏 |
AI 聊天是典型的「客户端发请求 → 服务器持续推送回复」——单向流。SSE 正合适,不需要 WebSocket 的双向能力。
七、常见坑点和最佳实践 ⚠️
7.1 错误处理
try {
const response = await fetch(endpoint, { ... });
if (!response.ok) {
content.value = `请求失败:${response.status}`;
return;
}
// ...流式读取...
} catch (error) {
content.value = `网络错误:${error.message}`;
}
7.2 取消请求
用户可能在中途发起新请求或关闭页面,需要取消旧请求释放资源:
const controller = new AbortController();
fetch(endpoint, {
signal: controller.signal, // 绑定信号
// ...
});
// 用户发起新请求前
controller.abort(); // 中断旧请求
7.3 reader?.read() 的兼容性
代码中用了 reader?.read() 而不是 reader.read()——?. 可选链保护。老浏览器可能不支持 ReadableStream,这样写不会报白屏错误。生产环境建议做特性检测。
7.4 显示「正在输入」状态
<div>{{ content }}<span v-if="isLoading" class="cursor">|</span></div>
.cursor { animation: blink 1s step-end infinite; }
@keyframes blink { 50% { opacity: 0; } }
一个闪烁光标告诉用户「AI 工作中」,体验瞬间提升。
八、一张图总结全文 🗺️
┌──────────────────────────────────────────────────────────────┐
│ 流式输出全景图 │
│ │
│ DeepSeek 服务器 │
│ ┌──────────────┐ stream: true │
│ │ token 逐个生成 │────── SSE ──────→ ┌─────────────────┐ │
│ │ 生成一个发一个 │ data: {...} │ Vue 3 前端 │ │
│ │ │ data: {...} │ │ │
│ │ │ data: [DONE] │ response.body │ │
│ └──────────────┘ │ .getReader() │ │
│ │ ↓ │ │
│ 非流式(对比) │ reader.read() │ │
│ ┌──────────────┐ │ ↓ │ │
│ │ 全部生成完 │──── JSON ────→ │ TextDecoder │ │
│ │ 一次性返回 │ {choices:[...]} │ ↓ │ │
│ └──────────────┘ │ split+filter │ │
│ │ data: 行 │ │
│ │ ↓ │ │
│ │ JSON.parse │ │
│ │ ↓ │ │
│ │ content += token │ │
│ │ ↓ │ │
│ │ 界面逐字更新 │ │
│ └─────────────────┘ │
└──────────────────────────────────────────────────────────────┘
九、三个核心 API 速记
整个流式输出就靠这三个 API:
| API | 作用 | 一句话 |
|---|---|---|
response.body.getReader() | 获取流读取器 | 在水管上装个水龙头 |
new TextDecoder() | 创建文本解码器 | 二进制 → 人类可读文字 |
reader.read() | 读取下一个数据块 | 拧开水龙头接一杯水 |
记住它们,你就掌握了流式输出的核心。
十、写在最后
- 为什么:优化用户感知等待时间,AI 产品的核心体验
- 是什么:
stream: true协议约定 + SSE 数据格式 + ReadableStream - 怎么做:
getReader()→TextDecoder→reader.read()循环 → SSE 解析 → JSON 提取 → Vue 响应式更新 - 怎么做好:buffer 处理跨 chunk、错误处理、取消请求、闪烁光标
作为前端工程师,流式输出不仅是一道面试必考题,更是做出优秀 AI 产品的基本功。
水管接好了,水过来了,如何优雅地接水和展示——这就是前端的天职。