从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 的各种能力,比如ref、reactive、computed等。Vue3 新增的<script setup>是一个语法糖,它让script里定义的全局数据可以被template直接使用,还支持一些宏函数(如defineProps、defineEmits)。<style>:样式。可以加scoped属性让样式只作用于当前组件,避免污染全局。
这就是封装的威力。我们后面写 App.vue 的时候,就会在里面同时管理输入框的交互、流式请求的逻辑和界面的样式,整个聊天功能就封装在一个组件里。
2.2 数据驱动与双向绑定:告别 DOM 编程
传统前端开发是"DOM编程":你要先 document.querySelector 找到元素,然后 .innerHTML = ... 来修改内容。数据一变,你得手动去更新界面。这种模式在复杂应用里会变成维护噩梦。
Vue 的核心思想是数据驱动(Data Driven) :你只负责管理数据,界面自动跟着变。像笔记里写的 count {{count}} 这种单向数据流绑定,就是数据→视图的自动同步。
但表单元素是个"例外":它既要显示值,又要接收用户的输入把值传回数据层。这时就需要双向数据流 v-model 。v-model 指令会同时做两件事:
- 把数据的值绑定到输入框的
value属性上(数据→视图) - 监听用户的输入事件,把新值写回数据(视图→数据)
这样一来,我们完全不用碰 DOM,只需聚焦在 question、content 这些响应式变量上。数据变了,界面自动更新。这就是现代前端框架的核心魅力。
三、项目启动:从 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个。如果我们直接按块解析,遇到不完整的字符就会出现乱码。
解决方案有两个层面:
TextDecoder的{stream: true}选项:解码器会记住不完整的多字节序列,等下一次数据来了再拼上,保证字符不会碎裂。- 行缓冲区
buffer:我们把解码后的文本拼进缓冲区,再按换行符切割。每次切完后,把最后剩下的不完整行留在buffer里,等下一个 chunk 到来再继续拼接。这确保了 SSE 的data:消息是完整的。
5.3 逐字更新:Vue 响应式系统的魔法
循环体内,每解析出一个 token,我们执行 content.value += token。因为 content 是 ref,它的任何变化都会通知 Vue 的依赖追踪系统,触发组件的重新渲染。所以页面上会一个字一个字地蹦出来,完美模拟打字机效果。

六、融会贯通:用笔记和注释串起整个知识图谱
现在,我们把散落在各处的笔记和注释收拢一下,你会发现它们不是孤立的知识点,而是一整条逻辑链:
-
为什么要流式?
笔记:"复杂的计算,推理生成,耗时挺久,让人等的不耐烦"。于是"不用一次性给,一个token一个token推理生成,实时演示"。这对应了产品体验的痛点。
-
前后端约定
笔记:"服务器约定接受 stream:true token生成就是输出;客户端发送 stream:true 表示流式输出"。这统一了 API 层面的沟通语言。
-
Vue 组件化
笔记:"组件就是组成网页的最小单位,可以想象成乐高积木,方便封装、复用、维护"。然后封装分三部分
<template>,<script>,<style>。这解释了为什么我们的聊天界面、逻辑、样式可以合在一个.vue文件里,并且数据绑定({{ }})、响应式更新都能无缝工作。 -
数据驱动与 v-model
笔记:"表单元素是个例外,用户的输入需要传回数据,双向数据流 v-model"。我们的
<input v-model="question">就是这样,让用户输入立即反映到question变量上,而content的单向绑定则负责展示。 -
脚手架和 Key 的安全
笔记:"vite 前端项目中集成deepseek apiKey?vite 会帮我们读取.env.local,vite 是脚手架(node 写的)"。这让我们知道
import.meta.env.VITE_DEEPSEEK_API_KEY的来源,以及它最终会被打包进前端,只能用于开发调试。 -
挂载点与启动
代码注释:"有且只有一个#app vue/react 挂载点内部,组件,vue/react 接管","// 创建一个App 挂载到#App 挂载点上"。这解释了为什么我们的 Vue 组件能渲染在页面上------整个应用从
main.js开始,把App组件树"种"进#app花盆。 -
流式实现的水管比喻
笔记:"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 应用开发的脉络,欢迎点赞收藏,有什么疑问我们评论区见~