Java LLM Gateway 生产级实战:SSE 流式响应、Redis 与 K8s 部署
发布时间:2026/10/7 9:42:23 锦皓数字建站

1. 从荒天帝的修炼境界说起为什么大模型网关需要他化自在法第一次看到荒天帝炼大模型网关这个标题我脑子里蹦出来的不是玄幻小说的剧情而是一个很现实的技术问题当一个团队从调通一个模型接口走到要在云上生产环境扛住真实流量这一步时中间隔着的不是一层封装而是整整十八层境界。前面十七境你可能都在本地跑得好好的一到第十八境——云上生产——各种问题就全冒出来了。所谓他化自在法放到工程语境里我的理解是网关本身不生产能力它把后端各种异构的大模型服务化成一套统一的、可观测、可限流、可降级的对外接口。它自己自在是因为它把复杂性都转嫁给了抽象层和中间件。这套东西的核心技术栈从热搜词里也能看出来Java、LLM Gateway、Kubernetes、Redis、SSE。这五个词基本就是一套生产级大模型网关的骨架。这篇文章我想聊的不是怎么调通 OpenAI 接口这种入门内容而是一个 Java 写的 LLM Gateway怎么在 Kubernetes 上真正跑成生产级服务。适合谁看如果你已经写过 Spring Boot 项目懂一点 Redis正在或者准备把大模型能力接入自己的业务系统那这篇就是给你准备的。如果你还在纠结 Java 和 Python 哪个好那可能得先补补基础再来。我会把整个网关拆成几个关键战场流式响应的 SSE 长连接治理、Redis 在网关里的多重角色、K8s 上的部署与弹性、以及生产环境里那些文档不会告诉你的坑。每一块我都会讲清楚为什么这么做而不只是怎么做。2. SSE 流式响应网关里最容易断气的一环2.1 为什么大模型网关必须用 SSE 而不是普通 HTTP大模型生成内容是逐 token 吐出来的如果网关等模型全部生成完再一次性返回用户体验就是转圈十秒然后唰一下全出来。这在对话场景里是灾难。所以流式输出是刚需而SSEServer-Sent Events是目前最贴合这个场景的协议选择。为什么不用 WebSocket我实际对比过。WebSocket 是全双工适合双向频繁通信但大模型对话本质上是客户端发一次请求服务端持续推流是单向的。SSE 基于普通 HTTP天然支持断线重连浏览器 EventSource 自带重连还能复用现有的 HTTP 基础设施——负载均衡、鉴权、日志、链路追踪全都能用。用 WebSocket 反而要重新搭一套。在 Java 里实现 SSESpring 提供了SseEmitter但生产环境我强烈建议用Spring WebFlux 的FluxServerSentEvent原因后面讲背压的时候会说。2.2 stream disconnected before completion: idle timeout waiting for sse 这个报错到底怎么回事热搜词里有一条特别扎眼stream disconnected before completion: idle timeout waiting for sse。这个报错我踩过不止一次它的本质是连接在等待下一个数据块的过程中超过了某一层的空闲超时时间被强制断开了。关键在于这个某一层可能是很多层中的任意一层层级常见超时配置典型默认值症状Nginx Ingressproxy_read_timeout60s60秒后连接被切断云厂商 LBidle timeout60s / 300s静默断连Spring 容器server.tomcat.connection-timeout20s连接被回收网关自身自定义 idle 检测视实现主动关闭模型上游上游 API 超时视厂商上游先断排查这个问题的正确姿势是从外到内逐层确认超时值而不是一上来就改代码。我的经验是先把 Nginx Ingress 的proxy_read_timeout和proxy_send_timeout都调到 300s 以上再确认云 LB 的空闲超时最后才看应用层。但光调大超时是不够的因为大模型有时候真的会卡住很久比如推理排队。这时候需要一个心跳机制网关在等待上游 token 的间隙定期往 SSE 连接里推一个注释行: heartbeat\n\n保持连接活跃。这个注释行不会被客户端解析成数据但能让中间所有代理层认为连接是活的。// WebFlux 里给流加心跳的典型写法 FluxServerSentEventString body upstreamFlux .map(chunk - ServerSentEvent.builder(chunk).build()) .mergeWith(Flux.interval(Duration.ofSeconds(15)) .map(i - ServerSentEvent.builder().comment(hb).build()));注意心跳间隔要小于所有中间层里最小的那个空闲超时值。如果 Nginx 是 60s心跳设 15s 就很稳如果设成 90s那心跳还没发出去连接就断了。2.3 背压为什么 WebFlux 比 SseEmitter 更适合生产SseEmitter是阻塞式 Servlet 栈的产物它把数据往响应里写的时候如果客户端消费慢数据会在内存里堆积。大模型 token 生成速度可能很快客户端网络又慢堆积起来就是 OOM 风险。WebFlux 的Flux天然支持背压Backpressure下游消费不过来时上游会被通知减速。虽然 SSE 协议本身对背压支持有限HTTP 响应流没法真正暂停上游但 WebFlux 至少能让你在网关层做缓冲控制比如用onBackpressureBuffer设置一个有界缓冲区超了就丢弃或断开而不是无限堆积。我实测下来一个网关实例在 WebFlux 下能稳定维持几千条并发 SSE 连接而 SseEmitter 在几百条时就开始出现内存抖动。这个差距在云上生产环境是致命的。2.4 断线重连与幂等用户刷新页面后对话不能乱SSE 断线后客户端会自动重连但重连时如果直接重新发起模型请求用户会看到内容重复。正确做法是给每个流分配一个streamId网关把已生成的内容按streamId缓存在 Redis 里带 TTL重连时带上Last-Event-ID网关从断点续传。这里有个细节SSE 的id:字段就是给这个用的。网关每推一个 chunk 就带上递增的 id客户端重连时浏览器会自动带上Last-Event-ID头网关据此从 Redis 里取后续内容。这套机制配合好了用户在网络抖动时几乎无感知。3. Redis 在网关里的四副面孔不只是缓存很多人一提 Redis 就想到缓存但在一个 LLM Gateway 里Redis 承担的角色远不止于此。我把它总结成四副面孔每一副都对应一个具体的生产问题。3.1 面孔一分布式限流防止一个用户打爆整个集群大模型调用是花钱的一个失控的客户端可能在几秒内发起上千次请求。网关必须做限流而且是分布式限流——因为网关是多副本部署的单机限流挡不住。用 Redis 做限流最经典的是滑动窗口或令牌桶。我推荐用 Lua 脚本保证原子性因为读计数-判断-写计数这三步如果分开执行在并发下会出错。-- 固定窗口限流的简化 Lua 脚本 local key KEYS[1] local limit tonumber(ARGV[1]) local window tonumber(ARGV[2]) local current redis.call(INCR, key) if current 1 then redis.call(EXPIRE, key, window) end if current limit then return 0 end return 1提示固定窗口在窗口边界会有双倍突发问题比如限制 100/分钟用户在 59 秒发 100 次61 秒又发 100 次。对成本敏感的网关建议用滑动窗口或者令牌桶精度更高。限流的维度也要设计好按用户 ID、按 API Key、按 IP、按模型分别限流粒度越细越能防住异常。我一般会做两层外层按用户粗粒度限流比如 60 次/分钟内层按模型细粒度限流比如 GPT-4 类模型 10 次/分钟。3.2 面孔二会话上下文缓存让多轮对话不丢记忆大模型本身是无状态的多轮对话靠的是把历史消息一起传上去。如果每次都从数据库读历史延迟高如果放本地内存多副本又不一致。Redis 是天然的会话存储。我的做法是以conversationId为 key存一个 List 或 Stream每条消息带角色和时间戳。设置合理的 TTL比如 2 小时过期自动清理。读取时按需截断——因为上下文窗口有限不能无限往里塞。这里有个坑Redis 序列化方式选错会导致性能暴跌。默认的 JDK 序列化又慢又占空间一定要换成StringRedisSerializer JSON或者用GenericJackson2JsonRedisSerializer。热搜词里出现redis序列化不是没道理的这是高频踩坑点。3.3 面孔三分布式锁保护那些不能并发的操作网关里有些操作必须串行比如给某个用户充值额度刷新某个模型的配置。这些用 Redis 分布式锁最方便。但分布式锁的坑极多我列几个必须注意的锁必须设过期时间否则持锁进程挂了就死锁。释放锁必须校验持有者用 Lua 脚本比对 value 再删否则可能删掉别人的锁。业务执行时间可能超过锁过期时间需要看门狗续期机制Redisson 的RLock自带。热搜词里redis分布式锁是高频面试题但面试答案和生产的差距在于面试只讲 SETNX生产要考虑续期、可重入、锁等待、以及 Redis 主从切换时的锁丢失问题。如果对一致性要求极高其实应该用 etcd 或 ZooKeeperRedis 锁更适合防重复而非强一致场景。3.4 面孔四指标与熔断状态共享网关需要实时知道每个上游模型的健康度成功率、平均延迟、错误率。这些指标如果只在单机内存里多副本之间就没法协同熔断。把熔断状态放 Redis任何一个副本发现某模型连续失败就能让所有副本一起降级。实现上可以用 Redis 的计数器 过期时间做滑动统计也可以用 RedisTimeSeries 模块。简单场景下一个model:health:{modelName}的 Hash 存最近 N 次调用的结果就够了。4. Kubernetes 上的部署从能跑到扛得住4.1 为什么网关特别适合上 K8sLLM Gateway 是典型的无状态 需要弹性的服务流量波动大白天高峰、夜间低谷突发性强某个业务上线后流量翻倍。这正是 K8s 的强项。而且网关本身不存数据状态都在 Redis副本可以随意扩缩。但适合不等于随便部署就行。我见过太多团队把网关往 K8s 一扔结果 SSE 连接各种断、扩缩容时连接被粗暴切断。下面几个配置是必须调对的。4.2 优雅关闭别让扩缩容切断用户的流K8s 缩容或滚动更新时会向 Pod 发 SIGTERM然后等terminationGracePeriodSeconds秒后强杀。如果网关正在给用户推流直接被杀掉用户那边就是stream disconnected。正确做法是收到 SIGTERM 后网关停止接受新连接从 Service 摘除。给现有 SSE 连接一个宽限期让它们自然结束或推送一个服务即将重启的事件。宽限期要大于最长可能的流式响应时间。# Deployment 里的关键配置 spec: template: spec: terminationGracePeriodSeconds: 120 containers: - name: gateway lifecycle: preStop: exec: command: [sh, -c, sleep 15]那个preStop里的sleep 15很关键它让 Pod 在被摘除后、真正收到 SIGTERM 前还有时间让负载均衡把流量切走。没有这个 sleepSIGTERM 可能在 Endpoints 更新前就到了导致请求打到正在关闭的 Pod 上。4.3 探针配置别让健康检查误杀正在推流的 PodlivenessProbe和readinessProbe配错是另一个大坑。如果 liveness 探针的超时设得太短而网关在高负载下响应变慢K8s 会误判 Pod 挂了然后重启它——正在推流的连接全断。我的经验值livenessProbeinitialDelaySeconds: 30periodSeconds: 10timeoutSeconds: 5failureThreshold: 3。readinessProbe单独暴露一个轻量端点只检查进程是否活着不要检查 Redis 等外部依赖。因为 Redis 抖动时你不希望所有 Pod 同时被摘除导致服务全挂。注意健康检查端点一定要和业务端点分开。我见过把/chat当健康检查的结果探针请求也去调模型白白烧钱。4.4 HPA 弹性伸缩SSE 连接的扩容不是看 CPU默认的 HPA 按 CPU 或内存扩缩但 SSE 网关的瓶颈往往不是 CPU而是并发连接数。一个 Pod 可能 CPU 才 20%但已经维持了 5000 条连接再往上加就要出问题。解决方案是用自定义指标做 HPA。可以暴露一个 Prometheus 指标gateway_active_sse_connections然后用 Prometheus Adapter 把它接入 HPAmetrics: - type: Pods pods: metric: name: gateway_active_sse_connections target: type: AverageValue averageValue: 3000这样每个 Pod 连接数超过 3000 就扩容比看 CPU 准得多。4.5 Ingress 层的 SSE 特殊配置Nginx Ingress 默认对 SSE 不友好必须显式配置annotations: nginx.ingress.kubernetes.io/proxy-buffering: off nginx.ingress.kubernetes.io/proxy-read-timeout: 300 nginx.ingress.kubernetes.io/proxy-send-timeout: 300 nginx.ingress.kubernetes.io/proxy-http-version: 1.1proxy-buffering: off是必须的否则 Nginx 会缓冲响应SSE 就变成攒一批再发流式效果全没了。proxy-http-version: 1.1是为了支持 chunked 传输。5. 生产环境里那些文档不会写的坑5.1 Redis 连接超时redis command timed out的真实原因热搜词里有redis command timed out; nested exception is io.lettuce.core.RedisCommandTim这个报错我遇到过好几次原因五花八门连接池太小默认 Lettuce 共享连接高并发下命令排队。要调spring.redis.lettuce.pool.max-active。大 key 阻塞某个 key 存了几 MB 的会话历史读取时阻塞整个连接。要控制 value 大小会话历史做分片或截断。慢命令KEYS *、HGETALL大 Hash 这类命令在生产是禁忌要用SCAN替代。网络抖动云上跨可用区访问 Redis 延迟不稳定命令超时阈值要留足余量。排查顺序建议先看 Redis 慢日志SLOWLOG GET再看连接池指标最后看网络。5.2 模型上游的假死超时设置的艺术上游模型服务有时候不是挂了而是卡住——连接建立了但迟迟不返回第一个 token。如果网关的读超时设得太长请求会一直挂着占资源设得太短正常的长推理又被误杀。我的做法是分阶段超时连接超时5s连不上就快速失败。首 token 超时30s超过就认为上游异常触发熔断。token 间隔超时15s两个 token 之间超过这个时间就断开。总超时300s兜底。这套分阶段超时配合熔断器Resilience4j 或 Sentinel能有效隔离上游故障。5.3 日志与链路追踪SSE 场景下的特殊处理普通 HTTP 请求的日志很好打一个请求一行。但 SSE 是长连接一个连接可能持续几分钟日志怎么打我的方案是连接建立时打一条开始日志带 traceId连接结束时打一条结束日志带耗时、token 数、状态中间的 chunk 不打日志否则日志量爆炸。关键事件如上游切换、熔断触发单独打。链路追踪上SSE 的 span 会很长要注意采样率。全量采样在高流量下会拖垮追踪系统建议对 SSE 连接用较低的采样率或者只对异常连接采样。5.4 成本控制网关是花钱的闸门大模型调用按 token 计费网关是唯一能统一控制成本的地方。我一般会在网关做三件事按用户/租户统计 token 消耗实时写入 Redis超预算就限流。缓存相同请求的响应对确定性场景相同 prompt 直接返回缓存。模型路由降级预算紧张时把部分请求路由到更便宜的模型。这些策略都要在网关层实现因为业务代码里做太分散管不住。6. 从仙帝境回看一套能上生产的网关长什么样把上面这些拼起来一个生产级 LLM Gateway 的完整形态大概是这样的接入层Nginx Ingress关缓冲、调超时、支持 SSE。网关层Spring WebFlux 多副本处理 SSE 流、限流、鉴权、路由。状态层Redis 集群承担限流计数、会话缓存、分布式锁、熔断状态。上游层多个模型服务通过熔断器和分阶段超时隔离。编排层K8s Deployment HPA 优雅关闭 自定义指标。观测层Prometheus 指标 链路追踪 结构化日志。这套东西跑起来才算真正到了云上生产封帝的境界。但我要泼一盆冷水没有一劳永逸的架构。模型在变、流量在变、云厂商的 LB 行为也在变网关的配置需要持续调优。我个人在实际操作中的体会是SSE 的超时和心跳是最高频的故障源Redis 的连接池和大 key 是第二高频K8s 的优雅关闭和探针是第三高频。把这三块盯死网关的稳定性就能上一个台阶。至于那些花哨的功能等基础稳了再谈。最后分享一个小技巧上线前一定要做混沌测试——手动杀掉 Redis、模拟上游超时、强制缩容 Pod看网关能不能优雅应对。我在测试环境跑过一轮之后才发现优雅关闭的宽限期设短了生产上差点出事。这种测试比看一百篇文档都管用。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。