近一年我一直在做 AI 应用相关的项目,说实话,真正把体验做出来、让用户觉得“这玩意儿像那么回事”的关键,不在于模型选得多大、参数调得多花哨,而在于一个很基础但又容易被忽略的能力——AI 流式输出。
大家现在用 ChatGPT、Claude 这类产品,早就习惯了文字一个字一个字往外蹦的效果。一旦你做过自己的 AI 应用,会发现如果让用户盯着页面转圈等三五秒、然后“唰”地一下整段答案砸出来,体验会瞬间被打回原形。所以我这次的实战项目,就是用Vue3 + Node.js 从零实现一套 AI 流式输出,把大模型的流式响应经过自己的后端转发到前端页面,实现打字机式的逐字渲染效果。这篇文章我会把整个链路拆开揉碎,包括技术选型、前后端核心实现、踩过的坑和排查实录,全部写出来,适合已经会 Vue3 基础、想真正把大模型接进自己项目里的开发者。
1. 内容整体设计与思路拆解
1.1 流式输出要解决的核心问题
先想清楚一个问题:为什么 AI 对话一定要流式输出?
通常我们调大模型接口,模型生成完一整段回答可能需要几十秒。这段等待时间对用户来说是一种煎熬,而且会带来两个连锁问题:第一,用户不知道系统是在正常工作还是卡死了,焦虑感会迫使刷新页面或重复提问;第二,长内容的生成结果一次性返回,中间发生网络抖动,可能整段请求失败,只能重来。
流式输出的思路是:模型一边生成,服务器一边把已经生成好的内容推给前端,前端一边接收一边渲染。用户看到的效果是——你按下回车,文字就开始一个一个往外冒,完全没有“等待一整块内容”的过程。这跟以前下载文件看进度条是一个原理,只不过数据流是持续不断的文本。
从技术层面拆解,流式输出其实包含三个环节:
- 后端拿到大模型流式接口返回的数据块(chunk)
- 后端把这些数据块通过某种协议实时推送给浏览器
- 前端解析数据流,增量更新页面状态
任何一个环节没打通,流式体验都会失效。最容易翻车的就是第三环——很多人后端已经拿到流了,但前端 fetch 响应一直不触发,折腾半天发现是响应被浏览器缓冲了,根本原因是响应头没设置对。后面我会具体讲。
1.2 技术选型:为什么是 SSE 而不是 WebSocket
实现服务器向浏览器实时推送,有三个常见方案:轮询、WebSocket、SSE(Server-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 手动读取解析。
| 对比维度 | 轮询 | WebSocket | SSE(fetch模式) |
|---|---|---|---|
| 实时性 | 差 | 好 | 好 |
| 双向通信 | 支持(多次请求) | 支持 | 不支持(单向) |
| 协议复杂度 | 低 | 高 | 低 |
| 断线重连 | 需自己实现 | 需自己实现 | 可用原生,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 vite@latest 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_KEY=sk-你的密钥 AI_BASE_URL=https://api.openai.com/v1 AI_MODEL=gpt-3.5-turbo PORT=3000Node.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; charset=utf-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; charset=utf-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 class="chat-container"> <div class="message-list"> <div v-for="(msg, index) in messages" :key="index" class="message-item"> <div class="message-role">{{ msg.role === 'user' ? '我' : 'AI' }}</div> <!-- 用户消息直接渲染文本 --> <div v-if="msg.role === 'user'">{{ msg.content }}</div> <!-- AI 消息渲染 Markdown --> <div v-else v-html="renderMarkdown(msg.content)"></div> </div> </div> <button v-if="isStreaming" @click="stopGeneration">停止生成</button> <div class="input-area"> <textarea v-model="currentMessage" placeholder="输入你的问题..."></textarea> <button @click="sendMessage" :disabled="isStreaming">发送</button> </div> </div> </template>4.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 转成 HTML,DOMPurify.sanitize会把<script>、onerror这类危险内容过滤掉。这个组合是当前最基础稳妥的方案,没有之一。
提醒:不要把用户输入的内容和模型输出的内容混为一谈,两者的渲染都必须经过过滤。用户消息虽然是系统自己生成的,但也可能被有心人粘贴进脚本,谨慎一点总没错。
5. 常见问题与排查技巧实录
5.1 “node:util 不提供命名导出”的报错
热词里有一条很典型的报错:node:util does not provide an export named。这通常出现在 Node.js 版本不匹配的场景。我遇到过的情况是:项目是用新版 Node 初始化的,但服务器上跑的是老版本 Node.js(比如 Node 14),某些新 API(node: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看原始数据流。后端排通了再让前端介入,这样能快速定位问题出在链路哪一端,不至于前后端互相甩锅浪费几个小时。