最近,我一头扎进了 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()
value是Uint8Array—— 一串原始字节;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}`
}
}
}
这段代码的核心逻辑是:
- 所有新数据先拼到
buffer; - 尝试按行解析;
- 能解析的,立即展示;
- 不能解析的(如半截 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 应用加上流式输出吧——用户会感谢你的。🚀