目标读者:正在学习现代前端开发,想理解"流式输出"到底是怎么做出来的。
一、为什么需要流式输出?
你有没有遇到过这种情况:打开一个 AI 聊天应用,输入问题,然后......干等。
css
用户:讲一个关于中国龙的故事
↓
[等待...等待...等待...] ← 10秒过去了
↓
[一次性返回全部答案]
这就像去餐厅点菜,厨师做完所有菜才一起端上来------你在前面饿得不行。流式输出(Streaming) 就是做完一道端一道:
erlang
用户:讲一个关于中国龙的故事
↓
"在" → "遥远" → "的东方" → "有一条" → "巨龙" → ...
↑ 一个字一个字实时显示,像打字机一样
打个比方:
像水流一样,LLM 服务器和 chatbot 客户端之间接根管子,生成的 token 就像水流一样不断流向客户端。
二、项目骨架 ------ 文件都在干什么?
先看一眼项目结构(核心文件):
go
stream-demo/
├── index.html ← 页面入口,"插座"
├── package.json ← 项目配置,依赖声明
└── src/
├── main.js ← Vue 应用启动,"插头"
├── App.vue ← 根组件,核心逻辑都在这里
├── style.css ← 全局样式(全局重置)
└── components/
└── HelloWorld.vue ← 欢迎页组件(Vite 脚手架自带)
第一步:index.html ------ 一个空壳子
html
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
就一个 <div id="app"></div>,空的。它只是个挂载点------Vue 的"地盘"从这里开始。外面的事我不管,里面的一切由 Vue 接管。
第二步:main.js ------ 插上电源
js
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app') // 把 App 组件"插"进 #app
一行代码把整个 Vue 应用跑起来------createApp(App).mount('#app'),就像把插头插进插座。
第三步:App.vue ------ 主角登场
这是项目的灵魂文件。一个 .vue 文件分三部分:
xml
┌────────────────────────────────┐
│ <template> 你看到的页面 │
│ <script> 数据和逻辑 │
│ <style> 样式和美化 │
└────────────────────────────────┘
这就是 组件化思想------把 HTML、CSS、JS 打包成一个独立的、可复用的业务单元。Facebook 的网页由一万多个组件组成,就像乐高积木一样拼出整个应用。
三、数据绑定 ------ 告别手动操作 DOM
数据绑定的核心理念就是:
template 绑定数据,不需要 DOM 编程。
什么意思?对比一下就清楚了:
js
// ❌ 传统做法:手动 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.vue 里 update 函数的完整流程:
4.1 非流式模式(Stream = false)
javascript
用户输入 → 发送请求 → 等待LLM全部生成完 → 一次性返回整个 JSON → 显示
js
const data = await response.json();
content.value = data.choices[0].message.content; // 全部内容一次性赋值
简单粗暴,但等得久。
4.2 流式模式(Stream = true)
这是精华部分:
js
// 请求体里加上 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); // 读到一点就立马追加显示
}
用图来表示就是:
erlang
LLM 服务器 Chatbot 客户端
│ │
│──── token1: "在" ──────────────────→ │ content.value += "在"
│──── token2: "遥远" ────────────────→ │ content.value += "遥远"
│──── token3: "的" ──────────────────→ │ content.value += "的"
│──── token4: "东方" ────────────────→ │ content.value += "东方"
│──── ...继续... ────────────────────→ │ ...不断拼接...
│──── [DONE] ────────────────────────→ │ 读取结束
核心就两步:
- 服务器端 :接受
stream: true,token 生成一个就输出一个 - 客户端 :用
ReadableStream的 reader 不断读取,读到就拼接到content上
因为 content 是响应式数据(ref),所以每次追加内容,页面自动更新------用户就看到打字机一样的效果。
五、完整数据流
把整个项目串起来看,一次完整的对话过程是这样的:
markdown
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 产品的用户体验从"等到焦虑"变成了"丝滑享受" 🎯