别再让用户干等了:用 Express + SSE 实现《红楼梦》AI 问答实时输出

背景

上一篇文章 从 0 到 1 搭建 RAG 应用:用 LangChain + Chroma + qwen-plus 实现《红楼梦》问答 中,相关后端接口 /hongloumeng/chat 是一次性返回数据的。AI 大模型回答问题通常要比较久的时候,等 AI 回答完问题后,将答案一次性返回给客户端,会让用户等待较多时间。

认识 SSE

SSE,全称 Server-Sent Events ,可以理解为服务器主动持续向浏览器推送消息的一种方式。

和普通接口"后端一次性返回完整结果"不同,SSE 更像是:

  • 后端生成一点内容,就先发一点
  • 前端收到一点内容,就先显示一点

所以它非常适合 AI 问答、打字机回复、消息通知这类场景。

比如大模型回答问题时,不需要等整段答案全部生成完,前端就能一边接收、一边展示,用户会感觉响应更快、体验更自然。

实现 SSE 的关键步骤其实很简单:

  • 服务端把响应头设置为 text/event-stream
  • res.write() 持续返回数据,而不是一次性 res.json()
  • 每段数据按 event + data + 空行 的格式输出
  • 数据发送完成后,再调用 res.end() 关闭响应

后端实现 sse 流式接口

将上一篇文章 从 0 到 1 搭建 RAG 应用:用 LangChain + Chroma + qwen-plus 实现《红楼梦》问答 中的 post 接口 /hongloumeng/chat 改造为 SSE 接口核心是把接口从"一次性返回完整结果"改成"上游流式生成、服务端按 text/event-stream 持续 res.write() 输出的整链路流式传输,具体步骤为:

  • 响应格式从一次性 JSON 改成 text/event-stream
  • 服务端从 res.json() 改成持续 res.write()
  • 上游模型调用从一次性 invoke() 改成可迭代的 stream()
  • 补齐流式场景下的连接、错误、关闭、缓冲控制

真正的变化是 从"算完再返回"改成"边算边写、边写边发"。

普通 POST 版本的核心路径

  1. 读取 question
  2. 调用 chatWithHongLouMeng(question)
  3. 等整个答案生成完成
  4. res.json({ question, answer }) 一次性返回

关键代码:

ts 复制代码
const answer = await chatWithHongLouMeng(question);
res.json({ question, answer });

这是一种典型的"同步结果式接口":

  • 服务端必须先拿到完整答案
  • 浏览器只能最后一次性收到结果
  • 中间没有任何"逐步显示"的机会

SSE 版本的核心路径

  1. 先设置 SSE 相关响应头
  2. 立刻建立长连接
  3. 通过 for await ... of 持续读取模型输出
  4. 每拿到一段 chunk,就调用 writeSseEvent(res, "chunk", ...)
  5. 最后发送 done 事件并 res.end()

关键代码:

ts 复制代码
for await (const chunk of streamChatWithHongLouMeng(question)) {
  if (clientClosed) {
    break;
  }
  answer += chunk;
  writeSseEvent(res, "chunk", { content: chunk });
}

这就变成了"流式结果接口":

  • 答案不用等全部生成完
  • 生成一段就可以先发一段
  • 前端可以即时渲染

改造的第一个关键:上游能力必须先支持流式

这是最容易被忽略、但其实最本质的一点。

很多人以为改 SSE 只是在 Express 里加几个 header,实际上不是

如果你的上游还是一次性返回完整结果:

ts 复制代码
const response = await chain.invoke({ input: query });
return response.answer;

那么即使你外层写成 SSE,也只是:

  • 服务端先傻等
  • 等模型整段生成完
  • 再一块发给前端

这叫"套着 SSE 外壳的假流式"。

真正的改法

ts 复制代码
const stream = await chain.stream({
  context: formatContext(docs),
  input: query,
});

for await (const chunk of stream) {
  if (chunk) {
    yield chunk;
  }
}

这里的关键动作有两个:

  • invoke() 改成 stream()
  • AsyncGenerator 把 chunk 一段段往外吐

也就是说,SSE 改造的前提是:你的业务处理链本身要能流式产出数据。

如果上游不支持流式,下面这些 res.write() 再漂亮都没有意义。

改造的第二个关键:HTTP 响应语义彻底变化了

普通 JSON 接口的思路是:

  • 响应体是一个完整 JSON
  • 最后一次性结束

SSE 的思路是:

  • 响应体是一个持续输出的事件流
  • 中间不断写入
  • 最后手动结束

所以你必须把响应头改掉。

ts 复制代码
res.status(200);
res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
res.setHeader("Cache-Control", "no-cache, no-transform");
res.setHeader("Connection", "keep-alive");
res.setHeader("X-Accel-Buffering", "no");
res.flushHeaders?.();

这几行缺一不可地承担了不同职责。

Content-Type: text/event-stream

这是 SSE 的身份证。

它告诉客户端:

这不是普通 JSON,也不是普通文本,而是一条事件流。

没有它,前端和代理层通常不会按 SSE 的方式处理这次响应。

Cache-Control: no-cache, no-transform

作用有两个:

  • 不要缓存
  • 不要被中间层擅自改写响应内容

对流式输出很重要,因为 SSE 依赖"原样、实时、持续"地传输数据。

Connection: keep-alive

告诉连接先别关。

因为这不是"一问一答立刻断开"的短响应,而是一个会持续一段时间的输出流。

X-Accel-Buffering: no

它的作用是:

  • 告诉 Nginx / OpenResty 这类反向代理不要缓冲这次响应
  • 否则你明明已经 res.write() 了,代理却可能攒一堆再发

这会直接毁掉流式体验。

res.flushHeaders?.()

它的作用是:

尽快把响应头刷出去,正式建立流式响应。

这样客户端不用等到第一段正文写入后才"意识到"这是个 SSE 流。

改造的第三个关键:从 res.json() 改成 res.write()

这是 Express 层最直观的差异。

普通接口

普通接口最后通常这样结束:

ts 复制代码
res.json({ question, answer });

这代表:

  • 一次性序列化
  • 一次性写出
  • 一次性结束响应

SSE 接口

ts 复制代码
function writeSseEvent(res: Response, event: string, data: unknown): void {
  res.write(`event: ${event}\n`);
  res.write(`data: ${JSON.stringify(data)}\n\n`);
}

这个函数背后的关键点是:

  • SSE 不是随便写文本
  • 它有固定格式

标准格式大致就是:

txt 复制代码
event: chunk
data: {"content":"第一段"}

event: chunk
data: {"content":"第二段"}

event: done
data: {"answer":"完整答案"}

注意最后那个空行 ,很关键。

因为 SSE 客户端是靠空行来判断"一个完整事件块结束了"。

所以改造的关键不是简单把:

ts 复制代码
res.write(chunk);

写出去,而是:

  • 按 SSE 协议格式写
  • 一个事件一个事件地写

改造的第四个关键:接口函数要从"返回值"思维切到"持续产出"思维

普通版本的 chatWithHongLouMeng 是:

ts 复制代码
async function chatWithHongLouMeng(query: string) {
  ...
  const response = await chain.invoke({ input: query });
  return response.answer;
}

这是典型的:

输入一个问题,返回一个完整字符串。

而 SSE 版的 streamChatWithHongLouMeng 改成:

ts 复制代码
async function* streamChatWithHongLouMeng(query: string): AsyncGenerator<string> {
  ...
  for await (const chunk of stream) {
    if (chunk) {
      yield chunk;
    }
  }
}

这代表接口内部设计思想发生了变化:

  • 过去:return answer
  • 现在:yield chunk

这是 SSE 化的核心分水岭。

因为 SSE 天然要求你的程序具备:

"不断产生片段并不断往下游推送"的能力。

AsyncGenerator 非常适合扮演这个角色。

改造的第五个关键:定义事件类型,而不是只发裸文本

SSE 版没有只发内容,而是设计了多个事件:

  • start
  • chunk
  • done
  • error

为什么要这样做?

因为在真实业务里,流式响应不只是"吐字"。

你往往还需要告诉前端:

  • 现在开始了
  • 这是一段正文
  • 已经结束了
  • 出错了

如果只发裸文本,前端就很难区分:

  • 这是内容?
  • 这是完成信号?
  • 这是报错信息?

所以合理的事件设计是改造成功的关键之一。

当前实现的好处

ts 复制代码
writeSseEvent(res, "start", { question });
writeSseEvent(res, "chunk", { content: chunk });
writeSseEvent(res, "done", { question, answer });
writeSseEvent(res, "error", { error: "Failed to generate answer" });

这样前端就可以:

  • chunk -> 追加显示
  • done -> 收尾
  • error -> 显示错误提示

这比"只推字符串"健壮得多。

改造的第六个关键:连接关闭要能感知,否则会浪费资源

ts 复制代码
let answer = "";
let clientClosed = false;
res.on("close", () => {
  clientClosed = true;
});

for await (const chunk of streamChatWithHongLouMeng(question)) {
  if (clientClosed) {
    break;
  }
  answer += chunk;
  writeSseEvent(res, "chunk", { content: chunk });
}

为什么这一步很关键?

因为流式连接持续时间更长,用户更可能:

  • 刷新页面
  • 关闭页面
  • 切换路由
  • 网络断开

如果服务端不知道客户端已经走了,仍然继续:

  • 调模型
  • 拼答案
  • res.write()

那就是资源浪费。

所以 SSE 场景下,必须关注:

  • 客户端是否还在线
  • 连接是否已经关闭

这也是流式接口和普通接口的重要差异。

改造的第七个关键:错误处理不能只按普通 JSON 接口的思路写

在普通接口里,错误处理很简单:

ts 复制代码
res.status(500).json({ error: "Failed to generate answer" });

因为只要没成功,就直接返回一个错误 JSON 即可。

但流式接口里要分两种情况。

ts 复制代码
if (!res.headersSent) {
  res.status(500).json({
    error: "Failed to generate answer",
  });
  return;
}
writeSseEvent(res, "error", {
  error: "Failed to generate answer",
});

情况 1:响应头还没发出去

这时还可以按普通 HTTP 错误响应返回:

  • 500
  • JSON body

情况 2:SSE 已经开始了

这时你已经不能突然改成普通 JSON 了。

因为客户端这边已经按 text/event-stream 在读了。

所以此时正确做法是:

  • 发一个 error 事件
  • 再结束连接

这就是为什么 SSE 接口的错误处理要比普通接口更讲究状态判断。

改造的第八个关键:必须显式结束响应

ts 复制代码
if (!res.writableEnded) {
  res.end();
}

这一步很重要。

因为 SSE 是长连接,但不是永不结束。

当前这个接口的业务模型是:

  • 收到一个问题
  • 流式返回这一轮回答
  • 回答结束后关闭连接

所以最后必须 res.end()

否则会出现:

  • 前端一直等待
  • 请求不结束
  • 资源不释放

改造清单

如果把这次改造总结成一个"最小必要变更清单",就是下面这些。

业务返回方式改造

  • invoke() 改为 stream()
  • 从返回完整字符串改为逐段 yield

Express 输出方式改造

  • res.json() 改为 res.write()
  • 引入 writeSseEvent() 封装 SSE 事件格式

响应头改造

  • Content-Type: text/event-stream
  • Cache-Control: no-cache, no-transform
  • Connection: keep-alive
  • X-Accel-Buffering: no

生命周期控制改造

  • 开始时发送 start
  • 过程中持续发送 chunk
  • 结束时发送 done
  • 异常时发送 error
  • 最后 res.end()

连接状态感知改造

  • 监听 res.on("close")
  • 客户端断开后停止继续写流,避免资源浪费

sse 流式接口效果演示

总结

/hongloumeng/chat 从普通 POST 接口改造成 SSE 流式接口,真正的关键不是某一行 header,而是一整套链路协同变化:

  • 模型层 :从 invoke() 变成 stream()
  • 函数层 :从 return answer 变成 yield chunk
  • Express 层 :从 res.json() 变成 res.write()
  • 协议层 :从普通 JSON 响应变成 text/event-stream
  • 连接层:处理 keep-alive、close、end、buffering
  • 错误层:区分流开始前与流开始后的处理方式

所以,把 POST 接口改造为 SSE 流式接口的关键,是让整条调用链都具备"持续产出、持续传输、持续消费"的能力。

相关推荐
怕浪猫1 小时前
Electron 开发实战(十四):实战项目|从零搭建轻量化桌面代码编辑器
前端·electron·node.js
java_cj1 小时前
从kubectl源码学Cobra:打造专业级Go命令行工具的完整实践
运维·开发语言·后端·云原生·golang·kubernetes·k8s
晓13131 小时前
【Cocos Creator 3.x】篇——第五章 项目实战优化技术
前端·javascript·游戏引擎
AZaLEan__1 小时前
JavaScript 基础语法
开发语言·javascript·ecmascript
有梦想的程序星空1 小时前
【环境配置】使用 Vue CLI 构建 Vue 项目脚手架完整指南
前端·javascript·vue.js
copyer_xyf1 小时前
Agent MCP
后端·python·agent
影视飓风TIM2 小时前
C++ 核心语法笔记:拷贝构造、深浅拷贝与运算符重载
java·开发语言·javascript
之歆2 小时前
Ajax 进阶:跨域、CORS、JSONP 与请求封装实战
前端·javascript·ajax
sugar__salt2 小时前
前端Ajax核心原理与实战:从异步机制到接口请求全解析
前端·javascript·ajax