Java大模型网关云上生产部署:K8s与Redis实战
1. 从"荒天帝"说起:一个LLM网关的封帝之路
"荒天帝炼大模型网关"这个系列我追了很久,从第1境一路写到第18境,每一境都在解决一个具体的工程问题。到了"仙帝境"这一层,核心命题就变成了——怎么把一套在本地跑得通的大模型网关,真正搬到云上生产环境,扛住真实流量。这个阶段的关键词是Java、LLM Gateway、Kubernetes、Redis、SSE,说白了就是:用Java写网关核心,用K8s做编排,用Redis做状态与限流,用SSE做流式输出,最后在云上完成一次"他化自在法"式的生产级部署。
为什么叫"他化自在法"?因为到了生产环境,网关不再是单打独斗的一个进程,它要"化"出多个副本、多个租户、多条链路,每个副本都能独立处理请求,又能共享限流计数、会话状态、模型路由配置。这种"化身千万而本体不散"的能力,正是K8s加Redis组合能提供的。而"封帝"这个词,指的是这套系统终于达到了可以对外稳定提供服务的状态——不是demo,不是玩具,是能扛住并发、能观测、能回滚、能扩容的生产系统。
这篇文章适合谁看?如果你是一个Java后端工程师,正在做AI相关的中间件,或者你手上有一个大模型调用服务需要从单机搬到集群,那这篇内容基本就是为你写的。我会把整个网关的设计思路、核心代码结构、K8s部署细节、Redis在其中的角色、SSE流式输出的坑,以及生产环境踩过的雷,全部摊开讲。不玩虚的,直接上干货。
先说清楚这个网关到底解决什么问题。大模型调用和普通HTTP接口最大的区别有三个:第一,响应是流式的,一个请求可能持续几十秒甚至几分钟;第二,模型调用成本高,需要精细的限流和配额管理;第三,上游模型服务可能不稳定,需要重试、降级、熔断。一个生产级LLM Gateway必须同时处理这三件事,而"仙帝境"要做的,就是让这三件事在K8s集群里稳定运转。
2. 网关整体架构设计与技术选型逻辑
2.1 为什么是Java而不是Go或Python
很多人第一反应是:网关这种IO密集型的东西,不应该用Go吗?Python不是有FastAPI吗?我当初也纠结过,但最后选Java,理由很实在。
第一,团队技术栈。如果你的团队本来就是Java为主,用Java写网关意味着任何人都能维护,不需要额外招Go工程师。第二,生态成熟度。Spring Boot加WebFlux处理SSE流式响应,配合Reactor的背压机制,实际上非常稳。第三,Redis客户端Lettuce本身就是Java生态里最成熟的响应式Redis客户端之一,和WebFlux天然契合。第四,可观测性。Micrometer加Prometheus加Grafana这套组合在Java里是开箱即用的,Go和Python虽然也有,但Java的成熟度确实高一个档次。
当然Java也有代价。JVM启动慢,内存占用高,在K8s里做快速扩缩容时不如Go灵活。我的应对方式是:用GraalVM做native image编译,把启动时间从8秒压到1.5秒以内,内存从512MB压到200MB左右。这个后面部署章节会详细讲。
2.2 网关的核心分层
整个网关我分成四层,从上到下依次是:
- 接入层:负责HTTP/SSE协议处理,鉴权,请求解析。用Spring WebFlux的RouterFunction实现,不阻塞线程。
- 路由层:根据租户、模型名、请求特征,决定把请求转发到哪个上游模型服务。路由规则存在Redis里,支持热更新。
- 治理层:限流、配额、熔断、重试、降级。这是网关最核心的部分,也是Redis用得最多的地方。
- 适配层:把统一的内部请求格式,转换成各个模型厂商的API格式(OpenAI、Anthropic、国内各家),再把响应转回来。
这四层之间通过Reactor的Mono/Flux串联,整个链路是响应式的,没有阻塞点。这一点非常关键,因为SSE连接可能持续几分钟,如果用传统的Servlet线程模型,一个连接占一个线程,1000个并发就把线程池打满了。WebFlux用事件循环,几千个并发连接只占少量线程。
2.3 Redis在网关中的三重角色
Redis在这个架构里不是简单的缓存,它承担了三个关键角色:
角色一:分布式限流计数器。每个租户、每个模型、每个API Key都有独立的限流窗口。用Redis的滑动窗口算法(基于ZSET或者Lua脚本实现),保证多个网关副本之间计数一致。这里有个坑:如果用INCR加EXPIRE的固定窗口,边界处会有双倍流量问题,必须用滑动窗口或者令牌桶。
角色二:路由配置与会话状态存储。路由规则、模型映射、租户配置全部存在Redis里,网关启动时拉取,运行时监听变更。会话状态指的是多轮对话的上下文ID映射,因为SSE连接可能断线重连,需要能恢复上下文。
角色三:分布式锁与幂等控制。某些操作比如配置热更新、配额重置,需要保证集群内只有一个副本执行,用Redis的SET NX EX实现分布式锁。请求幂等用Redis记录requestId,防止重复扣费。
2.4 K8s编排的关键决策
为什么用K8s而不是Docker Compose或者直接跑在虚机上?因为生产环境需要:滚动更新不中断服务、自动扩缩容应对流量波峰、健康检查自动摘除故障副本、配置和密钥的集中管理。这些K8s都原生支持。
但K8s也带来了新问题:SSE长连接和K8s的负载均衡、超时设置、优雅关闭都有冲突。比如默认的Ingress超时是60秒,而SSE连接可能持续5分钟,不改配置的话连接会被强制断开。这些细节后面会专门讲。
3. 核心细节解析:SSE流式输出与Redis限流的实现要点
3.1 SSE在WebFlux中的正确打开方式
SSE(Server-Sent Events)的本质是:客户端发一个HTTP请求,服务端保持连接不关闭,持续往响应体里写data: xxx\n\n格式的文本。在WebFlux里,返回一个Flux<ServerSentEvent<String>>就行,框架会自动处理。
但实际生产中有几个必须注意的点:
第一,必须设置正确的Content-Type和缓存头。text/event-stream是必须的,同时要加Cache-Control: no-cache和X-Accel-Buffering: no。最后这个头是给Nginx用的,告诉它不要缓冲响应,否则SSE消息会被Nginx攒着一起发,流式效果就没了。
第二,心跳机制。SSE连接如果长时间没有数据,中间的负载均衡器或防火墙可能会认为连接空闲而断开。解决办法是每隔15到30秒发一个注释行(: heartbeat\n\n),保持连接活跃。这个心跳在WebFlux里可以用Flux.merge把业务流和一个Flux.interval合并实现。
第三,背压处理。如果上游模型吐字很快,而客户端消费很慢,Reactor的背压机制会让上游减速。但模型API通常不支持背压,所以需要在网关层做一个缓冲队列,用onBackpressureBuffer并设置合理的容量上限,超过就丢弃或断开。
第四,超时控制。整个SSE连接要有总超时,单个数据块之间也要有间隔超时。我遇到过上游模型卡住不吐字的情况,如果没有间隔超时,连接会一直挂着。用timeout(Duration.ofSeconds(30))加在Flux上,超时就发一个错误事件然后关闭。
3.2 Redis滑动窗口限流的Lua实现
限流算法我试过三种:固定窗口、滑动窗口、令牌桶。最后选滑动窗口,因为它在精度和实现复杂度之间平衡得最好。
固定窗口的问题前面说了,边界双倍流量。令牌桶需要维护令牌生成速率,在分布式环境下同步比较麻烦。滑动窗口用ZSET实现:每次请求把当前时间戳作为score插入ZSET,然后删除窗口外的旧记录,统计ZSET大小就是当前窗口内的请求数。
这个操作必须是原子的,所以用Lua脚本:
-- KEYS[1]: 限流key -- ARGV[1]: 当前时间戳(毫秒) -- ARGV[2]: 窗口大小(毫秒) -- ARGV[3]: 限流阈值 local key = KEYS[1] local now = tonumber(ARGV[1]) local window = tonumber(ARGV[2]) local limit = tonumber(ARGV[3]) -- 删除窗口外的记录 redis.call('ZREMRANGEBYSCORE', key, 0, now - window) -- 统计当前窗口内请求数 local count = redis.call('ZCARD', key) if count < limit then redis.call('ZADD', key, now, now .. '-' .. math.random()) redis.call('PEXPIRE', key, window) return 1 else return 0 end这里有个细节:ZADD的member用了now-random,因为同一毫秒可能有多个请求,如果member相同会覆盖,导致计数不准。加随机后缀保证唯一性。
还有一个坑:PEXPIRE每次请求都设置,会不断刷新过期时间。如果某个key一直有请求,它永远不会过期,ZSET会越来越大。解决办法是只在count为1时设置过期,或者用ZREMRANGEBYSCORE保证旧数据被清理。
3.3 路由配置的热更新机制
路由规则存在Redis的Hash结构里,key是gateway:routes,field是路由ID,value是JSON格式的路由配置。网关启动时全量拉取,然后订阅Redis的Pub/Sub频道gateway:route:change,有变更时增量更新本地缓存。
为什么用Pub/Sub而不是轮询?因为轮询有延迟,而且浪费Redis的QPS。Pub/Sub是实时的,配置变更后毫秒级生效。但Pub/Sub有个问题:如果网关副本在消息发布时正好重启,会漏掉这条消息。所以还需要一个兜底机制:每隔60秒全量拉取一次配置,和本地对比,有差异就更新。
本地缓存用Caffeine,设置最大容量和过期时间。路由查询是高频操作,每次请求都要查,必须走本地缓存,不能每次都查Redis。
3.4 分布式锁的正确姿势
配置热更新、配额重置这类操作需要分布式锁。用Redis实现分布式锁有几个经典坑:
第一,锁不能自动过期时间太短。如果业务执行时间超过锁的TTL,锁会自动释放,另一个副本就能拿到锁,导致并发执行。解决办法是设置合理的TTL(比如30秒),同时用看门狗机制定期续期。
第二,释放锁必须校验持有者。不能直接DEL,否则可能删掉别人的锁。正确做法是value存一个唯一标识(比如UUID),释放时用Lua脚本判断value是否匹配再删除。
第三,Redis主从切换可能丢锁。如果主节点写入锁后还没同步到从节点就宕机了,从节点升主后锁就丢了。这个问题在Redlock算法里有讨论,但Redlock本身也有争议。我的做法是:对于配置更新这种低频操作,用单节点Redis加合理的TTL就够了,不需要追求绝对安全。对于扣费这种敏感操作,用数据库的唯一约束做最终保证,Redis锁只是减少并发。
4. 实操过程:从本地到云上生产的完整部署
4.1 本地开发环境搭建
本地开发需要三个东西:Redis、一个模拟的上游模型服务、网关本身。
Redis用Docker跑最方便:
docker run -d --name redis-dev -p 6379:6379 redis:7-alpine redis-server --appendonly yes--appendonly yes开启AOF持久化,本地开发虽然不需要,但和生产保持一致能避免一些意外。
模拟上游模型服务我用Spring Boot写了一个简单的SSE接口,每隔100毫秒吐一个字,模拟流式输出。这样本地就能完整测试SSE链路,不需要真的调用模型API。
网关的配置文件用Spring Boot的application-dev.yml,关键配置:
spring: data: redis: host: localhost port: 6379 lettuce: pool: max-active: 16 max-idle: 8 gateway: rate-limit: default-qps: 10 window-ms: 1000 sse: heartbeat-interval: 15s idle-timeout: 30s total-timeout: 300s4.2 Docker镜像构建与GraalVM优化
生产镜像用多阶段构建。第一阶段用Maven编译,第二阶段用GraalVM native-image编译成可执行文件,第三阶段用distroless基础镜像打包。
FROM maven:3.9-eclipse-temurin-21 AS build WORKDIR /app COPY pom.xml . RUN mvn dependency:go-offline COPY src ./src RUN mvn package -DskipTests FROM ghcr.io/graalvm/native-image:21 AS native WORKDIR /app COPY --from=build /app/target/*.jar app.jar RUN native-image -jar app.jar -o gateway --no-fallback FROM gcr.io/distroless/base-debian12 COPY --from=native /app/gateway /gateway EXPOSE 8080 ENTRYPOINT ["/gateway"]GraalVM native image有几个坑:反射需要配置、动态代理需要配置、某些库不兼容。Spring Boot 3.x对GraalVM的支持已经很好,但Lettuce和Caffeine需要额外配置。我在src/main/resources/META-INF/native-image下加了reflect-config.json,把用到的类都注册进去。
编译出来的镜像大小从原来的280MB降到45MB,启动时间从8秒降到1.2秒。这个优化在K8s滚动更新时非常明显,新副本秒级就绪,更新过程几乎无感。
4.3 K8s部署清单详解
部署清单我分成四个文件:Namespace、ConfigMap、Deployment、Service。
Deployment的关键配置:
apiVersion: apps/v1 kind: Deployment metadata: name: llm-gateway namespace: ai-gateway spec: replicas: 3 strategy: type: RollingUpdate rollingUpdate: maxSurge: 1 maxUnavailable: 0 selector: matchLabels: app: llm-gateway template: metadata: labels: app: llm-gateway spec: containers: - name: gateway image: registry.example.com/llm-gateway:v18.0.0 ports: - containerPort: 8080 env: - name: SPRING_DATA_REDIS_HOST valueFrom: configMapKeyRef: name: gateway-config key: redis-host - name: JAVA_TOOL_OPTIONS value: "-XX:MaxRAMPercentage=75 -XX:+UseZGC" resources: requests: memory: "256Mi" cpu: "250m" limits: memory: "512Mi" cpu: "1000m" readinessProbe: httpGet: path: /actuator/health/readiness port: 8080 initialDelaySeconds: 5 periodSeconds: 5 livenessProbe: httpGet: path: /actuator/health/liveness port: 8080 initialDelaySeconds: 15 periodSeconds: 10 lifecycle: preStop: exec: command: ["sh", "-c", "sleep 10"]几个关键点解释:
maxUnavailable: 0保证滚动更新时不会减少可用副本数,配合maxSurge: 1先起新副本再停旧副本,实现零中断。
preStop的sleep 10秒是必须的。因为K8s从Service的Endpoints里摘除Pod需要时间,如果Pod收到SIGTERM立刻关闭,正在处理的SSE连接会被强制断开。sleep 10秒给K8s时间更新Endpoints,同时网关自己也要做优雅关闭,等待正在处理的请求完成。
JAVA_TOOL_OPTIONS用ZGC是因为SSE场景下堆内存分配比较频繁,ZGC的停顿时间在1毫秒以内,不会影响流式输出的流畅度。
4.4 Ingress配置与SSE超时处理
Ingress是SSE最容易出问题的地方。默认配置下,Nginx Ingress的proxy-read-timeout是60秒,SSE连接超过60秒没数据就会被断开。必须显式配置:
apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: llm-gateway namespace: ai-gateway 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 ""; spec: ingressClassName: nginx rules: - host: gateway.example.com http: paths: - path: /v1/chat pathType: Prefix backend: service: name: llm-gateway port: number: 8080proxy-buffering: off是关键,否则Nginx会缓冲SSE响应。proxy-http-version: 1.1和清空Connection头是为了保持长连接。proxy-read-timeout设成600秒,覆盖大部分模型调用的最长耗时。
4.5 Redis生产部署方案
生产Redis用主从加哨兵,或者直接用云厂商的托管Redis。我选的是K8s里部署Redis Cluster,三个主三个从,跨可用区分布。
为什么不用单节点?因为限流计数器如果丢了,会导致限流失效,瞬间大量请求打到模型API,可能触发上游的封禁。Redis Cluster虽然有槽位迁移的复杂性,但可用性有保证。
Redis的配置要点:
maxmemory 2gb maxmemory-policy allkeys-lru appendonly yes appendfsync everysecallkeys-lru是因为限流key和路由配置都是可重建的,内存不够时淘汰旧数据没问题。appendfsync everysec在性能和持久化之间平衡,最多丢1秒数据,对限流场景可以接受。
连接池用Lettuce,配置max-active: 32,max-idle: 16。Lettuce是响应式的,连接是共享的,不需要太多连接。但要注意设置命令超时,默认是60秒,太长,改成2秒:
spring: data: redis: timeout: 2s lettuce: shutdown-timeout: 200ms命令超时2秒是因为限流操作必须快速返回,如果Redis卡住,宁可快速失败降级(比如放行请求),也不要让请求堆积。
5. 常见问题与排查技巧实录
5.1 SSE连接频繁断开
这是上线后遇到的第一个问题。现象是客户端经常收到stream disconnected before completion: idle timeout waiting for sse错误。
排查思路分三步:
第一步,确认是网关断开还是中间层断开。在网关日志里加连接关闭的原因记录,发现是网关主动关闭的,原因是idle timeout。
第二步,检查心跳是否正常发送。发现心跳间隔配置的是15秒,但实际日志里心跳间隔不稳定,有时候30秒才发一次。原因是心跳Flux和业务Flux用merge合并时,如果业务流有背压,心跳也会被阻塞。
第三步,解决办法是把心跳改成独立的Flux.interval,用concatWith或者mergeWith时加onBackpressureDrop,保证心跳不被业务流影响。同时把idle timeout从30秒改成45秒,给心跳留足余量。
最终的心跳实现:
Flux<ServerSentEvent<String>> heartbeat = Flux.interval(Duration.ofSeconds(15)) .map(i -> ServerSentEvent.<String>builder().comment("heartbeat").build()) .onBackpressureDrop(); Flux<ServerSentEvent<String>> business = modelService.stream(request) .map(chunk -> ServerSentEvent.<String>builder().data(chunk).build()); return Flux.merge(heartbeat, business) .timeout(Duration.ofSeconds(45));5.2 Redis命令超时导致限流失效
现象是日志里大量redis command timed out; nested exception is io.lettuce.core.RedisCommandTimeoutException,同时限流形同虚设。
根因分析:Redis Cluster在做槽位迁移时,部分请求会重定向,延迟增加。默认超时60秒,请求堆积在连接池里,新的请求拿不到连接,直接失败。而限流代码里失败时是放行策略,所以限流失效。
解决办法有三个层面:
第一,缩短超时到2秒,快速失败。第二,限流失败时的降级策略改成"本地限流兜底",每个网关副本维护一个本地计数器,Redis不可用时用本地计数,虽然不精确但能防止完全失控。第三,Redis Cluster的槽位迁移在低峰期做,避免高峰期迁移。
本地兜底限流用Caffeine的AtomicLong实现,每个key一个计数器,每秒重置。精度不如Redis,但作为降级方案够用。
5.3 K8s滚动更新时SSE连接被强制断开
现象是每次发版,部分用户反馈对话中断。
根因是Pod收到SIGTERM后,Spring Boot默认立即关闭,不等正在处理的请求完成。
解决办法分两步:
第一步,配置Spring Boot的优雅关闭:
server: shutdown: graceful spring: lifecycle: timeout-per-shutdown-phase: 30s第二步,在preStop里加sleep,给K8s时间更新Endpoints。这两步配合,保证新请求不再路由到旧Pod,旧Pod处理完存量请求再退出。
但SSE连接可能持续几分钟,30秒的优雅关闭不够。我的做法是:在收到关闭信号时,给所有活跃SSE连接发一个特殊的结束事件,告诉客户端"服务端正在重启,请重连"。客户端收到后自动重连到新Pod,对话上下文通过Redis恢复。这样用户几乎无感。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| SSE连接60秒断开 | Ingress超时 | 查Nginx日志 | 调大proxy-read-timeout |
| SSE消息攒着一起发 | Nginx缓冲 | 抓包看时序 | proxy-buffering off |
| 限流不准 | 固定窗口边界问题 | 压测边界流量 | 改用滑动窗口Lua |
| Redis超时 | 连接池不足或槽位迁移 | 查Redis慢日志 | 缩短超时加本地兜底 |
| 滚动更新断连 | 优雅关闭未配置 | 查Pod事件 | graceful shutdown加preStop |
| 内存持续增长 | ZSET未清理 | 查Redis内存 | 检查PEXPIRE逻辑 |
| 启动慢 | JVM预热 | 测启动时间 | GraalVM native image |
| 路由不生效 | Pub/Sub消息丢失 | 查订阅日志 | 加定时全量刷新兜底 |
5.5 几个血泪教训
教训一:不要用KEYS命令查限流key。生产环境Redis有几十万个限流key,KEYS gateway:rate:*会阻塞Redis好几秒,导致所有请求超时。要用SCAN渐进式遍历。
教训二:SSE的data字段不能包含换行。SSE协议里,data:后面到换行符之间的内容是数据,如果数据本身有换行,会被截断。解决办法是把数据里的换行替换成\n转义,或者用JSON编码后再放进去。
教训三:Redis的ZSET member必须唯一。前面提过,同一毫秒的多个请求如果member相同会覆盖。我一开始用时间戳做member,压测时发现限流计数偏少,排查了半天才发现这个问题。
教训四:K8s的terminationGracePeriodSeconds要大于优雅关闭时间。默认是30秒,如果优雅关闭配置了30秒,加上preStop的10秒,总共40秒,超过了30秒,Pod会被强制kill。要把terminationGracePeriodSeconds设成60秒。
教训五:GraalVM native image不支持JVM的一些动态特性。比如Spring的@ConditionalOnProperty在native image里可能不生效,因为编译时已经确定了。要用@Profile或者显式配置替代。
6. 生产环境的可观测性建设
6.1 指标采集与关键监控项
网关的可观测性用Micrometer加Prometheus加Grafana。关键指标分四类:
流量指标:QPS、活跃SSE连接数、请求成功率。活跃连接数用Gauge采集,每次连接建立加一,关闭减一。
延迟指标:首字节时间(TTFB)、总响应时间、上游模型调用时间。TTFB对SSE特别重要,用户感知的"快慢"主要取决于第一个字什么时候出来。
限流指标:限流触发次数、限流key分布、Redis操作延迟。限流触发次数突增说明要么有攻击,要么配额配置不合理。
资源指标:JVM堆内存、GC停顿、Redis连接池使用率、K8s Pod的CPU和内存。
Grafana面板我做了三个:总览面板看核心指标,租户面板看各租户的用量和限流,链路面板看从网关到上游模型的完整延迟分解。
6.2 日志规范与链路追踪
日志用Logback输出JSON格式,方便ELK采集。每条日志包含traceId、spanId、租户ID、模型名、请求ID。traceId用Micrometer Tracing生成,贯穿整个请求链路。
SSE场景下日志有个特殊问题:一个连接持续几分钟,如果每个数据块都打日志,日志量会爆炸。我的做法是:连接建立和关闭时打INFO日志,数据块只在DEBUG级别打,生产环境不开DEBUG。同时记录每个连接的首字节时间、总时长、数据块数量,这些汇总信息在连接关闭时打一条INFO。
链路追踪用OpenTelemetry,网关作为Span的起点,上游模型调用作为子Span。这样在Jaeger里能看到完整的调用链,定位延迟瓶颈很方便。
6.3 告警规则设计
告警不能太多,否则会麻木。我只设了五条核心告警:
- 请求错误率超过5%持续5分钟
- P99延迟超过10秒持续5分钟
- 活跃SSE连接数超过阈值(根据容量规划设定)
- Redis命令超时率超过1%
- Pod重启次数超过3次每小时
告警发到钉钉群,同时记录到告警历史表,用于事后复盘。每条告警都要有明确的处理预案,比如错误率告警先看是不是上游模型故障,延迟告警先看是不是Redis慢查询。
7. 容量规划与性能调优实战
7.1 单副本能扛多少并发
这个问题必须用压测回答。我用JMeter模拟SSE请求,每个请求持续30秒,每秒吐10个数据块。
单副本(2核4G,GraalVM native image)的压测结果:
| 并发连接数 | CPU使用率 | 内存使用 | P99首字节延迟 | 错误率 |
|---|---|---|---|---|
| 500 | 35% | 180MB | 120ms | 0% |
| 1000 | 62% | 220MB | 180ms | 0% |
| 2000 | 88% | 310MB | 450ms | 0.3% |
| 3000 | 98% | 380MB | 1200ms | 2.1% |
结论:单副本稳定支撑1000到1500个并发SSE连接。超过2000开始出现错误,主要是Redis连接池和CPU瓶颈。
7.2 水平扩缩容策略
基于压测数据,HPA配置:
apiVersion: autoscaling/v2 kind: HorizontalPodAutoscaler metadata: name: llm-gateway-hpa namespace: ai-gateway spec: scaleTargetRef: apiVersion: apps/v1 kind: Deployment name: llm-gateway minReplicas: 3 maxReplicas: 20 metrics: - type: Resource resource: name: cpu target: type: Utilization averageUtilization: 70 - type: Pods pods: metric: name: active_sse_connections target: type: AverageValue averageValue: "1200" behavior: scaleUp: stabilizationWindowSeconds: 60 policies: - type: Pods value: 2 periodSeconds: 60 scaleDown: stabilizationWindowSeconds: 300 policies: - type: Pods value: 1 periodSeconds: 120关键点:扩容要快(60秒稳定窗口,每次加2个),缩容要慢(300秒稳定窗口,每次减1个)。因为SSE连接是长连接,缩容太快会导致大量连接被断开。缩容时配合优雅关闭,让存量连接自然结束。
7.3 JVM调优参数
虽然是GraalVM native image,但有些场景还是用JVM模式(比如需要动态加载配置)。JVM模式的参数:
-XX:+UseZGC -XX:MaxRAMPercentage=75 -XX:InitialRAMPercentage=50 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/heapdump.hprof -Xlog:gc*:file=/tmp/gc.log:time,uptime:filecount=5,filesize=10MZGC的停顿在1毫秒以内,适合SSE场景。MaxRAMPercentage=75是因为容器内存限制512MB,堆用384MB,剩下128MB给元空间、线程栈、直接内存。GC日志保留5个文件,每个10MB,方便排查内存问题。
7.4 Redis性能优化
Redis的瓶颈通常在网络往返。优化手段:
第一,用Pipeline批量执行。限流操作虽然单个,但配额查询可以批量。第二,用Lua脚本减少往返。滑动窗口限流用Lua一次完成,比多次命令快3到5倍。第三,连接池复用。Lettuce的连接是共享的,但要确保shareNativeConnection为true(默认就是)。第四,避免大key。限流key的ZSET大小控制在窗口内的请求数,一般不超过几千,没问题。但路由配置的Hash如果字段太多,HGETALL会慢,要用HSCAN或者拆分成多个key。
8. 安全加固与多租户隔离
8.1 鉴权与配额管理
每个请求必须带API Key,网关校验Key的有效性,并从Redis里查该Key对应的租户ID和配额。API Key用SHA-256哈希后存储,不存明文。
配额管理分三级:租户级总配额、模型级配额、API Key级配额。三级配额都要检查,任何一级超限都拒绝。配额用Redis的Hash存储,定期从数据库同步。
8.2 多租户隔离
租户隔离分三个层面:
数据隔离:限流key、会话状态、路由配置都带租户前缀,比如tenant:123:rate:model:gpt-4。保证租户之间互不影响。
资源隔离:大租户可以分配独立的Redis实例或者独立的网关副本组,避免小租户被大租户的流量影响。这个用K8s的Namespace和ResourceQuota实现。
故障隔离:一个租户的异常请求不能影响其他租户。用Resilience4j的Bulkhead做并发隔离,每个租户最多占用一定比例的线程或连接。
8.3 输入输出安全
输入侧要做Prompt注入检测,虽然不能完全防住,但能挡住大部分低级攻击。输出侧要做敏感内容过滤,这个用正则加关键词库实现。
还有一个容易忽略的点:SSE响应里不能包含内部错误堆栈。异常处理要统一,把技术异常转成用户友好的错误消息,堆栈只记日志不返回给客户端。
9. 成本控制与模型路由策略
9.1 模型路由的降本逻辑
不同模型的成本差异巨大。同一个请求,用大模型可能花0.1元,用小模型只要0.001元。网关的路由层可以根据请求特征自动选择模型:
- 简单问答、分类任务路由到小模型
- 复杂推理、代码生成路由到大模型
- 用户显式指定模型时按用户指定
路由规则用Redis存储,支持按租户、按时间段、按请求长度动态调整。比如高峰期自动降级到小模型,低峰期用大模型。
9.2 缓存策略降低重复调用
很多请求是重复的,比如相同的FAQ问题。网关可以在Redis里缓存模型响应,key是请求内容的哈希,value是响应。命中缓存直接返回,不调用模型。
但SSE流式响应缓存比较麻烦,因为响应是分块的。我的做法是:缓存完整的响应文本,命中时把文本按固定大小分块,模拟流式输出。虽然不如真实流式自然,但能省下大量模型调用成本。
缓存要设置合理的TTL和容量上限,用LRU淘汰。同时要支持按租户清除缓存,避免数据串租户。
9.3 用量统计与计费
每个请求都要记录用量:输入token数、输出token数、模型名、租户ID、时间戳。这些数据写入Kafka,由下游的计费系统消费。
用量统计不能阻塞主流程,用异步方式。我的做法是:请求完成后,把用量数据放到一个内存队列,后台线程批量写入Kafka。队列满了就丢弃并告警,保证主流程不受影响。
10. 后续演进方向
这套网关目前稳定运行了几个月,支撑了多个租户的生产流量。后续我打算在几个方向继续优化:
第一,支持更多模型协议。目前适配了OpenAI和Anthropic格式,后续要支持更多国内模型厂商的协议。适配层用SPI机制,新增协议只需要实现一个接口。
第二,智能路由。基于历史数据训练一个简单的模型,预测哪个模型对当前请求的性价比最高。这个还在实验阶段。
第三,边缘部署。把网关的部分能力下沉到边缘节点,减少网络延迟。这个需要和CDN结合,复杂度比较高。
第四,可观测性增强。目前是事后排查,后续要做实时异常检测,在问题发生前预警。
这套东西从第1境写到第18境,踩的坑比写的代码多。但每次解决一个问题,系统就稳一分。到"仙帝境"这一层,最大的体会是:生产环境和本地环境是两个世界,本地跑通只是开始,真正的挑战在云上。限流、SSE、K8s、Redis,每一个单独看都不难,但组合在一起,边界情况会成倍增加。我的经验是:每个组件都要有降级方案,每个失败路径都要有兜底,每个配置都要有监控。做到这三点,系统才算真正"封帝"。