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_startcontent_block_startcontent_block_deltamessage_deltamessage_stoppingerror
这意味着生产系统不应该只把上游响应当成字符串拼接,而应将其转换为内部统一事件模型。例如:
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;
};
这个中间层有三点核心价值:
- 前端不直接绑定供应商事件格式:模型供应商更换后只需改 adapter,不需要重写前端渲染器。
- 区分事件语义:文本增量、工具参数增量、心跳事件、错误事件和最终 usage 各走各的通道。如果把所有内容都当成文本,很容易把工具调用中的 partial JSON 提前渲染出来,或者把心跳误认为模型输出。
- 为断流恢复提供稳定序号:即使不能从上游模型继续生成,也能在自己的网关层知道哪些事件已发给客户端,哪些仅在服务端缓冲区,哪些已落入持久账本。
事件边界必须由协议决定,不能按字符猜
MDN 指出 SSE 使用 text/event-stream MIME 类型,每个通知是一个文本块,由一对换行结束。WHATWG HTML 标准也定义了 data、event、id、retry 等字段的解析方式。
在 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-ID 或 lastSeq,网关补发已保存事件 | 只恢复展示,不恢复上游生成 |
| 上游失败后重新生成 | 基于同一 prompt 重新发起请求,UI 上明确替换策略 | 上游断了但业务允许重试 |
| 不可恢复失败 | 进入人工确认或任务状态机,不伪装成”恢复中” | 涉及工具调用、副作用、支付、下单、写数据库 |
实用规则:只有已落账且已校验顺序的事件可以补发;没有把握恢复语义一致时,就明确失败并允许用户重新生成。
四、代理层:很多”流式失败”其实是缓冲配置问题
流式输出经过 Nginx、API Gateway、Ingress、CDN 或企业代理时,可能被缓冲。结果是后端已经一边生成一边写,前端却等到缓冲区满或响应结束才收到内容。
上线前至少检查:
- 响应头是否包含
Content-Type: text/event-stream - 是否关闭不必要的响应压缩和代理缓冲
- 是否设置
Cache-Control: no-cache - 是否有足够长的
upstream/readtimeout - 是否允许长连接穿过网关和负载均衡器
- 是否对移动网络和浏览器后台标签页做过测试
不要只在本地直连后端测试 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 并发有上限
- 慢客户端不会无限占用内存
- 前端渲染做了批处理
恢复层
- 已定义可补发事件和不可恢复事件
- 已记录
lastSeq或Last-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 当作简单开关。它是一条跨模型、网关、代理、浏览器和用户行为的实时链路,只有把这些环节都纳入设计,才能真正稳定实时响应体验。
参考资料
- OpenAI, Streaming API responses: https://platform.openai.com/docs/guides/streaming-responses
- Anthropic, Streaming messages: https://docs.anthropic.com/en/docs/build-with-claude/streaming
- MDN, Using server-sent events: https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events
- WHATWG, HTML Standard: Server-sent events: https://html.spec.whatwg.org/multipage/server-sent-events.html
- Node.js, Backpressuring in Streams: https://nodejs.org/en/learn/modules/backpressuring-in-streams