Spring Cloud Gateway 下的流式输出: SSE实现细节(超时、缓冲与断流治理)
业务背景:用户在页面输入
提示词,后端调用大模型并实时流式输出生成内容,前端边接收边渲染,显著提升“可感知进度”的体验。
技术核心:SSE/流式输出一旦穿过 Nginx → Gateway → 后端服务 的多级转发链路,就会遇到 超时、缓冲、断流、重连与可观测性 等工程问题。
本文目标:用“问题画像 → 根因拆解 → 分层治理 → 验收清单”的结构,把这类问题沉淀成可复用的解决方案。
1. 为什么选 SSE(流式输出)?
大模型生成天然是一个“逐 token/逐片段产出”的过程。如果后端等全部生成完再返回:
-
用户体感会变成“长时间无响应 → 突然出现一大段”
-
失败/超时也更难提前感知
SSE/流式输出的优势:
-
体验更好:边生成边展示,用户知道系统在工作
-
实现成本可控:基于 HTTP,不必引入完整 WebSocket 体系
-
更容易做中途取消:用户点击停止即可中止生成
注意:本文的“流式”既包含标准 SSE(
text/event-stream),也包含“按 SSE 协议格式输出,但前端用fetch + ReadableStream读取”的实现方式。
2. 真实链路长什么样(哪里最容易出问题)
flowchart LR
A[Browser<br/>fetch/ReadableStream 或 EventSource] -->|/api/...| N[Nginx]
N --> G[Spring Cloud Gateway]
G --> B[Backend Service<br/>LLM Streaming]
B -->|stream| G -->|stream| N -->|stream| A
“断流/卡顿”通常出在 Nginx/Gateway 的默认超时与缓冲,而不是业务代码本身。
3. 常见故障
3.1 固定时间必断(比如 60s/300s/600s)
-
现象:连接建立后规律性断开,刷新/重试立刻恢复
-
高概率根因:某一层的
read_timeout / response-timeout / idle timeout生效
3.2 后端日志在写,前端不刷新;过一会儿突然一大段一起出现
-
现象:生成“看似卡住”,但最终一次性刷出
-
高概率根因:缓冲(buffering),尤其是 Nginx 默认缓冲上游响应
3.3 偶发断流 + 自动重连后出现重复/缺字/顺序错乱
-
现象:用户看到内容重复、缺片段或乱序
-
高概率根因:缺少事件
id/断点续传语义,或前端没有做幂等去重
4. 关键结论:流式稳定性取决于“分层治理”
建议按 Nginx → Gateway → 后端 → 前端 → 可观测 的顺序落地。
5. Nginx 层治理(最关键:关闭缓冲 + 拉长超时)
下面这段是典型 SSE location 的关键配置要点(IP/域名需替换为你们环境的真实值):
location ^~ /api/agent/sse/ {
proxy_pass http://<gateway_or_upstream>;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# 避免错误的 Connection 头影响长连接
proxy_set_header Connection "";
# 关键:关闭缓冲与缓存,否则会“攒一大段才吐给前端”
proxy_buffering off;
proxy_cache off;
add_header X-Accel-Buffering no;
# 关键:读写超时拉长(SSE 可能持续很久)
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
send_timeout 3600s;
keepalive_timeout 3600s;
# 是否忽略客户端断开:业务上要谨慎评估
proxy_ignore_client_abort on;
}
5.1 为什么 proxy_buffering off 必配?
因为 SSE/流式输出的价值就是“实时刷新”。一旦 Nginx 缓冲,上游虽然在持续写入,但数据会被积累在 Nginx 里,前端只能“延迟批量看到”,体验直接退化。
5.2 proxy_ignore_client_abort on 的取舍
它会让 Nginx 在客户端断开后仍继续读上游数据。对 LLM 生成类场景:
-
优点:避免偶发网络抖动导致上游生成被意外打断
-
风险:客户端已经不需要了,上游仍在耗费算力/资源
建议做法:
-
如果开启该项,后端要配合实现“取消”能力(Abort/Stop),并设置合理的最大生成时长/最大 token,避免资源泄漏。
6. Gateway 层治理(为 SSE 单独开路由策略)
6.1 给 SSE 路由配置“无限响应超时”
网关通常对响应有超时保护;但 SSE 是“持续响应”,对 SSE 必须放开。
spring:
cloud:
gateway:
routes:
- id: agent-backend-sse
uri: ${BACKEND_URL:http://localhost:8085}
predicates:
- Path=/api/agent/sse/**
metadata:
response-timeout: -1 # 关键:避免网关超时切断流
6.2 避免在 SSE 路由上使用“读 body 的过滤器”
凡是需要完整响应体才能工作的功能,在 SSE 上都可能造成:
-
直接阻塞(永远等不到响应结束)
-
缓冲导致不实时
-
内存不断增长
典型风险点:
-
统一响应封装 / 响应体 rewrite
-
记录完整响应 body 的日志
-
需要聚合/压缩的过滤器(需谨慎评估是否引入缓冲)
7. 后端服务层:SSE 的“三件套”
7.1 正确的输出格式(最容易被忽略)
建议输出符合 SSE 约定的帧(每条事件以空行结束):
data: {"text":"第一段"}
data: {"text":"第二段"}
data: [DONE]
并确保响应头正确:
-
Content-Type: text/event-stream; charset=utf-8 -
Cache-Control: no-cache
7.2 心跳(heartbeat):防止“空闲超时”
很多中间层按“连接空闲”回收连接。解决办法:
-
每 10–30 秒发一次心跳(注释帧或业务帧均可)
7.3 断点续传与幂等(可选但强烈建议)
更稳定的方式是给每条事件加 id:
id: 101
data: {"text":"..."}
浏览器/客户端重连后会携带 Last-Event-ID,服务端可从该位置继续推。
即便做不到精确续传,也建议:
-
前端按
chunkId/tokenIndex做去重 -
后端在同一次生成会话中保持输出顺序与唯一性
8. 前端实现:为什么很多团队不用 EventSource(而用 fetch stream)
在真实项目里,常见约束是:
-
请求需要带
Authorization、X-Tenant-ID、刷新 token 等 自定义 Header -
需要 POST 提交用户提示词与上下文
-
需要可控的 Abort(中止生成)
标准 EventSource 不支持自定义 header,也不支持 POST。因此很多团队会采用:
-
fetch(POST)+ReadableStream.getReader()自己读流
一个最小的读取逻辑(示意):
const res = await fetch(url, { method: 'POST', headers, body });
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
if (!line.startsWith('data:')) continue;
const payload = line.slice(5).trim();
if (payload === '[DONE]') return;
// 解析 JSON 或当纯文本追加
}
}
8.1 前端需要补齐的“稳定性能力”
仅能读流还不够,建议补齐:
-
重试/重连:带指数退避与上限(避免抖动时雪崩)
-
幂等去重:重连后避免重复输出
-
中止:AbortController 立刻停止并通知后端取消
9. 可观测性:不要记录每个 token,要记录“连接生命周期”
SSE 最常见的排障痛点是:断了,但不知道谁断的、为什么断的。
9.1 建议的日志/指标
在网关和后端都建议记录:
-
连接建立:
requestId/traceId、path、tenant(脱敏)、用户(脱敏) -
连接断开:持续时长、事件数/字节数、最后一个 chunk id(若有)、断开原因分类
9.2 脱敏底线
不要在日志出现:
-
token、真实用户输入提示词原文、模型输出全文、真实 IP/域名、真实租户/客户名称 可以保留:
-
字符数、耗时、事件数、错误码、阶段标记
10. 验收清单
-
不断流:连续 30–120 分钟稳定不断
-
实时性:后端持续写入时,前端能稳定“边来边显示”(不成批刷出)
-
取消生效:前端点击停止后,链路能及时释放资源(网关/后端都不继续耗算力)
-
异常可定位:能回答“是谁断的、为什么断、断前推了多少”
-
高并发可控:连接数上升时,网关与后端 CPU/内存/连接数/FD 不爆
11. 常见坑速查
-
只放开了 Gateway 超时,Nginx 仍在 buffering → 仍然“卡住不刷新”
-
没有心跳 → 线上按固定 idle timeout 断
-
只做了前端重连,没做幂等 → 断了之后重复输出
-
开启
proxy_ignore_client_abort on但后端不支持取消 → 算力被“幽灵请求”吃掉 -
日志打印 token/提示词/输出全文 → 成本爆炸 + 安全风险
12. 小结
LLM 流式输出的体验提升很大,但前提是把 SSE 当成“基础设施能力”治理:
Nginx 关闭缓冲 + 超时拉长、Gateway 对 SSE 路由放开 response-timeout、后端补齐心跳/结束语义、前端支持中止与重试、再加上连接生命周期可观测,才能在生产长期稳定运行。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐


所有评论(0)