Node.js AI 网关的真流式输出:SSE、背压与安全隔离
本文目录
聊天接口返回了 text/event-stream,不等于用户真的能逐段看到内容。若服务端先把上游响应读成一个完整字符串,再包装成 SSE 返回,协议外壳虽然像流,首字延迟和内存占用却仍由整段缓冲决定。可靠的 AI 网关要把上游读取、客户端响应、断连取消、超时、权限隔离和日志策略作为一条数据链路设计。
本文结合一次本地 Node.js 服务的静态源码审阅,整理流式代理的实现边界与可复用检查清单。审阅没有启动真实上游服务、浏览器会话或线上环境;文中的问题是静态代码观察,不代表已证明发生了用户数据泄露或生产故障。示例使用授权的服务端 Provider API,不依赖浏览器登录态或提取 Cookie。
先分清“流式接口”和“流式传输”
可以把常见实现分成三类:
- 整段缓冲:服务端等待上游完成,再一次性返回正文。实现简单,但首字延迟接近总生成时间,峰值内存随输出体积增长。
- 缓冲后伪分块:服务端拿到完整回答后,拆成一个或几个 SSE
data:事件。客户端收到的是事件格式,却没有获得生成期间的增量内容。 - 逐块透传:上游产生数据后,服务端尽快把字节写给客户端,并处理慢消费者、断连、超时和错误。只有这条路径具备端到端增量传输的基础。
判断方法很直接:检查服务端有没有在转发前调用 text()、json()、arrayBuffer() 等把完整响应消费完;再用低速生成的测试上游测首个有效事件时间,并确认客户端在上游尚未结束时已经收到数据。响应头写着 SSE、HTTP 200 或者前端使用 EventSource 都不能单独证明端到端流式成立。
直接透传:让 Node 处理背压
如果网关只负责认证、选择固定上游并透传字节,不需要解析或改写事件,优先避免在应用层拼接字符串。Node 的 Readable.fromWeb() 可以把 Fetch 返回的 Web ReadableStream 接入 Node 流;pipeline() 负责把源流接到 HTTP 响应,并传播背压和中断错误。
import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
async function proxyChat(req, res, payload) {
const clientAbort = new AbortController();
const onRequestAborted = () => clientAbort.abort();
const onResponseClosed = () => {
if (!res.writableEnded) clientAbort.abort();
};
req.once('aborted', onRequestAborted);
res.once('close', onResponseClosed);
const signal = AbortSignal.any([
clientAbort.signal,
AbortSignal.timeout(120_000),
]);
try {
// 地址和凭据由服务端配置;不要允许客户端任意指定上游 URL。
const upstream = await fetch(configuredProviderUrl, {
method: 'POST',
headers: providerHeaders,
body: JSON.stringify(payload),
signal,
});
if (!upstream.ok || !upstream.body) {
await upstream.body?.cancel();
if (!res.headersSent && !res.destroyed) {
res.writeHead(502, { 'content-type': 'application/json; charset=utf-8' });
res.end(JSON.stringify({ error: 'upstream_unavailable' }));
}
return;
}
res.writeHead(200, {
'content-type': 'text/event-stream; charset=utf-8',
'cache-control': 'no-cache, no-transform',
'x-accel-buffering': 'no',
});
res.flushHeaders();
await pipeline(Readable.fromWeb(upstream.body), res, { signal });
} catch {
// 响应头已发送后不能再改成 JSON 错误;只记录脱敏错误码并关闭连接。
if (!res.destroyed) {
if (!clientAbort.signal.aborted && !res.headersSent) {
res.writeHead(502, { 'content-type': 'application/json; charset=utf-8' });
res.end(JSON.stringify({ error: 'gateway_failure' }));
} else {
res.destroy();
}
}
} finally {
req.off('aborted', onRequestAborted);
res.off('close', onResponseClosed);
}
}
这是说明数据流边界的最小示例,不是可直接复制上线的完整网关。它用到了 AbortSignal.any() 与 Node/Web Streams 互操作;需要根据 Node 运行时版本确认这些 API 均受支持,尤其 AbortSignal.any() 的最低版本与 Web Streams pipeline() 支持版本不同。还需要在路由层完成身份认证、资源授权、请求体 schema/大小限制、并发配额、模型白名单、审计关联 ID 和优雅关闭;providerHeaders 必须由服务端安全配置创建,不能把用户令牌或服务端密钥透传到日志。
pipeline() 的背压很关键:当客户端读取速度低于上游生成速度时,Node 流会协调读取,不应把无界数据不断积累到字符串或数组里。请求断开、响应被关闭或总时限到达时,应取消上游请求及其 reader,释放连接和计费中的生成任务。若还需逐事件计量或内容过滤,应设定最大缓冲帧大小,避免单个未结束事件无限增长内存。
需要转换事件时,正确解析 SSE
SSE 是有帧边界的文本协议,不是“按网络 chunk 拆一行”。一次网络读取可能只有半个 UTF-8 字符、一行的一部分、多个完整事件,或把 \r\n 分到不同 chunk。若要改写事件,解析器应保留未完成尾部,直到遇到空行才完成一帧,并覆盖:
LF、CRLF和协议允许的行结束处理;边界CR/LF可能横跨两个网络 chunk。- 同一事件中的多条
data:行按规范合并,而不是只保留最后一行或把每行误当作独立事件。 event、id、retry字段、以冒号开头的注释行,以及字段值前至多一个空格的处理规则。- UTF-8 解码器跨 chunk 保留不完整字符;EOF 时按协议规则完成或丢弃最后一个未闭合状态。
- 上游错误事件、取消信号、超长帧和解析失败的处理;不能把解析错误静默伪装成正常完成。
如果只是透传,不要为了“显示进度”把整段响应读入内存再解析。可以独立观察字节计数、首字节时间和结束状态;若必须检查事件内容,则用有上限的增量解析器,并评估敏感内容留存与合规边界。
HTTP 兼容层的几个细节
- 在第一段正文发出前完成身份校验、额度检查和上游状态检查。首个数据块写出后,HTTP 状态码和普通响应头基本不可更改;之后的失败应按 SSE 错误事件约定或直接断开,不能再追加一段 JSON 错误对象。
- SSE 响应通常使用
Content-Type: text/event-stream、禁缓存,并关闭会缓存响应的代理缓冲。X-Accel-Buffering: no只对配置认可该头的代理有帮助,仍要在真实部署链路检查 Nginx/CDN/负载均衡器行为。 - 为首块等待、上游总时长、空闲时长和客户端读取分别设预算。若使用心跳注释保活,应避免心跳被代理缓冲;并确认客户端超时与网关空闲超时不冲突。
fetch超时、网关请求超时、反向代理读取超时、负载均衡器空闲超时和浏览器取消要协调设置。单独调大其中一层,不会自动延长整个链路。- 连接失败可在发送响应头前返回标准错误;一旦开始流式响应,重试通常无法安全地从中间继续。生成请求有副作用或成本时,重试还需考虑幂等键、重复扣费和上游是否已经接受任务。
- 浏览器的
EventSource通常以 GET 建立单向连接;需要 POST JSON 时,常用fetch()读取ReadableStream。跨域、Cookie 和 CSRF 策略需与具体认证方式匹配,SSE 本身不提供身份认证。
从静态审阅中提炼的典型风险
下面这些模式来自脱敏后的本地代码审阅记录。它们是值得复核的实现缺口,不应脱离完整部署环境夸大成已发生的安全事件:
- 先读完整正文再返回:即便外层接口最终写成 SSE,完整缓冲仍会推迟首字,并使内存随响应体增大。应改为真正的流透传,或明确把该接口定位为非流式。
- 按换行符拆完整字符串:这无法证明能正确解析任意 SSE 网络分块、多行数据和跨 chunk 字符。应采用增量解析器并用边界输入做测试。
- 重试循环的次数语义不清:静态审阅发现某条失败路径会在循环后再发一次上游请求,实际调用数可能比配置值多。为“总尝试次数”定义唯一含义,集中实现重试预算,并测试 0、1、N 次、超时和客户端断开。
- 会话或对话缓存没有按调用者隔离:如果共享状态只按模型等较粗键值索引,不同用户的上下文可能被串用。应按认证主体和会话 ID 隔离,校验每次读取权限,并设置 TTL、容量上限和注销清理。静态结构风险不等于已证实跨用户泄露。
- 只检查浏览器登录态标记:Cookie 存在不等于凭据仍有效、属于当前用户或具备目标权限。用第三方/Provider API 时应处理明确的鉴权失败、撤销和过期,不要把 cookie 存在性当作认证结论。
- 日志记录响应片段:排障日志若写入上游正文或错误片段,可能留存提示词、回答、个人数据或临时凭据。默认只记录请求 ID、耗时、状态、模型类别、字节数和经枚举化的错误码;内容日志必须有目的、授权、脱敏、访问控制和保留期限。
依赖浏览器登录态的上游适配方式脆弱且难以形成稳定授权边界。优先使用服务提供的官方、受支持 API;若某个实验必须依赖交互会话,应把它限制在单用户、本机、非生产场景,明确凭据生命周期和隔离,不要将其包装成可供不可信用户共用的公共代理。
本地服务的网络边界
将开发服务绑定到 loopback 地址,可以减少它直接暴露到外网的机会;校验 Host 与 Origin 也能缓解一部分浏览器跨站请求和 DNS rebinding 风险。但它们都不等于用户认证:同机的其他进程可以发请求,命令行客户端也不受浏览器 CORS 限制。若使用反向代理、容器端口映射、隧道或共享机器,必须重新检查真实监听地址、网络 ACL、代理信任链和身份鉴权。
CORS 是浏览器读取跨域响应的策略,不是服务端访问控制。不要以“只允许本机页面的 Origin”代替认证、授权或 CSRF 防护;对 cookie 认证的写请求,应检查可信来源并使用 CSRF token。Host/Forwarded 头只应信任受控代理按明确规则覆盖的值。
回归测试清单
至少自动覆盖以下行为:
- 上游分多次延迟输出;客户端在上游结束前收到首个事件,证明不是完成后整段返回。
- 慢速客户端触发背压;进程内存有上限,且上游 reader 不会无限预取。
- 客户端在请求阶段、首块前和首块后分别断开;每条路径都取消上游并回收监听器、计时器和连接。
- 上游拒绝、超时、空 body、无效 SSE、超长帧和中途断流;验证头发送前后的错误语义不同且稳定。
- SSE parser 输入随机切块,包括把 UTF-8 字符、CRLF、字段和事件边界拆开;解析后的事件应与不分块输入一致。
- 不同认证主体使用相同模型和相同会话标识时,缓存/历史严格隔离;无权读取他人上下文的请求被拒绝。
- 重试预算精确符合配置,客户端已断开时不再重试;带成本的请求不会因超时不确定而盲目重复提交。
- 日志断言不包含提示词、回答正文、Cookie、访问令牌、完整上游错误体或个人数据。
单元测试可以验证状态分支和写入序列;增量传输、代理缓冲、慢客户端与断连行为还需要真实 HTTP 集成测试。生产验收应分别观测客户端首事件时间、上游首字节时间、输出字节数、取消数、超时数和每个请求的上游调用次数。
上线检查
- 上游地址、模型和凭据由服务端 allowlist/config 管理,不接受任意用户提供的目标 URL,避免 SSRF。
- 认证与每篇资源/会话授权在流开始前完成;缓存键包含可信调用者边界。
- 设置请求体、并发数、队列长度、输出大小、上游连接/读取超时和速率上限。
- 客户端断连必须取消上游;重试上限、幂等性和计费语义有自动化断言。
- 反向代理/CDN 已验证关闭响应缓冲,空闲超时与心跳符合预期;没有只在本机浏览器验证。
- 日志默认不记录正文、密钥、Cookie 或完整上游异常;保留和访问策略已确定。
- 首块延迟、内存峰值、取消清理、跨用户隔离和代理故障都经过集成测试或受控压测。
参考资料
Live Notes
留言提交后会先进入审核,审核通过后对其他读者公开。