Vue3 + Node.js实现AI流式输出:从原理到实战 📅 发布时间:2026/9/19 11:37:13 👁 浏览次数: 最近在做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或者SSEServer-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; charsetutf-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其实大部分场景完全不需要。方案传输方向实现复杂度适用场景SSEServer-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而不是axiosaxios默认走XHRXMLHttpRequestXHR在没有onprogress和responseType配合的情况下很难拿到流式中间态的数据。虽然现代XHR也支持onprogress但处理起来不如fetch优雅而且axios封装了一层之后对流的控制更弱——你想读response.body它不给你。fetch拿到的是Response对象response.body是一个Web StreamReadableStream可以用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 langts 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 classchat-box div classmessage p{{ content }}/p span v-ifisStreaming classcursor / /div button clickhandleSend :disabledisStreaming发送/button button v-ifisStreaming clickstop停止生成/button /div /templatecontent是响应式变量每次content.value delta都会触发Vue更新。打字机效果天然成立。isStreaming控制loading和停止按钮的显示用户交互状态一目了然。3.4 为什么需要节流直接把字符串塞给模板不行吗前面代码里每次拿到delta就往content.value加如果AI生成速度很快比如每秒输出100个tokenVue的响应式系统就要在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: trueconst 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前后端都动了一遍教训足够深刻。