最近在做AI对话产品时被"用户等不了"这个问题反复教育。之前接大模型接口用的是常规HTTP请求,服务端必须等大模型把整段回答全部生成完才一次性返回,碰上生成长文的时候,十几秒的空白等待是常态,用户早把页面关了。后来把整个方案改成流式输出,第一个字几十毫秒就能出现在屏幕上,配合Vue3前端逐字渲染,体验完全换了个层级。
这篇文章围绕Vue3 + Node.js这套组合,把AI流式输出从原理到落地、从后端到前端完整拆开讲一遍。适合正在做、或准备做AI对话、AI写作、智能客服、AI辅助编辑这类项目的开发者。不管你是全栈还是只负责其中一端,都能从里面找到可以直接抄的代码,以及常规文档里不会写出来的坑。
1. 先把"流式输出"这件事说透:从等待体验到底层机制
1.1 普通请求为什么让人觉得"慢到窒息"
传统的HTTP请求响应模型是:前端发请求 -> 服务端处理 -> 服务端把完整结果一次性返回 -> 前端拿到全部数据再渲染。这中间有两个无法绕开的等待:
一是服务端处理时间。大模型生成回答是一个token一个token推理出来的,每秒大约几十到上百个token,生成500个字可能就需要5到10秒。如果后端在拿到全部token之后才组装JSON返回,用户就必须等满这5到10秒。
二是连接的生命周期。HTTP响应只要没结束,浏览器这只"手"就一直悬在空中。页面既不显示数据,也没有进度,用户根本不知道系统是在正常工作还是卡死了。
我做过一个对比测试:同样一段500字的回答,非流式接口平均耗时8.6秒,用户在这个时间内看到的只有一个loading旋转图标。改成流式后,首个token大约900毫秒到达页面,完整输出时间还是8秒多,但用户在第1秒就开始看到内容了,流失率明显下降。这就是从"等待全部"到"边等边看"的体验变化。
1.2 流式响应的底层逻辑:连接不关闭,数据一块块发
流式输出的本质是:服务端不把连接立即关掉,而是用分块传输(Chunked Transfer)或者SSE(Server-Sent Events)的方式,把数据一块一块地推给客户端。HTTP/1.1协议本身就支持这个能力,不需要特殊协议。
用生活类比来说:非流式是"等厨师把所有菜做完,一次性端上餐桌";流式是"每炒好一道菜就端出来一道"。菜没全部上齐,但客人已经能边吃边等了。
对大模型场景来说,后端拿到上游AI接口的数据后,不要攒起来,而是每收到一小块数据就立刻通过响应对象 write 出去。前端通过 fetch 拿到 Response 对象后,也不调用.json(),而是从response.body里拿到一个 ReadableStream 读取器,持续往出读字节流,读一点、解析一点、渲染一点。
1.3 什么场景真的需要上流式
不是所有AI接口都需要流式。我归纳了三个硬场景:
- AI对话/聊天:这是最典型的场景,打字机效果直接决定产品观感,几乎必须用。
- 长时间生成任务:AI写作、周报生成、长文总结,生成时间超过3秒的,不用流式会大量流失用户。
- 实时反馈型交互:比如AI逐步分析、Agent思考过程展示,用户需要看到"它在干活"的证据。
反过来说,如果接口本身很快、返回结果就是一两句话,那流式带来的复杂度就不值得。判断标准很简单:预期首token时间大于1秒、或者总生成时间大于3秒,就值得改造。
2. Node.js后端:从大模型API透传到SSE,完整可跑的实现
2.1 最朴素的方案:Node原生HTTP直接写流
先不引入任何框架,用Node原生http模块就能实现流式输出。核心就两句话:设置响应头,然后调用res.write()写入内容,最后用res.end()结束响应。
// server.js import http from 'node:http'; import process from 'node:process'; const server = http.createServer(async (req, res) => { if (req.url === '/api/chat' && req.method === 'POST') { let body = ''; for await (const chunk of req) { body += chunk; } const { messages } = JSON.parse(body); // 关键:告诉浏览器这是SSE流,不要等连接关闭 res.statusCode = 200; res.setHeader('Content-Type', 'text/event-stream; charset=utf-8'); res.setHeader('Cache-Control', 'no-cache, no-transform'); res.setHeader('Connection', 'keep-alive'); // 调用上游大模型的流式接口 const upstream = await fetch('https://api.your-llm.com/v1/chat/completions', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${process.env.LLM_API_KEY}` }, body: JSON.stringify({ model: 'your-model', messages, stream: true }) }); if (!upstream.ok) { const errText = await upstream.text(); res.write(`data: ${JSON.stringify({ error: errText })}\n\n`); res.end(); return; } // 把上游的字节流原样转发给前端 const reader = upstream.body.getReader(); const decoder = new TextDecoder('utf-8'); try { while (true) { const { done, value } = await reader.read(); if (done) break; const text = decoder.decode(value, { stream: true }); res.write(text); } res.end(); } catch (err) { console.error('stream error:', err); res.end(); } } }); server.listen(3000, () => { console.log('server listening on 3000'); });用原生HTTP是因为它最透明,你能清楚地看到res.write()是在做什么。生产环境用Express、Koa、Fastify都没问题,res.write、res.end这些底层接口是相通的。
2.2 SSE、WebSocket、轮询:三个方案怎么选
后端实现流式输出有几种常见方案,很多新手一上来就纠结要不要上WebSocket,其实大部分场景完全不需要。
| 方案 | 传输方向 | 实现复杂度 | 适用场景 |
|---|---|---|---|
| SSE(Server-Sent Events) | 服务端单向到客户端 | 低,原生HTTP即可 | 大模型流式输出、消息推送、进度通知 |
| WebSocket | 双向 | 高,需要协议升级、心跳、连接管理 | 实时协同、在线游戏、双向频繁交互 |
| 短轮询 | 单向,靠前端定时拉 | 最低,但浪费严重 | 不推荐用于流式输出 |
我现在的项目里,所有AI对话流式输出用的都是SSE。理由是:AI对话本质是"服务端往客户端推内容"的单向流,用户虽然要发送消息,但发送动作走一个普通POST就完了,响应流再用SSE返回,根本不需要双向通道。SSE还自带断线重试机制(retry字段),浏览器原生 EventSource 都支持,用起来省心很多。
有同学问过:如果我的前端用了fetch而不是EventSource,响应格式还是SSE吗?答案是:可以。SSE消息格式data: xxx\n\n只是一个约定格式,你用fetch读取响应流,照样可以按这个格式解析,二者不冲突。EventSource的优点是自动重连,缺点是只能GET且不能自定义请求头。我们在需要携带JWT的场景下,通常用fetch + SSE格式,手动解析。
2.3 对接上游大模型API:两条主流路线
Node.js后端对接大模型流式接口一般有两条路线:
路线一:直接使用HTTP fetch(推荐)。大模型服务商的OpenAI兼容接口,只要在请求体里带上stream: true,返回的就是一个SSE流。用Node.js 18+内置的fetch发请求,拿到response.body后按流式读取即可。这种方式依赖最少、可控性最强,出现问题也最容易排查,因为你不必猜测SDK内部做了什么。
路线二:使用官方SDK。比如openainpm包,把stream设为true后,返回值是一个异步可迭代对象,可以for await遍历。
import OpenAI from 'openai'; const openai = new OpenAI({ apiKey: process.env.LLM_API_KEY }); const stream = await openai.chat.completions.create({ model: 'your-model', messages, stream: true }); for await (const chunk of stream) { const delta = chunk.choices[0]?.delta?.content || ''; if (delta) { res.write(`data: ${JSON.stringify({ delta })}\n\n`); } } res.end();SDK的好处是帮你处理了重试和类型定义,坏处是隐藏了底层细节。如果上游返回的格式变了、或者你想做统一网关,调试起来会多一层阻碍。个人建议:项目初期直接写fetch,搞清楚协议细节之后,再用SDK也不迟。
2.4 后端不做透传,而是重新封装的原因
很多人问:上游返回什么,我就往res里写什么,这样最简单,为什么还要二次封装?
直接透传确实简单,但有个问题:前端会直接拿到上游原始数据格式。一旦你切换模型服务商,或者想往流里追加一些业务字段(比如当前使用的模型名、token用量、知识库引用来源),前端就得跟着改。接口耦合太深了。
我的做法是:后端先把上游的SSE事件解析一遍,提取出delta、usage、finish_reason等字段,然后重新封装成统一的事件格式发给前端:
event: message data: {"delta": "你好"} event: done data: {"usage": {"prompt_tokens": 20, "completion_tokens": 88}}前端只认这一套格式,后端内部接的是哪家模型,前端完全无感。下次换模型只要改后端一行配置,前端一行不用动。
2.5 后端容易被忽略的两个细节
第一个是超时问题。大模型生成慢的话,一个请求可能持续几十秒。Node默认没有超时,但Nginx这类反向代理默认proxy_read_timeout是60秒,很容易在长回复时掐断连接。解决方案是显式修改Nginx配置,加上proxy_read_timeout 300s;或者proxy_buffering off;,否则中间代理会把流缓存住,前端等半天才看到内容。
第二个是请求关闭时取消上游调用。用户如果前端点了停止,后端必须能感知到连接关闭,并主动abort掉上游请求,否则浪费的token是要真金白银扣费的。
req.on('close', () => { if (!res.writableEnded) { controller.abort(); } });这里controller是你创建fetch请求时传入的AbortController,务必在收到请求关闭事件时去掉上游连接。
3. Vue3前端接收流:fetch + ReadableStream 的完整实战
3.1 为什么拿流必须用fetch,而不是axios
axios默认走XHR(XMLHttpRequest),XHR在没有onprogress和responseType配合的情况下,很难拿到流式中间态的数据。虽然现代XHR也支持onprogress,但处理起来不如fetch优雅,而且axios封装了一层之后,对流的控制更弱——你想读response.body它不给你。
fetch拿到的是Response对象,response.body是一个Web Stream(ReadableStream),可以用getReader()拿到读取器,然后像拉水管一样一截一截地读数据。Vue3不管是用Options API还是Composition API,底层都是标准的fetch操作,这里没有框架间的差异。
3.2 核心读取循环:逐块读取与解码
写一个可复用的组合式函数,把流式读取逻辑封装起来:
// composables/useStreamChat.ts import { ref } from 'vue' export function useStreamChat() { const content = ref('') const isStreaming = ref(false) let controller: AbortController | null = null async function sendMessages(messages: Array<{ role: string; content: string }>) { content.value = '' isStreaming.value = true controller = new AbortController() try { const response = await fetch('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ messages }), signal: controller.signal }) if (!response.ok) { throw new Error(`HTTP ${response.status} ${response.statusText}`) } const reader = response.body!.getReader() const decoder = new TextDecoder('utf-8') let buffer = '' while (true) { const { done, value } = await reader.read() if (done) break // 将二进制分块解码为字符串 buffer += decoder.decode(value, { stream: true }) // 按换行符切割,处理上一轮留下的半行 const lines = buffer.split('\n') buffer = lines.pop() ?? '' for (const line of lines) { handleSSELine(line) } } } catch (err: any) { if (err.name === 'AbortError') { console.log('用户中止了生成') } else { console.error('流式请求失败', err) } } finally { isStreaming.value = false controller = null } } function handleSSELine(line: string) { const trimmed = line.trim() if (!trimmed.startsWith('data:')) return const data = trimmed.slice(5).trim() if (data === '[DONE]') return try { const json = JSON.parse(data) const delta = json.delta ?? json.choices?.[0]?.delta?.content ?? '' if (delta) { content.value += delta } } catch (err) { console.warn('SSE JSON解析失败', data) } } function stop() { controller?.abort() } return { content, isStreaming, sendMessages, stop } }这段代码里有几个关键设计:
TextDecoder('utf-8', { stream: true })处理了多字节字符被切分到两个chunk的情况。网络传输不保证按字符边界切分,一个汉字三个字节,很可能上一个chunk只有两个字节,必须用stream: true让解码器缓存未完成的字符,下次解码时拼上。这是很多人踩中文乱码坑的根源。
buffer累积解码后的字符串,按换行符切出完整行,剩下一段不完整的继续留在buffer里等下一次读取。这样SSE事件的边界不会被截断,解析才准确。
3.3 页面里的Vue3组件怎么用
组合式函数封装好之后,页面组件使用起来非常简洁:
<script setup lang="ts"> import { useStreamChat } from '../composables/useStreamChat' const { content, isStreaming, sendMessages, stop } = useStreamChat() const messages = ref([{ role: 'user', content: '用流式输出介绍下你自己' }]) async function handleSend() { await sendMessages(messages.value) } </script> <template> <div class="chat-box"> <div class="message"> <p>{{ content }}</p> <span v-if="isStreaming" class="cursor" /> </div> <button @click="handleSend" :disabled="isStreaming">发送</button> <button v-if="isStreaming" @click="stop">停止生成</button> </div> </template>content是响应式变量,每次content.value += delta都会触发Vue更新。打字机效果天然成立。isStreaming控制loading和停止按钮的显示,用户交互状态一目了然。
3.4 为什么需要节流,直接把字符串塞给模板不行吗
前面代码里每次拿到delta就往content.value加,如果AI生成速度很快,比如每秒输出100个token,Vue的响应式系统就要在1秒内更新100次DOM。这在现代浏览器里不至于卡死,但如果前端同时渲染Markdown、高亮代码,CPU占用会明显上升,滚动也会出现卡顿。
实测中,长文本回答时高频更新会让页面掉帧。解决方案是给渲染加一层节流:用一个requestAnimationFrame或者setTimeout节流阀门,把最新的content以每帧最多一次(约60fps)的频率提交给模板渲染。
let renderTimer: number | null = null function scheduleRender(text: string) { if (renderTimer !== null) return renderTimer = requestAnimationFrame(() => { content.value = text renderTimer = null }) }当然,这种做法只优化渲染频率,不改变最终结果。如果产品对实时性要求没那么高,也可以把节流间隔设成100ms,体感差别很小,CPU占用却降很多。
4. 工程化细节不能省:停止生成、断线、会话管理
4.1 AbortController:让"停止生成"真正停止
AI对话产品里,停止生成是标配功能。用户点了停止,前端要立刻停止展示,后端要取消上游调用,避免继续计费。
核心是AbortController。前端创建controller,在fetch请求里通过signal传入;用户点击停止时调用controller.abort(),fetch会抛出一个名字为AbortError的异常,在catch里判断err.name就能区分是用户中止还是网络错误。
后端的配合同样重要:当前端中断连接后,Node服务的req对象会触发close事件,后端在这个事件里执行上游请求的abort(),才能真正中断token消耗。很多初学者只做了前端停止,后端还在默默生成,白白浪费调用次数。
4.2 断线重连与超时处理
SSE流在移动网络下很容易断。断线有两种:一种是连接被中间层掐断,前端读取循环收到done但发现内容没完整;另一种是长时间没有数据,被浏览器或代理判定为超时。
对流式输出这种场景,我的建议是:
- 前端保存当前已经累积的内容,如果掉线,再次发送请求时把
lastText传给后端做续跑拼接,或者干脆重新请求并从头渲染。大多数聊天场景不需要精确续接,重新拉一次对用户更友好。 - 协议层利用SSE的
retry字段控制重连间隔,如果是fetch实现,就自己封装一个重连逻辑:检测到异常结束时,延时1-3秒自动重试。 - 服务端同时设置心跳机制,每15秒发送一个
: keep-alive\n\n注释行,防止代理把连接当成死连接清掉。这个在Nginx代理场景下尤其重要。
4.3 多轮会话与消息持久化
流式输出只是生成逻辑的一部分。真实项目里,前端发送的是一个消息数组,包含历史上下文。当用户发新消息时,旧消息已经保存在本地或数据库中。流式输出过程中产生的新内容,最终要持久化下来。
我遇到的一个实际问题是:用户消息发出后,AI回答还没输出完用户就刷新了页面,历史记录里缺了最后一段。解决方式是前端在content更新的同时,把增量数据通过防抖批量写入本地存储或IndexedDB;服务端则在前端请求结束后统一接收一次完整的消息记录。
生产上还可以给每个会话分配sessionId,后端把每条消息的messageId、parentId串起来,方便前端做分叉回退和重新生成。这块用关系表就能实现,不需要上太重的中间件。
4.4 并发请求与token用量统计
AI对话产品经常出现用户连点多次发送的情况。前端要在isStreaming为true时禁用发送按钮,这是第一道防线。后端也要加并发校验,同一个sessionId的请求正在处理时,新请求返回429或等待队列。
流式输出的token用量统计比较特殊,因为SSE过程中可能不带usage字段。我用的方案是:在生成结束时,上游会返回一个包含完整usage的最后一帧,或者在后端把每次delta的token数累加,生成结束时汇总一次。把这个统计和会话记录一起持久化,后续做成本核算和配额控制都有数据支撑。
5. 实测踩坑记录:标签未完整、中文乱码、渲染卡顿
5.1 AI返回的Markdown标签不完整:这是一个绕不开的坎
这是流式渲染里最经典的问题。AI生成Markdown时,前端收到一半的内容可能长这样:
这是一个 **加粗或者更常见的:
这是一段代码:**还没闭合,代码块也没结束。如果前端直接把这段半成品丢给Markdown渲染器,轻则渲染异常,重则显示成乱码甚至空白。
我踩过坑之后总结出三层处理策略:
- 容错渲染库优先:选择不会因为未闭合标签而崩溃的Markdown库。实测
markdown-it对不完整标签的容忍度较好,会把它按纯文本处理;marked在某些版本下会渲染出预期外的HTML结构。如果选型时没注意这个问题,建议先用容错性更强的库。 - 流式阶段不做完整解析,只展示纯文本或轻量渲染:在流式输出过程中,我可以选择直接显示纯文本(把Markdown符号原样展示),等流结束后再完整渲染。这样最稳妥,缺点是用户会在流结束前看到原始Markdown语法符号,略微影响观感。
- 做标签闭合兜底:在流式渲染前,写一个后处理函数,检测常见的未闭合标记并补齐。比如检测到代码块围栏符 ``` 没有配对,就自动补上;检测到
**未闭合,也补一个**。
第五部分要展开讲到实际代码:
function fixIncompleteMarkdown(text: string): string { let result = text // 检测代码块围栏是否闭合 const fenceCount = (result.match(/```/g) || []).length if (fenceCount % 2 === 1) { result += '\n```' } // 检测行内代码 ` 是否配对 const inlineCount = (result.match(/`/g) || []).length if (inlineCount % 2 === 1) { result += '`' } // 粗体星号配对 const boldCount = (result.match(/\*\*/g) || []).length if (boldCount % 2 === 1) { result += '**' } return result }这个方案在流式阶段仍然可以渲染Markdown,同时不会因为未闭合标签导致布局崩坏。等流结束后再用完整文本渲染一次,用户看到的就是规范格式。
5.2 中文乱码:问题几乎都出在解码方式上
流式输出中,中文字符可能被网络包切成两半。一个汉字UTF-8编码占三个字节,如果第一个chunk只到了两个字节,直接按字符串拼接就会产生乱码。
正确做法是使用TextDecoder并开启stream: true:
const decoder = new TextDecoder('utf-8') const { done, value } = await reader.read() console.log(decoder.decode(value, { stream: true })) // 解出能解的部分Nostream: true时,解码器遇到未完成的字节会直接返回replacement character(�),再也不会回头修复。开启后它会把残余字节缓存,等你下一次继续喂数据。这正好对应了搜索热词里"jdbc查询流式输出"等场景的理念:底层无论是什么传输介质,读取大文本时都要考虑字节边界。
5.3 高频流式更新导致页面卡顿
我在测试环境用3000字长回答压测,不节流直接渲染,页面滚动明显掉帧,CPU占用飙升到80%以上。加了requestAnimationFrame节流后,CPU降到30%,观感几乎无差别。
另外一个容易被忽视的点是:v-html渲染Markdown转换后的HTML时,如果整个大块都替换,浏览器重排开销极大。更好的方案是把长回答按段落拆分,每个段落是一个独立组件,流式更新时只更新最后一个段落节点,之前的段落不参与diff。这个优化在长文本场景下收益巨大。
5.4 代码块、表格等特殊内容的渲染建议
AI生成的回答里代码块很常见,连续输出几十行代码时,如果每来一个token都重新高亮一遍,性能会很差。我的做法是:代码高亮只在流结束后做,流式过程中只渲染纯色背景的代码区域,不调用highlight.js。等整个回答输出完了,再触发一次高亮。
表格的处理类似,|组成的Markdown表格在流式过程中经常因为列数不齐而渲染错乱。兜底方案是:检测到当前行以|开头但还没形成闭合表格时,暂时按普通文本显示,流结束再完整渲染。
还有一点安全层面的提醒:AI返回的内容经过Markdown渲染时,如果经过v-html直接插入DOM,存在XSS风险。用marked或markdown-it渲染时必须开启HTML转义或者用DOMPurify做一遍 sanitize,阻止AI输出中被诱导写入的恶意脚本执行。
最后再分享一个实操中的小发现:别把流式输出当成一个"外加功能"去接,它应该从一开始就纳入接口协议设计。后端把流事件格式定好,前端组件天然支持增量渲染,两者配合好之后,后面接任何模型、任何知识库都是水到渠成的事。我自己就是一开始图省事用了普通JSON接口,后来返工改成SSE,前后端都动了一遍,教训足够深刻。