ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

基于Java与K8s的LLM网关生产实践:SSE流式响应与Redis分布式协调

基于Java与K8s的LLM网关生产实践:SSE流式响应与Redis分布式协调 1. 从“荒天帝”到生产网关这个项目到底在做什么第一次看到“荒天帝炼大模型网关-第18境-仙帝境-他化自在法-云上生产封帝”这个标题我笑了半天。用修仙境界来比喻一个 LLM Gateway 的演进过程确实很贴切——从最初能跑通一个/v1/chat/completions转发到后面要处理流式断连、多租户限流、分布式锁、K8s 滚动发布每一步都像在渡劫。而“他化自在法”这个词用得太妙了它恰好点出了大模型网关最核心的能力把不同厂商、不同协议、不同计费方式的模型化成一个统一的、对上层应用透明的接口。这个项目本质上是一个基于Java技术栈构建的LLM Gateway部署在Kubernetes上用Redis做分布式协调与缓存通过SSEServer-Sent Events向客户端推送流式响应。它解决的问题非常具体当你的业务需要同时接入多个大模型供应商或者需要在自建模型和云端模型之间做路由、降级、限流、计费、审计时直接在业务代码里写死调用逻辑会变成一场灾难。网关层就是把这些脏活累活收拢到一个独立服务里让业务侧只面对一个稳定的、语义统一的入口。适合谁看如果你是一个 Java 后端工程师正在被“流式响应老是断”“多实例部署后限流不准”“Redis 锁在 K8s 里莫名失效”这些问题折磨那这篇内容就是写给你的。我会把我在实际生产环境里踩过的坑、调过的参数、写过的核心代码逻辑尽量完整地摊开来讲。不会只讲概念而是讲清楚每一个选择背后的“为什么”。2. 整体架构设计与技术选型逻辑2.1 为什么是 Java 而不是 Go 或 Node很多人第一反应是网关这种 IO 密集型服务不是应该用 Go 或者 Node 吗Java 不是笨重吗这个判断在五年前可能成立但现在情况变了。Java 21 的虚拟线程Virtual Threads让阻塞式 IO 的吞吐量有了质的飞跃而 Spring Boot 3.x 对虚拟线程的支持已经相当成熟。更重要的是大多数企业的核心业务系统是 Java 写的网关用 Java 意味着团队不需要维护两套技术栈排查问题时不需要跨语言追踪链路。另一个现实考量是生态。Redis 的 Java 客户端 Lettuce对响应式编程和连接池管理做得非常完善Kubernetes 的 Java 客户端fabric8 或 official client在服务发现和配置监听方面也很稳定。如果你用 Go 写网关确实性能会好一些但当你需要接入公司内部的 Java 鉴权 SDK、Java 版的风控组件时跨语言调用带来的复杂度会抵消掉性能优势。我的经验是网关的瓶颈通常不在语言本身而在网络 IO 和下游模型的响应速度。大模型一次生成动辄几秒到几十秒Java 的处理开销在这个时间尺度下几乎可以忽略。2.2 网关的核心职责边界在设计之初我画了一张职责边界图明确哪些事情网关做哪些不做。这个边界如果模糊后面会无穷无尽地加需求最后网关变成一个什么都管的怪物。网关应该做的事情协议转换把不同厂商的请求格式统一成内部格式、鉴权与配额校验、请求路由与负载均衡、流式响应的透传与缓冲、调用日志与计费埋点、超时与重试策略、降级与熔断。网关不应该做的事情Prompt 工程与模板管理这属于应用层、模型微调与训练这属于模型层、复杂的业务逻辑编排这属于编排层、用户会话状态管理这属于业务层。这个边界定下来之后整个项目的代码结构就清晰了。adapter包负责协议转换router包负责路由决策limiter包负责限流stream包负责 SSE 处理audit包负责日志与计费。每个包只关心自己的事通过接口交互。2.3 部署形态为什么选择 K8s 而非裸机用Kubernetes部署网关最直接的好处是滚动发布和弹性伸缩。大模型调用有明显的波峰波谷白天请求量大凌晨几乎为零。如果跑在裸机上你得预留峰值资源成本浪费严重。K8s 的 HPAHorizontal Pod Autoscaler可以根据 CPU 或自定义指标比如队列长度自动调整 Pod 数量。但 K8s 也带来了新的问题多实例部署后本地缓存和本地锁全部失效。这就是为什么必须引入Redis做分布式协调。限流计数器要放在 Redis 里分布式锁要放在 Redis 里会话级的 SSE 连接状态也要考虑跨实例的可见性。后面我会专门讲这块的坑。3. 核心细节解析SSE 流式响应的正确处理方式3.1 SSE 协议的本质与常见误解SSE本质上就是一个长连接的 HTTP 响应Content-Type 是text/event-stream服务端持续往这个连接里写数据每条消息以data:开头以两个换行符结束。它和 WebSocket 的区别在于SSE 是单向的服务端到客户端基于普通 HTTP不需要协议升级对代理和负载均衡器更友好。但很多人第一次写 SSE 时会犯一个错误以为只要把数据写进HttpServletResponse的OutputStream就行了。实际上你必须确保几件事响应头正确设置、缓冲区及时刷新、连接不被中间层提前关闭。我见过最典型的报错就是stream disconnected before completion: idle timeout waiting for sse这个错误几乎全部来源于中间层Nginx、负载均衡器、K8s Ingress的空闲超时设置。3.2 解决 idle timeout 的完整配置链路这个问题的排查需要从客户端到服务端逐层检查。下面是我在实际生产环境中验证过的一套配置。首先是Nginx Ingress的配置。默认情况下Nginx 的proxy_read_timeout是 60 秒如果大模型超过 60 秒没有输出任何 token连接就会被切断。你需要把它调大同时关闭缓冲。apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: llm-gateway-ingress annotations: nginx.ingress.kubernetes.io/proxy-read-timeout: 600 nginx.ingress.kubernetes.io/proxy-send-timeout: 600 nginx.ingress.kubernetes.io/proxy-buffering: off nginx.ingress.kubernetes.io/proxy-http-version: 1.1 nginx.ingress.kubernetes.io/configuration-snippet: | proxy_set_header Connection ; chunked_transfer_encoding off;这里有几个关键点。proxy-buffering: off是必须的否则 Nginx 会等缓冲区满了才转发流式效果就没了。proxy-http-version: 1.1确保使用 HTTP/1.1因为 SSE 在 HTTP/2 下的行为在某些代理实现中不一致。Connection 清除连接头避免 keep-alive 干扰。然后是Spring Boot 侧的配置。如果你用的是 Spring MVC 的SseEmitter需要设置超时时间如果用的是 WebFlux 的FluxServerSentEvent则需要在返回的 Flux 上做超时控制。GetMapping(value /v1/chat/stream, produces MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter streamChat(RequestBody ChatRequest request) { SseEmitter emitter new SseEmitter(600_000L); // 10分钟超时 executor.execute(() - { try { chatService.streamCompletion(request, chunk - { emitter.send(SseEmitter.event() .data(chunk, MediaType.APPLICATION_JSON)); }); emitter.complete(); } catch (Exception e) { emitter.completeWithError(e); } }); return emitter; }注意SseEmitter的超时时间必须大于下游模型的最大响应时间。如果你接入的模型最长可能跑 5 分钟那这里至少要设 6 分钟。但也不能无限大否则连接泄漏会耗尽线程池。3.3 心跳机制让连接“活着”即使调大了超时有些中间层还是会因为“没有数据传输”而判定连接空闲。解决办法是定期发送心跳注释。SSE 协议允许以:开头的行作为注释客户端会忽略它但中间层会认为连接活跃。ScheduledExecutorService heartbeatScheduler Executors.newScheduledThreadPool(1); public void startHeartbeat(SseEmitter emitter) { ScheduledFuture? future heartbeatScheduler.scheduleAtFixedRate(() - { try { emitter.send(SseEmitter.event().comment(heartbeat)); } catch (IOException e) { // 连接已断开取消心跳 } }, 15, 15, TimeUnit.SECONDS); emitter.onCompletion(() - future.cancel(true)); emitter.onTimeout(() - future.cancel(true)); }15 秒是一个比较安全的间隔。太频繁会增加不必要的网络开销太稀疏则可能被 30 秒空闲超时的中间层切断。这个值需要根据你的实际链路来调我一般建议先设 15 秒观察日志后再优化。4. Redis 在网关中的三类核心用法与避坑4.1 分布式限流滑动窗口 vs 令牌桶网关必须做限流否则某个租户的突发流量会把下游模型打挂。在单机时代用 Guava 的RateLimiter就够了。但在 K8s 多实例部署下每个 Pod 都有自己的计数器限流就不准了。比如你设置每分钟 100 次部署了 5 个 Pod实际可能放过 500 次。用Redis做分布式限流常见的有两种算法。固定窗口最简单用INCR加EXPIRE就能实现但存在临界问题如果请求集中在窗口边界实际速率可能是限制值的两倍。滑动窗口更精确可以用 Redis 的 ZSet 实现把每次请求的时间戳作为 score 存进去每次校验时先移除窗口外的记录再统计数量。public boolean tryAcquire(String tenantId, int limit, int windowSeconds) { String key rate_limit: tenantId; long now System.currentTimeMillis(); long windowStart now - windowSeconds * 1000L; return redisTemplate.execute((RedisCallbackBoolean) connection - { byte[] rawKey key.getBytes(StandardCharsets.UTF_8); connection.zRemRangeByScore(rawKey, 0, windowStart); Long count connection.zCard(rawKey); if (count ! null count limit) { return false; } connection.zAdd(rawKey, now, String.valueOf(now).getBytes()); connection.expire(rawKey, windowSeconds 1); return true; }); }这里有个坑zRemRangeByScore和zCard之间不是原子的。在高并发下两个请求可能同时通过校验。如果你对限流精度要求极高需要用 Lua 脚本把这几步包起来利用 Redis 的单线程特性保证原子性。4.2 分布式锁Redisson 的正确打开方式网关在做什么事情的时候需要分布式锁比如当某个模型供应商的 API Key 需要轮换时多个实例可能同时去刷新 Token这时候需要一把锁保证只有一个实例去刷新其他实例等待结果。又比如计费模块在结算某个租户的账单时需要防止并发重复扣费。用 Redis 做分布式锁最怕的就是锁提前失效。比如你设置了 30 秒超时但业务执行了 35 秒锁自动释放了另一个线程就拿到了锁导致临界区代码并发执行。Redisson的看门狗机制解决了这个问题它会在锁持有期间定期续期只要业务没结束锁就不会过期。Autowired private RedissonClient redissonClient; public void refreshApiKey(String providerId) { RLock lock redissonClient.getLock(lock:apikey: providerId); try { if (lock.tryLock(5, 30, TimeUnit.SECONDS)) { // 执行刷新逻辑 doRefresh(providerId); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } } }注意isHeldByCurrentThread()这个判断。如果不加当锁因为超时被其他线程持有时当前线程调用unlock()会抛出IllegalMonitorStateException。这个异常在日志里很常见但很多人不知道原因。4.3 缓存模型元数据序列化方式的选择网关需要频繁读取模型配置比如某个模型的 endpoint、超时时间、计费单价。这些数据变化不频繁适合放在 Redis 里缓存。但Redis 序列化方式选不对会带来两个问题一是存储空间浪费二是反序列化时类版本不一致导致报错。我推荐用GenericJackson2JsonRedisSerializer而不是默认的 JDK 序列化。JDK 序列化会把类的全限定名写进去一旦你改了包名或类名旧数据就反序列化失败了。JSON 序列化可读性好跨语言兼容而且体积更小。Bean public RedisTemplateString, Object redisTemplate(RedisConnectionFactory factory) { RedisTemplateString, Object template new RedisTemplate(); template.setConnectionFactory(factory); GenericJackson2JsonRedisSerializer serializer new GenericJackson2JsonRedisSerializer(); template.setKeySerializer(new StringRedisSerializer()); template.setValueSerializer(serializer); template.setHashKeySerializer(new StringRedisSerializer()); template.setHashValueSerializer(serializer); template.afterPropertiesSet(); return template; }踩过的坑GenericJackson2JsonRedisSerializer默认会在 JSON 里写入class字段来记录类型信息。如果你的模型类有继承关系反序列化时可能拿到错误的子类型。解决办法是给需要缓存的类加上明确的类型注解或者改用Jackson2JsonRedisSerializer指定具体类型。5. 生产环境实操从零到一部署网关5.1 本地开发环境的最小依赖在本地跑通网关你不需要完整的 K8s 集群。一个 Docker Compose 文件就够了。下面是我常用的本地开发配置。version: 3.8 services: redis: image: redis:7.2-alpine ports: - 6379:6379 command: redis-server --appendonly yes --requirepass dev123 volumes: - redis-data:/data gateway: build: . ports: - 8080:8080 environment: - SPRING_REDIS_HOSTredis - SPRING_REDIS_PORT6379 - SPRING_REDIS_PASSWORDdev123 depends_on: - redis volumes: redis-data:这里开启了 AOF 持久化因为限流计数器和锁在重启后如果丢失可能会导致短暂的限流失效。开发环境用密码认证是为了模拟生产环境的安全配置避免代码里硬编码无密码连接。5.2 K8s 生产部署的关键配置生产环境的 Deployment 配置有几个地方需要特别注意。首先是就绪探针和存活探针的区分。就绪探针决定 Pod 是否加入 Service 负载均衡存活探针决定 Pod 是否需要重启。网关启动时需要预热 Redis 连接池和加载模型配置这段时间不应该接收流量。apiVersion: apps/v1 kind: Deployment metadata: name: llm-gateway spec: replicas: 3 strategy: type: RollingUpdate rollingUpdate: maxSurge: 1 maxUnavailable: 0 template: spec: containers: - name: gateway image: llm-gateway:1.0.0 ports: - containerPort: 8080 readinessProbe: httpGet: path: /actuator/health/readiness port: 8080 initialDelaySeconds: 20 periodSeconds: 5 livenessProbe: httpGet: path: /actuator/health/liveness port: 8080 initialDelaySeconds: 40 periodSeconds: 10 resources: requests: memory: 512Mi cpu: 500m limits: memory: 1Gi cpu: 1000mmaxUnavailable: 0确保滚动发布时始终有足够的实例在服务。initialDelaySeconds给应用留出启动时间避免还没初始化完就被探针判定为失败而反复重启。5.3 灰度发布与流量切换网关作为所有模型调用的入口发布风险很高。我一般采用金丝雀发布先部署一个新版本的 Pod通过 Istio 或 Nginx Ingress 的权重配置把 5% 的流量导到新版本观察错误率和延迟指标确认无误后再逐步扩大比例。apiVersion: networking.istio.io/v1beta1 kind: VirtualService metadata: name: llm-gateway-vs spec: hosts: - llm-gateway http: - route: - destination: host: llm-gateway subset: stable weight: 95 - destination: host: llm-gateway subset: canary weight: 5如果没有 Istio用 Nginx Ingress 的canary-weight注解也能实现类似效果。关键是监控指标要跟上否则灰度发布就是盲发。我通常关注三个指标SSE 连接建立成功率、首 Token 延迟、流式响应完整率。6. 常见问题与排查技巧实录6.1 SSE 连接频繁断开的问题排查这个问题我遇到过至少三种不同的根因排查时需要逐层排除。现象可能原因排查方法解决方案60秒准时断开Nginx 默认超时查看 Nginx 错误日志调大proxy_read_timeout30秒无数据断开负载均衡器空闲超时抓包看最后一条数据时间增加心跳机制随机断开Pod 被驱逐或重启查看 K8s 事件调整资源限制和探针客户端收到部分数据后断开缓冲区未刷新检查代码是否调用 flush确保每次 send 后 flush一个容易被忽略的点如果你在 K8s 里用了 Service 的sessionAffinity: None默认客户端的 SSE 连接可能在不同 Pod 之间漂移。虽然 SSE 是长连接理论上不会漂移但如果连接中断后客户端重连可能连到不同的 Pod。如果你的会话状态存在本地内存里就会出问题。解决办法是把会话状态放到 Redis 里或者开启sessionAffinity: ClientIP。6.2 Redis 命令超时的典型场景redis command timed out; nested exception is io.lettuce.core.RedisCommandTimeoutException这个报错在网关日志里出现的频率很高。原因通常有三个一是 Redis 服务器负载过高二是网络延迟三是 Lettuce 连接池配置不合理。Lettuce 默认的命令超时是 60 秒这个值太长了。如果 Redis 真的挂了你的请求会挂起 60 秒才报错用户体验极差。我一般把它调到 2 到 3 秒配合重试机制。spring: redis: lettuce: pool: max-active: 16 max-idle: 8 min-idle: 4 max-wait: 1000ms timeout: 3000ms connect-timeout: 2000ms连接池的max-active不是越大越好。每个连接都会占用 Redis 的文件描述符和内存。16 个连接对于大多数网关场景足够了。如果你发现连接不够用先检查是不是有连接泄漏比如没有正确关闭连接而不是盲目调大。6.3 多实例部署下限流失效的排查如果你发现限流在单实例下正常多实例下就失效了大概率是以下原因之一。第一限流 key 没有包含实例无关的维度比如用了System.currentTimeMillis()做 key 的一部分。第二Redis 的INCR和EXPIRE不是原子操作在并发下EXPIRE可能没执行成功导致 key 永不过期计数器一直累加。第三用了本地缓存做限流判断没有走 Redis。排查方法很简单在限流逻辑里打日志把 key 和当前计数值打出来观察多个 Pod 的日志是否共享同一个计数器。如果每个 Pod 的计数值是独立的说明 key 不一致或者根本没走 Redis。7. 一些关于网关演进的个人体会这个项目从最初的一个简单转发服务到现在能支撑生产环境的网关中间经历了无数次重构。我最大的体会是网关的复杂度不在于代码量而在于边界情况的处理。一个正常的请求转发可能只需要 50 行代码但处理超时、重试、降级、限流、鉴权、日志、计费、灰度、监控代码量会膨胀到几千行。另一个体会是关于“他化自在法”这个比喻。大模型网关确实像一种变化之术对上层应用它化成一个统一的 OpenAI 兼容接口对下层模型它化成各个厂商的原生协议对运维它化成一组可观测的指标和日志对财务它化成一张张计费账单。每一层看到的都是不同的形态但底层是同一套逻辑。如果你正在考虑自建网关我的建议是先从最小可用版本开始只做协议转换和 SSE 透传把流式响应跑通。然后再逐步加限流、加鉴权、加计费。不要一上来就设计一个“什么都能做”的架构那样大概率会过度设计最后连最基本的流式响应都调不稳。先把stream disconnected before completion这个问题彻底解决你就已经超过一半的同行了。
返回列表