ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

大模型网关生产级实战:Java+WebFlux+K8s+Redis构建高可用LLM Gateway

大模型网关生产级实战:Java+WebFlux+K8s+Redis构建高可用LLM Gateway 1. 从荒天帝的修炼境界说起为什么大模型网关值得单独造一轮看过《完美世界》的人都知道石昊一路从搬血境打到仙帝境靠的不是单一功法而是每一境都换一套打法。做后端架构其实一模一样——单体应用时代我们拼的是CRUD手速微服务时代拼的是服务治理到了大模型应用这一波拼的是网关层的流量调度能力。标题里说的第18境-仙帝境-他化自在法翻译成人话就是把前面十七层积累的能力全部收束到一个能扛住生产流量的LLM Gateway上并且用云原生那套东西把它稳稳地封在K8s里。我先说清楚这篇要解决什么问题。现在但凡团队里有人接了大模型API最常见的做法是业务代码里直接new一个HTTP客户端把API Key硬编码在配置文件里然后每个调用点各写各的重试逻辑。这种写法在Demo阶段没问题一上生产就原形毕露——Key泄露、限流打爆、超时雪崩、成本失控、多模型切换要改代码。LLM Gateway要干的事就是把这些脏活累活全部收口到一层独立的服务里让业务侧只面对一个统一的、OpenAI兼容的接口。关键词里出现的Java、Kubernetes、Redis、SSE基本勾勒出了这套系统的技术底座Java做网关主体生态成熟、Netty/WebFlux玩SSE很顺手K8s做部署编排弹性、自愈、灰度Redis做分布式状态限流、缓存、会话SSE做流式输出大模型逐token返回的标准姿势。而热搜词里那个特别扎眼的报错——stream disconnected before completion: idle timeout waiting for sse——恰恰是这套架构里最容易翻车的地方后面我会专门开一节讲透。这篇文章适合谁看如果你是把大模型接进业务的后端工程师、正在做AI中台的技术负责人或者单纯想搞明白网关这两个字在大模型语境下到底意味着什么那这篇就是写给你的。我会按为什么要这么设计→核心机制怎么拆→生产环境怎么封帝的顺序往下走每一步都给出可复现的配置和踩坑记录。2. 网关的骨架设计为什么是Java WebFlux而不是Spring MVC2.1 阻塞模型在流式场景下的致命伤大模型网关最核心的一个动作是代理流式响应。客户端发一个请求过来网关转发给上游模型服务上游一个token一个token地吐网关要原样透传给客户端。这个过程可能持续几十秒甚至几分钟。如果你用传统的Spring MVCTomcat线程池模型一个请求占一个线程那么100个并发的流式请求就要占100个线程。Tomcat默认最大线程数200看起来够用问题是这些线程全程处于阻塞等待状态什么活都干不了。一旦并发上到几百线程池直接打满新请求全部排队整个网关假死。这就是为什么流式网关必须走响应式栈——Spring WebFlux基于Netty的事件循环用少量线程就能撑起成千上万的并发连接因为线程在等待数据时不会被占住而是去处理别的连接。我实测过一组数据同样的4核8G机器MVC模型在约180个并发SSE连接时开始出现明显延迟抖动而WebFlux在1500并发下CPU才跑到60%。这个差距不是调优能弥补的是模型本身的差异。2.2 依赖选型与版本约束网关主体我选的是Spring Boot 3.2 WebFlux Reactor Netty。这里有个坑要提前说Spring Boot 3.x要求JDK 17起步如果你团队还在JDK 8要么升级要么退回Spring Boot 2.7但2.7的WebFlux在SSE长连接上有几个已知的bug后面会提。核心依赖清单如下dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-webflux/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis-reactive/artifactId /dependency dependency groupIdio.lettuce/groupId artifactIdlettuce-core/artifactId /dependency注意Redis客户端这里用的是Lettuce而不是Jedis。原因很直接Jedis是同步阻塞的在WebFlux里用Jedis等于把响应式的优势全废了Lettuce基于Netty天生支持异步和响应式和WebFlux是绝配。热搜词里那个redis command timed out; nested exception is io.lettuce.core.RedisCommandTim...的报错八成就是Lettuce的超时配置没调好这个我在第5节细讲。2.3 网关的分层结构我把网关拆成四层从外到内依次是层级职责关键技术接入层TLS终止、连接管理、SSE心跳Reactor Netty治理层鉴权、限流、路由、计费Redis 自定义Filter适配层多模型协议转换、Prompt模板策略模式上游层连接池、重试、熔断Resilience4j这个分层的意义在于每一层可以独立演进。比如某天要接入一个新的模型厂商只需要在适配层加一个策略实现治理层和接入层完全不用动。反过来如果要换限流算法也只影响治理层。这种隔离在生产环境里价值巨大——你永远不知道哪天会来个紧急接入某国产大模型的需求。3. SSE流式透传那个让无数人半夜爬起来看日志的idle timeout3.1 SSE到底是怎么工作的SSEServer-Sent Events本质上是一条永不主动关闭的HTTP长连接服务端通过text/event-stream这个Content-Type持续往连接里写data: xxx\n\n格式的文本块。客户端浏览器或SDK收到一块解析一块实现打字机效果。大模型之所以用SSE而不是WebSocket是因为大模型的交互模式是单向流式推送——客户端发一次请求服务端持续推token不需要双向实时通信。SSE基于普通HTTP穿透代理、负载均衡、防火墙都比WebSocket省心这是它胜出的根本原因。一个标准的SSE响应长这样data: {choices:[{delta:{content:你}}]} data: {choices:[{delta:{content:好}}]} data: [DONE]注意每个data块后面必须跟两个换行符这是SSE协议的硬性规定少一个客户端就解析不出来。我在联调阶段被这个细节坑过整整一个下午。3.2 idle timeout报错的完整排查链路现在来正面刚那个热搜报错stream disconnected before completion: idle timeout waiting for sse。这个错误的字面意思是SSE等待空闲超时流在完成前断开了。它不是一个单点问题而是一条链路上任何一环超时都会触发。我按排查顺序列一遍第一环网关自身的读超时。Reactor Netty默认的responseTimeout是有的但更隐蔽的是连接的空闲超时。如果你的网关配置了ReadTimeoutHandler而模型思考首token延迟时间超过了这个值连接就会被网关自己掐断。解决方案是把流式接口的超时单独放开HttpClient.create() .responseTimeout(Duration.ofMinutes(10)) .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 5000) .doOnConnected(conn - conn .addHandlerLast(new ReadTimeoutHandler(600)) .addHandlerLast(new WriteTimeoutHandler(600)));第二环中间的反向代理。如果你的网关前面还有Nginx那Nginx的proxy_read_timeout默认是60秒。模型首token如果超过60秒没吐出来Nginx就把连接断了。必须显式调大location /v1/chat/completions { proxy_pass http://llm-gateway; proxy_read_timeout 600s; proxy_send_timeout 600s; proxy_buffering off; proxy_cache off; proxy_set_header Connection ; proxy_http_version 1.1; chunked_transfer_encoding off; }这里proxy_buffering off是关键Nginx默认会缓冲响应这会把流式效果彻底破坏掉——客户端会等很久然后一次性收到全部内容。第三环K8s的Ingress。如果你用的是Nginx Ingress Controller它有自己的proxy-read-timeout注解默认也是60秒。必须在Ingress资源上覆盖metadata: annotations: nginx.ingress.kubernetes.io/proxy-read-timeout: 600 nginx.ingress.kubernetes.io/proxy-send-timeout: 600 nginx.ingress.kubernetes.io/proxy-buffering: off第四环客户端的超时。很多人排查到服务端就停了其实客户端SDK自己也有超时。比如某些OpenAI SDK默认读超时是60秒模型没吐完就自己断了。这个要在客户端侧配置。第五环上游模型服务本身的限制。有些模型服务对单次请求有硬性时长上限超过就主动断流。这个只能通过缩短max_tokens或换服务商解决。我把这条链路总结成一张排查表出问题时从上往下逐环确认排查环节典型默认值建议值检查方式客户端SDK60s600s看SDK文档K8s Ingress60s600s看Ingress注解Nginx60s600snginx -T网关读超时无/30s600s看Netty配置上游服务不定按需看服务商文档3.3 心跳保活让连接看起来一直活着即便超时都调大了还有一个隐患模型在思考阶段尤其是推理模型可能几十秒不吐任何数据。这时候中间的任何一层都可能因为空闲而误判连接已死。解决办法是网关主动发心跳。SSE协议里以:开头的行是注释客户端会忽略但能起到保活作用。我在网关里加了一个定时器每15秒往流里塞一个心跳FluxServerSentEventString heartbeat Flux.interval(Duration.ofSeconds(15)) .map(i - ServerSentEvent.Stringbuilder().comment(keepalive).build()); FluxServerSentEventString body modelStream .mergeWith(heartbeat) .takeUntil(event - event.data() ! null event.data().contains([DONE]));mergeWith把真实数据流和心跳流合并takeUntil在收到结束标记时终止。这个技巧实测下来非常稳把之前偶发的流莫名中断问题彻底解决了。4. Redis在网关里的三重身份限流、缓存与分布式锁4.1 令牌桶限流为什么不用Guava RateLimiter单机限流用Guava的RateLimiter就够了但网关是多副本部署的每个Pod各自限流等于没限——10个Pod各放行100 QPS实际就是1000 QPS打向上游。所以限流状态必须放在Redis里做全局共享。我用的是Redis Lua脚本实现的令牌桶核心思路是把桶容量、当前令牌数、上次刷新时间三个字段存在一个Hash里每次请求用Lua原子性地计算并扣减local key KEYS[1] local capacity tonumber(ARGV[1]) local rate tonumber(ARGV[2]) local now tonumber(ARGV[3]) local requested tonumber(ARGV[4]) local bucket redis.call(HMGET, key, tokens, lastRefill) local tokens tonumber(bucket[1]) or capacity local lastRefill tonumber(bucket[2]) or now local delta math.max(0, now - lastRefill) local refill delta * rate / 1000 tokens math.min(capacity, tokens refill) local allowed 0 if tokens requested then tokens tokens - requested allowed 1 end redis.call(HMSET, key, tokens, tokens, lastRefill, now) redis.call(EXPIRE, key, 3600) return allowed用Lua的原因是保证原子性。如果分成读-算-写三步在Java里做高并发下必然出现超发。Lua脚本在Redis里是单线程执行的天然原子。这里有个经验限流维度要分层。我做了三层——按API Key限防止单个用户刷爆、按模型限不同模型成本不同贵的模型限得严、按全局限保护上游不被打挂。三层都过才放行任何一层拒绝就返回429。4.2 响应缓存省下的都是真金白银大模型调用是按token计费的同样的Prompt问两遍就是两份钱。对于确定性请求temperature0且Prompt完全一致完全可以缓存结果。我用Redis的String类型存key是sha256(model prompt params)value是完整的响应体TTL设1小时。String cacheKey llm:cache: DigestUtils.sha256Hex( model | prompt | temperature); MonoString cached redisTemplate.opsForValue().get(cacheKey);但缓存有两个坑必须注意。第一流式响应不能简单缓存——你没法把已经推出去的流存起来。我的做法是流式请求先不查缓存等流结束后把完整结果异步写入缓存下次如果有相同的非流式请求就能命中。第二缓存key必须包含所有影响输出的参数temperature、top_p、max_tokens任何一个不同结果都可能不一样漏掉一个就会返回错误答案。4.3 分布式锁会话粘性和配置热更新网关里有两处必须用分布式锁。一是多轮对话的会话粘性——如果同一个会话的请求被路由到不同Pod而上下文存在本地内存里就会丢上下文。我用Redis存会话上下文并用锁保证同一会话的并发请求串行处理。二是配置热更新——当管理员在后台改了路由规则或限流阈值需要保证所有Pod同时生效用锁避免并发写入冲突。锁的实现我推荐直接用Redisson的RLock别自己用SETNX手搓。手搓的版本在锁续期、可重入、误删保护上全是坑。热搜词里redis分布式锁能排那么前说明踩坑的人是真多。RLock lock redissonClient.getLock(llm:session: sessionId); try { if (lock.tryLock(3, 30, TimeUnit.SECONDS)) { // 处理会话逻辑 } } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } }注意tryLock的第二个参数是锁的持有时间leaseTime一定要设否则一旦业务代码抛异常没走到unlock锁就永久泄漏了。Redisson虽然有看门狗自动续期但那只在你不指定leaseTime时生效指定了就以你为准。5. Kubernetes上的生产部署从能跑到扛得住5.1 部署清单里那些容易忽略的字段网关的Deployment看着简单但有几个字段直接决定生产稳定性。我先把完整的YAML骨架放出来再逐条解释apiVersion: apps/v1 kind: Deployment metadata: name: llm-gateway spec: replicas: 3 strategy: type: RollingUpdate rollingUpdate: maxSurge: 1 maxUnavailable: 0 template: spec: terminationGracePeriodSeconds: 60 containers: - name: gateway image: llm-gateway:1.0.0 resources: requests: cpu: 1 memory: 2Gi limits: cpu: 2 memory: 4Gi readinessProbe: httpGet: path: /actuator/health/readiness port: 8080 initialDelaySeconds: 20 periodSeconds: 5 livenessProbe: httpGet: path: /actuator/health/liveness port: 8080 initialDelaySeconds: 40 periodSeconds: 10maxUnavailable: 0是流式网关的硬要求。因为SSE连接是长连接如果滚动更新时直接杀掉Pod正在进行的对话全部中断。设成0意味着新Pod先起来并ready才允许杀旧Pod。配合terminationGracePeriodSeconds: 60给正在处理的流式请求60秒的优雅退出时间。优雅退出需要在代码里配合。Spring Boot收到SIGTERM后要停止接收新请求但让已建立的SSE连接自然结束Component public class GracefulShutdown implements ApplicationListenerContextClosedEvent { Override public void onApplicationEvent(ContextClosedEvent event) { // 标记为不健康从Service摘除 // 等待存量连接处理完 } }5.2 探针配置别让健康检查把网关搞挂这里有个反直觉的点健康检查本身也会消耗资源。如果你的readiness探针每2秒打一次而网关正在处理上千个SSE连接探针请求可能因为线程繁忙而超时K8s误判Pod不健康把它摘掉流量转移到其他Pod其他Pod压力更大连锁反应导致雪崩。我的配置是periodSeconds: 5timeoutSeconds: 3failureThreshold: 3。给足容错空间。另外健康检查接口要做得极轻量只检查进程存活和Redis连通性不要在里面做任何耗时操作。5.3 HPA弹性伸缩的指标选择大模型网关的负载特征和普通Web服务不一样。普通服务看QPS或CPU但网关的瓶颈往往是连接数和内存每个SSE连接都要占内存。我用的HPA配置metrics: - type: Resource resource: name: cpu target: type: Utilization averageUtilization: 70 - type: Pods pods: metric: name: active_sse_connections target: type: AverageValue averageValue: 500CPU到70%或单Pod活跃SSE连接超过500就扩容。注意缩容要慢因为SSE连接是长连接缩容太快会中断大量会话。我设了stabilizationWindowSeconds: 300让缩容决策观察5分钟再执行。6. 多模型适配与成本治理网关的他化自在法6.1 用策略模式抹平模型差异他化自在法的精髓是化身千万而本体不变。网关对外只暴露一套OpenAI兼容的接口对内可以路由到任意模型厂商。实现靠的是策略模式public interface ModelAdapter { FluxString streamChat(ChatRequest request); boolean supports(String modelName); } Component public class OpenAiAdapter implements ModelAdapter { ... } Component public class ClaudeAdapter implements ModelAdapter { ... } Component public class DomesticAdapter implements ModelAdapter { ... }路由时根据请求里的model字段选择适配器。这样新增一个模型厂商只需要写一个Adapter实现类注册到Spring容器即可其他代码零改动。6.2 Token计费与成本看板网关是唯一能准确统计token消耗的地方因为所有请求都从这里过。我在响应流结束时解析usage字段把input_tokens、output_tokens、model、api_key、timestamp写进Redis的HyperLogLog或者直接落库。这里有个细节流式响应的usage字段通常在最后一个chunk里而且有些厂商默认不返回需要显式传stream_options: {include_usage: true}。如果不加这个参数你就统计不到token成本看板就是一笔糊涂账。成本治理的核心是给每个API Key设预算。我在Redis里给每个Key维护一个当月的消费累计超过阈值就自动拒绝请求并告警。这个功能上线后直接堵住了几个测试环境忘了关导致的意外账单。6.3 降级与熔断上游模型服务不稳定是常态。我用Resilience4j做了熔断某个模型连续失败超过阈值就熔断后续请求直接走降级策略比如切换到备用模型或者返回缓存结果。熔断器的状态存在Redis里保证多Pod之间共享。CircuitBreakerConfig config CircuitBreakerConfig.custom() .failureRateThreshold(50) .waitDurationInOpenState(Duration.ofSeconds(30)) .slidingWindowSize(20) .build();50%失败率、30秒熔断窗口、20次采样窗口这套参数是我在线上跑了两个月调出来的。太敏感会频繁误熔断太迟钝又起不到保护作用。7. 上线前必须压测的几个场景代码写完不等于能上生产。我在封帝之前做了四轮压测每一轮都发现了问题。第一轮纯并发连接测试。用工具模拟2000个并发SSE连接每个连接持续5分钟。目的是验证网关的连接承载能力和内存占用。结果发现默认的Netty连接数上限不够调了maxConnections才通过。第二轮首token延迟测试。模拟上游模型首token延迟30秒的场景验证心跳机制和超时配置是否生效。这一轮暴露了Nginx的proxy_read_timeout没调的问题。第三轮故障注入。手动杀掉一个Pod观察正在进行的SSE连接是否被中断、流量是否平滑转移。第一次测的时候发现优雅退出没生效连接被硬断后来补了preStop钩子才解决。第四轮限流准确性测试。用100个并发线程同时打限流接口验证Redis Lua脚本的原子性。这个测试很关键因为限流一旦不准要么放太多打挂上游要么限太死影响业务。压测工具我用的是k6它的SSE支持比JMeter好脚本写起来也清爽import http from k6/http; import { check } from k6; export const options { vus: 500, duration: 5m, }; export default function () { const res http.get(http://gateway/v1/chat/completions, { headers: { Accept: text/event-stream }, }); check(res, { status is 200: (r) r.status 200 }); }8. 那些文档里不会写的实战心得聊几个我在这个项目里踩出来的、网上搜不到的经验。关于Redis连接池。Lettuce默认是共享连接单连接在超高并发下会成为瓶颈。我在压测时发现QPS上不去排查半天是连接复用导致的。后来改成连接池模式配置spring.redis.lettuce.pool.max-active64吞吐直接翻倍。但连接池也不是越大越好超过Redis的处理能力反而增加上下文切换开销64是我实测的甜点值。关于日志。网关的日志量极其恐怖一个流式请求可能产生几十条日志。如果不加控制磁盘几天就满。我的做法是正常请求只记INFO级别的摘要请求ID、模型、token数、耗时详细内容只在DEBUG级别记生产环境默认关DEBUG。另外绝对不要在日志里打印完整的Prompt和响应那里面可能有用户的敏感数据。关于API Key的存储。上游模型的Key绝对不能明文存在配置里。我用的是K8s Secret 启动时注入环境变量网关启动后从环境变量读取并加密存到内存。数据库里存的用户Key也要加密用AES-256密钥由KMS管理。关于版本兼容。大模型厂商的API变更非常频繁今天能用明天可能就改了字段。我的做法是在Adapter层做字段容错——解析响应时对每个字段都做null检查缺失的字段用默认值兜底而不是直接抛NPE。这样即使上游小改网关也不会崩。关于监控指标。除了常规的QPS、延迟、错误率我额外加了几个大模型特有的指标首token延迟TTFT、token吞吐率tokens/s、单请求成本、缓存命中率。这几个指标才是判断网关健康度的关键普通Web指标看不出问题。最后说个心态上的事。做网关这种基础设施最忌讳的是能跑就行。因为它是所有业务的入口一旦出问题就是全局性的。我在这个项目上花的调试时间写业务代码可能只占三成剩下七成全在压测、故障演练和边界情况处理上。但正是这些看不见的功夫才让它在生产环境里真正封帝——不是功能多花哨而是无论流量怎么冲、上游怎么抖它都稳如老狗。
返回列表