前阵子搭 DeepSeek 的调用 demo,点完提交按钮,页面要僵个三五秒才蹦出一整段回答。 说实话那体验特别差,用户点完按钮没反馈,大概率会以为页面卡了,再点一次。 本来我以为就是模型生成慢,没办法的事。直到翻文档看到
stream: true这个参数,我才反应过来 ------ 人家本来就支持边生成边返回,是我自己用成了 "整段下载"。我本来以为就是改个参数的事,真动手写才发现,这里面的弯弯绕绕比我想的多。二进制流、断包、buffer 拼接...... 踩了好几个坑才跑通。今天就把我踩坑的全过程捋一遍。
先搞懂:流式输出到底在 "流" 什么
没碰之前,我对流式输出的理解就是 "打字机效果"。真跑起来才发现,效果只是表面,底层是数据传输方式变了。
以前我们调用接口,都是 "请求 - 等待 - 完整响应"。就像你去饭馆点菜,厨师把整桌菜做好了一起端上来,你才能动筷子。菜越多,等的时间越长。大模型生成几千字的回答,就要等全部生成完,接口才一次性把结果返回给你。
流式输出不一样。它更像吃火锅,涮好一片就捞给你一片,边煮边吃。模型生成一个 token,就往前端发一段,前端收到就立刻显示到页面上。用户不用等全部生成完,第一眼就能看到内容,体感上快了很多。
说穿了,后端返回的不再是一个完整的 JSON 对象,而是一条二进制文本流。数据一小段一小段地从服务器流到浏览器,就像水管里的水,一点一点流过来。

从零写一个最小的流式 Demo
我用 Vue3 写的 demo,还加了个复选框可以随时切换流式和非流式,对比起来特别直观。
先写个非流式版本热热身
最开始我写的就是普通的 POST 请求,拿到结果直接赋值,相信大家都写过。
javascript
import { ref } from 'vue'
const question = ref('讲一个中国龙的故事')
const content = ref('')
const stream = ref(false) // 先关掉流式
const update = async () => {
if (!question.value) return
content.value = '思考中...'
const response = await fetch(import.meta.env.VITE_DEEPSEEK_API_BASE_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`
},
body: JSON.stringify({
model: import.meta.env.VITE_DEEPSEEK_API_MODEL,
messages: [{ role: 'user', content: question.value }],
stream: stream.value
})
})
// 非流式:直接拿完整 JSON
const data = await response.json()
content.value = data.choices[0].message.content
}
就这么几行,逻辑简单直接。但缺点也很明显:生成内容越长,空白等待的时间就越久。短回答还好,长回答真的能等到人不耐烦。
改成流式,第一步就懵了
我想着把 stream 改成 true 不就完事了?改完一跑,直接报错。 因为流式返回的时候,response.json() 是用不了的 ------ 响应体根本不是完整的 JSON,而是一个流。
打印一下 response.body,出来的是一个 ReadableStream 对象。这玩意儿我以前只在文档里见过,真实业务里第一次碰。
后来才搞明白,要读这个流,得先拿一个 "读取器",就像你要喝水管里的水,得先接个水龙头。
javascript
const reader = response.body?.getReader()
拿到 reader 之后,就可以循环调用 read() 方法,一口一口 "嘬" 数据。每调用一次,就返回当前读到的数据片段,以及一个 done 标记,告诉你流是不是读完了。
二进制转文本,别漏了解码
读出来的 value 是什么?是 Uint8Array,一堆 0-255 的数字,纯二进制数据。直接打印根本看不懂。 这时候就需要 TextDecoder 出场,把二进制数据解码成我们能读的字符串。
javascript
const decoder = new TextDecoder()
while (!done) {
const { value, done: doneReading } = await reader.read()
done = doneReading
const chunkText = decoder.decode(value) // 二进制转成普通文本
console.log(chunkText)
}
到这一步,我终于能看到流过来的文本长啥样了。每一行都是 data: {...} 格式的 JSON,最后一行是 data: [DONE] 代表结束。
最坑的部分:数据包被截断了
本来我以为解码完,逐行解析就完事了。结果跑起来时好时坏,时不时就报 JSON.parse 错误。 我盯着控制台看了半天,终于发现问题:一个完整的 JSON 对象,可能被拆成两段发送。
为什么会断包?
大模型生成的速度不是恒定的,有时候一次生成好几个 token,就多发几行;有时候生成慢,就只发半行。网络传输的时候也不会管你 JSON 是不是完整,到了数据包大小就截断发送。
举个例子,本来完整的一行是:
javascript
data: {"choices":[{"delta":{"content":"你好"}}]}
结果第一次只发了前半段:
javascript
data: {"choices":[{"delta":{"con
第二次才发后半段:
javascript
tent":"你好"}}]}
这时候你按行拆分去 JSON.parse,第一次肯定解析失败。如果直接把错误扔掉,那这段文字就丢了,页面上显示的内容就会缺字。
我的解决方案:加个 buffer 缓存
想明白这点就好办了。解析失败的不完整数据,别扔,存到 buffer 里,下一次读到新数据的时候,先把上一次的残片拼在前面,再一起解析。
javascript
let buffer = '' // 存上一次没解析完的残片
let done = false
while (!done) {
const { value, done: doneReading } = await reader.read()
done = doneReading
// 把上一次的残片和本次的数据拼在一起
const chunkValue = buffer + decoder.decode(value)
buffer = '' // 拼完就清空,等下一次用
// 按换行拆分,只保留 data: 开头的行
const lines = chunkValue.split('\n')
.filter(line => line.startsWith('data: '))
for (const line of lines) {
const incoming = line.slice(6) // 去掉 "data: " 前缀
if (incoming === '[DONE]') {
done = true
break
}
try {
const data = JSON.parse(incoming)
const delta = data.choices[0].delta.content
if (delta) content.value += delta
} catch (error) {
// 解析失败,说明是断包,存起来下次拼
buffer = `data: ${incoming}`
}
}
}
注意 存 buffer 的时候一定要把
data:前缀也带上。不然下一次拼接后,这一行就没有前缀,filter 的时候会被过滤掉,照样解析失败。 别问我为什么知道,试了三次才反应过来。
加上 buffer 之后,之前时好时坏的问题就彻底解决了。不管数据包怎么截断,都能拼回去再解析。
往深了想:流式输出的本质是什么
跑通之后我翻了下 MDN 上关于 ReadableStream 的文档,其实这玩意儿不是为大模型发明的。大文件上传下载、视频流媒体,早就用这套逻辑了。
本质上就是不用等资源全部加载完成,就可以开始处理。放到大模型场景里,就是不用等全部回答生成完,用户就能开始阅读。体验上的提升是实打实的。
而且我发现这和 Vite 的热更新思路有点像 ------ 以前改代码要刷新整个页面,状态全丢;现在只更新修改的模块,页面状态保留。都是把 "整批处理" 改成 "流式增量处理",体验就上了一个台阶。
现在做 Agent 应用更是这样,AI 生成内容本来就慢,你再让用户等十几秒出结果,体验直接劝退。流式输出差不多是现在大模型前端的标配了。
我踩过的几个坑,你们别再踩了
1. 别漏了 DONE 结束标记
流的最后会发一条 data: [DONE],代表生成结束。收到这条就要跳出循环,不然 read() 会一直等,页面就卡住了。 不同厂商的结束标记可能略有差异,接入前最好看一眼文档。
2. 换行符可能不止一个
有时候两行数据之间会有多个 \n,split 之后会出现空行。所以一定要用 filter 过滤出真正以 data: 开头的行,不然空行也会拿去解析,照样报错。
3. delta.content 可能为空
不是每一条数据都有内容。比如第一条可能只有角色信息,content 是 undefined。直接拼接的话页面上会出现 "undefined" 字符串。所以赋值前最好判断一下。
4. 汉字也可能被截断
一个 UTF-8 的汉字占 3 个字节,网络分片时刚好切在汉字中间的话,直接 decode 会出乱码。可以给 TextDecoder 加上 { stream: true } 选项,解码器会记住上次没解完的字节,下一次自动拼接。
javascript
const decoder = new TextDecoder('utf-8', { stream: true })
最后聊聊什么时候没必要用流式
不是所有场景都适合开流式。 如果你拿到结果之后还要做二次处理 ------ 比如要把完整回答存数据库、要做摘要、要调用下一个工具链,那流式反而麻烦。你还是得等全部收完才能往下走,这时候直接用非流式,代码简单还不容易出错。
只有需要直接展示给用户看的场景,流式的价值才最大。毕竟用户感知到的 "快",才是真的快。
最开始我以为流式输出就是个前端特效,真啃完才发现,核心是对 "数据流" 的理解。从二进制到文本,从断包到拼接,每一步都有细节在里面。
回头看其实也不难,就是得亲手踩一遍坑,印象才深刻。
你们写流式输出的时候踩过什么奇葩坑?或者有更优雅的写法?评论区留个言,我也学学。