从0到1彻底理解流式输出:先读懂Vue的“乐高积木”,再拧开LLM的“数据水龙头”

从0到1彻底理解流式输出:先读懂Vue的"乐高积木",再拧开LLM的"数据水龙头"

你以为流式输出只是加个 stream:true?别急,从"为什么组件像乐高"到"二进制流怎么变成打字机",我们一行一行代码吃透它。


前言:你等的不是答案,是"正在输入..."的安心

你有没有过这样的体验:打开一个AI聊天应用,问了一个比较复杂的问题,然后页面就卡住了------没有反馈,只有一个转圈圈。你开始怀疑网断了,或者程序崩了,甚至想刷新重来。突然,一坨文字猛地拍在屏幕上,你反而被吓了一跳。

这种体验,在AI产品里是致命的。而解决它的那把钥匙,就是流式输出(Streaming)

今天这篇文章,我会用最"笨"的方式------从 Vue 组件的本质、脚手架启动流程,到一条二进制数据如何变成屏幕上跳动的字------把流式输出掰开揉碎讲给你听。里面会频繁引用我学习时记的零散笔记和代码注释,因为它们才是真正让人"恍然大悟"的关键点。读完,你不仅能写出流式聊天机器人,还会对"现代前端框架到底在干什么"产生一种通透感。


一、流式输出:一根连接服务器与用户的水管

1.1 问题:一次性返回的痛

我们先回到问题的起点。大模型生成回答的过程叫"推理"。这个过程的耗时取决于两个因素:推理所花费的时间(transformer计算)问题的复杂度(难度和长度)。有时候一个长回答可能要5-10秒。如果不做优化,我们的请求流程是这样的:

复制代码
用户点击发送 → 前端等待 → 服务器完整生成回答 → 一次性返回 → 前端一次性显示

在这漫长的几秒钟内,用户眼前一片空白,只有浏览器标签页上的小圈在孤独地旋转。

1.2 解法:让token像水流一样涌出来

优化的思路非常朴素:不用一次性给,而是一个一个token实时展示。LLM每推理出一个词元(token),就立刻发送给前端,前端收到后立马拼接到对话框里。整个通信过程就像在LLM服务器和Chatbot客户端之间接了一根管子,生成token就像流水一样不断流向客户端。

css 复制代码
[LLM服务器]  ---- token流 ----> [Chatbot客户端]
             (一根水管)

对用户来说,他看到的不再是"漫长的空白→突然的文字",而是一个"像打字机一样流式输出"的思考过程。从认知心理学角度看,即时反馈能大幅降低等待焦虑,甚至让人觉得整体响应变快了------哪怕总耗时是一样的。

这就是流式输出的终极意义:AI产品的第一个关键用户体验,本质上是前端工程师的"必考题"

1.3 前后端约定:stream:true

要实现流式,前后端需要一个小小的约定:

  • 客户端发送 stream:true:告诉服务器"我要流式输出"。
  • 服务器接受 stream:true:一旦识别这个参数,它就不再等所有token生成完才回复,而是生成一个token就输出一个数据块。

这个约定很简单,但它改变了整个HTTP请求-响应的交互模式。


二、地基一定要打牢:Vue的"乐高积木"世界

在真正写流式代码之前,我们把Vue的基础理一遍。很多同学上手就是 npm init vite,然后对着 .vue 文件敲代码,但对"组件到底是什么"只有一个模糊的印象。理解这些,才能明白数据是怎么流动的。

2.1 组件:网页的乐高积木

我学习时的笔记里有一句话:"Facebook网页由一万多个component组成,组件就是组成网页的最小单位,可以想象成乐高积木"。这真是最形象的比喻。

过去我们写网页,最小单位是HTML标签(div, p, span...),它们数量繁多、零散,很难作为一个完整的业务单元去复用。而组件将一堆相关的HTML、CSS、JS打包在一起,形成一个有独立功能、可复用的"积木块"。比如一个按钮、一个输入框、一个对话框,都可以是组件。

Vue的 .vue 文件就是这个积木块的标准封装格式,它由三部分组成:

  • <template> :模板。HTML 是静态的,但模板是动态的 ------它是一段可以绑定数据、能够响应式更新的HTML。你用 {{ }} 插值表达式或者 v-bind 绑定一个数据,当数据变化时,模板自动重新渲染。
  • <script> :JavaScript逻辑。引入 Vue/React 的各种能力,比如 refreactivecomputed 等。Vue3 新增的 <script setup> 是一个语法糖,它让 script 里定义的全局数据可以被 template 直接使用,还支持一些宏函数(如 definePropsdefineEmits)。
  • <style> :样式。可以加 scoped 属性让样式只作用于当前组件,避免污染全局。

这就是封装的威力。我们后面写 App.vue 的时候,就会在里面同时管理输入框的交互、流式请求的逻辑和界面的样式,整个聊天功能就封装在一个组件里。

2.2 数据驱动与双向绑定:告别 DOM 编程

传统前端开发是"DOM编程":你要先 document.querySelector 找到元素,然后 .innerHTML = ... 来修改内容。数据一变,你得手动去更新界面。这种模式在复杂应用里会变成维护噩梦。

Vue 的核心思想是数据驱动(Data Driven) :你只负责管理数据,界面自动跟着变。像笔记里写的 count {{count}} 这种单向数据流绑定,就是数据→视图的自动同步。

但表单元素是个"例外":它既要显示值,又要接收用户的输入把值传回数据层。这时就需要双向数据流 v-modelv-model 指令会同时做两件事:

  • 把数据的值绑定到输入框的 value 属性上(数据→视图)
  • 监听用户的输入事件,把新值写回数据(视图→数据)

这样一来,我们完全不用碰 DOM,只需聚焦在 questioncontent 这些响应式变量上。数据变了,界面自动更新。这就是现代前端框架的核心魅力。


三、项目启动:从 index.html 到 Vue 掌控全局

有了 Vue 基础,我们来搭建项目。一个 npm init vite 命令下去,脚手架帮我们生成了项目骨架。但我们不能只当黑盒用,得读懂里面的每一个文件。

3.1 Vite 脚手架:Node 后端在偷偷工作

笔记里写:"vite 前端项目中集成deepseek apiKey?vite 会帮我们读取 .env.local,vite 是脚手架(node 写的),node 后端"

是的,Vite 开发服务器是用 Node.js 写的。当我们在根目录创建 .env.local 文件并写入 VITE_DEEPSEEK_API_KEY=sk-xxx,Vite 会在Node 端 读取这个文件,然后把以 VITE_ 开头的变量注入到前端代码中,通过 import.meta.env 访问。这中间有一个编译时替换的过程,所以我们代码里写 import.meta.env.VITE_DEEPSEEK_API_KEY,最后打包出来的 JS 中会被替换成实际的字符串。

⚠️ 注意:以 VITE_ 前缀暴露的变量最终会被写入前端资源包,所以只能放公开的、有权限限制、可随时吊销的开发 Key。真正的生产环境应该通过后端代理请求。

3.2 index.html:只有一个 #app 的花盆

打开 index.html,它长得几乎和最普通的 HTML 一样,但有几处关键细节:

html 复制代码
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>stream-demo</title>
  </head>
  <body>
    <!-- npm run dev
    可以在 http://localhost:5173 端口查看 需要启动一个服务器http server
    / -> index.html
    有且只有一个#app vue/react 
    挂载点 内部,组件,vue/react 接管-->
    <div id="app"></div>
    <!-- 入口文件 -->
    <script type="module" src="/src/main.js"></script>
  </body>
</html>

笔记告诉我:"有且只有一个#app vue/react 挂载点,内部,组件,vue/react 接管" 。这揭示了一个 SPA(单页应用)的基本结构:整个页面只有一个 <div id="app"> 容器,剩下的一切都由 Vue 动态生成并注入进去。它就像一个"花盆",后面 createApp(App).mount('#app') 这行代码会往里面栽一棵完整的组件树。

另一个重点是 <script type="module" src="/src/main.js">。浏览器不认识 .vue 文件,也不支持裸路径的模块导入。Vite 开发服务器会在后台处理模块转换,把 .vue 文件编译成浏览器可执行的 JS,然后响应给浏览器。所以这个 main.js 就是应用入口。

3.3 main.js:点火启动

javascript 复制代码
// 创建一个APP
import { createApp } from 'vue'
// 适合全局 组件共享的样式
import './style.css'
import App from './App.vue'
// App 根组件 会有子组件
// 创建一个App 挂载到#App 挂载点上
createApp(App).mount('#app')

这段代码每行的注释都是精华:

  • createApp(App):以 App.vue 作为根组件创建一个 Vue 应用实例。这个根组件内部可以嵌套无数子组件,形成一棵组件树。
  • import './style.css':引入全局样式,任何组件都能享受到这些 CSS 规则,适合放一些重置样式或主题变量。
  • .mount('#app'):把应用实例挂载到 id="app" 的真实 DOM 元素上。从这一刻起,Vue 完全接管了这个容器内的所有渲染工作。

运行流程可以总结为:

浏览器请求 index.html → 遇到 <script type="module" src="/src/main.js"> → 请求并执行 main.js → 创建 Vue 应用实例 → 挂载到 #app → 页面呈现。


四、先跑通非流式对话,感受"等待"

在切入流式之前,我们先实现一个最普通的非流式请求,完整感受一下差别。假设我们已经在 App.vue 里搭好了简单的界面:

vue 复制代码
<template>
  <div class="container">
    <div>
      <label>提问:</label>
      <input type="text" v-model="question" />
      <button @click="submit">发送</button>
    </div>
    <div>
      <label>
        <input type="checkbox" v-model="stream" /> 流式输出
      </label>
    </div>
    <div class="output">{{ content }}</div>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const question = ref('讲一个关于中国龙的故事')
const stream = ref(false)  // 先关闭流式
const content = ref('')
</script>

这是笔记中"封装三部分"的完美体现:模板里用 v-model 做双向绑定,{{ content }} 单向展示;script setup 里定义响应式数据;样式可以写在 <style scoped> 里(这里暂时略去)。

然后写 submit 函数:

javascript 复制代码
const submit = async () => {
  if (!question.value) return
  content.value = '思考中...'

  const response = await fetch('https://api.deepseek.com/v1/chat/completions', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`
    },
    body: JSON.stringify({
      model: 'deepseek-chat',
      messages: [{ role: 'user', content: question.value }],
      stream: false
    })
  })

  const data = await response.json()
  content.value = data.choices[0].message.content
}

勾掉流式复选框,点击发送。你会发现:点了按钮之后,页面上的"思考中..."会停留几秒,然后一大段完整回答瞬间出现。这就是传统的一次性返回体验------总让人觉得卡了一下。


五、拧开水龙头:真正实现流式打字机

现在,把 stream 的值改为 true,再把 submit 函数的后半段改成流式处理逻辑。这才是本文的高潮部分。

5.1 从二进制流到文本:ReadableStream 和 TextDecoder

stream:true 时,DeepSeek(兼容 OpenAI 协议)服务器返回的响应体不再是完整的 JSON,而是一系列符合 SSE(Server-Sent Events) 格式的数据块,例如:

css 复制代码
data: {"id":"...","choices":[{"delta":{"content":"我"}}]}

data: {"id":"...","choices":[{"delta":{"content":"来"}}]}

...
data: [DONE]

每个数据块包含一个 delta.content,我们需要把这个 token 取出来,累加到 content 上。核心代码如下:

javascript 复制代码
if (stream.value) {
  content.value = ''   // 清空之前的内容

  // 1. 获取可读流的读取器
  const reader = response.body.getReader()
  // 2. 创建文本解码器
  const decoder = new TextDecoder('utf-8')
  let buffer = ''      // 行缓冲区

  while (true) {
    const { value, done } = await reader.read()
    if (done) break

    // 3. 将二进制数据块解码,追加到缓冲区
    buffer += decoder.decode(value, { stream: true })

    // 4. 按换行符切分行
    const lines = buffer.split('\n')
    // 最后一行可能不完整,保留到下次循环
    buffer = lines.pop() || ''

    for (const line of lines) {
      const trimmed = line.trim()
      if (!trimmed || !trimmed.startsWith('data:')) continue

      const jsonStr = trimmed.slice(5).trim()
      if (jsonStr === '[DONE]') {
        return  // 流结束
      }

      try {
        const chunk = JSON.parse(jsonStr)
        const token = chunk.choices[0]?.delta?.content || ''
        content.value += token   // 逐字追加,Vue自动更新DOM
      } catch (e) {
        // 忽略解析失败的行(可能因为网络抖动数据不全)
      }
    }
  }
}

5.2 为什么要有缓冲区?

网络传输是流式的,一个 reader.read() 拿到的二进制块(chunk)不一定恰好是完整的行,它可能在任意字节处被截断。比如一个中文 UTF-8 字符占3个字节,可能这次读到前2个字节,下次才读到第3个。如果我们直接按块解析,遇到不完整的字符就会出现乱码。

解决方案有两个层面:

  1. TextDecoder{stream: true} 选项:解码器会记住不完整的多字节序列,等下一次数据来了再拼上,保证字符不会碎裂。
  2. 行缓冲区 buffer :我们把解码后的文本拼进缓冲区,再按换行符切割。每次切完后,把最后剩下的不完整行留在 buffer 里,等下一个 chunk 到来再继续拼接。这确保了 SSE 的 data: 消息是完整的。

5.3 逐字更新:Vue 响应式系统的魔法

循环体内,每解析出一个 token,我们执行 content.value += token。因为 contentref,它的任何变化都会通知 Vue 的依赖追踪系统,触发组件的重新渲染。所以页面上会一个字一个字地蹦出来,完美模拟打字机效果。


六、融会贯通:用笔记和注释串起整个知识图谱

现在,我们把散落在各处的笔记和注释收拢一下,你会发现它们不是孤立的知识点,而是一整条逻辑链:

  1. 为什么要流式?

    笔记:"复杂的计算,推理生成,耗时挺久,让人等的不耐烦"。于是"不用一次性给,一个token一个token推理生成,实时演示"。这对应了产品体验的痛点。

  2. 前后端约定

    笔记:"服务器约定接受 stream:true token生成就是输出;客户端发送 stream:true 表示流式输出"。这统一了 API 层面的沟通语言。

  3. Vue 组件化

    笔记:"组件就是组成网页的最小单位,可以想象成乐高积木,方便封装、复用、维护"。然后封装分三部分 <template>, <script>, <style>。这解释了为什么我们的聊天界面、逻辑、样式可以合在一个 .vue 文件里,并且数据绑定({{ }})、响应式更新都能无缝工作。

  4. 数据驱动与 v-model

    笔记:"表单元素是个例外,用户的输入需要传回数据,双向数据流 v-model"。我们的 <input v-model="question"> 就是这样,让用户输入立即反映到 question 变量上,而 content 的单向绑定则负责展示。

  5. 脚手架和 Key 的安全

    笔记:"vite 前端项目中集成deepseek apiKey?vite 会帮我们读取.env.local,vite 是脚手架(node 写的)"。这让我们知道 import.meta.env.VITE_DEEPSEEK_API_KEY 的来源,以及它最终会被打包进前端,只能用于开发调试。

  6. 挂载点与启动

    代码注释:"有且只有一个#app vue/react 挂载点内部,组件,vue/react 接管","// 创建一个App 挂载到#App 挂载点上"。这解释了为什么我们的 Vue 组件能渲染在页面上------整个应用从 main.js 开始,把 App 组件树"种"进 #app 花盆。

  7. 流式实现的水管比喻

    笔记:"chatbot 客户端不断拼接token,流式输出就成了水流,llm 服务器和chatbot 客户端两端接一个管子,生成token就像流水一样不断流向客户端"。这段比喻直接映射到我们的代码实现:reader.read() 是拧开水龙头,decoder 是滤网,buffer 是蓄水池,content.value += token 是把水引流到屏幕。

当你在 submit 函数里写下 content.value += token 这行代码时,其实已经跨越了计算机网络(HTTP流式响应)、Web API(ReadableStream、TextDecoder)、Vue 响应式系统、前端工程化(Vite 环境变量)等多个领域。而你用短短几十行代码把它们缝合在了一起------这就是现代前端开发的魅力。


七、结尾:好的体验,藏在这些细节里

流式输出不是什么"黑科技",但它却是 AI 产品工程师需要跨过的第一道门槛。从用户点下发送按钮,到文字开始跳动,中间经历了环境变量的注入、Vue 的组件初始化、挂载点的渲染、HTTP 分块传输、二进制流解码、SSE 协议解析、响应式数据更新......每一步都承载着"体验"二字。

当你下一次面对一个让你不耐烦的空白等待页面时,也许就能会心一笑:不过是没有拧开那根水管而已。而掌握了这些的你,已经知道怎么把水接通了。


推荐实践

  • 把本文的代码在本地跑一遍,对比流式和非流式的体验差异。
  • 尝试中断流:增加一个"停止生成"按钮,调用 reader.cancel()
  • 思考如何对接 Markdown 渲染,让回答支持代码块和表格实时生成。

全文完,感谢阅读。如果这篇文章帮你串起了 Vue 基础、工程化和 AI 应用开发的脉络,欢迎点赞收藏,有什么疑问我们评论区见~

相关推荐
ting94520001 小时前
纯技术硬核解析:Timbal AI 全栈底层架构、引擎原理与生产级 AI 应用落地实现
人工智能·架构
用户6919026813391 小时前
Agent 开发 主流 skills推荐
人工智能
意图共鸣1 小时前
意图共鸣科技7月13日发布《智能体三角模型白皮书》|商用AI智能体的通用构造范式深度解读
人工智能·科技
触底反弹1 小时前
Vue 实战:手把手教你实现 ChatGPT 同款打字机效果
前端·javascript·人工智能
秦先生在广东1 小时前
Hallmark:一个拒绝“AI味“的设计技能工具深度解析
人工智能
老金带你玩AI1 小时前
Codex109天重置23次,明天还要再送一次
人工智能
kylin-运维1 小时前
k8s可视化看板嵌入AI助手(悬浮对话球)
人工智能·容器·kubernetes·aicoding
code_pgf2 小时前
LLM/MLLM 与世界模型在具身智能中的角色、局限与分类
人工智能
I-NullMoneyException2 小时前
0713|油价飙升、蓝牙致癌辟谣、AI推理成本、曼谷火灾、世界杯超清
人工智能·区块链