文章

LLM Streaming Backpressure 生产实战:用取消、超时与流控稳定长输出体验

本文系统讲解大模型流式输出中的背压、取消与超时治理,覆盖 SSE 事件流、客户端慢读、服务端队列、连接中断、资源回收、降级策略和上线检查,帮助团队稳定长输出体验。

背景问题

大模型应用把输出改成流式以后,用户体验通常会立刻变好:首个 token 更早出现,长答案不再让用户盯着空白页面,前端也能做打字机效果、实时进度和逐段渲染。

但很多生产事故也会从这里开始。

常见问题不是”模型不会流式输出”,而是 流式输出链路没有背压、取消和超时治理

  • 用户关闭页面后,后端还在继续生成几千个 token。
  • 浏览器、移动网络或代理变慢时,服务端继续把 token 塞进内存缓冲。
  • 前端只处理文本 delta,却没有处理 error、heartbeat、message_stop 等事件。
  • 网关只设置请求总超时,没有区分首包超时、token 空窗超时和最大流持续时间。
  • 下游 LLM API 已经返回 overloaded / rate limit,但上游仍然把它当成普通网络错误重试。
  • 内容审核、审计日志和 token 计费只在完整响应后执行,流式中断时账务和排障数据缺失。

OpenAI 的 streaming 文档说明,Responses API 可以通过 stream=true 使用 SSE 增量返回事件;Chat Completions 流式模式也会以 event stream 增量返回 chunk。Anthropic 的 Messages API 同样支持通过 SSE 增量返回文本、工具调用和扩展思考事件,并明确事件流中可能出现 pingerror 事件。SSE 本身只是传输机制,不等于完整的生产治理方案。

本文讨论的是:在 LLM 应用中,如何把 SSE 流式输出、客户端慢读、服务端背压、请求取消、超时策略、资源回收和观测指标 串成一个可上线的工程闭环。

核心原理

1. 流式输出解决的是”更早看到”,不是”资源自动安全”

非流式请求通常是:客户端发起请求,服务端等模型生成完整结果,然后一次性返回。

流式请求则是:模型每生成一段内容,服务端就把增量事件推给客户端。对用户来说,TTFT(time to first token) 会更可感;对系统来说,请求生命周期变得更长、更复杂。

一个流式 LLM 请求至少包含五段链路:

  1. 浏览器或客户端读取响应流。
  2. API 网关维持长连接。
  3. 应用服务解析 LLM provider 的 event stream。
  4. LLM provider 或自建推理服务持续生成 token。
  5. 审计、计费、日志和安全组件异步记录请求状态。

其中任何一段变慢,都会形成背压。

2. 背压的本质是”下游处理不过来时,上游必须减速”

MDN 对 Streams API 的解释中,背压是指流或管道链路调节读写速度的过程:当后面的流还没准备好接收更多 chunk 时,它会向前传播信号,要求上游减速。Node.js 的 backpressure 文档也强调,如果没有背压,数据会在缓冲区中持续积累,造成内存占用、GC 压力甚至内存耗尽。

在 LLM 流式输出中,背压可能来自多个方向:

方向典型场景
客户端慢读移动端弱网、浏览器标签页后台、代理缓存、前端渲染阻塞
服务端慢写SSE 响应写入缓冲满、HTTP/2 stream 窗口受限
应用层慢处理每个 token 都做正则、审计、翻译、敏感词检查或数据库写入
下游 provider 慢生成模型负载高、上下文长、工具调用等待
上游业务超时用户已经取消,但后端没有把取消信号传给 LLM 调用

如果系统没有背压意识,就会出现一种隐蔽浪费:用户早已离开,但 GPU、API 额度和后端连接仍在为一个无主请求工作

3. 取消传播比”前端 stop 按钮”更重要

前端有一个”停止生成”按钮,只是用户体验入口。真正的工程问题是:这个停止信号能不能一路传到服务端、provider SDK、HTTP 请求、推理队列和日志状态机。

在浏览器侧,AbortController 可以中止 fetch 请求、响应体消费和 stream;ReadableStreamcancel() 方法用于在不再需要数据时取消流。服务端也需要监听客户端连接关闭。例如 SSE 服务端代码通常要检测连接是否已中断,避免在客户端关闭后继续循环发送事件。

生产系统中,取消传播至少应该覆盖:

  • 前端:用户点击停止、切换页面、关闭标签页、路由跳转。
  • BFF / API:取消上游 provider 请求,关闭 SSE writer。
  • 队列:如果请求还没进入推理执行,直接从队列移除。
  • 推理后端:如果已进入生成,尽量 abort request 或标记为 canceled。
  • 账务与日志:记录 canceled 状态、已生成 token、已发送 token、取消原因。

工程落地

1. 先定义流式请求状态机

不要把流式请求简单记成 success / failed。建议至少定义这些状态:

状态含义
accepted应用已接收请求
provider_started已调用下游模型或推理服务
first_token_sent首个可展示 token 已发送给客户端
streaming持续增量输出
client_cancelled客户端主动取消或连接断开
server_timeout服务端超时终止
provider_error下游 provider 返回错误事件或异常
completed完整输出结束
partial_completed生成过部分内容,但因取消、超时或错误提前结束

这个状态机会影响账务、重试、用户提示和告警。尤其是 partial_completed,不要简单归为失败——它可能已经向用户展示了部分答案,也可能已经消耗了大量 token。

2. 拆分三个超时,而不是只设一个全局超时

流式请求常见的错误配置是只设置一个总超时,例如 120 秒。这样很难区分问题发生在哪里。

更稳的方式是拆成三类:

  • 首包超时:从请求发起到第一个事件到达的最长时间。它通常反映排队、冷启动、上下文过长或 provider 拒绝。
  • 空窗超时:流已经开始,但连续 N 秒没有任何 token、ping 或事件。它通常反映网络卡死、provider 卡住或代理缓冲。
  • 最大流持续时间:不管是否持续有 token,单个请求最长允许持续多久。它用于防止极端长输出占住连接和资源。

示例配置:

streaming_timeouts:
  first_event_timeout_ms: 15000
  idle_event_timeout_ms: 20000
  max_stream_duration_ms: 180000
  client_cancel_grace_ms: 1000

其中 client_cancel_grace_ms 表示客户端断开后,后端最多等待多久完成状态写入和资源释放,不应该继续生成完整答案。

3. 在应用层实现取消传播

下面是一个简化的 Node.js 代码示例,重点不是 SDK 细节,而是取消链路:

import { OpenAI } from "openai";

const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });

export async function streamAnswer(req, res) {
  const controller = new AbortController();
  let firstTokenSent = false;
  let generatedText = "";
  let status = "accepted";

  req.on("close", () => {
    if (!res.writableEnded) {
      status = "client_cancelled";
      controller.abort("client connection closed");
    }
  });

  res.setHeader("Content-Type", "text/event-stream");
  res.setHeader("Cache-Control", "no-cache");
  res.setHeader("Connection", "keep-alive");

  const firstEventTimer = setTimeout(() => {
    status = "server_timeout";
    controller.abort("first event timeout");
  }, 15_000);

  try {
    status = "provider_started";
    const stream = await client.responses.create({
      model: "gpt-5.5",
      input: req.body.messages,
      stream: true,
      signal: controller.signal,
    });

    for await (const event of stream) {
      clearTimeout(firstEventTimer);
      if (controller.signal.aborted) break;

      if (event.type === "response.output_text.delta") {
        firstTokenSent = true;
        status = "streaming";
        generatedText += event.delta;
        const ok = res.write(
          `event: token\ndata: ${JSON.stringify({ text: event.delta })}\n\n`
        );
        if (!ok) {
          await new Promise(resolve => res.once("drain", resolve));
        }
      }

      if (event.type === "response.completed") {
        status = "completed";
        break;
      }

      if (event.type === "error") {
        status = "provider_error";
        break;
      }
    }
  } catch (err) {
    if (controller.signal.aborted) {
      status = status === "accepted" ? "client_cancelled" : status;
    } else {
      status = "provider_error";
    }
  } finally {
    clearTimeout(firstEventTimer);
    await writeStreamAudit({
      requestId: req.id,
      status,
      firstTokenSent,
      generatedChars: generatedText.length,
    });
    if (!res.writableEnded) res.end();
  }
}

这里有两个容易被忽略的点:

  1. res.write() 返回 false 时,说明写缓冲已达到高水位,应该等待 drain,而不是继续无脑写入。
  2. 客户端断开不等于服务端自然结束。必须主动触发 abort,并在日志中记录取消状态。

4. SSE 事件不要只发 token

很多系统只发送一种事件:data: token。上线后会发现前端很难判断”模型还在思考、网络断了、被限流、还是已经结束”。

建议至少定义这些事件:

event: start    data: {"request_id":"..."}
event: token    data: {"text":"..."}
event: heartbeat data: {"ts":"2026-07-03T11:01:31-04:00"}
event: warning  data: {"code":"slow_provider","message":"generation is slower than usual"}
event: error    data: {"code":"provider_overloaded","retryable":true}
event: done     data: {"finish_reason":"stop"}

Anthropic 的流式文档中提到事件流可能包含 pingerror 事件;MDN 的 SSE 示例也强调可以发送事件类型、定期刷新,并在客户端断开时结束循环。这些机制在 LLM 场景中不只是协议细节,而是生产排障入口。

5. 区分 provider 错误与传输错误

流式场景下,错误可能出现在 HTTP 建连前,也可能出现在 event stream 中。

建议把错误拆成四类:

错误类型场景重试策略
provider_rejected请求被 provider 在开始前拒绝(鉴权、限流、参数错误)按错误码决定
provider_stream_error流已开始,但 event stream 中返回错误事件视已输出内容量决定
transport_error网络中断、代理关闭、TLS/HTTP 异常可重试(幂等前提)
client_cancelled客户端主动取消或断开通常不自动重试

这四类不能采用同一重试策略。尤其是 client_cancelled,通常不应自动重试;provider_stream_error 是否重试,要看是否已经向用户发送了大量内容。若已经输出较多文本,自动重试可能导致重复内容、语义冲突和额外成本。

适用场景

LLM Streaming Backpressure 最适合这些场景:

  • 长答案生成,例如技术文档、报告、邮件草稿、代码解释。
  • 交互式 Agent,需要持续显示阶段性结果。
  • 移动端或 Web 应用,用户可能频繁切换页面或取消请求。
  • 高并发 SaaS,多租户共享 provider quota,需要避免无主请求消耗预算。
  • 自建推理服务,GPU 资源紧张,取消请求可以直接释放队列和推理资源。

不太适合的场景包括:

  • 必须完整审核后才能展示的合规内容。
  • 短文本分类、结构化抽取、embedding、reranker 等低延迟任务。
  • 离线批处理任务。
  • 强事务任务,例如支付、下单、审批提交。

常见误区

误区一:用了 stream=true 就自然降低成本

流式输出降低的是用户等待体感,不必然降低 token 成本。用户如果不取消,模型仍然会生成完整输出;用户如果取消但后端没有 abort,成本仍然会继续发生。

真正降低浪费的是 取消传播、最大输出限制、超时终止和部分结果账务

误区二:前端停止渲染就等于请求停止

前端停止渲染只是 UI 状态改变。如果没有关闭 fetch、取消 reader、通知服务端和中止 provider 请求,后端仍可能继续生成。

误区三:所有流式错误都应该重试

流式请求一旦已经返回部分内容,自动重试会让用户看到重复、断裂或互相矛盾的答案。更稳的策略是提示”连接中断,可继续生成或重新生成”,由用户选择。

误区四:只记录完整响应,不记录部分输出

流式中断时,完整响应可能不存在。但生产排障需要知道:首 token 是否发出、发了多少 chunk、最后一个事件是什么、谁触发取消、是否已向用户展示部分答案。

上线检查清单

上线前至少检查以下项目:

  • ✅ 是否区分首包超时、空窗超时和最大流持续时间。
  • ✅ 客户端关闭页面、点击停止、路由切换时,是否触发请求取消。
  • ✅ 服务端是否监听连接关闭,并向下游 provider 传播 abort。
  • res.write() 或等价写入接口是否处理背压信号。
  • ✅ SSE 是否包含 starttokenheartbeaterrordone 等事件。
  • ✅ 流式 error 是否能被前端识别,而不是只表现为”打字停止”。
  • ✅ 取消请求是否记录 token 使用、已发送 chunk 数和取消原因。
  • ✅ 已输出部分内容时,自动重试策略是否关闭或改成人工确认。
  • ✅ 网关、CDN、反向代理是否关闭不必要的响应缓冲。
  • ✅ 是否监控活跃流数量、平均流持续时间、取消率、首 token 延迟、空窗超时率和 provider stream error 率。

参考资料

  1. OpenAI API Docs: Streaming API responses
  2. Anthropic Claude Docs: Streaming messages
  3. MDN: Using server-sent events
  4. MDN: Streams API concepts
  5. MDN: AbortController
  6. MDN: ReadableStream cancel()
  7. Node.js Learn: Backpressuring in Streams

常见问题

LLM 流式输出为什么还需要背压治理?
因为流式输出只是把结果分块更早返回,不能自动解决客户端慢读、网络抖动、浏览器连接限制、服务端缓冲堆积和用户中途取消后的资源浪费。
用户关闭页面后,后端是否应该继续生成完整答案?
通常不应该。生产系统应把客户端断开、AbortController、网关超时和推理后端取消串起来,尽量释放模型推理、网络连接和队列资源。
流式输出是否适合所有 LLM 请求?
不适合。短答案、严格审核后才能展示的内容、离线批处理和强事务场景通常更适合同步或异步非流式模式。