文章

LLM Streaming 输出生产实战:用 SSE 事件边界、背压与断流恢复稳定实时响应

本文从大模型流式输出的生产故障入手,深入讲解 SSE 事件边界、背压控制、断流恢复、代理超时与前端渲染优化,帮助工程团队构建可靠、可观测的流式响应链路,稳定实时交互体验。

LLM Streaming 输出生产实战:用 SSE 事件边界、背压与断流恢复稳定实时响应

流式输出不是”把 token 一边生成一边打印”这么简单。它是一条持续数秒到数分钟的实时状态链路,涉及模型生成速度、上游事件格式、服务端缓冲、反向代理、浏览器解析、移动网络、前端渲染和用户主动关闭页面。只要一个环节处理粗糙,就会出现看似随机的故障。


一、问题本质:Streaming 为什么比普通 HTTP 难得多

大模型应用一旦进入生产,用户往往不愿意等待完整响应生成结束。长答案、代码生成、分析报告、Agent 任务进度,都需要尽早展示中间结果。于是很多系统会打开模型 API 的 streaming 模式,把上游 token 或事件转发给前端。

但流式输出在实际运行中容易出现以下典型故障:

现象根因
首 token 很快出现,后续 token 卡住代理层缓冲未关闭,或服务端未处理背压
前端偶尔收到半截 JSON按网络 chunk 解析而非按 SSE 事件边界解析
用户关闭页面后上游仍在生成,继续烧钱未监听客户端断开事件,未取消上游请求
代理层把事件缓冲到一定大小才吐出Nginx/Ingress/CDN 打开了响应缓冲
断流后重试生成出另一版答案,界面出现重复或语义漂移无事件序号和替换策略,新旧内容静默拼接
慢客户端拖垮服务端连接池和内存无单连接缓冲上限、无背压控制

本文讨论的重点不是模型推理本身,而是 LLM Streaming 输出链路的工程治理:如何定义事件边界,如何做背压,如何处理断流,如何让 SSE 网关在生产环境中可观测、可回退、可压测。


二、核心原理:把 Token 流升级为事件流

SSE 是事件协议,不只是换行传文本

OpenAI 的流式响应通过 stream=true 使用 Server-Sent Events,Chat Completions 的流式结果以 data-only SSE 形式增量返回。Anthropic 则更明确地把流拆成具名事件:

  • message_start
  • content_block_start
  • content_block_delta
  • message_delta
  • message_stop
  • ping
  • error

这意味着生产系统不应该只把上游响应当成字符串拼接,而应将其转换为内部统一事件模型。例如:

type StreamEvent = {
  requestId: string;
  seq: number;
  provider: "openai" | "anthropic" | "other";
  type:
    | "message_start"
    | "text_delta"
    | "tool_input_delta"
    | "heartbeat"
    | "usage"
    | "error"
    | "message_stop";
  textDelta?: string;
  partialJson?: string;
  finishReason?: string;
  usage?: { inputTokens?: number; outputTokens?: number };
  createdAt: string;
};

这个中间层有三点核心价值:

  1. 前端不直接绑定供应商事件格式:模型供应商更换后只需改 adapter,不需要重写前端渲染器。
  2. 区分事件语义:文本增量、工具参数增量、心跳事件、错误事件和最终 usage 各走各的通道。如果把所有内容都当成文本,很容易把工具调用中的 partial JSON 提前渲染出来,或者把心跳误认为模型输出。
  3. 为断流恢复提供稳定序号:即使不能从上游模型继续生成,也能在自己的网关层知道哪些事件已发给客户端,哪些仅在服务端缓冲区,哪些已落入持久账本。

事件边界必须由协议决定,不能按字符猜

MDN 指出 SSE 使用 text/event-stream MIME 类型,每个通知是一个文本块,由一对换行结束。WHATWG HTML 标准也定义了 dataeventidretry 等字段的解析方式。

在 LLM 场景中,最常见的错误是按网络 chunk 做业务解析。网络 chunk 只是传输层分段,不等于 SSE 事件,也不等于 JSON 对象,更不等于一个完整 token。

正确做法:

  • 先按 SSE 规则解析事件块
  • 再按事件的 event 或 payload type 分发
  • text_delta 可以增量渲染
  • input_json_delta 或 partial JSON 只能累积,直到工具输入结束后再解析
  • message_stop 或 final event 才关闭 UI 状态

如果你在前端看到 Unexpected end of JSON input,通常不是模型错了,而是代码把 partial JSON 当成 complete JSON 解析了。


三、工程落地:构建一个可靠的 Streaming Gateway

1. 网关负责协议归一化

建议在应用服务和模型供应商之间加一个轻量 Streaming Gateway,至少承担四项职责:

  • 把不同供应商的 streaming 格式转为内部 StreamEvent
  • 为每个事件生成 requestId + seq
  • 统一输出给前端的 SSE 格式
  • 管理取消、超时、心跳、错误和指标

简化的输出格式示例:

event: text_delta
id: req_123:42
data: {"seq":42,"textDelta":"下一段文本"}

对于普通 Web 文本生成场景,SSE 通常足够简单:浏览器天然支持事件流,服务端也容易通过 HTTP 输出。

2. 明确连接生命周期

每个 streaming 请求至少应有以下状态:

accepted → upstream_started → first_event_sent → streaming → completed
                |                    |                 |
                |                    |                 → client_aborted
                |                    → upstream_error

建议至少记录以下指标:

指标含义
time_to_first_event_ms从请求进入网关到第一条可见事件发出
stream_duration_ms流式连接持续时间
events_sent_total成功写给客户端的事件数
client_abort_total客户端主动断开数量
upstream_cancel_total客户端断开后成功取消上游的数量
stream_buffer_bytes单连接待写缓冲
final_usage_missing_total没有拿到最终 usage 的请求数

只记录”请求成功/失败”不够,因为流式请求经常处于半成功状态:上游已经生成,客户端断开;前端收到部分 token,但最终 usage 没回来;模型返回 error event,但 HTTP 状态码仍然是 200。

3. 背压:不要向慢客户端无限写

Node.js 官方文档解释了一个典型问题:当接收端比发送端慢时,数据会在缓冲区中堆积;如果没有背压机制,内存和 GC 压力都会被放大。

LLM streaming 也一样。模型可能每几十毫秒吐出一个 delta,但客户端可能处在弱网、后台标签页、移动网络或低性能 WebView 中。如果服务端不关心写入是否成功,最终会把所有事件堆在内存里。

核心原则是尊重 res.write() 的返回值

import { once } from "node:events";
import type { Response } from "express";

async function writeSse(res: Response, event: string, id: string, data: unknown) {
  const payload = `event: ${event}\nid: ${id}\ndata: ${JSON.stringify(data)}\n\n`;
  if (!res.write(payload)) {
    await once(res, "drain");
  }
}

这段代码表达了一个底线:当下游写不动时,网关要暂停继续写,而不是继续把事件塞进内存

更完整的实现还需要:

  • 为单连接设置最大 pending bytes
  • 为单用户设置最大并发 streaming 连接
  • 客户端断开时立即取消上游模型请求
  • 对 UI 端做小批量渲染,避免每个 token 都触发昂贵 DOM 更新
  • 对后台标签页或移动端弱网降低刷新频率

4. 心跳:区分”模型还在想”和”连接死了”

SSE 支持注释行和 ping 事件。生产系统可以设置两类心跳:

  • 上游心跳:判断模型供应商连接是否仍然活着
  • 下游心跳:防止浏览器、代理或负载均衡器把空闲连接断掉

推荐做法是发送不影响业务语义的 heartbeat:

: keep-alive

或:

event: heartbeat
data: {"ts":"2026-07-09T15:00:00-04:00"}

前端收到 heartbeat 后只更新连接状态,不要把它当作模型输出。

5. 断流恢复:不要承诺”原地续写”

断流恢复是 LLM streaming 最容易被低估的部分。普通文件下载可以通过 Range 请求续传,但大模型生成不是文件读取。多数模型 API 无法从”第 154 个 token”继续同一次采样过程

生产系统应把恢复分成三类:

恢复类型策略适用场景
前端重连恢复显示前端带上 Last-Event-IDlastSeq,网关补发已保存事件只恢复展示,不恢复上游生成
上游失败后重新生成基于同一 prompt 重新发起请求,UI 上明确替换策略上游断了但业务允许重试
不可恢复失败进入人工确认或任务状态机,不伪装成”恢复中”涉及工具调用、副作用、支付、下单、写数据库

实用规则:只有已落账且已校验顺序的事件可以补发;没有把握恢复语义一致时,就明确失败并允许用户重新生成。


四、代理层:很多”流式失败”其实是缓冲配置问题

流式输出经过 Nginx、API Gateway、Ingress、CDN 或企业代理时,可能被缓冲。结果是后端已经一边生成一边写,前端却等到缓冲区满或响应结束才收到内容。

上线前至少检查:

  • 响应头是否包含 Content-Type: text/event-stream
  • 是否关闭不必要的响应压缩和代理缓冲
  • 是否设置 Cache-Control: no-cache
  • 是否有足够长的 upstream / read timeout
  • 是否允许长连接穿过网关和负载均衡器
  • 是否对移动网络和浏览器后台标签页做过测试

不要只在本地直连后端测试 streaming。生产路径中的任意一层代理都可能改变行为。


五、前端渲染:不要让 UI 成为瓶颈

前端常见误区是每收到一个 delta 就立即更新整个 Markdown。长回答中,这会触发大量布局、语法高亮和滚动计算。

更稳妥的策略:

  • token delta 进入内存 buffer
  • 以 30–100ms 为窗口批量刷新 UI
  • Markdown 解析和代码高亮分阶段执行
  • 正在生成的代码块先用纯文本展示,结束后再完整高亮
  • 用户滚动查看历史内容时,不要强制自动滚到底

对 Agent 场景,还要把”模型文本""工具调用""工具结果""系统进度”分成不同事件类型。不要把工具调用 JSON 直接混进自然语言输出。


六、适用场景与不适用场景

适合使用 Streaming 的场景

  • 在线聊天、智能客服、知识问答
  • 代码生成、SQL 生成、报告生成
  • Agent 任务进度展示
  • 长文本分析、文档总结、审查意见生成
  • 多供应商模型网关,需要统一前端事件格式
  • 对 TTFT、用户等待体验和中途取消成本敏感的业务

不适合过度使用 Streaming 的场景

  • 离线批处理任务
  • 需要完整结构化 JSON 才能进入下一步的请求
  • 结果必须一次性签名、审计、归档的合规场景
  • 下游系统只接受完整响应的后端任务

在这些场景中,普通非流式调用或异步任务队列往往更可靠。


七、常见误区

误区一:打开 stream=true 就等于用户体验更好

不一定。流式输出只能改善”开始看到内容”的时间。如果前端渲染很卡、代理缓冲没关、心跳缺失、断流不可恢复,用户体验反而更差。

误区二:网络 chunk 就是一个 token

不是。网络 chunk、SSE event、JSON delta、模型 token 是不同层次。生产代码必须按协议解析,不要按 chunk 猜测业务边界。

误区三:断流后直接重试并拼接即可

风险很高。重新生成可能产生不同内容,尤其在温度较高、工具调用或上下文变化时。除非有严格的事件账本和替换策略,否则不要拼接两个不同生成过程。

误区四:只监控 HTTP 200 即可

流式请求可能 HTTP 200,但中途出现 error event;也可能已经向前端发出一半内容,最终 usage 丢失。必须监控事件级状态。


八、上线检查清单

协议层

  • 已统一供应商 streaming 事件到内部 StreamEvent
  • 已区分文本 delta、工具输入 delta、heartbeat、error、stop
  • 已按 SSE 事件边界解析,不按网络 chunk 解析
  • 已处理 partial JSON,不提前解析工具参数
  • 已为每个事件生成 requestId + seq

连接层

  • 客户端断开时会取消上游请求
  • 有连接级最大持续时间
  • 有空闲超时和 heartbeat
  • 代理层关闭了不必要缓冲
  • 已测试弱网、移动端、后台标签页和刷新页面

背压层

  • 服务端尊重写入返回值或等价背压信号
  • 单连接缓冲有上限
  • 单用户 streaming 并发有上限
  • 慢客户端不会无限占用内存
  • 前端渲染做了批处理

恢复层

  • 已定义可补发事件和不可恢复事件
  • 已记录 lastSeqLast-Event-ID
  • 重新生成不会和旧输出静默拼接
  • 工具调用和副作用请求有幂等与人工确认策略
  • final usage 缺失时有补偿账本或标记

可观测层

  • 记录 TTFE、stream duration、event count、client abort、upstream error
  • 区分 upstream error、gateway error、client abort
  • 有按供应商、模型、租户、接口维度的指标
  • 有抽样保存事件序列用于排障
  • 有流式链路的压测脚本

九、结论

LLM streaming 的价值在于降低用户感知等待,而不是让系统跳过可靠性设计。生产级流式输出至少要解决五个问题:

事件边界清晰、背压可控、断流有策略、代理不缓冲、指标能解释。

如果团队刚开始建设,建议先做一个统一 Streaming Gateway:把供应商事件适配成内部事件,输出标准 SSE,记录事件序号,处理取消和 heartbeat。等链路稳定后,再逐步加入断流补发、前端批量渲染、事件账本和多供应商兼容。

不要把 streaming 当作简单开关。它是一条跨模型、网关、代理、浏览器和用户行为的实时链路,只有把这些环节都纳入设计,才能真正稳定实时响应体验。


参考资料

常见问题

LLM 流式输出一定要用 SSE 吗?
不一定。浏览器单向接收 token 时 SSE 足够简单;如果需要双向实时音频、持续输入或低延迟交互,WebSocket 或 WebRTC 更合适。
断流后能从上一个 token 继续吗?
多数模型 API 不能从任意 token 偏移继续生成。生产系统应保存稳定事件、请求账本和恢复策略,而不是假设上游可无缝续传。
为什么流式输出还需要背压控制?
客户端、浏览器、代理或移动网络可能比模型输出慢。没有背压控制时,服务端会累积缓冲、增加内存占用,并放大断连后的资源浪费。
Streaming 和普通接口的超时应该分开配置吗?
应该分开。普通接口关注总响应时间,streaming 更关注首事件时间、事件间隔、连接总时长和空闲时间。沿用普通接口超时可能导致模型正常生成时被错误断开。