资讯动态

Vue3+Node.js实现AI流式输出:SSE实战与坑点解析

发布时间:2026/9/18 2:29:04 来源:尧图企业网站定制
近一年我一直在做 AI 应用相关的项目说实话真正把体验做出来、让用户觉得“这玩意儿像那么回事”的关键不在于模型选得多大、参数调得多花哨而在于一个很基础但又容易被忽略的能力——AI 流式输出。大家现在用 ChatGPT、Claude 这类产品早就习惯了文字一个字一个字往外蹦的效果。一旦你做过自己的 AI 应用会发现如果让用户盯着页面转圈等三五秒、然后“唰”地一下整段答案砸出来体验会瞬间被打回原形。所以我这次的实战项目就是用Vue3 Node.js 从零实现一套 AI 流式输出把大模型的流式响应经过自己的后端转发到前端页面实现打字机式的逐字渲染效果。这篇文章我会把整个链路拆开揉碎包括技术选型、前后端核心实现、踩过的坑和排查实录全部写出来适合已经会 Vue3 基础、想真正把大模型接进自己项目里的开发者。1. 内容整体设计与思路拆解1.1 流式输出要解决的核心问题先想清楚一个问题为什么 AI 对话一定要流式输出通常我们调大模型接口模型生成完一整段回答可能需要几十秒。这段等待时间对用户来说是一种煎熬而且会带来两个连锁问题第一用户不知道系统是在正常工作还是卡死了焦虑感会迫使刷新页面或重复提问第二长内容的生成结果一次性返回中间发生网络抖动可能整段请求失败只能重来。流式输出的思路是模型一边生成服务器一边把已经生成好的内容推给前端前端一边接收一边渲染。用户看到的效果是——你按下回车文字就开始一个一个往外冒完全没有“等待一整块内容”的过程。这跟以前下载文件看进度条是一个原理只不过数据流是持续不断的文本。从技术层面拆解流式输出其实包含三个环节后端拿到大模型流式接口返回的数据块chunk后端把这些数据块通过某种协议实时推送给浏览器前端解析数据流增量更新页面状态任何一个环节没打通流式体验都会失效。最容易翻车的就是第三环——很多人后端已经拿到流了但前端 fetch 响应一直不触发折腾半天发现是响应被浏览器缓冲了根本原因是响应头没设置对。后面我会具体讲。1.2 技术选型为什么是 SSE 而不是 WebSocket实现服务器向浏览器实时推送有三个常见方案轮询、WebSocket、SSEServer-Sent Events服务器发送事件。我直接说结论AI 对话这种场景首选 SSE。轮询就不用考虑了每隔几秒发一次请求既不实时又浪费资源。WebSocket 是老牌的全双工通信方案但在这里属于“杀鸡用牛刀”。WebSocket 的优点是双向通信但 AI 对话场景里数据流向几乎是一边倒的——客户端只负责发一次请求剩下的全是服务器单方向往客户端推数据。用 WebSocket 意味着要处理连接升级、心跳保活、二进制帧解析、断线重连策略……工程复杂度一下子就上去了。SSE 是构建在 HTTP 之上的轻量协议浏览器通过EventSource接口或fetch就能直接接收服务器推送的文本流。它有几个很实在的优势协议简单服务端只要设置Content-Type: text/event-stream然后不断往响应里写入data: {...}\n\n格式的数据就行自动重连EventSource自带了断线重连机制不用自己写基于 HTTP可以复用现有的鉴权、代理、负载均衡体系不需要额外维护长连接服务器只做后端转发我的项目里 Node.js 服务器还要去请求第三方大模型接口这个模型接口本身返回的就是 SSE 数据流直接原样透传给前端非常顺有一个技术难点要在选型时想明白EventSourceAPI 只支持 GET 请求而很多业务场景比如传用户消息给后端需要用 POST。这时候不用死磕EventSource改用fetch配合ReadableStream来手动解析 SSE 数据既解决了 POST 问题又能拿到更细腻的控制权比如中断请求。我这次项目的方案就是后端 Node.js 按 SSE 协议输出前端用 fetch ReadableStream 手动读取解析。对比维度轮询WebSocketSSEfetch模式实时性差好好双向通信支持多次请求支持不支持单向协议复杂度低高低断线重连需自己实现需自己实现可用原生fetch模式需手写与 HTTP 兼容性好一般好适合场景低频数据游戏、实时协作AI 流式、消息推送1.3 整体架构梳理这个项目的完整链路是这样的浏览器 (Vue3) │ ① 用户输入 → 点击发送 → POST /api/chat {messages: [...]} ▼ Node.js 后端服务器 │ ② 拿到请求拼好 system/user 消息调用大模型 SDK 或 HTTP 接口 │ ③ 大模型 API 返回 SSE 流data: {...}\n\n ▼ Node.js 转发处理 │ ④ 逐块读取模型的响应按 SSE 协议写入给前端 ▼ 浏览器 (Vue3) │ ⑤ fetch 拿到响应流用 ReadableStream 读取 data 块 │ ⑥ 解析 JSON提取 delta.content │ ⑦ 用 Vue3 响应式状态累积内容实时渲染到页面 ▼ 用户看到打字机式输出后端不直接暴露大模型 API 的密钥给前端前端只跟自己服务器通信。这样做的好处一是安全二是可以在中间层做统一封装——比如替换模型供应商、加上对话历史管理、做敏感词过滤、记录日志等等。这个架构也方便扩展成 Nginx 负载均衡后面挂多台 Node 实例。2. 环境准备与工程初始化2.1 Node.js 版本与环境配置老规矩动手之前先把环境理顺。我的机器上用的是Node.js 18 LTS 及以上版本这个版本对 Web Streams API 的原生支持比较完善用起来省心。关于版本问题我多说一句网上很多教程直接让你装最新版但我建议装 LTS 长期支持版。你去看 npm 上很多大包的engines字段都会标注支持 Node 的版本范围LTS 版踩坑最少。如果你用了一个较老的 Node 14后面代码里用ReadableStream、TextDecoder这些 API 时大概率会踩莫名奇妙的兼容坑。在终端先检查版本node -v npm -v如果你还没装 Node.js就去官网下载对应系统的安装包一路下一步装好。Windows 用户注意勾选“Add to PATH”选项macOS 用户建议直接装 nvm 管理器方便日后切换版本但这不是必须的。2.2 Vue3 项目初始化前端项目我直接用 Vite 脚手架创建命令如下npm create vitelatest ai-chat-web -- --template vue cd ai-chat-web npm install npm run dev这一步会生成一个纯净的 Vue3 项目。我需要额外安装两个依赖npm install marked dompurifymarked把模型输出的 Markdown 文本转成 HTMLdompurify对生成的 HTML 做 XSS 清洗防止模型输出里的恶意脚本被执行为什么需要marked因为大模型的输出大多是 Markdown 格式代码块、列表、加粗等不渲染就是一片纯文本阅读体验很差。但引入 Markdown 渲染就得考虑安全问题——模型在极少数对抗场景下也可能输出带script的内容所以必须用 DOMPurify 过滤一遍再插入 DOM。这个坑我后面还会强调一次。后端项目我单独建一个目录叫ai-chat-server用 Express 来搭不用写太多繁琐的原生 HTTP 处理逻辑mkdir ai-chat-server cd ai-chat-server npm init -y npm install express热词里有人提到“若依 vue3 ts 报错”“vue3使用jsx”之类的问题都是环境相关的坑我这里没用到 TypeScript 和 JSX就不过多展开了。但如果你把 TypeScript 引入进来需要注意 Node.js 侧的tsx或ts-node配置避免运行时模块解析报错。2.3 环境变量管理大模型的 API 密钥绝对不能写进代码里一是不安全二是以后换模型、换账号都要改代码太蠢。我习惯用.env文件来管理# ai-chat-server/.env AI_API_KEYsk-你的密钥 AI_BASE_URLhttps://api.openai.com/v1 AI_MODELgpt-3.5-turbo PORT3000Node.js 里加载.env文件需要装一个依赖npm install dotenv然后在入口文件最顶部加上require(dotenv).config();就能够通过process.env.AI_API_KEY读取到密钥了。别忘了在.gitignore里把.env加进去否则密钥一提交到仓库就裸奔了。3. 后端核心实现Node.js 流式转发3.1 后端整体逻辑与接口定义后端要做的事情很清晰暴露一个POST /api/chat接口接收前端传来的消息数组将消息数组加上 system prompt原样转发给大模型接口并声明启用流式响应拿到大模型的流式响应后逐块读取原样写入前端响应设置正确的响应头告诉浏览器这是text/event-stream格式为什么强调“原样”呢因为大模型供应商OpenAI、Anthropic 等返回的流式数据格式是标准 SSE 格式开头是data:最后以\n\n结束。我只要原样透传给前端前端解析数据时也是按这个格式来拆。中间如果做一次 JSON 解析再重组反而增加了出错的概率。来看接口的核心代码const express require(express); const app express(); app.use(express.json()); // CORS 中间件 app.use((req, res, next) { res.header(Access-Control-Allow-Origin, *); res.header(Access-Control-Allow-Headers, Content-Type); if (req.method OPTIONS) { return res.sendStatus(200); } next(); }); app.post(/api/chat, async (req, res) { const { messages } req.body; // 设置 SSE 响应头 res.setHeader(Content-Type, text/event-stream; charsetutf-8); res.setHeader(Cache-Control, no-cache); res.setHeader(Connection, keep-alive); res.flushHeaders(); try { const aiResponse await callAIModel(messages); // 模型返回的本身是流 for await (const chunk of aiResponse) { // chunk 是 Uint8Array转成字符串 const text new TextDecoder().decode(chunk); // 原样写入前端响应 res.write(text); } } catch (err) { console.error(AI 请求失败:, err); res.write(data: ${JSON.stringify({ error: AI 服务暂时不可用 })}\n\n); } finally { res.end(); } }); function callAIModel(messages) { // 此处根据你所使用的大模型 SDK 来写 // 以 OpenAI Node SDK 为例 const OpenAI require(openai); const client new OpenAI({ apiKey: process.env.AI_API_KEY, baseURL: process.env.AI_BASE_URL, }); return client.chat.completions.create({ model: process.env.AI_MODEL, stream: true, messages, }); } app.listen(process.env.PORT || 3000, () { console.log(Server running on port ${process.env.PORT || 3000}); });这里callAIModel的返回值是一个流对象。在 OpenAI 官方 Node SDK 中当stream: true时.create()返回的是一个 Stream 实例可以for await...of遍历每个 chunk 对象里面包含choices[0].delta.content字段。如果你不想引入 SDK直接用 Node.js 内置的fetch也可以而且更直观async function callAIModel(messages) { const response await fetch(${process.env.AI_BASE_URL}/chat/completions, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${process.env.AI_API_KEY}, }, body: JSON.stringify({ model: process.env.AI_MODEL, stream: true, messages, }), }); if (!response.ok) { throw new Error(API 请求失败: ${response.status} ${response.statusText}); } return response.body; }这样aiResponse就是一个ReadableStream对象同样可以for await遍历不过遍历出来的是Uint8Array类型需要解码。用这个方式的好处是不依赖任何 SDK换哪家大模型都能用——只要它是 OpenAI 兼容接口现在市面上一堆国内模型、开源模型都兼容这个协议。注意res.flushHeaders()这行不要省略。它的作用是立刻把响应头刷给客户端让浏览器知道这是一个流式响应不再等待后续内容。如果不调用某些代理服务器会把响应积压到一定量才发送流式效果就废了。3.2 断连与取消处理流式请求有个前端点击“停止生成”的场景后面会讲前端用AbortController取消 fetch 请求。但这里有一个容易被忽略的坑前端的请求取消了后端的 Node.js 服务器要不要停止对大模型 API 的请求如果不处理后端会继续拉取大模型的流直到全部拉完才结束。模型生成一段长回答可能要几十秒这期间服务器白白占着一个连接、一个任务并发一高服务器就崩了。解决方案是监听响应对象的close事件app.post(/api/chat, async (req, res) { res.setHeader(Content-Type, text/event-stream; charsetutf-8); res.setHeader(Cache-Control, no-cache); res.setHeader(Connection, keep-alive); res.flushHeaders(); const controller new AbortController(); // 客户端断开连接时触发 req.on(close, () { controller.abort(); }); try { const aiResponse await callAIModel(messages, controller.signal); for await (const chunk of aiResponse) { const text new TextDecoder().decode(chunk); res.write(text); } } catch (err) { if (err.name AbortError) { console.log(客户端已断开停止生成); } else { console.error(AI 请求失败:, err); res.write(data: ${JSON.stringify({ error: AI 服务暂时不可用 })}\n\n); } } finally { res.end(); } });在使用fetch调用大模型接口时把controller.signal作为signal参数传进去就可以在客户端断开时取消对上游 API 的请求。这个细节在实际生产中非常重要不做的话一次对话用户在生成到一半时刷新页面后端那个生成任务会一直跑到完非常浪费资源。3.3 SSE 协议格式回顾SSE 的数据格式很简单每一块消息长这样data: {choices:[{delta:{content:你}}]}\n \n data: {choices:[{delta:{content:好}}]}\n \n每行以data:开头后面跟实际数据消息之间用一个空行即两个\n分隔数据可以是一整行也可以跨多行多行时会被拼接我的后端直接透传大模型返回的数据所以前端解析时面对的就是这种格式。这里只是搭个底完整解析逻辑放到前端部分讲。3.4 后端联调测试后端写完先不急着写前端用curl直接测一下接口能不能正常工作。启动服务器之后curl -N -X POST http://localhost:3000/api/chat \ -H Content-Type: application/json \ -d {messages:[{role:user,content:用一句话介绍你自己}]}关键参数是-N它告诉 curl 不要缓冲输出立即显示服务器推送的每一块数据。如果看到一堆data: {...}陆续打印出来说明后端流式转发已经通了。这里我实测遇到的一个状况是如果忘记设置Cache-Control: no-cache某些浏览器或代理服务器会把整个 SSE 响应当成普通 HTTP 响应缓冲起来直到流结束才一次性给用户流式效果完全失效。响应头三个字段Content-Type、Cache-Control、Connection一个都不能少。4. 前端核心实现Vue3 流式渲染4.1 fetch ReadableStream 解析 SSE前端核心是把流式响应读进 Vue 应用。直接上代码先看核心封装// src/api/chat.js export async function streamChat(messages, { onMessage, onDone, onError, signal }) { const response await fetch(/api/chat, { method: POST, headers: { Content-Type: application/json, }, body: JSON.stringify({ messages }), signal, }); if (!response.ok) { throw new Error(请求失败: ${response.status}); } const reader response.body.getReader(); const decoder new TextDecoder(); let buffer ; while (true) { const { done, value } await reader.read(); if (done) break; // 解码本次数据块 buffer decoder.decode(value, { stream: true }); // 按 SSE 消息分隔符切分 const lines buffer.split(\n); // 最后一行可能是不完整的留到下一次拼接 buffer lines.pop(); for (const line of lines) { const trimmed line.trim(); if (!trimmed || !trimmed.startsWith(data:)) continue; const data trimmed.slice(5).trim(); // 处理数据流结束标记 if (data [DONE]) { onDone(); return; } try { const json JSON.parse(data); const delta json.choices?.[0]?.delta?.content; if (delta) { onMessage(delta); } } catch (e) { console.warn(解析 JSON 失败:, data); } } } onDone(); }这段代码有几个细节值得说明为什么要用buffer拼接因为网络传输的数据块边界是任意的一次reader.read()返回的内容可能在一条 SSE 消息的中间也可能包含好几条完整的消息。比如大模型一次性生成了 30 个 token网络传输时可能分成两批每批 15 个但单个 token 可能被拆在两批里。所以必须维护一个缓冲区以行为单位切分最后一段不完整的留到下次循环再处理。decoder.decode(value, { stream: true })是什么意思这是TextDecoder的流式解码模式。如果不传{ stream: true }遇到一个分成两段传入的多字节 UTF-8 字符比如中文“你”的三个字节被拆成两批解码就会出错产生乱码。开启流式模式后解码器会缓存未完成的字节序列等下一批数据补全后再输出完整字符。这个细节在渲染中文内容时尤为重要。为什么要用choices[0]?.delta?.content这是 OpenAI 兼容接口流式输出的标准结构每个 chunk 里的增量文本在delta.content里。某些情况下这个字段可能是undefined比如该 chunk 携带的是角色信息或使用统计所以要安全取值。4.2 Vue3 组件中的状态管理消息状态放在组件里每次收到增量就拼接到当前消息的响应文本后面!-- src/App.vue -- script setup import { ref } from vue; import { streamChat } from ./api/chat; const messages ref([]); const currentMessage ref(); const isStreaming ref(false); const abortController ref(null); async function sendMessage() { const userText currentMessage.value.trim(); if (!userText || isStreaming.value) return; // 追加用户消息 messages.value.push({ role: user, content: userText }); // 追加一个空的助手消息后面逐字填充 const assistantMsg { role: assistant, content: }; messages.value.push(assistantMsg); currentMessage.value ; isStreaming.value true; abortController.value new AbortController(); // 组装发送给后端的历史消息 const apiMessages messages.value.map(({ role, content }) ({ role, content })); try { await streamChat(apiMessages, { onMessage: (delta) { assistantMsg.content delta; }, onDone: () { isStreaming.value false; }, onError: (err) { console.error(err); assistantMsg.content 出错了请重试; isStreaming.value false; }, signal: abortController.value.signal, }); } catch (err) { if (err.name AbortError) { console.log(用户取消生成); } else { console.error(err); } isStreaming.value false; } } function stopGeneration() { if (abortController.value) { abortController.value.abort(); } } /script在模板里展示消息列表template div classchat-container div classmessage-list div v-for(msg, index) in messages :keyindex classmessage-item div classmessage-role{{ msg.role user ? 我 : AI }}/div !-- 用户消息直接渲染文本 -- div v-ifmsg.role user{{ msg.content }}/div !-- AI 消息渲染 Markdown -- div v-else v-htmlrenderMarkdown(msg.content)/div /div /div button v-ifisStreaming clickstopGeneration停止生成/button div classinput-area textarea v-modelcurrentMessage placeholder输入你的问题.../textarea button clicksendMessage :disabledisStreaming发送/button /div /div /template4.3 打字机效果的性能优化到这里流式渲染已经可以工作了但直接实测会发现一个体验问题如果模型生成速度很快每秒钟回调几十次Vue 响应式系统每次都会触发 DOM 更新页面会有明显的卡顿感尤其是渲染 Markdown 时解析和 DOM 插入开销更大。解决思路有几种第一种使用requestAnimationFrame节流。把回调里产生的增量先存到一个队列里然后用requestAnimationFrame统一消费let pendingText ; let isRafScheduled false; onMessage: (delta) { pendingText delta; if (!isRafScheduled) { isRafScheduled true; requestAnimationFrame(() { assistantMsg.content pendingText; pendingText ; isRafScheduled false; }); } }这样渲染频率被限制在浏览器帧率通常是 60fps内不会因为模型输出太快导致 Vue 内部重复做 diff 更新。第二种针对 Markdown 渲染的优化。不要每次收到增量都用marked从头解析整段内容那样代价太高。我的做法是在流式过程中只渲染纯文本内容或者简单地把代码块标记出来等流结束后再一次性渲染完整 Markdown。如果你不想做复杂缓存也可以采用一个折中方案在流式过程中用一个独立的displayContent变量频率控制在 50ms 更新一次 DOM。认真测一下效果已经很接近“逐字输出”的顺滑感了。4.4 停止生成功能用户点击“停止生成”按钮最直观的做法是调用abortController.abort()取消前端的 fetch 请求。浏览器会中止读取响应流同时连接会被关闭后端的req.on(close)事件也就触发了从而实现整个链路的中断。但这里要注意AbortController 中断后streamChat 函数内的reader.read()会抛出一个 AbortError 异常所以需要捕获这个错误并区分是“主动取消”还是“意外错误”。上面的代码里已经做了处理这里再强调一下判断条件if (err.name AbortError) { // 主动取消不需要提示用户 } else { // 其他错误需要展示错误信息 }4.5 划重点XSS 安全在流式过程中我用了v-html渲染 Markdown这就把 XSS 风险带进来了。大模型本身一般不产生恶意代码但你没法保证第三方模型接口没有被恶意注入过 prompt提示词注入攻击所以渲染前过滤是必须的。我用marked解析用dompurify清洗的代码如下import { marked } from marked; import DOMPurify from dompurify; function renderMarkdown(text) { const rawHtml marked.parse(text); return DOMPurify.sanitize(rawHtml, { USE_PROFILES: { html: true } }); }marked.parse把 Markdown 转成 HTMLDOMPurify.sanitize会把script、onerror这类危险内容过滤掉。这个组合是当前最基础稳妥的方案没有之一。提醒不要把用户输入的内容和模型输出的内容混为一谈两者的渲染都必须经过过滤。用户消息虽然是系统自己生成的但也可能被有心人粘贴进脚本谨慎一点总没错。5. 常见问题与排查技巧实录5.1 “node:util 不提供命名导出”的报错热词里有一条很典型的报错node:util does not provide an export named。这通常出现在 Node.js 版本不匹配的场景。我遇到过的情况是项目是用新版 Node 初始化的但服务器上跑的是老版本 Node.js比如 Node 14某些新 APInode:util、node:stream等在新版里导出了旧版没有的字段一启动就报这个错。排查思路很简单node -v确认本地版本和服务器版本一致尽量都升级到 Node.js 18 LTS 及以上。npm 里有些依赖包对 Node 版本有硬性要求装依赖时如果看到 engine 相关的警告就要留意了。5.2 前端一直收不到数据直到流结束才一次性返回这几乎是一个必踩的坑。表现形式是后端打印日志显示数据一直在输出但前端页面纹丝不动等整个响应结束后内容“哗”地一下全出来了。原因有两个层面第一后端响应头没设置对。Content-Type不是text/event-stream或者没调res.flushHeaders()导致中间层比如 Nginx、Express 自身的响应缓冲迟迟不把数据下发。第二前端解析方式有问题。如果你用的是EventSource它接收 SSE 消息是没有问题的但如果你用fetch要注意必须及时消费response.body的流。如果代码里写了const data await response.text()那就等于把整个流全读完了再一次性返回那跟流式就完全不搭边了。我之前看过有人用 axios 写流式也是这个毛病——axios 默认会等整个响应完成才 resolve对流式支持并不友好建议直接用原生 fetch。另外如果后端前面还挂了 Nginx需要在 Nginx 配置里关掉缓冲proxy_buffering off;否则 Nginx 默认会把上游数据缓冲到一定量再转发给客户端流式效果也会被破坏。5.3 界面出现漏字、乱码乱码的根源是 UTF-8 字符被拆开解码了。我在讲到TextDecoder时特别强调了{ stream: true }参数忘了加就会在渲染中文、表情符号时出现乱码。漏字则通常是缓冲拆分逻辑写错了。比如 SSE 消息是以\n\n为分隔符的我用\n来切然后靠data:前缀过滤空行这个逻辑没问题。但如果你在解析时把多行data:只当作一行处理就会丢数据。标准 SSE 允许一条消息跨多行data:解析时需要用\n把这些行拼起来。我用的是按行过滤天然支持这种格式。5.4 用户取消后后端还在继续生成这个问题我前面讲断连处理的时候提过这里再展开一次。前端的 fetch abort 之后HTTP 连接会被浏览器关闭后端的req对象会触发close事件。如果后端不监听这个事件去中止对上游模型的请求就会造成资源浪费。严重的时候一个用户连续几次快速取消后端就会卡住几个甚至十几个模型请求任务机器直接被打挂。我在实际生产环境里还曾遇到过一个隐性问题即使前端没有 abort只是关闭了浏览器标签页req的close事件一样会触发。所以这个监听是无条件的、必须的。5.5 闪烁的 Markdown 渲染流式过程中如果直接渲染完整 Markdown会发现代码块老是“跳动”。这是很正常的体验问题——Markdown 解析器会在刚输出 “javascript” 还没输出后续代码时就把这个片段解析成空代码块然后每来一个字符重新解析一次页面 DOM 一直变。我的经验是流式过程中先渲染纯文本等接收完成后再切换成渲染 Markdown。这样既保证了打字机效果的流畅又不会看到闪烁。用户看到的是文字一行行打出打完的瞬间内容“刷”地变成规整的排版视觉上很舒服。5.6 常见问题速查表问题现象可能原因解决方案页面不打印最后一次性输出响应头缺失或中间层缓冲检查 SSE 响应头、flushHeaders()、Nginxproxy_buffering off中文乱码TextDecoder 未开启流式模式new TextDecoder()后用decode(value, { stream: true })界面漏字缓冲拆分逻辑有误以\n切行保留最后一行到下次处理取消后后端仍在跑未监听req.on(close)监听 close 事件并中止上游请求启动报 util 导出错误Node 版本过低升级到 Node.js 18 LTS 及以上Markdown 渲染闪烁流式过程中重复解析流式中只渲染纯文本结束后再转 Markdown请求失败但没有错误提示未捕获 fetch 异常在 catch 中区分 AbortError 和其他错误6. 进一步优化与后续扩展流式输出的基础链路打通只是第一步真正上线一个 AI 对话产品还有几个值得认真做的优化方向。第一个是对话上下文管理。我之前直接把messages数组全量发给后端简单但生硬。实际使用中用户对话次数一多历史消息就会超出模型的上下文窗口。比较常见的做法是在前端或后端维护一个窗口只保留最近 N 轮对话或者按 token 数剪裁。这块可以引入tiktoken之类的分词计数工具在发送前估算 token 数超出阈值就丢弃最老的消息。第二个是错误重试与退避策略。大模型接口偶尔会超时或返回 429请求过多错误直接抛给用户“服务不可用”体验很差。可以做个简单的自动重试机制比如第一次失败后等 500ms 重试第二次等 1 秒最多重试三次。注意重试只对不能重复的生成请求有风险用户可能会拿到重复内容但对于聊天这种场景丢一条消息偶尔重复一下问题不大。保险起见重试逻辑放在后端做前端只负责展示最终错误状态。第三个是流式输出统计与监控。当产品有了一定用户量你会非常关心首字延迟从用户发送到收到第一个 token 的时间、平均每秒生成多少 token、失败率等指标。这些数据需要前端埋点上报或者后端在流式转发时打日志。我的做法是在后端每个请求开始和结束时记录时间戳中间每转发了多少个 chunk 也累计一下请求结束后把日志格式化输出到控制台后续可以接到日志系统里。第四个是Markdown 渲染的升级。当模型输出包含代码块时用户大概率有“复制代码”的需求。可以引入highlight.js给代码块做语法高亮同时加上一个复制按钮。这个升级对开发者用户来说感知度极高。我个人在实际项目中最深刻的体会是流式输出不是一个“锦上添花”的功能而是 AI 应用体验的及格线。以前做一个普通接口后端等结果再返回天经地义但 AI 生成的耗时是以秒甚至十秒计的用户等不了。你把流式打通之后哪怕模型本身速度一般用户看着文字逐字蹦出来心理上也会觉得“这系统很快”。最后分享一个小技巧调试流式接口时别只依赖浏览器的 Network 面板它对流式响应展示不太友好最直观的排查方式就是curl -N看原始数据流。后端排通了再让前端介入这样能快速定位问题出在链路哪一端不至于前后端互相甩锅浪费几个小时。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价