背景
上一篇文章 从 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 版本的核心路径
- 读取
question - 调用
chatWithHongLouMeng(question) - 等整个答案生成完成
res.json({ question, answer })一次性返回
关键代码:
ts
const answer = await chatWithHongLouMeng(question);
res.json({ question, answer });
这是一种典型的"同步结果式接口":
- 服务端必须先拿到完整答案
- 浏览器只能最后一次性收到结果
- 中间没有任何"逐步显示"的机会
SSE 版本的核心路径
- 先设置 SSE 相关响应头
- 立刻建立长连接
- 通过
for await ... of持续读取模型输出 - 每拿到一段
chunk,就调用writeSseEvent(res, "chunk", ...) - 最后发送
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 版没有只发内容,而是设计了多个事件:
startchunkdoneerror
为什么要这样做?
因为在真实业务里,流式响应不只是"吐字"。
你往往还需要告诉前端:
- 现在开始了
- 这是一段正文
- 已经结束了
- 出错了
如果只发裸文本,前端就很难区分:
- 这是内容?
- 这是完成信号?
- 这是报错信息?
所以合理的事件设计是改造成功的关键之一。
当前实现的好处
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-streamCache-Control: no-cache, no-transformConnection: keep-aliveX-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 流式接口的关键,是让整条调用链都具备"持续产出、持续传输、持续消费"的能力。