啃完流式输出:从一个卡顿的 LLM 接口开始,我搞懂了数据流到底怎么 “流”

前阵子搭 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 })

最后聊聊什么时候没必要用流式

不是所有场景都适合开流式。 如果你拿到结果之后还要做二次处理 ------ 比如要把完整回答存数据库、要做摘要、要调用下一个工具链,那流式反而麻烦。你还是得等全部收完才能往下走,这时候直接用非流式,代码简单还不容易出错。

只有需要直接展示给用户看的场景,流式的价值才最大。毕竟用户感知到的 "快",才是真的快。


最开始我以为流式输出就是个前端特效,真啃完才发现,核心是对 "数据流" 的理解。从二进制到文本,从断包到拼接,每一步都有细节在里面。

回头看其实也不难,就是得亲手踩一遍坑,印象才深刻。

你们写流式输出的时候踩过什么奇葩坑?或者有更优雅的写法?评论区留个言,我也学学。

相关推荐
To_OC1 小时前
调了一上午 DeepSeek 参数,我终于摸透了 temperature 和 Top K 的真实作用
人工智能·llm·deepseek
阳光是sunny1 小时前
LangGraph实战教程:defer延迟节点——让收尾工作自动排到最后
前端·人工智能·后端
kyriewen2 小时前
我用了三周Claude Code Skills——总结出5条铁律,第3条最反直觉
前端·ai编程·claude
阳光是sunny2 小时前
LangGraph实战教程:控制流详解
前端·人工智能·后端
格尔曼Noah3 小时前
Safari浏览器中如何只允许指定网站下载
前端·safari
用户059540174463 小时前
用了3年Redis,才发现我一直没搞懂缓存一致性测试
前端·css
swipe3 小时前
07|(前端转后全栈)为什么后端也要缓存?从前端缓存思维理解 Redis
前端·后端·全栈
IT小盘3 小时前
05-企业项目统一接入多个大模型-适配器模式实战
前端·人工智能·适配器模式
swipe3 小时前
06|(前端转后全栈)登录后端到底在做什么?JWT、Spring Security 和权限链路
前端·后端·全栈