基于Vue3+DeepSeek实现流式打字机效果
引言
大模型时代,流式输出(打字机效果)早已成为对话类产品的标配。但很多同学接入 DeepSeek 等大模型 API 时,直接照搬示例代码却频繁踩坑:JSON 解析报错、内容随机丢失、页面出现 null 字符串、思考过程无法展示……
本文基于 Vue3 + Vite 项目,从底层二进制流到上层业务逻辑,逐行拆解流式响应的处理全流程,把 Buffer 缓存、粘包处理、字段兼容这些核心细节一次性讲透。
一、先搞懂:流式输出到底是什么?
1.1 为什么必须做流式?
大模型生成回答不是一次性算出完整结果,而是逐 Token(可以理解为逐字 / 逐词)生成。如果等到全部生成完再返回,用户会面临几秒甚至几十秒的空白等待,体验极差。
流式输出的核心价值就是:边生成、边返回、边渲染,用户看到第一个字的时间从 “等完整回答” 缩短到 “生成第一个 Token”,极大降低等待焦虑。
1.2 流式协议标准:SSE 数据格式
当前主流大模型的流式接口,基本都遵循 SSE(Server-Sent Events)规范,返回的是持续的二进制文本流,规则非常明确:
- 传输载体是
Uint8Array(8 位无符号整数数组,0-255,对应一个个字节的二进制数据) - 业务层面以
\n换行符分割每条独立消息 - 每条消息固定以
data:开头,后面紧跟 JSON 格式的内容 - 全部生成结束后,会推送一条
data: [DONE]作为结束标记
对应到真实传输中,流的原始内容大概是这样:
data: {"choices":[{"delta":{"content":"你"}}]}
data: {"choices":[{"delta":{"content":"好"}}]}
data: [DONE]这里还有一个工程设计细节:LLM 生成过程中的分片 JSON 只保留核心字段,尽量精简。目的是降低单条数据包体积,提升网络传输效率,保证打字机的流畅度;等全部生成结束后,再返回完整的统计信息(Token 消耗、结束原因等),兼顾速度与完整性。
1.3 绕不开的问题:为什么一定要 Buffer?
很多人疑惑:我直接读数据、转字符串、解析 JSON 不行吗?
不行,因为网络传输的分片是不受业务控制的。TCP 协议只负责把数据传过去,不会按我们的 \n 分界线来切分。一次 read() 读取到的内容,可能出现三种情况:
- 刚好是完整的 1 条或多条
data:消息 - 只拿到半条消息(后半截在下一次读取里)
- 前半条是上一轮残留的,后半条是新的
如果直接解析,半截 JSON 必然触发 JSON.parse 报错,还会导致内容丢失。Buffer 的作用就是缓存这部分残缺数据,等下一轮读取到新数据后拼在一起再处理,行业里也常叫 “粘包处理”。
二、项目基础配置
2.1 环境准备
我们用 Vue3 + Vite 做前端项目,调用 DeepSeek 官方的流式接口。先在项目根目录的 .env 文件里配置 API 密钥:
VITE_DEEPSEEK_API_KEY=你的DeepSeek密钥
2.2 开发环境跨域处理
注意:前端直接请求 https://api.deepseek.com 会触发浏览器 CORS 跨域限制。开发阶段可以在 vite.config.js 里配置代理:
export default defineConfig({
server: {
proxy: {
'/api': {
target: 'https://api.deepseek.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^/api/, '')
}
}
}
})
请求地址换成 /api/chat/completions 即可。生产环境建议通过后端服务转发,不要把密钥暴露在前端。
三、核心代码逐行拆解:流式处理全流程
下面我们以 Vue3 SFC 组件为载体,从发起请求到渲染文字,把每一步逻辑讲透。
3.1 初始化:拿到流读取器
请求接口时开启 stream: true,响应体 response.body 就是一个 ReadableStream 可读流对象。
const response = await fetch(endpoint, {
method: 'POST',
headers,
body: JSON.stringify({
model: 'deepseek-v4-flash',
messages: [{ role: 'user', content: question.value }],
stream: stream.value
})
})
const reader = response.body?.getReader() // 创建读取器
const decoder = new TextDecoder('utf-8') // 二进制→字符串解码器
let done = false // 流结束标记
let buffer = '' // 残缺数据缓存
这里两个核心工具:
reader:用来从流里 “一口一口” 读取二进制数据,每次调用read()都会返回{ value, done }decoder:把读取到的Uint8Array二进制数据解码为可读的 UTF-8 字符串
3.2 循环读取:两层结束条件
外层用 while(!done) 持续读取,直到流结束。done 会在两种情况下被置为 true:
- 流本身关闭:
reader.read()返回的doneReading为true - 业务结束:读取到
data: [DONE]标记
while (!done) {
const { value, done: doneReading } = await reader?.read()
done = doneReading
// ... 处理数据
}
3.3 最关键:Buffer 粘包处理
这是绝大多数人写错的地方。错误写法是直接清空 buffer,分割后丢弃尾部数据,导致残缺内容永久丢失。
正确逻辑只有三步:拼接 → 分割 → 存残片
// 1. 拼接:上一轮残留的buffer + 本轮新解码的内容
const chunkValue = buffer + decoder.decode(value)
// 2. 分割:按换行符切成一个个片段
const allChunks = chunkValue.split('\n')
// 3. 存残片:弹出最后一段,大概率是不完整的,存回buffer留给下一轮
buffer = allChunks.pop()
// 剩下的都是完整行,过滤出合法的 data: 消息
const lines = allChunks.filter(line => line.startsWith('data: '))
举个直观例子:
- 第一轮读到:
data: {"content":"你"}\ndata: {"content":"好 split('\n')得到['data: {"content":"你"}', 'data: {"content":"好']pop()取出后半截'data: {"content":"好'存入 buffer- 只处理前面完整的那一行
第二轮读到新数据:"世界"}\n,和 buffer 拼接后就变成了完整的一行,正常解析。
3.4 逐行解析:消息处理与结束判断
遍历过滤后的完整行,先切掉 data: 前缀,再做分支处理:
for (const line of lines) {
const incoming = line.slice(6) // 切掉 "data: " 6个字符
// 遇到结束标记,终止循环
if (incoming === '[DONE]') {
done = true
break
}
try {
const data = JSON.parse(incoming)
// 防御:防止 choices 为空导致报错
if (!data.choices?.length) continue
// 只取回答正文,null/undefined 兜底为空字符串
const text = data.choices[0].delta.content ?? ''
if (text) {
content.value += text
}
} catch (err) {
// 极端情况解析失败,重新拼回 data: 前缀存入 buffer
buffer = `data: ${incoming}`
}
}
这里有两个必须注意的细节:
- 空值兜底:DeepSeek 的分片里
content可能为null(比如思考阶段、结束分片),不用?? ''兜底会直接拼出null字符串。 - 边界防御:接口异常、格式变动时,
choices可能为空,直接写choices[0]会导致白屏,必须加判空。
如果你使用的是带思考能力的模型(如 deepseek-reasoner),还需要额外取 reasoning_content 字段,拼接逻辑改成:
const delta = data.choices[0].delta const reasoning = delta.reasoning_content ?? '' const answer = delta.content ?? '' content.value += reasoning + answer
3.5 非流式降级处理
如果关闭流式,就走常规的 JSON 解析:
} else {
const data = await response.json()
content.value = data.choices[0].message.content
}
四、完整可运行组件代码
下面是修复了所有边界问题的完整 Vue3 组件,可直接复制使用:
<script setup>
import { ref } from 'vue'
const question = ref('讲一个中国龙的故事')
const content = ref('')
const stream = ref(true)
const update = async () => {
if (!question.value) return
content.value = '思考中....'
const endpoint = '/api/chat/completions' // 走Vite代理,避免跨域
const headers = {
'Content-Type': 'application/json',
Authorization: `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`
}
try {
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 (!response.ok) throw new Error('请求失败')
if (stream.value) {
content.value = ''
const reader = response.body?.getReader()
const decoder = new TextDecoder('utf-8')
let done = false
let buffer = ''
while (!done) {
const { value, done: doneReading } = await reader?.read()
done = doneReading
// 拼接上一轮残留数据
const chunkValue = buffer + decoder.decode(value)
const allChunks = chunkValue.split('\n')
// 尾部残缺数据存回buffer
buffer = allChunks.pop()
// 过滤有效消息行
const lines = allChunks.filter(line => line.startsWith('data: '))
for (const line of lines) {
const incoming = line.slice(6)
if (incoming === '[DONE]') {
done = true
break
}
try {
const data = JSON.parse(incoming)
if (!data.choices?.length) continue
const delta = data.choices[0].delta
// 仅拼接回答正文,如需思考过程可加上 reasoning_content
const text = delta.content ?? ''
if (text) content.value += text
} catch (e) {
buffer = `data: ${incoming}`
}
}
}
} else {
const data = await response.json()
content.value = data.choices[0].message.content
}
} catch (err) {
content.value = `出错了:${err.message}`
}
}
</script>
<template>
<div class="container">
<div class="input-row">
<label>输入:</label>
<input v-model="question" class="input" />
<button @click="update">提交</button>
</div>
<div class="output">
<label class="stream-toggle">
<input type="checkbox" v-model="stream" />
开启流式输出
</label>
<div class="content">{{ content }}</div>
</div>
</div>
</template>
<style scoped>
.container {
max-width: 800px;
margin: 40px auto;
padding: 0 20px;
}
.input-row {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 20px;
}
.input {
flex: 1;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
}
button {
padding: 8px 16px;
background: #165DFF;
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
}
.output {
border: 1px solid #eee;
border-radius: 8px;
padding: 16px;
min-height: 300px;
background: #fafafa;
}
.stream-toggle {
display: block;
margin-bottom: 12px;
font-size: 14px;
color: #666;
}
.content {
line-height: 1.6;
white-space: pre-wrap;
}
</style>
五、最后
流式输出看似只是 “打字机效果”,背后其实是网络传输、流处理、边界容错等一系列工程细节的集合。一个健壮的流式实现,既要保证文字不丢失、不报错,也要兼容模型的各种字段格式。
放到 Agent 开发的大背景下,流式能力更是基础中的基础 —— 未来的智能体不再是 “一次性返回结果”,而是边思考、边调用工具、边输出结果,流式交互就是承载这种动态过程的最佳载体。把底层的流处理逻辑吃透,后续做更复杂的 Agent 交互才会得心应手。
以上就是基于Vue3+DeepSeek实现流式打字机效果的详细内容,更多关于Vue3 DeepSeek流式打字机效果的资料请关注脚本之家其它相关文章!
相关文章
vue+elementUI封装一个根据后端变化的动态table(完整代码)
这篇文章主要介绍了vue+elementUI,封装一个根据后端变化的动态table,实现了自动生成和插槽两个方式,主要把el-table 和el-pagination封装在一起,结合示例代码给大家介绍的非常详细,需要的朋友可以参考下2022-08-08
VUE中Echarts的resize事件报错和移除windows的事件问题
这篇文章主要介绍了VUE中Echarts的resize事件报错和移除windows的事件问题,具有很好的参考价值,希望对大家有所帮助。如有错误或未考虑完全的地方,望不吝赐教2023-07-07


最新评论