前端必知必会:AI 流式输出(Streaming)原理与 Vue 3 实战 🚀

0 阅读8分钟

前端必知必会: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.htmlApp.vue。应该:

  1. npm create vite@latest 一键生成项目脚手架
  2. 或者去 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 调用方式:fetchresponse.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.bodynullundefined,不报错,直接返回 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);                          // 你好

💡 TextEncoderTextDecoder 是一对——一个编码(字符串→二进制),一个解码(二进制→字符串)。流式场景只用解码。


第 ③ 层: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。我们:

  1. 切掉 "data: " 前缀(6 个字符)
  2. 遇到 [DONE] 就跳过(这是结束信号)
  3. JSON.parse 解析出对象
  4. 沿着 choices[0].delta.content 路径取出 token 文本
  5. 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——面试高频题 🔥

对比维度SSEWebSocket
通信方向单向(服务器 → 客户端)双向(全双工)
协议基于 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()读取下一个数据块拧开水龙头接一杯水

记住它们,你就掌握了流式输出的核心。


十、写在最后

  1. 为什么:优化用户感知等待时间,AI 产品的核心体验
  2. 是什么stream: true 协议约定 + SSE 数据格式 + ReadableStream
  3. 怎么做getReader()TextDecoderreader.read() 循环 → SSE 解析 → JSON 提取 → Vue 响应式更新
  4. 怎么做好:buffer 处理跨 chunk、错误处理、取消请求、闪烁光标

作为前端工程师,流式输出不仅是一道面试必考题,更是做出优秀 AI 产品的基本功

水管接好了,水过来了,如何优雅地接水和展示——这就是前端的天职。