文章目录
-
- 一、什么是流式输出?为什么需要它?
- 二、客户端与服务端的约定
- [三、Vue 基础:`.vue` 组件文件(已熟悉的可跳过)](#三、Vue 基础:
.vue组件文件(已熟悉的可跳过)) - [四、动手实战:Vue3 + DeepSeek 流式输出](#四、动手实战:Vue3 + DeepSeek 流式输出)
-
- [4.1 项目结构与入口文件](#4.1 项目结构与入口文件)
- [4.2 API Key 配置](#4.2 API Key 配置)
- [4.3 完整代码实现](#4.3 完整代码实现)
- [4.4 流式输出核心逻辑拆解](#4.4 流式输出核心逻辑拆解)
- 五、项目文件清单
- 六、全文总结
- 七、核心知识点复盘
- [八、常见问题 / 避坑指南](#八、常见问题 / 避坑指南)
一、什么是流式输出?为什么需要它?
一次性返回的痛点
当你调用大语言模型(LLM)的 API 时,如果不开启流式输出,整个流程是这样的:
用户发送问题 → 服务器开始推理 → 推理完成 → 一次性返回全部结果 → 前端渲染
推理过程可能非常漫长------比如"用 JavaScript 写一个贪吃蛇游戏",LLM 可能需要推理 30 秒甚至更久。用户盯着空白页面干等半分钟,这是 AI 产品绝不能出现的体验。
流式输出的解决思路
不要等所有结果都生成完再返回,而是生成一个 token 就立即返回一个 token:
用户发送问题 → 服务器开始推理 → 生成第1个token立即发送 → 生成第2个token立即发送 → ... → 生成完毕
↓ ↓ ↓
前端显示第一个字 前端追加第二个字 前端持续拼接
可以把它想象成两根水管 :LLM 服务器是出水口,客户端是接水口,生成的 token 像水流一样源源不断地从服务器流向客户端。最终效果就是 Chatbot 像老式打字机一样,一个字一个字地把答案"打"出来,用户感知到"AI 正在思考"而不是"卡死了"。
时间消耗在哪里?
LLM 生成回答的耗时主要来自两方面:
| 耗时来源 | 说明 |
|---|---|
| Transformer 推理计算 | 每次生成下一个 token 都需要走一遍完整的神经网络前向传播,计算密集型操作 |
| 问题复杂度 | 问题越难、越长,需要生成的 token 数越多,总耗时自然越长 |
流式输出并不能减少总耗时,但它把"等待 30 秒 → 看到完整答案"变成"等待 0.5 秒 → 看到第一个字 → 持续看到新字",用户的感知等待时间大幅缩短。
二、客户端与服务端的约定
流式输出需要客户端和服务端配合才能实现。
以 DeepSeek API(兼容 OpenAI 协议)为例,服务端收到请求后检查 stream 字段:
stream: true→ 采用 SSE(Server-Sent Events) 格式,逐 token 推送数据stream: false→ 等全部生成完毕后,一次性返回完整 JSON
客户端在发送请求时,需要在请求体中明确告知服务端:
json
{
"model": "deepseek-v4-flash",
"messages": [{ "role": "user", "content": "讲一个故事" }],
"stream": true
}
这个 stream: true 就是客户端和服务端之间的"暗号"------双方约好用流式的方式传输数据。服务端看到这个字段为 true,就会切换为 SSE 模式,每当 LLM 推理出一个新 token,就立即推送给客户端。
三、Vue 基础:.vue 组件文件(已熟悉的可跳过)
什么是组件?
现代前端框架(Vue / React)都遵循组件化思想 ------把页面上一个独立、可复用的功能块封装成一个 .vue 文件。就像乐高积木一样,页面由一个个组件拼装而成。组件化带来三个好处:
- 封装:HTML、CSS、JS 放在一起,职责清晰
- 复用:同一组件可在多处使用
- 维护:改一个功能只需改对应的组件文件
一个 .vue 组件文件由三个区块组成:
<template> → HTML 模板(动态的,可绑定数据)
<script> → JS 逻辑(数据、方法、生命周期)
<style> → CSS 样式
template(模板)
不同于静态 HTML,template 里可以写插值表达式 {``{ }} 来绑定数据。当数据变化时,页面对应内容自动更新------不再需要手动操作 DOM。
script(脚本)
Vue3 推荐使用 <script setup> 语法糖,在 <script> 里定义的变量和函数可以直接在 template 中使用:
html
<script setup>
import { ref } from 'vue'
const count = ref(1); // ref 创建一个响应式数据
count.value = 2; // script 里要用 .value 访问
</script>
ref() 是 Vue 创建响应式数据 的核心 API。它的工作原理是:Vue 内部用 JavaScript 的 Proxy 对象拦截对 .value 的读写操作,当值发生变化时自动通知所有用到它的地方进行更新------这就是"数据驱动视图"的底层实现。
理解双向绑定 v-model
Vue 的数据流通常是单向的 (数据变了 → 视图更新)。但表单元素是个例外------用户可以在输入框里打字,数据需要从视图"流回"到数据。v-model 实现了双向数据绑定:
html
<input type="text" v-model="question" />
- 数据 → 视图:
question的值显示在输入框中 - 视图 → 数据:用户输入内容时,
question自动更新
v-model的底层其实是:value(属性绑定)+@input(事件监听)的语法糖。
四、动手实战:Vue3 + DeepSeek 流式输出
4.1 项目结构与入口文件
src/main.js ------ 应用的启动入口:
js
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
// 创建 Vue 应用实例并挂载到 #app 节点
createApp(App).mount('#app')
index.html ------ 浏览器加载的 HTML 页面:
html
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>stream-demo</title>
</head>
<body>
<!-- #app 是 Vue 的挂载点,Vue 会接管这个 div 内部的一切 -->
<div id="app"></div>
<!-- 入口文件:浏览器从这里开始加载 Vue 应用 -->
<script type="module" src="/src/main.js"></script>
</body>
</html>
整个启动链路:index.html → main.js → createApp(App).mount('#app') → Vue 接管 #app 下所有渲染。
4.2 API Key 配置
在 Vite 项目中,环境变量通过 .env.local 文件配置:
VITE_DEEPSEEK_API_KEY=sk-xxxxxxxxxxxxx
为什么用
.env.local? Vite(基于 Node.js 的构建工具)启动时会自动读取.env.local中的变量并注入到前端代码。变量名必须以VITE_开头才能被前端访问------这是 Vite 的安全机制,防止后端敏感变量意外泄露到浏览器端。
在代码中通过 import.meta.env.VITE_DEEPSEEK_API_KEY 读取。这个 API Key 会在构建时被静态替换为实际值,打入最终产物中。
4.3 完整代码实现
下面是 App.vue 的完整代码,同时支持流式和非流式两种模式:
html
<template>
<div class="container">
<div class="input-area">
<label>输入:</label>
<input type="text" class="input" v-model="question" />
<button @click="update">提交</button>
</div>
<div class="output">
<div class="stream-toggle">
<label>Streaming</label>
<input type="checkbox" v-model="stream" />
<span v-if="stream">(流式模式已开启)</span>
</div>
<!-- 核心:AI 回复内容在这里实时渲染 -->
<div class="content">{{ content }}</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
// ========== 响应式数据 ==========
const question = ref('讲一个关于中国龙的故事')
const stream = ref(false)
const content = ref('')
// ========== 核心方法:发送请求并处理响应 ==========
const update = async () => {
if (!question.value) return
content.value = '思考中...'
const endpoint = 'https://api.deepseek.com/chat/completions'
const headers = {
'Content-Type': 'application/json',
Authorization: `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`
}
const response = await fetch(endpoint, {
method: 'POST',
headers,
body: JSON.stringify({
model: 'deepseek-v4-flash',
messages: [{ role: 'user', content: question.value }],
stream: stream.value
})
})
// ========== 流式输出分支 ==========
if(stream.value){
content.value='';
//大文件上传 慢慢流向 权限+形式 js 原生提供了ReadableStream
//llm 服务器 streamAble对象 数据流?
//stream 对象 水流 服务器端流向浏览器
//response.body 服务器端响应体 二进制流
console.log(response.body);
// 水管子,嘬一口 返回 读取器对象
// await 等token 流来为止
const reader=response.body?.getReader();
console.log(reader);
// 二进制编解码
let done = false;// 开关变量 data:[DONE]
let buffer ='';// 截断准备了 上一次JSON.parse() 失败的
// 不完整的json completion
const decoder = new TextDecoder();// 二进制流服务的
while(!done){
// 嘬一口,嘬到了resolve,没嘬到,继续等
// data: [Done]
console.log(await reader?.read());
const {done:doneReading,value}=await reader?.read();// reader对象 兼容性,老浏览器不一定支持
done = doneReading;
// 除了把本轮的value 要处理之外,之前会有的东西一起处理
const chunkValue = buffer+decoder.decode(value);
// chunk 一小块 JSON 格式
// delta 偏移量 一小块一小块 的增量
// 解析 json 字符串 chices[0].delta.content
// json 有断开的可能 动态 buffer 不一定有值
// 如果有,上一个chunk 最后一行,不完整的json
// console.log(chunkValue);
buffer='';// 上一次的已经拼到这一次来了,buffer 任务完成了
// json 字符串 多行数据
// 一次发送一行,也可能发送多行 llm 计算速度和任务量
// data: {开始,又有数据来了}
// // data: {开始,又有数据来了}
const lines = chunkValue.split('\n')
// 严谨性 \n 不止一个 也可能有多个
.filter((line) => line.startsWith('data: '));
for(const line of lines){
// data: {"id":"chatcmpl-123","choices":[{"delta":{"content":"你好"}}]}
const incoming = line.slice(6);// 切掉申明头
if(incoming ==='[DONE]'){ // 流完成
// 两种情况,一种是在next Token 就设置了done:true
// 一种是单独发送一条data: [DONE] 文本流
done = true;
break;
}
// incoming content json 字符串
try{
const data = JSON.parse(incoming);
const delta = data.choices[0].delta.content;
if(data && delta){
content.value += delta;
}
}catch(e){
// data: 一定要加 没有} 结束
buffer=`data: ${incoming}`;
}
}
}
}else{
const data=await response.json();
//只需要修改数据状态,响应式
content.value=data.choices[0].message.content;
}
};
</script>
<style>
.container {
display: flex;
flex-direction: column;
align-items: start;
height: 100vh;
font-size: 0.85rem;
padding: 20px;
}
.input-area {
display: flex;
align-items: center;
gap: 6px;
}
.input {
width: 260px;
padding: 4px 8px;
}
button {
padding: 4px 16px;
cursor: pointer;
}
.output {
margin-top: 16px;
width: 100%;
max-width: 700px;
}
.stream-toggle {
margin-bottom: 8px;
}
.content {
min-height: 200px;
padding: 12px;
background: #f5f5f5;
border-radius: 6px;
white-space: pre-wrap;
word-break: break-word;
line-height: 1.7;
}
</style>
4.4 流式输出核心逻辑拆解
上面代码中 if (stream.value) 分支是整个流式输出的核心,逐步拆解。
第一步:获取读取器
js
const reader = response.body?.getReader()
response.body 是 Fetch API 返回的 ReadableStream 对象。getReader() 获取一个读取器,通过它可以逐块(chunk) 读取二进制数据,而不是等所有数据到达后再处理。?. 是可选链操作符------如果 response.body 为 null,reader 就是 undefined,不会报错(理论上 body 不会为 null,但防御性编程是好习惯)。
第二步:创建解码器
js
const decoder = new TextDecoder()
网络传输的原始数据是二进制(Uint8Array) ,TextDecoder 负责将二进制解码为 UTF-8 字符串。
第三步:缓冲区处理------最容易出错的环节
js
const chunkValue = buffer+decoder.decode(value);
......
try{
const data = JSON.parse(incoming);
const delta = data.choices[0].delta.content;
if(data && delta){
content.value += delta;
}
}catch(e){
// data: 一定要加 没有} 结束
buffer=`data: ${incoming}`;
}
}
网络传输是分 chunk 的,一个 chunk 可能在任意字节处被截断:
// Chunk 1 到达: "data: {"choi"
// Chunk 2 到达: "ces":[{"delta":{"content":"你好"}}]}\n\n"
如果直接对每个 chunk 单独解析 JSON,必然失败。解决方案:
- 用
buffer累积所有已到达但尚未处理完的文本 - 如果数据都能完整解析,立即处理
- 不完整片段留在
buffer,等下一个 chunk 到来时拼接
decoder.decode(value, { stream: true }) 中的 { stream: true } 参数告诉解码器:"这是一个流,后面还有数据"。当遇到不完整的 UTF-8 多字节字符时(比如中文字符"你"的 3 个字节被切成 2 字节 + 1 字节分布在两个 chunk),解码器会保留内部状态,不会用替换字符 � 填充------这就保证了中文不会出现乱码。
第四步:解析 SSE 数据格式
DeepSeek API 的流式响应使用 SSE(Server-Sent Events) 协议:
data: {"id":"chatcmpl-xxx","choices":[{"delta":{"content":"你"}}],...}
data: {"id":"chatcmpl-xxx","choices":[{"delta":{"content":"好"}}],...}
data: [DONE]
- 每条数据以
data:开头(注意后面有一个空格) - 数据之间用空行(
\n\n)隔开 - 服务端发送
data: [DONE]表示流结束
提取增量内容并更新页面
js
const data = JSON.parse(incoming);
const delta = parsed.choices?.[0]?.delta?.content
if (data && delta) content.value += delta
每收到一个 token 就拼接到 content 上。由于 content 是 ref() 创建的响应式数据,Vue 会自动把变化同步到页面------用户看到的就是逐字输出的打字机效果。
关键区分 :以上代码使用了可选链
?.访问嵌套属性,这是一种安全的写法------如果某层属性不存在,表达式短路返回undefined而不是直接报错,代码更健壮。
流式 vs 非流式,取数路径不同:
这是最容易写错的地方,务必注意:
| 模式 | 内容所在路径 | 含义 |
|---|---|---|
| 流式(stream: true) | choices[0].delta.content |
delta 是增量,每次只包含新生成的一个或几个 token |
| 非流式(stream: false) | choices[0].message.content |
message 是完整消息体,包含全部生成的文本 |
流式模式下如果写成了 message.content,拿到的始终是 undefined,页面不会有任何输出。
五、项目文件清单
stream-demo/
├── index.html # HTML 入口,包含 #app 挂载点
├── vite.config.js # Vite 配置,引入 @vitejs/plugin-vue
├── .env.local # 环境变量,存放 API Key(不提交到 Git)
├── package.json # 项目依赖
└── src/
├── main.js # 应用入口:创建 Vue 实例并挂载
├── style.css # 全局样式
└── App.vue # 根组件:包含全部核心逻辑
启动命令:
bash
npm install
npm run dev
# 浏览器访问 http://localhost:5173
六、全文总结
本文从一个真实的用户体验痛点出发------LLM 生成太慢,用户等得不耐烦 ------引出了流式输出这一关键技术方案。核心思路是:不再等所有结果生成完再返回,而是生成一个 token 就推送一个 token,让用户感知到持续的反馈。
在技术实现上,我们完成了四件事:
- 理解了 SSE 协议 :服务端以
data: {json}\n\n的格式逐条推送,以data: [DONE]结束 - 掌握了 ReadableStream API :通过
response.body.getReader()逐块读取二进制数据 - 处理了流式解码边界问题 :
TextDecoder的{ stream: true }+ 缓冲区 buffer 解决跨 chunk 截断 - 用 Vue3 响应式系统实现了页面自动更新,不需要手动操作 DOM
这篇文章的完整代码是一个最小可运行的流式 AI Chatbot 原型,可以在此基础上扩展:美化 UI、添加对话历史、支持多轮对话、增加停止生成按钮等。
七、核心知识点复盘
| 知识点 | 一句话总结 |
|---|---|
| 流式 vs 非流式 | 流式:stream: true,逐 token 返回,取 delta.content;非流式:一次性返回,取 message.content |
| SSE 协议 | 以 data: 开头,\n\n 分隔每条数据,[DONE] 表示结束 |
| ReadableStream | response.body.getReader() 获取读取器,.read() 返回 { value, done } |
| TextDecoder | 二进制 → 字符串,{ stream: true } 避免多字节字符(中文)截断乱码 |
| 缓冲区 buffer | 累积跨 chunk 的不完整行,按 \n 分割,pop() 取最后不完整行留在缓冲区 |
| ref() 响应式 | 数据变化自动更新页面,script 中通过 .value 读写 |
| v-model | 双向数据绑定,底层是 :value + @input 的语法糖 |
| Vite 环境变量 | 变量名必须以 VITE_ 开头,通过 import.meta.env.VITE_XXX 读取 |
八、常见问题 / 避坑指南
Q1:为什么流式输出时页面会卡顿或抖动?
每个 token 都触发一次 Vue 响应式更新。如果 LLM 吐 token 的速度很快(每秒 50+ 个),频繁的 DOM 更新会造成性能压力。可以用 requestAnimationFrame 做批量更新:
js
let pending = ''
let rafId = null
// 收到 delta 时不直接更新 content.value
pending += delta
if (!rafId) {
rafId = requestAnimationFrame(() => {
content.value += pending
pending = ''
rafId = null
})
}
这样约每 16ms(一帧)才更新一次 DOM,大幅减少渲染压力。
Q2:网络断了或者用户关闭页面,流式输出会怎样?
会卡死。reader.read() 在服务端异常断连时可能永远不会返回 done: true。生产环境需要:
js
const AC = new AbortController()
const timeout = setTimeout(() => AC.abort(), 30000) // 30 秒超时
const response = await fetch(endpoint, {
// ...
signal: AC.signal // 关联 AbortController
})
// 正常结束后清除超时
clearTimeout(timeout)
Q3:delta.content 是 undefined,页面不出来东西怎么办?
确认三件事:
- 请求体中的
stream字段是否为true - SSE 数据解析路径是否正确:流式是
choices[0].delta.content,不是choices[0].message.content - 浏览器 Network 面板中查看响应体,确认服务端确实返回了 SSE 格式的数据
Q4:decoder.decode(value, { stream: true }) 的 { stream: true } 到底有什么用?
中文字符在 UTF-8 中占 3 个字节。如果网络 chunk 刚好把一个中文字符的 3 个字节切成两部分(前 2 个字节在上一个 chunk,后 1 个字节在下一个 chunk),不加 { stream: true } 时,解码器会把前 2 个字节替换成 �(替换字符 U+FFFD),导致文字变成乱码。加上这个参数后,解码器会保留内部状态,等下一个 chunk 的字节到了再一起解码出正确的字符。
Q5:.env.local 和 .env 有什么区别?提交到 Git 应该怎么做?
| 文件 | 用途 | 是否提交 Git |
|---|---|---|
.env |
所有环境共享的配置(如端口号) | ✅ 可以提交 |
.env.local |
本地覆盖,存放敏感信息(API Key) | ❌ 绝对不能提交 |
.env.development |
仅开发环境加载 | ✅ 可以提交 |
.env.production |
仅生产环境加载 | ✅ 可以提交 |
Vite 加载优先级(后面的覆盖前面的):.env → .env.local → .env.[mode].local → .env.[mode]
本文配套完整代码可在 stream-demo 项目中找到,npm install && npm run dev 即可运行体验。