从零理解流式输出 —— 一个 Vue + DeepSeek 的前端实战

249 阅读5分钟

目标读者:正在学习现代前端开发,想理解"流式输出"到底是怎么做出来的。


一、为什么需要流式输出?

你有没有遇到过这种情况:打开一个 AI 聊天应用,输入问题,然后……干等

用户:讲一个关于中国龙的故事
        ↓
     [等待...等待...等待...]10秒过去了
        ↓
     [一次性返回全部答案]

这就像去餐厅点菜,厨师做完所有菜才一起端上来——你在前面饿得不行。流式输出(Streaming) 就是做完一道端一道:

用户:讲一个关于中国龙的故事
        ↓
     "在""遥远""的东方""有一条""巨龙" → ...
        ↑ 一个字一个字实时显示,像打字机一样

打个比方:

像水流一样,LLM 服务器和 chatbot 客户端之间接根管子,生成的 token 就像水流一样不断流向客户端。


二、项目骨架 —— 文件都在干什么?

先看一眼项目结构(核心文件):

stream-demo/
├── index.html              ← 页面入口,"插座"
├── package.json            ← 项目配置,依赖声明
└── src/
    ├── main.js             ← Vue 应用启动,"插头"
    ├── App.vue             ← 根组件,核心逻辑都在这里
    ├── style.css            ← 全局样式(全局重置)
    └── components/
        └── HelloWorld.vue  ← 欢迎页组件(Vite 脚手架自带)

第一步:index.html —— 一个空壳子

<div id="app"></div>
<script type="module" src="/src/main.js"></script>

就一个 <div id="app"></div>,空的。它只是个挂载点——Vue 的"地盘"从这里开始。外面的事我不管,里面的一切由 Vue 接管。

第二步:main.js —— 插上电源

import { createApp } from 'vue'
import App from './App.vue'

createApp(App).mount('#app')   // 把 App 组件"插"进 #app

一行代码把整个 Vue 应用跑起来——createApp(App).mount('#app'),就像把插头插进插座。

第三步:App.vue —— 主角登场

这是项目的灵魂文件。一个 .vue 文件分三部分:

┌────────────────────────────────┐
│  <template>   你看到的页面      │
│  <script>     数据和逻辑       │
│  <style>      样式和美化       │
└────────────────────────────────┘

这就是 组件化思想——把 HTML、CSS、JS 打包成一个独立的、可复用的业务单元。Facebook 的网页由一万多个组件组成,就像乐高积木一样拼出整个应用。


三、数据绑定 —— 告别手动操作 DOM

数据绑定的核心理念就是:

template 绑定数据,不需要 DOM 编程。

什么意思?对比一下就清楚了:

// ❌ 传统做法:手动 DOM 操作(噩梦)
document.getElementById('result').innerHTML = '思考中...';
// 每次数据变了都要手动更新 DOM,100 个地方用到就要写 100 次

// ✅ Vue 数据绑定:声明式,自动同步
const content = ref('');
content.value = '思考中...';  // 改数据就行了,页面自动更新

Vue 在实际代码中提供了多种数据绑定方式:

语法代码示例作用
{{ }} 插值{{ content }}数据 → 页面显示
v-model 双向v-model="question"数据 ⇄ 输入框,双向同步
v-if 条件v-if="Stream"根据数据控制元素显示/隐藏

v-model 特别值得多说一句——表单元素是个例外。普通数据显示是单向的(数据 → 页面),但输入框需要把用户的输入传回数据,所以需要双向绑定


四、核心实战 —— 流式输出是怎么实现的

这就是 App.vueupdate 函数的完整流程:

4.1 非流式模式(Stream = false

用户输入 → 发送请求 → 等待LLM全部生成完 → 一次性返回整个 JSON → 显示
const data = await response.json();
content.value = data.choices[0].message.content;  // 全部内容一次性赋值

简单粗暴,但等得久。

4.2 流式模式(Stream = true

这是精华部分:

// 请求体里加上 stream: true
body: JSON.stringify({
  model: 'deepseek-chat',
  messages: [{ role: 'user', content: question.value }],
  stream: true,  // ← 关键!告诉服务器:一个一个 token 发,别攒着
})

// 然后不是 response.json(),而是用"水管"读取
const reader = response.body?.getReader();   // 获取读取器
const decoder = new TextDecoder();            // 二进制 → 文本解码器

let done = false;
while (!done) {                               // 循环读取
  const { value, done: doneReading } = await reader.read();
  done = doneReading;
  if (done) break;
  content.value += decoder.decode(value);     // 读到一点就立马追加显示
}

用图来表示就是:

LLM 服务器                          Chatbot 客户端
   │                                      │
   │──── token1: "在" ──────────────────→ │ content.value += "在"
   │──── token2: "遥远" ────────────────→ │ content.value += "遥远"
   │──── token3: "的" ──────────────────→ │ content.value += "的"
   │──── token4: "东方" ────────────────→ │ content.value += "东方"
   │──── ...继续... ────────────────────→ │ ...不断拼接...
   │──── [DONE] ────────────────────────→ │ 读取结束

核心就两步:

  1. 服务器端:接受 stream: true,token 生成一个就输出一个
  2. 客户端:用 ReadableStream 的 reader 不断读取,读到就拼接到 content

因为 content 是响应式数据(ref),所以每次追加内容,页面自动更新——用户就看到打字机一样的效果。


五、完整数据流

把整个项目串起来看,一次完整的对话过程是这样的:

1. 用户输入 "讲一个关于中国龙的故事"
   ↓  v-model 双向绑定
2. question.value = "讲一个关于中国龙的故事"
   ↓  点击提交按钮 @click="update"
3. content.value = "思考中...."  → 页面显示"思考中...."
   ↓
4. fetch() 请求 DeepSeek API
   - URL 来自 .env.local(Vite 自动读取环境变量)
   - 请求体携带 stream: true
   ↓
5. 读取响应体(ReadableStream)
   - decoder 把二进制转成文本
   - while 循环不断 reader.read()
   - 每次读到就 content.value += 新内容
   ↓
6. 页面实时更新 {{ content }}
   → 用户看到打字机般的输出效果

六、技术要点清单

这篇文章覆盖了所有关键概念:

概念说明
流式输出(Streaming)一个一个 token 实时推送,不等全部生成完
Vue SFC.vue 文件,template + script + style 三位一体
组件化页面由组件拼成,不是由 HTML 标签拼成
挂载点<div id="app"> 是 Vue 的入口锚点
响应式数据ref() 包裹的数据,变了页面就自动更新
数据绑定{{ }} 文本插值,v-model 双向绑定,v-if 条件渲染
vite脚手架工具,帮忙读 .env.local 环境变量
ReadableStream浏览器 API,逐个读取服务器返回的数据块

七、写在最后

最后用一句话总结:

用户体验的打造,是前端的责任。流式输出是 AI 产品核心体验,也是前端工程师必考内容。

现在回头看,实现流式输出其实并不神秘——就是在请求时加一个 stream: true,然后在响应端用 reader 循环读取。但就是这个看似简单的技术,把 AI 产品的用户体验从"等到焦虑"变成了"丝滑享受" 🎯