你是否还在让用户盯着空白屏幕等待 AI 生成结果?Streaming 流式输出是 AI 产品体验的“生命线”。本文将带你从零搭建一个 Vue 3 项目,手写一套完整的流式对话功能,并深入解析二进制流、SSE 协议等核心概念。全程高能注释,新手也能轻松跟练。
一、为什么流式输出是 AI 产品的“标配”?
在传统请求中,用户点击提交后,必须等后端完全计算完毕才能看到结果。对于大语言模型(LLM),一次推理可能需要几秒甚至十几秒,用户会感到焦虑、不确定,甚至以为页面卡死了。
流式输出(Streaming)的思路很简单:生成一个 token,就立即推送一个 token。就像在客户端和服务端之间接了一根水管,数据像水流一样源源不断涌来,页面上呈现出“打字机”效果。这不仅让用户感知到“系统正在工作”,还能提前阅读部分内容,体验提升是质的飞跃。
要实现流式输出,需要前后端双向约定:
- 服务端支持
stream: true参数,并返回text/event-stream格式。 - 客户端发送该参数,并准备处理分块响应。
本文我们使用 DeepSeek API 作为后端,Vue 3 作为前端,完整实现这一过程。
二、项目结构速览
text
stream-demo2/
├── .vscode/
│ └── extensions.json
├── 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.config.js # Vite 配置文件(可选)
这是一个标准的 Vite + Vue 3 项目。我们所有的业务逻辑都写在 App.vue 中,便于演示。
三、Vue 3 组合式 API 快速回顾
在开始之前,简单梳理几个核心概念,确保阅读顺畅:
<script setup>:Vue 3 的编译时语法糖,让组件代码更简洁,无需export default,顶层变量自动暴露给模板。ref:将基本类型或对象变为响应式数据,访问时需用.value,模板中自动解包。v-model:双向数据绑定,常用于表单输入。@click:事件绑定,调用方法。
这些知识点在我们接下来的代码中会反复出现,注释会一一说明。
四、核心代码逐行解析(App.vue)
为了方便阅读,我将代码拆分为 模板(Template) 、脚本(Script) 和 样式(Style) 三部分,每一行都附上详细注释。
4.1 模板部分 —— 界面与数据绑定
vue
<template>
<!--
整个应用只有一个容器,采用 flex 列布局
v-model 实现双向绑定,用户输入自动同步到 question
@click 触发 update 方法
-->
<div class="container">
<!-- 输入区域 -->
<div>
<label>输入:</label>
<input class="input" v-model="question" />
<button @click="update">提交</button>
</div>
<!-- 输出区域 -->
<div class="output">
<div>
<label>Streaming</label>
<!-- checkbox 绑定 stream 状态,true 表示开启流式 -->
<input type="checkbox" v-model="stream" />
</div>
<!-- 展示 LLM 返回的内容,支持多行文本 -->
<div>{{ content }}</div>
</div>
</div>
</template>
4.2 脚本部分 —— 响应式状态与请求逻辑
这是文章最核心的部分,我们采用 组合式 API 将相关逻辑聚合在一起,便于维护。
vue
<script setup>
// ============================================================
// 1. 导入 Vue 响应式 API
// ============================================================
import { ref } from 'vue'
// ============================================================
// 2. 定义响应式状态(数据状态)
// ============================================================
const question = ref('讲一个中国龙的故事') // 用户输入的问题
const stream = ref(true) // 是否开启流式输出(默认开启)
const content = ref('') // 存储 LLM 返回的完整内容
// ============================================================
// 3. 核心方法:处理提交事件
// ============================================================
const update = async () => {
// 空值校验
if (!question.value) return
// 设置占位文字,提示用户开始请求
content.value = '思考中......'
// ==========================================================
// 3.1 配置请求参数
// ==========================================================
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}`
}
// ==========================================================
// 3.2 发起 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 // 关键:是否开启流式
})
})
// ==========================================================
// 3.3 分支一:流式输出(stream: true)
// ==========================================================
if (stream.value) {
content.value = '' // 清空占位,准备逐字追加
// --- 3.3.1 获取可读流读取器 ---
// response.body 是一个 ReadableStream 对象,代表响应体的二进制数据流
console.log(response.body) // 打印查看结构
// 调用 getReader() 获取读取器,就像在水管上装了一个水龙头
const reader = response.body?.getReader()
console.log(reader)
// --- 3.3.2 准备解码器与缓冲区 ---
// TextDecoder 用于将二进制(Uint8Array)转为文本
const decoder = new TextDecoder()
let done = false // 是否读取完成(遇到 data: [DONE])
let buffer = '' // 缓冲区,处理不完整的 chunk
// --- 3.3.3 循环读取数据块 ---
while (!done) {
// 读取一块数据:返回 { value: Uint8Array, done: boolean }
// 如果流结束,done 为 true
const { value, done: doneReading } = await reader?.read()
done = doneReading
// 解码当前块,stream: true 表示可能还有后续数据,保留内部状态
const chunk = decoder.decode(value, { stream: true })
buffer += chunk // 拼接到缓冲区
// --- 3.3.4 解析 SSE 格式数据 ---
// SSE 数据格式为多行,每行以 "data: " 开头,最后以 "data: [DONE]" 结束
// 按换行分割,过滤掉空行和结束标记
const lines = buffer.split('\n')
.filter(line => line.startsWith('data:') && line !== 'data: [DONE]')
// 保留未处理完的碎片(例如某一行被截断)
// 这里简单处理:将 buffer 重置为最后一行之后的内容(实际更严谨应保留不完整行)
// 简单起见,我们采用更稳健的方式:不截断 buffer,而是每次处理所有完整行后清空
// 下面采用更推荐的写法:
// 将所有完整行取出,将 buffer 剩余部分保留
// 为简化演示,这里使用更常见的处理方式:
const linesArray = buffer.split('\n')
const validLines = linesArray.filter(line => line.startsWith('data:') && line !== 'data: [DONE]')
// 将 buffer 重置为未处理完的部分(最后一行可能不完整)
// 此处演示一种常见处理:保留最后一行(可能不完整)
const lastIndex = buffer.lastIndexOf('\n')
buffer = buffer.slice(lastIndex + 1)
// 遍历每一行,提取 token
for (const line of validLines) {
// 去掉 "data: " 前缀,得到 JSON 字符串
const jsonStr = line.replace('data: ', '')
try {
const resJson = JSON.parse(jsonStr)
// delta.content 是本次新增的文本片段
const token = resJson.choices[0].delta?.content || ''
// 追加到响应式变量,Vue 自动更新视图(打字效果)
content.value += token
} catch (err) {
// 解析失败(如遇到不完整的 JSON),跳过继续
continue
}
}
}
}
// ==========================================================
// 3.4 分支二:非流式输出(stream: false)
// ==========================================================
else {
const data = await response.json()
// 直接取完整结果
content.value = data.choices[0].message.content
}
}
// ============================================================
// 小彩蛋:演示响应式数据(注释掉,供参考)
// ============================================================
// const count = ref(0)
// console.log(count, count.value)
</script>
💡 关键点解读:
response.body是一个ReadableStream对象,属于浏览器原生 API,用于处理流式数据。getReader()返回一个读取器,通过read()异步读取数据块,每次返回{ value, done }。TextDecoder将二进制(Uint8Array,值范围 0~255)解码为字符串。- SSE 格式:每行
data: {...},最后一行data: [DONE]表示结束。- 缓冲区
buffer用于拼合可能被截断的 chunk,确保 JSON 完整解析。
4.3 样式部分 —— 流式布局与移动端适配
vue
<style>
/* ============================================================
文档流(Normal Flow)是页面布局的基础:
块级元素从上到下,行内元素从左到右。
每个盒子在文档流中都有自己的位置和大小(盒模型)。
通过 display: flex 开启新的格式化上下文(BFC),
让子元素按 flex 规则排列。
============================================================ */
.container {
display: flex;
flex-direction: column; /* 列方向排列 */
align-items: start; /* 交叉轴起点对齐 */
justify-content: start; /* 主轴起点对齐 */
height: 100vh; /* 占满视口高度 */
font-size: 0.85rem; /* 移动端适配,使用相对单位 */
padding: 20px;
box-sizing: border-box;
}
.input {
width: 200px;
padding: 6px 10px;
}
.output {
margin-top: 10px;
min-height: 300px;
width: 100%;
text-align: left;
border: 1px solid #ccc;
padding: 10px;
border-radius: 6px;
background: #f9f9f9;
white-space: pre-wrap; /* 保留换行和空格 */
}
button {
padding: 0 10px;
margin-left: 6px;
cursor: pointer;
}
</style>
五、深入理解二进制流与编解码
在流式处理中,我们频繁接触到 Uint8Array 和 TextDecoder/TextEncoder,这里额外解释一下:
- Uint8Array:一个 0~255 之间的无符号整数数组,是二进制数据的原始表示。例如,中文字符 “你好” 通过 UTF-8 编码会变成多个字节。
- TextEncoder:将字符串编码为 Uint8Array。
- TextDecoder:将 Uint8Array 解码为字符串。
我们可以在 JS1.js 中做个实验(见图片):
javascript
const encoder = new TextEncoder()
const bytes = encoder.encode("你好")
console.log(bytes) // Uint8Array(6) [228, 189, 160, 229, 165, 189]
const decoder = new TextDecoder()
const str = decoder.decode(bytes)
console.log(str) // "你好"
在流式场景中,decoder.decode(value, { stream: true }) 告诉解码器:数据可能不完整,保留内部状态以便下次继续解码。这是处理流式文本的关键。
六、Vite 热更新与开发体验
在项目开发中,Vite 提供了极快的热更新(HMR)。当你修改 .vue 文件时,Vite 会局部替换修改的组件,而不会刷新整个页面,从而保留页面状态(如输入框内容)。这比传统 Webpack 的刷新体验好得多。
- 环境变量通过
.env.local文件管理,Vite 使用import.meta.env读取,且只有以VITE_开头的变量才会暴露给客户端。 vite.config.js可自定义配置,但本 Demo 不需要额外配置。
七、总结与延伸思考
| 模块 | 技术点 | 作用 |
|---|---|---|
| 前端框架 | Vue 3 + <script setup> | 组件化开发,响应式数据驱动视图 |
| 数据绑定 | v-model、{{ }} | 实现双向绑定和单向渲染 |
| 流式请求 | fetch + ReadableStream | 处理分块响应,实时获取 token |
| 数据解析 | SSE 格式 + TextDecoder | 将二进制流还原为文本,提取增量内容 |
| 环境管理 | .env.local + import.meta.env | 安全存储 API Key |
| 开发工具 | Vite | 极速热更新,提升开发效率 |
进阶思考:
- 实际生产环境中,需要处理网络异常、重试机制、取消请求等。
- 对于更复杂的场景,可以封装一个通用的
useSSE组合式函数,复用流式逻辑。 - 也可以考虑使用
EventSourceAPI(原生 SSE 客户端),但它只支持 GET 请求,而我们的对话接口需要 POST 携带消息体,因此用fetch+ReadableStream更灵活。
前端工程师的价值,在于将复杂的技术细节封装成丝滑的用户体验。 流式输出只是一个起点,未来 Agent 时代,我们还需要学会将工作拆分:把 AI 擅长的交给 Agent,我们负责审核与兜底。项目初始化也不必从零开始,可以直接从 GitHub 拉取模板工程——但理解底层原理,永远是进阶的基石。
希望这篇文章能帮你彻底搞懂 Vue 3 + 流式输出的全链路。如果觉得有帮助,欢迎点赞、收藏、评论,我们下期再见!🚀