用户体验的核心在于“快”与“实时”,而流式输出(Streaming)正是 AI 对话产品中提升体验的关键技术。本文将从零开始,带你手写一个 Vue3 流式对话 Demo,并深入解析其背后的原理。
一、为什么要做流式输出?
在传统的 HTTP 请求中,客户端发起请求后,需要等待服务端完全处理完毕,再将完整结果一次性返回。这种方式在面对 LLM(大语言模型) 这类需要大量推理计算的场景时,会带来明显的等待焦虑——用户盯着空白屏幕,不知道发生了什么。
流式输出(Streaming) 的解决方案是:生成一个 token,就输出一个 token。就像水流一样,LLM 服务器和客户端之间“接了一根管子”,token 源源不断地流向客户端,实时展示在界面上。
这种 “打字机效果” 极大地提升了用户体验,也是目前主流 AI 聊天产品的标配。
二、技术背景:SSE 与 ReadableStream
流式输出依赖服务端和客户端的双向约定:
- 服务端约定:支持
stream: true参数,开启后边生成边输出。 - 客户端约定:请求时携带
stream: true,并准备处理流式响应。
在浏览器端,我们通过 Fetch API 返回的 response.body 获取一个 ReadableStream 可读流,然后逐块读取、解析、渲染。
如果你还不熟悉 SSE(Server-Sent Events)或 ReadableStream,没关系,本文会带你一步步实现。
三、项目概览
先来看看我们的项目结构:
text
stream-demo/
├── .vscode/
├── node_modules/
├── public/
├── src/
│ ├── assets/
│ ├── components/
│ ├── App.vue # 根组件,包含所有逻辑
│ ├── main.js # 入口文件
│ └── style.css # 全局样式
├── .env.local # 环境变量(存放 API Key)
├── .gitignore
├── index.html
├── package.json
└── pnpm-lock.yaml
这是一个标准的 Vite + Vue3 项目,我们主要关注 App.vue 和 .env.local。
四、Vue 3 基础速览
如果你已经熟悉 Vue 3,可以跳过这一节。但为了照顾新手,我们快速梳理几个核心概念:
1. 组件化(Component)
Vue 组件以 .vue 文件为单位,将 模板(HTML) 、逻辑(JS) 和 样式(CSS) 封装在一起,形成可复用、易维护的“乐高积木”。Facebook 的网页由一万多个组件组成,足见其威力。
2. 数据绑定与响应式
- 单向数据流:模板中使用
{{ count }}绑定数据,数据变化时视图自动更新。 - 双向数据绑定:表单元素(如
<input>)使用v-model,用户输入可以自动同步到数据状态。
3. <script setup> 语法糖
Vue 3 新增的编译时语法糖,让组件代码更简洁。配合 ref / reactive 定义响应式数据,且无需 return,模板中可直接使用。
五、核心代码解析(App.vue 逐行注释)
下面是完整的 App.vue 代码,我加上了详尽的注释,方便你理解每一部分的作用。
vue
<template>
<!--
模板部分:定义界面结构
数据绑定 {{ message }} 会动态渲染
-->
<div class="container">
<!-- 输入区域 -->
<div>
<label>输入:</label>
<!-- v-model 实现双向数据绑定 -->
<input type="text" class="input" v-model="question" />
<button @click="update">提交</button>
</div>
<!-- 输出区域 -->
<div class="output">
<div>
<label>Streaming</label>
<!-- checkbox 绑定 stream 状态 -->
<input type="checkbox" v-model="stream" />
<div v-if="stream">
<label>Streaming 模式已开启</label>
</div>
<div>
<!-- 渲染 LLM 返回的内容 -->
{{ content }}
</div>
</div>
</div>
</div>
</template>
<script setup>
// ============================================================
// 脚本部分:组件逻辑
// ============================================================
// 1. 导入 Vue 响应式 API
import { ref } from 'vue'
// 2. 定义响应式状态
const question = ref('讲一个中国龙的故事') // 用户问题
const stream = ref(false) // 是否开启流式输出
const content = ref('') // LLM 返回的内容
// 3. 核心方法:发送请求并处理响应
const update = async () => {
// 校验:问题不能为空
if (!question.value) return
// 设置初始状态
content.value = '思考中......'
// ==========================================================
// 配置请求参数
// ==========================================================
const endpoint = 'https://api.deepseek.com/chat/completions'
const headers = {
'Content-Type': 'application/json',
// 从环境变量读取 API Key,安全性更高
Authorization: `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`
}
// 发起 fetch 请求
const response = await fetch(endpoint, {
method: 'POST',
headers,
body: JSON.stringify({
model: 'deepseek-v4-flash',
messages: [{ role: 'user', content: question.value }],
stream: stream.value // 关键:是否开启流式输出
})
})
// ==========================================================
// 分支一:流式输出(stream: true)
// ==========================================================
if (stream.value) {
content.value = '' // 清空占位文字
// 获取响应体的可读流
const reader = response.body?.getReader()
const decoder = new TextDecoder() // 解码器:二进制 → 文本
let done = false
let buffer = '' // 缓冲区,用于处理不完整的 SSE 数据
while (!done) {
// 读取数据块(二进制 Unit8Array)
const { value, done: doneReading } = await reader.read()
done = doneReading
// 解码二进制数据,stream: true 表示可能还有后续数据
const chunk = decoder.decode(value, { stream: true })
buffer += chunk
// 按换行符分割,得到每一条 SSE 数据
const lines = buffer.split('\n').filter(line => line && line !== 'data: [DONE]')
// 保留未处理完的碎片(可能被截断)
buffer = buffer.slice(buffer.lastIndexOf('\n') + 1)
// 逐行解析,提取 token
for (const line of lines) {
const jsonStr = line.replace('data: ', '')
try {
const resJson = JSON.parse(jsonStr)
// delta.content 是本次新增的 token,不是完整回复
const token = resJson.choices[0].delta?.content || ''
// 追加到响应式变量,页面自动更新(打字机效果)
content.value += token
} catch (err) {
// 遇到解析失败的碎片,跳过继续
continue
}
}
}
}
// ==========================================================
// 分支二:非流式输出(stream: false)
// ==========================================================
else {
const data = await response.json()
content.value = data.choices[0].message.content
}
}
// 小彩蛋:演示响应式数据更新(注释掉)
// let count = ref(1)
// setTimeout(() => {
// count.value = 2
// }, 2000)
</script>
<style>
/* 样式部分:略,可根据需要自定义 */
</style>
六、流式输出核心原理解析
6.1 什么是 SSE?
SSE(Server-Sent Events)是一种基于 HTTP 的轻量级推送技术。服务端返回的 Content-Type 为 text/event-stream,数据格式为:
text
data: {"choices":[{"delta":{"content":"你"}}]}
data: {"choices":[{"delta":{"content":"好"}}]}
data: [DONE]
每一条 data: 开头的数据都是一个独立的 JSON 片段,客户端按行解析并逐步渲染。
6.2 ReadableStream 处理流程
- 获取读取器:
response.body.getReader() - 逐块读取:
reader.read()返回{ value, done } - 解码:使用
TextDecoder将二进制转为文本 - 分割与解析:按
\n分割,过滤空行和[DONE]标记 - 增量渲染:将
delta.content追加到content.value,触发 Vue 响应式更新
6.3 缓冲区(Buffer)的作用
由于网络传输的不可控性,一个完整的 SSE 数据包可能被拆分成多个 chunk。缓冲区 buffer 暂存未处理完的碎片,确保数据完整解析。
七、环境变量配置(.env.local)
在项目根目录创建 .env.local 文件,Vite 会自动读取:
env
VITE_DEEPSEEK_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
VITE_DEEPSEEK_API_URL=https://api.deepseek.com/chat/completions
注意:以
VITE_开头的变量才会暴露给客户端,且.env.local应加入.gitignore,避免密钥泄露。
八、运行项目
bash
# 安装依赖
pnpm install
# 启动开发服务器
pnpm run dev
访问 http://localhost:5173,输入问题,勾选 "Streaming",点击提交,即可看到打字机效果。
九、总结与思考
通过这个小 Demo,我们不仅学会了如何在 Vue3 中集成 DeepSeek API,还深入理解了:
| 知识点 | 要点 |
|---|---|
| 流式输出 | 边生成边传输,提升用户体验 |
| SSE 协议 | data: 格式,[DONE] 结束标记 |
| ReadableStream | 浏览器原生 API,处理二进制流 |
| TextDecoder | 解码二进制数据为文本 |
| Vue 响应式 | ref + v-model 实现数据驱动视图 |
| 环境变量 | Vite 通过 import.meta.env 读取 .env.local |
前端工程师的职责,不仅仅是实现功能,更是打造极致的用户体验。流式输出正是 AI 产品中“体验优先”的典型实践。