流式响应背压内存泄漏SSE

AI API 流式响应越用越卡?背压、内存泄漏与慢客户端治理指南

2026-09-14 · 约 10 分钟阅读

AI 聊天接口改成流式输出后,首段内容更快出现,但生产环境可能出现另一类问题:进程内存持续上涨、并发连接越来越多、用户关闭页面后上游仍在生成,最终普通请求也被拖慢。根因常常不是模型慢,而是中转链路没有处理背压。

背压指下游消费速度低于上游生产速度时,系统要求上游减速或暂停的机制。如果服务端持续读取 AI API 数据,却来不及写给弱网浏览器,未发送内容就会堆在内存里。本文聚焦通用工程方法,不使用任何特定供应商的版本、价格或性能数字。

流式链路里谁可能变慢

完整链路通常包含模型供应商、AI API 中转站、你的后端、反向代理、浏览器和页面渲染。任意一环都可能成为慢消费者:移动网络吞吐不足,标签页进入后台,Markdown 渲染阻塞主线程,反向代理缓冲数据,或者客户端干脆已经断开。

因此,看到后端内存高不能只增加容器内存。先回答三个问题:数据在哪里积压?断开信号有没有向上游传播?每条连接最多允许缓存多少字节?没有边界的“先读完再说”迟早会放大故障。

最容易踩坑的转发方式

以下模式会主动读取上游,再无条件写入下游。若 writer.write 的完成速度跟不上读取速度,而代码又把写入 Promise 收集起来,缓存会不断膨胀。

const pending: Promise<void>[] = [];
for await (const chunk of upstream.body!) {
  pending.push(writeToClient(chunk)); // 错误:不等待下游
}
await Promise.all(pending);

另一个常见错误是把所有片段拼成一个字符串,结束后才释放。长回答、并发会话和重试叠加时,字符串复制也会制造额外内存压力。日志系统若记录每个原始片段,同样可能形成隐藏缓冲区。

正确做法:让背压沿链路传播

在支持 Web Streams 的运行时中,尽量直接把可读流交给响应对象,让运行时控制读取节奏;需要解析或审核时,则在每次写入完成后再读取下一段,并使用有界 TransformStream。

export async function POST(req: Request) {
  const controller = new AbortController();
  req.signal.addEventListener("abort", () => controller.abort("client_closed"));
  const upstream = await fetch(process.env.AI_URL!, {
    method: "POST",
    headers: buildHeaders(),
    body: await req.text(),
    signal: controller.signal,
  });
  if (!upstream.ok || !upstream.body) {
    controller.abort();
    return new Response("upstream failed", { status: 502 });
  }
  return new Response(upstream.body, {
    headers: {
      "content-type": "text/event-stream; charset=utf-8",
      "cache-control": "no-cache, no-transform",
    },
  });
}

若必须做增量审核,不要把全文无限保存在闭包中。只保留完成判断所需的滑动窗口,并给单连接设置最大缓冲字节数。达到上限时可以暂停读取;运行时无法暂停时,应取消上游并返回明确错误,而不是继续吃内存。

客户端取消必须到达最上游

用户点击“停止生成”或关闭标签页后,浏览器应调用 AbortController.abort();你的后端监听请求信号并取消上游 fetch;中转站也应继续向模型供应商传播取消。任一层只关闭自己的 UI、不关闭网络,后台调用都可能继续占用连接和预算。

需要注意,取消存在竞争窗口:最后一个片段可能已经在网络中,供应商也可能已完成部分处理。因此日志应记录客户端取消时间、后端检测时间、上游关闭状态和最终 usage,而不是假定点击按钮等于立即停止所有计费。

反向代理缓冲也要检查

应用代码正确,不代表部署链路正确。部分代理、压缩层或 CDN 会等待积累一定数据再发送,使用户看到“假流式”,也让服务端更晚感知断开。验收时使用 curl -N 观察片段到达节奏,并分别绕过 CDN、代理和应用入口测试。

不要盲目关闭所有代理能力。应针对 SSE 路径配置禁用响应缓冲、合理的空闲超时和长连接上限,同时保留普通静态资源的缓存与压缩策略。流式接口和普通 JSON 接口最好采用独立路由配置。

慢客户端的治理策略

有界缓冲

为每条连接设置缓存上限,也设置进程级总上限。单连接超限时终止该会话;总水位过高时拒绝新流式请求或切换为排队,避免一批弱网用户拖垮全部实例。

心跳与空闲超时

心跳用于区分“模型暂时无内容”和“链路已经失活”,但不能替代业务数据。空闲超时应覆盖合理推理等待,同时不能无限长。超时后必须释放 reader、writer、定时器和监听器。

渲染节流

浏览器没必要每收到几个字节就重跑 Markdown。可以把片段放进小型队列,按动画帧或短间隔批量更新视图;页面卸载时清除定时器并取消请求。这样既减少主线程压力,也避免前端成为慢消费者。

必须监控哪些指标

按实例观察活动流连接数、每连接缓冲字节、进出字节速率、连接持续时间、客户端取消比例、上游取消成功率、堆内存与事件循环延迟。按路由记录首段时间和完整时间,但不要把两个指标混为一个平均值。

排障时还应抽样记录关闭原因,例如 completed、client_abort、idle_timeout、buffer_limit、upstream_error。只有知道连接为何结束,才能区分正常长回答与资源泄漏。

上线检查清单

  • 下游写入变慢时,上游读取会暂停或被取消
  • 每连接和每实例都有明确缓冲上限
  • 浏览器取消能传到后端、中转站与上游
  • 所有超时分支都会释放流、监听器与定时器
  • SSE 路径未被代理错误缓冲,并可通过 curl -N 验证
  • 前端使用批量渲染,不按每个字符触发重排
  • 仪表盘能按关闭原因定位连接堆积

总结

流式响应稳定性的核心不是“能收到 chunk”,而是生产者和消费者之间有可传播的节奏与取消边界。直接透传、有限窗口、双层水位和完整清理可以把慢客户端限制在单条连接内。准备评估中转线路时,可前往 openairouter.net 查看 AI API 中转站对比与排行榜,并用弱网、主动取消和长输出三类用例验证真实流式能力。

找到最适合你的 AI API 中转站

收录 125+ 服务商,按价格、模型、标签一键筛选

查看所有中转站 →