🚀 Vue 3 流式输出实战:从零开始玩转LLM对话!
你以为AI对话边生成边输出是魔法?不!这是前端工程师的浪漫操作!✨
🌟 一、整体功能:让AI在你眼前"活"起来
想象一下:你输入"讲一个喜洋洋和灰太狼的故事,200字",然后每个字都像打字机一样蹦出来 ,而不是等整段答案蹦出来!这就是我们今天要实现的流式输出。用Vue 3 + DeepSeek API,打造一个能实时吐字的AI小助手,让科技感拉满!🎯
废话不多说,看效果:

💻 二、项目初始化:Vite,前端脚手架的扛把子!
"Vite 是最近最优秀的前端脚手架" ------ 这句话我反复念了10遍才敢信!😱
npm init vite@latest
用最新版的 Vite 脚手架初始化一个新项目!执行这行命令后,终端会问你几个问题:
less
Project name: >> my-ai-chat ← 你起的项目名
Select a framework: >> Vue ← 选 Vue
Select a variant: >> JavaScript ← 用 JS(不是 TS)
// 选 **Vue 3** + **JavaScript**(别选TypeScript,新手友好!)
cd your-project+npm install+npm run dev
切换到刚刚创建的项目文件夹+下载项目需要的所有依赖(第三方库)+启动开发服务器在浏览器里看到网页
- 项目结构瞬间搭建好:
src/是你的主战场!📁
所有组件、逻辑、样式都在这里写
css
my-ai-chat/
├── src/
│ ├── App.vue ← 根组件(整个应用的入口)
│ └── main.js ← 启动文件(把 App.vue 挂到 HTML 上)
├── index.html ← 单页应用的 HTML 入口
├── vite.config.js ← Vite 配置文件(比如代理设置)
└── package.json ← 项目信息 + 依赖列表 + 脚本命令
💡 为什么Vite这么香?
传统脚手架要编译几秒,Vite直接秒开!就像从自行车升级到火箭🚀
📝 三、App.vue:三明治结构,前端的"灵魂"!
xml
<script setup> <!-- 逻辑层(核心!) -->
// ja代码
</script>
<template> <!-- 视图层(UI) -->
// html代码
<div>输入框+按钮</div>
</template>
<style scoped> <!-- 样式层(美颜) -->
// css代码
</style>
✨ 三明治精髓 :
你只管写业务逻辑(
script),Vue自动帮你管DOM(template)!以前要写
document.getElementById的苦日子,一去不复返!😭
💡 四、响应式数据:从DOM操作到"写代码像写日记"的革命
1、过去:手动操作 DOM 的"机械时代":
在没有 Vue/React 的年代,想实现一个"点击按钮数字+1"的功能,你得这样写:
✅ 原生 JavaScript(HTML + JS 分离)
xml
html
<!-- index.html -->
<div>
<p id="count">0</p>
<button id="btn">+1</button>
</div>
<script>
let count = 0; // 数据
// 1. 找到按钮和显示区域
const btn = document.getElementById('btn');
const countEl = document.getElementById('count');
// 2. 监听事件
btn.addEventListener('click', () => {
// 3. 修改数据
count++;
// 4. 手动同步到 DOM
countEl.innerText = count;
});
</script>
❌ 问题在哪?
- 要先找 DOM 元素(
getElementById)- 数据变了,必须手动更新 UI
- 如果有多个地方显示
count,你得改 N 次!- 代码像"操作手册":先做A,再做B,最后做C......繁琐且毫无乐趣!
2、现在:Vue 3 的"响应式浪漫时代":
在 Vue 3 中,同样的功能,只需关注数据本身,UI 自动跟着变!
✅ Vue 3 示例(使用 <script setup>)
xml
vue
<!-- App.vue -->
<script setup>
import { ref } from 'vue'
// 1. 定义响应式数据
const count = ref(0)
// 2. 定义业务逻辑(只关心数据!)
function increment() {
count.value++ // 注意:要加 .value!
}
</script>
<template>
<!-- 3. 模板直接消费数据 -->
<div>
<p>{{ count }}</p> <!-- 显示数据 -->
<button @click="increment">+1</button> <!-- 绑定事件 -->
</div>
</template>
✅ 神奇之处:
你只写了
count.value++,没碰任何 DOM!页面上
{{ count }}自动更新!即使有 10 个地方用了
count,也全部同步,零额外代码!
💖 这就是 "声明式编程" vs "命令式编程" 的魅力:旧时代: "你要怎么做" (步骤清单)
新时代: "你想要什么" (描述状态)
🔥 五、对代码的深度解读:流式响应处理(重点!)
1️⃣ 请求三件套:向 LLM 发起"召唤"
javascript
js
const endpoint = '/api/chat/completions'
const headers = {
'Authorization': `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`,
'Content-Type': 'application/json'
}
const body = {
model: 'deepseek-chat',
stream: true,
messages: [{ role: 'user', content: question.value }]
}
🔑 关键点解释:
-
/api/chat/completions- 这不是直接调 DeepSeek 官方 API!
- 而是通过 Vite 开发服务器代理 (在
vite.config.js中配置)。 - 为什么?→ 绕过浏览器 CORS 限制(后面详述)。
-
stream: true-
告诉 LLM:"请一个字一个字地吐出来,别等全部生成完!"
-
服务器会以 SSE(Server-Sent Events) 格式返回数据:
csstext data: {"choices": [{"delta": {"content": "喜"}}]} data: {"choices": [{"delta": {"content": "羊"}}]} data: [DONE]
-
-
VITE_DEEPSEEK_API_KEY- Vite 只会暴露以
VITE_开头的环境变量到前端。 - ⚠️ 注意:生产环境中绝不能直接在前端用 API Key!
(这里仅用于开发,真实项目应由后端代理)
- Vite 只会暴露以
-
Content-Type
-
这是 HTTP 请求头(Headers)中的一个字段,用于声明请求体(body)的数据格式。
-
DeepSeek、OpenAI、Anthropic 等主流 LLM API 都遵循 OpenAI 兼容协议。
-
它们只接受 JSON 格式的请求体。
-
如果你不声明
Content-Type: application/json,服务器可能:- 拒绝请求(400 Bad Request)
- 无法正确解析你的
body(当成纯文本处理)
-
2️⃣ 流式响应处理:核心循环逻辑
📌 核心代码(关键部分):
javascript
let buffer = '' // !核心变量!
while (!done) {
const { value, done: doneReading } = await reader.read()
done = doneReading
const chunkValue = buffer + decoder.decode(value) // 拼接缓冲
buffer = ''
// 按行分割
const lines = chunkValue.split('\n').filter(line => line.startsWith('data: '))
for (const line of lines) {
const incoming = line.slice(6) // 去掉 "data: "
if (incoming === '[DONE]') break
try {
const data = JSON.parse(incoming)
content.value += data.choices[0].delta.content
} catch {
buffer += `data: ${incoming}` // !关键!存回缓冲
}
}
}
📦 初始化关键变量
csharp
js
let buffer = '' // 👑 灵魂变量:暂存"没说完"的数据
let done = false // 标记流是否结束
const reader = response.body.getReader()
const decoder = new TextDecoder() // 把二进制转成字符串
💡
response.body是一个 ReadableStream,只能顺序读取。
🔁 主循环:一块一块读数据
bash
js
while (!done) {
const { value, done: doneReading } = await reader.read()
done = doneReading
-
reader.read()返回一个 Promise,解析后得到:value: 当前 chunk(Uint8Array 二进制数据)done: 是否已读完(最后一个 chunk 后为 true)
🧩 第一步:拼接缓冲区 ------ buffer 登场!
ini
js
const chunkValue = buffer + decoder.decode(value)
buffer = ''
- 把 上一轮没解析完的数据(buffer) + 当前新 chunk 拼成完整字符串。
- 然后清空
buffer(准备接收下一轮"残片")。
✅ 举例:
- 上次剩:
'data: {"choices": [{"delta": {"content": "喜羊羊'- 本次收到:
'和灰太狼"}}]}\n'- 拼接后:
'data: {"choices": [{"delta": {"content": "喜羊羊和灰太狼"}}]}\n'→ 完整!
✂️ 第二步:按行分割,只处理有效行
ini
js
const lines = chunkValue.split('\n')
.filter(line => line.startsWith('data: '))
- SSE 协议以
\n分隔每一行。 - 只保留以
data:开头的行(忽略空行或其他控制信息)。
🧪 第三步:逐行解析 JSON
kotlin
js
for (const line of lines) {
const incoming = line.slice(6) // 去掉 "data: "
if (incoming === '[DONE]') {
done = true
break
}
try {
const data = JSON.parse(incoming) // 反序列化,将JSON字符串转化为真正的JavaScript对象
// 若是一个完整的JSON字符串,这里就可以反序列化成功,否则进入catch
const delta = data.choices[0].delta.content
if (delta) content.value += delta
} catch (err) {
// ❗ 解析失败 → 说明这行不完整!
buffer += `data: ${incoming}`
}
}
🎯 关键细节:
-
line.slice(6)- 去掉
data:前缀,得到纯 JSON 字符串。
- 去掉
-
[DONE]终止信号- LLM 流结束时会发送
data: [DONE],此时退出循环。
- LLM 流结束时会发送
-
try...catch+buffer回写- 如果
JSON.parse失败(比如incoming = '{"choices": [{"delta": {"conten'),说明这一行被截断了。 - 于是把它原样加回
buffer(注意加上data:前缀),等下次 chunk 到达再拼!
- 如果
💡 这就是
buffer的魔法:永不丢弃任何数据片段,直到它变成合法 JSON!
📊 stream、done、buffer 的角色总览
| 变量 | 作用 | 通俗比喻 |
|---|---|---|
stream |
是否开启流式 | 开/关"打字机模式" |
done |
流是否结束 | "AI说完了没?" |
buffer |
暂存未完成的JSON | "等你把话说完再听" |
🎯 关键逻辑:
- 每次读取新数据 → 拼到
buffer- 按
\n分割 → 逐行解析- 解析失败 → 丢回
buffer(等待下次拼接)
🔄 六、流式 vs 非流式:体验大不同!
| 类型 | 体验 | 代码 | 适用场景 |
|---|---|---|---|
| 流式 | 每个字蹦出来(像真人打字) | stream: true |
需要实时反馈的场景(AI对话) |
| 非流式 | 等整段答案出来 | stream: false |
简单查询(如天气API) |
💡 为什么流式更香?
用户体验:等待3秒 vs 等3秒+实时看到"正在生成..."!
"思考中..." → "喜羊羊和灰太狼去野餐..." → "结果被红太狼发现了!"体验感直接拉满!🎯
⚠️ 七、CORS 错误:浏览器的"安全门卫"在作妖!
错误提示 :
"浏览器阻止了从 http://localhost:5173 请求 api.deepseek.com"🔥 原因:浏览器安全策略(CORS),阻止直接跨域请求!
❌ 为什么不能直接写?
scss
fetch('https://api.deepseek.com/chat/completions') // ❌ 被浏览器拦了!
✅ 正确方案:Vite 代理!
javascript
// vite.config.js
export default defineConfig({
server: {
proxy: {
'/api': {
target: 'https://api.deepseek.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^/api/, '')
}
}
}
})
🌈 代理原理:
- 前端请求
/api/chat/completions- Vite 代理服务器转成
https://api.deepseek.com/chat/completions- 浏览器只看到 localhost,不会触发CORS!
安全 + 体验双杀! 🔥
💎 八、总结:从"写代码"到"写故事"的蜕变
| 项目 | 以前 | 现在 |
|---|---|---|
| 项目初始化 | 配置Webpack、Babel... | npm init vite → 3秒搞定! |
| 响应式 | 手动操作DOM | ref + v-model → 业务优先! |
| 流式输出 | 无法实现 | buffer + ReadableStream → 字字清晰! |
| CORS | 搞不定 | Vite代理 → 一行配置搞定! |
✨ 终极感悟 :
Vue 3 不是框架,是前端工程师的浪漫 ------
你只管写"我想让AI说'喜羊羊和灰太狼'...",
Vue默默帮你把"打字机效果"实现得丝滑到哭!
代码写得像写诗,体验做得像魔术! 🌟