LLM Streaming SSE 实现全指南:从 Server-Sent Events 到前端增量渲染的生产级完整链路
为什么 LLM streaming SSE 实现比想象中复杂
用过 Claude Opus 4.8 或 GPT-5.5 的人都有直觉:流式输出让对话体验质变了。字符一个一个蹦出来,不用盯着空白框等 8 秒。但真正把这套东西搬进生产环境,绝大多数团队会在三个地方踩坑:SSE 连接不稳定、Token 乱序或丢失、前端渲染撕裂。
根源在于 LLM 的 streaming 响应本质上是一条持久 HTTP 连接上的文本流,跟传统 JSON 请求完全不同。一旦网络抖动、负载均衡超时或客户端切换 tab,连接就断了——而大多数入门教程根本不讲这之后怎么办。
这篇文章把完整链路拆开来讲,从 SSE 协议本身到断线重连策略,再到前端的增量渲染,每个环节都给出可落地的做法。
SSE 协议基础与后端接入
Server-Sent Events 是 HTML5 标准,走普通 HTTP,服务端推送、客户端只读。相比 WebSocket,它更轻量,天然支持 HTTP/2 多路复用,也更容易穿透企业防火墙。OpenAI 兼容接口的流式响应就是标准 SSE 格式:
data: {"id":"chatcmpl-xxx","object":"chat.completion.chunk","choices":[{"delta":{"content":"你好"},"index":0}]}
data: [DONE]
每条消息以 data: 开头,双换行 \n\n 分隔。[DONE] 是 OpenAI 约定的终止信号,不是 SSE 标准的一部分,解析时要单独处理。
后端实现要注意两点:
禁用缓冲。Nginx 默认会把响应攒到一定大小再发。必须在 location 块里加:
proxy_buffering off;
proxy_cache off;
X-Accel-Buffering: no;
Node.js 里用 res.flushHeaders() 提前发送响应头,再用 res.write() 逐块写入,不要等到 res.end()。
设置正确的 Content-Type。响应头必须是 Content-Type: text/event-stream; charset=utf-8,否则浏览器不会走 EventSource 解析路径。
用 fetch + ReadableStream 手动消费比 EventSource 更灵活,可以带自定义请求头(比如 Authorization),而原生 EventSource 不支持 POST 也不支持自定义头:
const response = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
body: JSON.stringify({ messages, stream: true }),
});
const reader = response.body!.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
// 解析 SSE 行...
}
断线重连:生产环境必须处理的边界情况
网络不可靠,LLM 推理又慢——Gemini CLI 跑一个复杂任务可能持续 30 秒,这段时间里断线概率不可忽视。
SSE 协议本身有重连机制:服务端可以在响应里发 id: <event-id> 字段,客户端断线后会带 Last-Event-ID 请求头重连。但依赖原生 EventSource 的自动重连有个致命缺陷:它不知道已经收到了多少 token,重连后如果服务端不做去重,用户会看到内容重复。
实际可行的方案是在应用层维护重连逻辑:
- 服务端为每次流式响应生成
stream_id,并在每个 SSE 事件里带上seq(序号,从 0 开始)。 - 客户端记录最后收到的
seq,断线重连时把stream_id和last_seq发给服务端。 - 服务端从 Redis 里读缓存的已发 token(建议 TTL 设 5 分钟),从
last_seq + 1开始补发。
重连退避策略用指数退避加抖动,避免所有客户端同时重连打垮后端:
const delay = Math.min(1000 * 2 ** retryCount + Math.random() * 1000, 30000);
最大重试次数建议设 5 次,超过后告知用户连接失败而不是无限等待。
另一个常被忽略的点:负载均衡器的超时设置。AWS ALB 默认 idle timeout 是 60 秒,对长推理任务来说太短。要么改大这个值(建议 300 秒),要么让服务端每隔 15 秒发一条注释行 : ping 来保活——SSE 规范里冒号开头的行是注释,客户端会忽略,但它能重置 TCP 的 idle 计时器。
前端增量渲染:从 Token 流到可读文本
Token 一个一个到达,但不能一个一个触发 React re-render——100 token/s 的速度意味着每秒 100 次状态更新,直接用 setState 会让主线程喘不过气。
正确做法是攒批再渲染,用 requestAnimationFrame 或固定间隔(16ms,约 60fps)刷新一次 UI:
let buffer = '';
let rafId: number;
function onToken(token: string) {
buffer += token;
cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(() => {
setContent(prev => prev + buffer);
buffer = '';
});
}
Markdown 渲染是另一个坑。流式状态下文本是不完整的,直接扔给 marked 或 react-markdown 会频繁触发 DOM 重建,代码块会出现闪烁。两个应对策略:
- 延迟 Markdown 渲染:流式过程中只显示纯文本,流结束后再转 Markdown。适合内容较短的场景。
- 增量 Markdown:用
marked的流式 lexer,只对已完成的块(段落、代码块)做渲染,未完成的尾部保持纯文本。stream选项在 marked v9+ 里已内置。
代码块的处理要特别小心。``` 开头但还没收到结束的 ```,先用 <pre> 包住原始文本,等结束符到了再触发语法高亮(highlight.js 或 shiki)。别在每个 token 上跑 hljs.highlightAuto(),开销是不必要的。
光标动画用 CSS 伪元素实现,不要用 JS 定时器:
.streaming-content::after {
content: '▋';
animation: blink 1s step-end infinite;
}
@keyframes blink { 50% { opacity: 0; } }
流结束后移除 .streaming-content class,光标自然消失,不需要额外的清理逻辑。
关键参数对比
| 场景 | 推荐方案 | 注意点 |
|---|---|---|
| 简单聊天界面 | fetch + ReadableStream |
禁用 Nginx proxy_buffering |
| 需要断线续传 | 应用层 seq + Redis 缓存 | TTL ≥ 单次推理最长时间 |
| 高频 token 渲染 | rAF 攒批 16ms 刷新 | 避免每 token setState |
| Markdown 流式 | marked v9+ stream lexer | 代码块尾部保持纯文本 |
| 负载均衡保活 | SSE : ping 每 15 秒 |
ALB idle timeout 设 300s |
把这套链路跑通之后,接下来自然要面对的问题是:在 GPT-5.5、Claude Opus 4.8、DeepSeek V3 这些模型之间做流式接入的切换和成本管理。我自己用的是 XycAi 词元平台——一个 OpenAI 兼容 API,200+ 模型统一接入,Claude Code、Codex、Gemini CLI 都能一键对接,不用改代码就能在模型间切换。官方价 1.4 折起的定价让测试成本大幅下降,全球节点 CN2 直连延迟低至 5ms,对流式输出的首 token 延迟体验提升很明显,企业用户还支持开全球发票,合规这块也不用操心。
一个 API 接入 200+ 全球 AI 模型
GPT · Claude · Gemini 官方模型低至 1.4 折起,持大模型算法备案号,CN2 直连低至 5ms,可开全球合规发票。
立即体验 XycAi →