从零到“爽”:用 Vue + 流式输出打造丝滑 AI 对话体验

10 阅读10分钟

最近,我一头扎进了 Vue 的世界——响应式数据、组件化开发、<script setup> 的简洁语法……一切都让我感受到现代前端框架的优雅与高效。而就在我刚学会用 ref 管理状态、用 v-model 绑定输入框时,一个更诱人的挑战出现了:如何让大模型(LLM)的回答像真人打字一样,逐字浮现?

答案就是——流式输出(Streaming)

以前,我总以为 AI 回复慢是因为“模型在思考”。但真正动手后才发现:不是模型慢,而是我们等得太蠢。如果能让用户在模型生成第一个字时就看到内容,而不是干等十秒才刷出整段文字,那体验简直是从“加载中…”跃迁到“哇,它在和我说话!”。

于是,我决定亲手实现一个支持流式输出的 AI 聊天界面。没有复杂框架,只用 Vue 3 + 原生 fetch + 一点点对二进制流的理解。过程中,我踩过坑、解过码、拼接过 Buffer,也终于明白了为什么大家都说:“开了流,真香”。

今天,就带你从实战出发,一步步揭开流式输出的神秘面纱——不仅讲“怎么做”,更要讲透“为什么这样能行”。


一、我的第一个 Vue 聊天框:简单,但不够“爽”

一切始于一个朴素的需求:

用户输入问题 → 点击提交 → 显示 AI 回答。

用 Vue 写起来非常直接:

const question = ref('')
const content = ref('')

const askLLM = async () => {
  const res = await fetch('/api/chat', {
    method: 'POST',
    body: JSON.stringify({ prompt: question.value })
  })
  const data = await res.json()
  content.value = data.reply // 等全部返回才赋值
}

跑起来没问题,但体验很割裂:

  • 用户点完“提交”,屏幕空白几秒;
  • 突然“唰”一下弹出整段文字;
  • 感觉像在等机器,而不是对话。

这显然不够“爽”。我想要的是——边生成,边看

💭 思考:为什么不能直接用 setInterval 模拟打字效果?
有人可能会想:“我可以先拿到完整回答,再用定时器一个字一个字显示啊!”
这确实能“看起来”流畅,但本质是欺骗用户——实际等待时间没变,只是延迟了展示。而真正的流式输出能提前数秒开始反馈,大幅降低首屏感知延迟。更重要的是,用户可随时中断生成(比如发现答偏了),这是模拟打字做不到的。


二、开启流式:一个参数,两种世界 🌍

查阅 LLM API 文档(比如 DeepSeek、OpenAI),我发现一个关键参数:stream

只要设为 true,API 就不会返回完整 JSON,而是以 Server-Sent Events (SSE) 格式持续推送数据块,形如:

data: {"choices": [{"delta": {"content": "你"}}]}

data: {"choices": [{"delta": {"content": "好"}}]}

data: [DONE]

每一行都是一个独立事件,前端可以实时消费。这意味着:不需要等模型说完,就能把“你”“好”一个个字展示出来

于是,我在请求中加上了 stream: true

body: JSON.stringify({
  model: 'deepseek-chat',
  stream: true, // 👈 就是它!
  messages: [{ role: 'user', content: question.value }]
})

但问题来了:如何读取这种“源源不断”的响应?

💭 思考:后端不支持 stream 怎么办?
如果你调用的 API 不支持流式(比如某些私有模型),那就无法实现真正的实时输出。此时只能退回到“全量等待”模式,或在后端自行封装流式逻辑(例如用 generator 分批 yield token)。所以,流式体验的前提是 LLM 服务本身支持 SSE 或类似协议


三、什么是 SSE?为什么 LLM 偏爱它?

你可能会好奇:为什么大模型 API 选择用 data: {...} 这种看似简陋的格式来推送流数据?这背后其实是一种成熟且轻量的 Web 标准——Server-Sent Events(SSE)

SSE 是 HTML5 引入的一种服务器向浏览器单向推送实时数据的机制。与 WebSocket 的双向通信不同,SSE 专为“服务端 → 客户端”的场景设计,比如新闻推送、股票行情、日志流,以及——AI 文本生成

它的协议极其简单:

  • 服务端返回 Content-Type: text/event-stream
  • 每条消息以 data: 开头,以 \n\n 结尾;
  • 可包含事件类型(event:)、唯一 ID(id:)等字段;
  • 浏览器通过 EventSource 或原生 fetch + ReadableStream 接收。

例如一段合法的 SSE 流:

data: {"token": "你"}

data: {"token": "好"}

data: [DONE]

💡 为什么 LLM 用 SSE 而不用 WebSocket?
因为 AI 生成是典型的“一问一答、服务端持续输出”场景,无需客户端频繁发消息。SSE 基于 HTTP,天然支持 CORS、鉴权、缓存,且前端无需额外库——用 fetch 就能消费,开发成本极低。

更重要的是,SSE 具备自动重连能力(通过 retry: 字段),在网络抖动时能自动恢复连接,这对长文本生成至关重要。

不过,在我们的 Vue 应用中,并没有使用 new EventSource(),而是直接操作 fetch().body。这是因为:

  • EventSource 只支持 GET 请求,而 LLM API 需要 POST;
  • fetch 提供更底层的字节流控制,便于处理编码和缓冲。

所以,我们是在手动解析 SSE 协议——这也正是理解流式输出的关键一步。

💭 思考:SSE 和 WebSocket 到底怎么选?
记住这个口诀: “单向用 SSE,双向用 WebSocket”
如果你只需要服务器推数据(如通知、日志、AI 生成),SSE 更轻量、更易调试;
如果需要频繁双向通信(如在线协作、游戏),才考虑 WebSocket。
此外,SSE 天然走 HTTP/HTTPS,穿透防火墙更容易,运维成本更低。


四、直面原始流:ReadableStream 与字节的舞蹈 💃

传统 response.json() 在这里失效了——因为响应永远不会“完成”。我们必须深入到 HTTP 响应的最底层:字节流(Byte Stream)

浏览器提供了 response.body.getReader(),返回一个 ReadableStreamDefaultReader。通过它,我们可以:

const reader = response.body.getReader()
const { value, done } = await reader.read()
  • valueUint8Array —— 一串原始字节;
  • done 表示流是否结束。

但字节对我们毫无意义,我们需要解码成字符串

💭 思考:为什么不能直接用 response.text()?
response.text() 会等待整个流结束才返回完整字符串,完全违背了“流式”的初衷。而 getReader() 让我们能在每个 chunk 到达时立即处理,实现真正的实时性。这是“拉模式” vs “等模式”的根本区别。


五、TextDecoder:把字节变文字的魔法杖 🪄

JavaScript 提供了 TextDecoder,专用于将 Uint8Array 解码为 UTF-8 字符串:

const decoder = new TextDecoder()
const text = decoder.decode(value)

但注意!在网络流中,一个汉字(如“你”)可能被拆成两半传输(比如前两个字节在一个 chunk,第三个在下一个)。如果直接 decode,会得到乱码。

解决方案:使用 { stream: true } 选项:

decoder.decode(value, { stream: true })

这个参数告诉解码器:“这只是流的一部分,请保留未完成的字节,等下一块来了再拼”。

✅ 这是避免中文乱码的关键!

💭 思考:TextDecoder 是同步的,会不会阻塞 UI?
不会。虽然 decode() 是同步方法,但它操作的是内存中的小块字节(通常几百字节),耗时微秒级,远低于一帧渲染时间(16ms)。而且整个流处理是 async/await 驱动的,不会阻塞主线程。放心使用!


六、Buffer:处理“半截数据”的安全网 🛡️

即便用了 stream: true,我们仍面临一个问题:JSON 可能被截断

比如,一个完整的 SSE 行是:

data: {"choices": [...]}

但网络可能先传来:

data: {"choic

然后下一秒才传来:

es": [...]}

如果直接 JSON.parse("data: {"choic"),必然报错。

解决思路:自建缓冲区(Buffer)

let buffer = '' // 自定义缓冲字符串

while (!done) {
  const { value, done: isDone } = await reader.read()
  done = isDone
  
  // 解码新字节,并拼接到缓冲区
  const chunk = buffer + decoder.decode(value, { stream: true })
  buffer = '' // 清空,准备接收下一轮

  // 按行分割(SSE 以 \n 分隔)
  const lines = chunk.split('\n').filter(line => line.startsWith('data: '))
  
  for (const line of lines) {
    const payload = line.slice(6) // 去掉 "data: "
    
    if (payload === '[DONE]') {
      done = true
      break
    }

    try {
      const data = JSON.parse(payload)
      const delta = data.choices[0].delta.content
      if (delta) content.value += delta // 实时更新!
    } catch (err) {
      // 解析失败?说明 JSON 不完整,放回 buffer
      buffer += `data: ${payload}`
    }
  }
}

这段代码的核心逻辑是:

  1. 所有新数据先拼到 buffer
  2. 尝试按行解析;
  3. 能解析的,立即展示;
  4. 不能解析的(如半截 JSON),留着下次拼接。

💡 这就是流式解析的“容错机制”——不丢任何一个字节。

💭 思考:为什么不用现成的流解析库?
当然可以用(如 eventsource-parser),但在学习阶段,手写一次 buffer 逻辑能让你彻底理解流式数据的脆弱性与处理原则。上线项目可引入成熟库提升健壮性,但原理必须自己掌握。


七、Vue 的响应式:让 UI 自动跟上流 🔄

最妙的是,Vue 的响应式系统让这一切变得无比自然。

我们只需维护一个 content 响应式变量:

const content = ref('')

然后在流处理循环中不断追加:

content.value += delta

Vue 会自动检测变化,并高效更新 DOM。用户看到的,就是文字像打字机一样逐字出现——无需手动操作 DOM,也无需防抖节流。

这种“数据驱动视图”的理念,让流式输出的前端实现既简洁又可靠。

💭 思考:频繁修改 ref 会导致性能问题吗?
不会。Vue 3 的响应式系统基于 Proxy,对字符串拼接做了优化。即使每 50ms 更新一次,DOM 也会在下一帧批量更新,不会造成卡顿。如果你极端追求性能(如每毫秒更新),才需考虑节流,但 AI 生成速度远达不到这个频率。


八、“爽”的背后:为什么流式如此重要?🎯

你可能会问:多等几秒有什么关系?

但用户体验研究早已证明:人类对延迟的容忍度极低

  • 等待 1 秒内:感觉流畅;
  • 等待 3 秒以上:开始焦虑;
  • 等待 10 秒:大概率放弃。

而流式输出巧妙地欺骗了大脑

  • 首字节 200ms 内返回 → 用户感知“立刻响应”;
  • 后续文字持续流入 → 大脑认为“正在认真思考”;
  • 整体等待时间没变,但心理感受完全不同

这就是为什么 ChatGPT、Claude、DeepSeek 等产品都默认开启流式——技术服务于体验,体验决定留存

💭 思考:流式输出会增加服务器压力吗?
相反,它可能降低压力!因为流式连接是长连接但低频交互(一问一答),而如果用户因等待太久反复点击“重试”,反而会产生更多短连接请求。此外,流式允许用户提前中断,节省计算资源。


九、避坑指南:我踩过的雷,你别再踩 ⚠️

在实现过程中,我遇到几个典型问题,分享给你:

❌ 1. 忘记 stream: true

结果:API 返回完整 JSON,前端无法流式处理。

✅ 解法:确认请求体包含 "stream": true

❌ 2. 直接 JSON.parse 未清洗的字符串

SSE 行包含 data: 前缀,直接 parse 会报错。

✅ 解法:先 line.slice(6) 去掉前缀。

❌ 3. 忽略 [DONE] 结束信号

导致循环无法退出,页面卡死。

✅ 解法:检测 payload === '[DONE]' 并 break。

❌ 4. 未处理跨 chunk 的 JSON 截断

部分内容丢失。

✅ 解法:用 buffer 缓存未解析片段。

💭 思考:如何测试流式逻辑?
可用 Mock Service Worker (MSW) 模拟 SSE 流,或用 Node.js 写一个简易流式服务器:

res.writeHead(200, {
  'Content-Type': 'text/event-stream',
  'Cache-Control': 'no-cache',
  'Connection': 'keep-alive'
})
setInterval(() => {
  res.write(`data: {"choices": [{"delta": {"content": "测"}}]}\n\n`)
}, 300)

十、结语:从“能用”到“好用”,只差一个流 🌊

回望整个过程,我从一个只会写 v-model 的 Vue 新手,到能驾驭二进制流、处理编码、管理缓冲区,最终实现丝滑的 AI 对话体验——这不仅是技术的进阶,更是对“用户体验”理解的深化。

流式输出看似只是“边生成边显示”,实则串联了:

  • 网络协议(HTTP Chunked + SSE)
  • 数据结构(Buffer、Stream)
  • 编码规范(UTF-8、TextDecoder)
  • 前端框架(Vue 响应式)
  • 人机交互(感知延迟、心理预期)

当你下次看到 AI 逐字回复时,不妨想想:

这背后,是一场从 GPU 到浏览器、从字节到情感的精密协作。

而作为开发者,我们的使命,就是让这场协作更快、更稳、更“爽”

现在,去给你的 AI 应用加上流式输出吧——用户会感谢你的。🚀