我差点把一个糟糕的 AI 集成发布上线。功能本身在技术上是通的:模型能正确回答问题,界面干净利落,内部演示时所有人都点头认可。可一旦换成真实用户,48 小时内,产品经理就在 Slack 上问我:为什么 60% 的会话以“没有收到任何响应”结束? 问题不在 API,也不在服务器。问题只是八秒钟的安静。 用户发出消息后,页面没有任何动静,用户以为请求失败了,于是关掉标签页。随后响应确实到了,但已经没人看。我们构建的是一个功能没有问题、体验却完全坏掉的 AI 产品。而修复方案并不是重构架构,也不是做性能优化,而是“流式输出”:模型一产生 token 就立刻发给浏览器,而不是等完整响应生成后一次性批量返回。 这个错误,我不止在一家公司的代码里见到过。非流式版本写起来更简单,测试也能通过,开发阶段没人发现问题,因为开发者在等待时耐心很好,而真实用户没有这种耐心。 这篇教程会带你完整实现流式版本:用 Node.js 后端配合 Server-Sent Events(SSE),把 token 一边生成一边推送到浏览器;加入会话管理,让模型在多轮对话中拥有记忆;并正确处理中止场景,避免为没人读取的 token 付费。 完整代码在:github.com/ziaongit/nodejs-openai-streaming。 没有 OpenAI key?我用 Groq 跑完了整篇文章的测试。Groq 免费,兼容 OpenAI SDK 的调用格式,申请 key 大约两分钟。仓库里已经预置了 `USE_GROQ=true` 开关。把 `.env.example` 复制为 `.env`,填上你的 Groq key,就能直接跑起来。我使用的是 `llama-3.3-70b-versatile` 模型,输出质量足够好,开发调试时几乎察觉不到差别。 ## 前提条件 - Node.js v18.11 或更高版本 - 一个 OpenAI API key,或者 Groq API key - 对 Express 和 async/await 足够熟悉 ## 先看架构,再写代码 不要急着写代码。先理解一次流式请求的完整路径。 当用户在浏览器里发出一条消息,前端需要先建立一个 SSE 连接。SSE 是单向事件流,特别适合 AI 生成场景:服务器不断把数据推给浏览器,浏览器只需要监听事件并更新界面。 这里不需要 WebSocket。AI 回复本质上是“服务器到浏览器”的持续输出,而用户在生成过程中很少需要向服务器额外发送数据。使用 Express 处理一条 SSE 路由,就能完成整套交互。 在后端,Node.js 收到用户消息后,把它追加到当前会话的历史记录里,然后带着完整的历史消息调用 OpenAI 或 Groq 的 chat completions 接口,并打开 `stream: true`。模型返回的不是一个完整 JSON,而是一串流式的 chunk。每个 chunk 包含一小段增量文本。服务器拿到这段文本,立刻通过 SSE 写入响应流,浏览器就能把文字逐个显示出来。 最后,当流结束时,服务器关闭 SSE 连接。如果用户中途关闭页面,Node.js 会触发连接关闭事件,这时必须主动终止上游 API 调用,避免继续计费。 ## 为什么非流式实现会毁掉体验 非流式实现看起来没什么问题。你调用 API,等待完整结果,然后一次性返回给前端。代码更少,测试更稳定,延迟虽然高,但 demo 里没人介意。 真实用户不是这样。他们看到的不是“正在生成中”,而是“页面死了”。人类对等待的感知是非线性的,超过两三秒没有反馈,就会开始怀疑。八秒没有任何视觉变化,足够让用户关闭页面重新开始,甚至彻底放弃产品。 AI 生成一个完整回答可能耗时数秒到十几秒。如果把这个时间全部压在用户面前,不管后端多么准确,产品都会被感知为不可用。流式输出的价值,不是降低总延迟,而是把一个大延迟拆成无数个小反馈。第一个 token 很快出现,后面每个字都持续证明系统还活着。产品从“卡死”变成“正在写”,这是体验层面根本性的差异。 ## 关键实现:SSE 路由 首先初始化 Express,创建一条 GET 路由,路径可以是 `/api/chat/stream`。 ``` app.get('/api/chat/stream', async (req, res) => { res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', }); res.flushHeaders(); }); ``` 注意:SSE 响应不能设置 `Content-Length`,因为响应长度未知,它会持续到流结束。 接下来,把用户消息和历史会话一起发给模型。 ``` const messages = buildMessages(sessionId, userMessage); const stream = await openai.chat.completions.create({ model: 'llama-3.3-70b-versatile', messages, stream: true, }); ``` 当 `stream` 为 `true` 时,返回的不是普通响应,而是一个可异步迭代的流。我们可以遍历每个 chunk,把增量文本提取出来,立即写入 SSE 响应。 ``` for await (const chunk of stream) { const text = chunk.choices[0]?.delta?.content || ''; if (text) { res.write(`data: ${JSON.stringify({ text })}\n\n`); } } res.end(); ``` 浏览器端收到的是标准 SSE 格式:以 `data:` 开头,后面跟 JSON,两个换行符结束一条事件。前端可以使用 `EventSource` 或者 `fetch` 配合 `ReadableStream` 来读取。 ## 会话管理:让模型记住上下文 模型本身是无状态的。每一轮请求都只看到你传进去的 messages。要想让多轮对话连贯,就必须把历史消息保存起来。 最简单的做法是按会话 ID 在内存中存一个消息数组。 ``` const sessions = new Map(); function getSession(sessionId) { if (!sessions.has(sessionId)) { sessions.set(sessionId, []); } return sessions.get(sessionId); } ``` 用户每发一条消息,就把用户输入推入会话历史,模型完成输出后,再把助理回复也推入历史。下次请求时,把这个历史数组完整传给模型。 ``` const history = getSession(sessionId); history.push({ role: 'user', content: userMessage }); // 调用模型后 history.push({ role: 'assistant', content: fullReply }); ``` 生产环境中,可以用 Redis 或数据库替代 Map,但核心逻辑不变:AI 的“记忆”就是请求里携带的上下文。 ## 中止处理:不要为没人读的内容买单 这是最容易被忽略的问题。用户如果中途关闭页面,后端仍然会继续等待模型生成完整结果,并继续消耗 token。这等同于一边开着水龙头,一边没人接水。 Node.js 中可以通过 `req.on('close')` 监听客户端断开事件。一旦断开,立即用 `AbortController` 中止上游请求。 ``` const controller = new AbortController(); req.on('close', () =>
![]()
特别声明:以上内容(如有图片或视频亦包括在内)为自媒体平台“网易号”用户上传并发布,本平台仅提供信息存储服务。
Notice: The content above (including the pictures and videos if any) is uploaded and posted by a user of NetEase Hao, which is a social media platform and only provides information storage services.