背景问题
大模型应用把输出改成流式以后,用户体验通常会立刻变好:首个 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 增量返回文本、工具调用和扩展思考事件,并明确事件流中可能出现 ping 和 error 事件。SSE 本身只是传输机制,不等于完整的生产治理方案。
本文讨论的是:在 LLM 应用中,如何把 SSE 流式输出、客户端慢读、服务端背压、请求取消、超时策略、资源回收和观测指标 串成一个可上线的工程闭环。
核心原理
1. 流式输出解决的是”更早看到”,不是”资源自动安全”
非流式请求通常是:客户端发起请求,服务端等模型生成完整结果,然后一次性返回。
流式请求则是:模型每生成一段内容,服务端就把增量事件推给客户端。对用户来说,TTFT(time to first token) 会更可感;对系统来说,请求生命周期变得更长、更复杂。
一个流式 LLM 请求至少包含五段链路:
- 浏览器或客户端读取响应流。
- API 网关维持长连接。
- 应用服务解析 LLM provider 的 event stream。
- LLM provider 或自建推理服务持续生成 token。
- 审计、计费、日志和安全组件异步记录请求状态。
其中任何一段变慢,都会形成背压。
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;ReadableStream 的 cancel() 方法用于在不再需要数据时取消流。服务端也需要监听客户端连接关闭。例如 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();
}
}
这里有两个容易被忽略的点:
res.write()返回false时,说明写缓冲已达到高水位,应该等待drain,而不是继续无脑写入。- 客户端断开不等于服务端自然结束。必须主动触发 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 的流式文档中提到事件流可能包含 ping 和 error 事件;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 是否包含
start、token、heartbeat、error、done等事件。 - ✅ 流式 error 是否能被前端识别,而不是只表现为”打字停止”。
- ✅ 取消请求是否记录 token 使用、已发送 chunk 数和取消原因。
- ✅ 已输出部分内容时,自动重试策略是否关闭或改成人工确认。
- ✅ 网关、CDN、反向代理是否关闭不必要的响应缓冲。
- ✅ 是否监控活跃流数量、平均流持续时间、取消率、首 token 延迟、空窗超时率和 provider stream error 率。