1. 从“荒天帝”说起:一个LLM网关的封帝之路
“荒天帝炼大模型网关”这个系列一路写到了第18境,终于到了“仙帝境”。如果你追过这个系列,大概知道前面17境都在折腾什么——从最开始的接口转发,到后来的多模型适配、流式响应、限流熔断、可观测性,每一步都是在给网关“叠甲”。而这一境叫“他化自在法”,名字听着玄乎,其实说的是一件很实在的事:让网关在生产环境里做到“自在”——不管上游模型怎么变、下游流量怎么抖,它都能稳住。
这一境的关键词是Java、LLM Gateway、Kubernetes、Redis、SSE。说白了,就是一套用Java写的、跑在K8s上的、靠Redis做状态协同的、支持SSE流式输出的大模型网关,要真正上云、上生产、扛住真实流量。这不是demo,不是本地跑个Hello World,是要在云上“封帝”——让整个系统在生产环境里立住。
我写这个系列一直有个原则:不写“教科书式”的教程,只写“踩过坑之后回头看”的总结。这一篇也一样。我会把从本地跑通到云上生产这中间的关键决策、参数计算、实操步骤、以及那些文档里不会写的坑,全部摊开来讲。适合谁看?如果你正在做LLM网关、正在用Java写高并发服务、正在把Spring Boot应用往K8s上搬,或者你只是好奇“一个网关怎么就能叫仙帝境了”,这篇都值得你花时间。
先说清楚“他化自在法”在这个语境下到底指什么。在网关的架构里,它对应的是动态路由与状态外置——网关本身不持有会话状态,所有会话、限流计数、模型健康状态都放到Redis里,网关实例可以随时扩缩容、随时重启,状态不丢。这就是“他化”:自己不执着于状态,而是借助外部存储“化”出稳定的服务能力。而“自在”就是K8s给的——滚动更新、自动扩缩、故障自愈,网关实例来去自如。
下面我会从整体设计思路开始拆,然后讲核心细节、实操过程、问题排查,最后落到生产上的一些经验。每一段都尽量把“为什么这么做”讲透,而不是只给一个结论。
2. 整体设计与思路拆解
2.1 为什么是Java而不是Go或Node
这个问题我被问过太多次了。LLM网关这个场景,很多人第一反应是用Go写,因为并发模型简单、内存占用低。但我选Java,原因有几个,而且都是实际踩出来的。
第一,生态。网关要对接的东西太多了:Redis、K8s API、各种模型厂商的SDK、监控埋点、配置中心。Java在这些方面的库成熟度是碾压级的。比如Redis的客户端,Lettuce和Jedis都经过大规模生产验证;K8s的Java Client虽然不如Go的client-go那么“原生”,但功能覆盖足够。你不需要自己造轮子。
第二,团队。我带的团队里Java工程师占多数,用Java写网关意味着维护成本低。一个网关项目最怕的不是性能不够,而是没人能改。Go写出来的东西,团队里能接手的人少一半。
第三,性能其实够。LLM网关的瓶颈不在网关本身,而在上游模型的响应时间。一个请求从网关出去到模型返回,动辄几秒到几十秒,网关在这期间主要是做流式转发和状态管理。Java的虚拟线程(Project Loom)在JDK 21之后已经可以很好地处理这种“大量长连接、低计算”的场景。实测下来,单实例用虚拟线程处理几千个并发SSE连接,CPU占用完全可控。
当然,Java也有代价:内存占用比Go高,启动比Go慢。但在K8s环境下,这两个问题都被弱化了——内存可以靠requests/limits控制,启动慢可以靠就绪探针和滚动更新来兜底。
2.2 网关的核心职责边界
在设计之初,我就给这个网关划了明确的职责边界。它只做四件事:
- 协议转换:把下游的OpenAI兼容请求,转换成各个模型厂商的实际请求格式。
- 流式转发:把上游的SSE流,原样、低延迟地转发给下游。
- 状态管理:会话、限流、模型健康状态,全部外置到Redis。
- 可观测性:日志、指标、链路追踪,一个不能少。
它不做的事:不做Prompt工程、不做内容审核、不做计费。这些应该由更上层的业务系统来做。网关越纯粹,越稳定。我见过太多网关最后变成“什么都干”的怪物,结果什么都干不好。
这个边界划清楚之后,整个架构就清晰了:网关是无状态的,所有状态在Redis;网关是可水平扩展的,因为无状态;网关是可观测的,因为每个请求都有traceId贯穿。
2.3 为什么状态一定要外置到Redis
这是“他化自在法”的核心。如果网关自己持有会话状态,那它就有状态了,有状态就意味着:扩容时要考虑状态迁移,重启时会丢状态,故障时会影响用户。这在生产环境是不可接受的。
把状态放到Redis之后,网关实例变成了“无状态计算节点”。用户请求打到哪个实例都行,因为实例会从Redis里读会话、写限流计数、更新模型健康状态。K8s的Service做负载均衡,随便怎么分发都可以。
但这里有个关键点:不是所有状态都适合放Redis。我分了三类:
| 状态类型 | 存储位置 | 原因 |
|---|---|---|
| 会话上下文 | Redis | 需要跨实例共享,且生命周期较长 |
| 限流计数 | Redis | 需要全局精确计数,本地计数会不准 |
| 模型健康状态 | Redis | 需要所有实例共享同一份健康视图 |
| 请求级临时数据 | 本地内存 | 生命周期仅限于单次请求,不需要共享 |
这个分类很重要。如果把请求级临时数据也放Redis,那Redis的QPS会爆炸,而且延迟会增加。本地内存处理请求级数据,快且不占Redis资源。
2.4 SSE流式转发的技术选型
SSE(Server-Sent Events)是LLM网关的命脉。用户要看到“打字机效果”,就必须用SSE。但SSE在Java里做转发,有几个坑。
首先,不能用传统的阻塞IO。一个SSE连接可能持续几十秒甚至几分钟,如果用Tomcat的阻塞线程模型,每个连接占一个线程,几千个连接就把线程池打满了。所以必须用异步Servlet或者WebFlux。
我选的是Spring WebFlux + Reactor Netty。原因:WebFlux天生支持背压(Backpressure),这在流式转发里很关键。上游模型返回快了,下游消费慢了,背压可以防止内存溢出。而且Reactor Netty的SSE支持很成熟,配合虚拟线程(在阻塞操作时切换)可以做到既高效又简单。
但WebFlux有个代价:调试困难。响应式编程的调用栈不像同步代码那么直观。我的经验是,在网关这种“转发为主”的场景里,WebFlux的复杂度是可控的,因为大部分逻辑就是“读-转发-写”,不需要复杂的业务编排。
2.5 K8s部署形态的选择
网关在K8s上怎么部署?我考虑过三种形态:
- Deployment + Service:最常规的方式,无状态服务,滚动更新。
- StatefulSet:适合有状态服务,但网关是无状态的,不需要。
- DaemonSet:适合每个节点跑一个的场景,但网关需要独立扩缩容,不适合。
最终选的是Deployment + Service + HPA。Deployment管理Pod,Service做负载均衡,HPA根据CPU和自定义指标(比如活跃SSE连接数)自动扩缩容。
这里有个细节:SSE连接是长连接,HPA基于CPU扩缩容会有延迟。因为CPU可能不高,但连接数已经很多了。所以我加了一个自定义指标:活跃SSE连接数。当平均每个Pod的连接数超过阈值时,就触发扩容。这个指标通过Micrometer暴露,由Prometheus采集,再由KEDA或者HPA的自定义指标API来驱动扩缩容。
3. 核心细节解析与实操要点
3.1 Redis数据结构设计:别用String存一切
Redis在网关里承担了状态管理的重任,数据结构设计直接决定了性能和可维护性。我见过太多项目把所有东西都序列化成JSON塞进String,结果就是:想更新一个字段要读整个JSON、改完再写回去,并发一高就丢更新。
我的设计是按用途选数据结构:
会话上下文用Hash。每个会话一个Hash,field是上下文的各种属性(比如messages、model、temperature),value是对应的值。这样更新单个字段用HSET,不需要读整个会话。而且Hash在Redis里是ziplist编码(小数据量时),内存效率高。
限流计数用String + INCR + EXPIRE。这是经典用法。key是ratelimit:{userId}:{window},value是计数。每次请求INCR,第一次设置EXPIRE。注意:INCR和EXPIRE要用Lua脚本保证原子性,否则可能出现INCR了但EXPIRE没设置的情况,导致key永不过期。
模型健康状态用Hash + 过期时间。每个模型一个Hash,field是健康指标(比如最近失败次数、最近成功时间),整个Hash设置一个较短的TTL(比如30秒)。这样如果网关实例挂了,健康状态会自动过期,不会一直显示“健康”。
分布式锁用String + SET NX PX。这是Redis分布式锁的标准用法。但要注意:锁的value必须是唯一标识(比如UUID),释放锁时要用Lua脚本判断value再删除,防止误删别人的锁。
注意:Redis的Hash在field数量少的时候用ziplist,内存效率高但操作是O(n)。如果field数量可能很大(比如超过128个),要考虑用其他结构或者拆分。
3.2 SSE转发的背压处理:别让内存爆了
SSE转发的核心链路是:上游模型返回SSE流 -> 网关读取 -> 网关转发给下游。这个链路里,如果上游返回速度快于下游消费速度,数据就会在网关里堆积,最终OOM。
WebFlux的背压机制可以解决这个问题。具体做法是:用Flux接收上游的SSE事件,然后用flatMap或者concatMap处理每个事件,最后用ServerSentEvent写回下游。Reactor会根据下游的请求量(demand)来控制上游的读取速度。
但这里有个坑:上游模型的SSE流不一定支持背压。很多模型厂商的HTTP客户端就是普通的HTTP响应,你读多快它就发多快。这时候背压只能控制网关内部的缓冲,不能控制上游。所以网关内部要设置一个有界缓冲区,比如用onBackpressureBuffer(1000),超过1000个事件就丢弃或者报错。丢弃比OOM好。
另一个坑是超时。SSE连接可能因为网络问题卡住,如果不设超时,连接会一直挂着。我设了两个超时:连接超时(比如10秒)和读超时(比如60秒)。读超时是指两次事件之间的最大间隔,超过就认为连接断了。
// 简化的SSE转发逻辑 public Flux<ServerSentEvent<String>> forward(String requestBody) { return webClient.post() .uri(upstreamUrl) .bodyValue(requestBody) .retrieve() .bodyToFlux(String.class) .timeout(Duration.ofSeconds(60)) // 读超时 .onBackpressureBuffer(1000) // 有界缓冲 .map(data -> ServerSentEvent.builder(data).build()) .onErrorResume(e -> { log.error("SSE forward error", e); return Flux.just(ServerSentEvent.builder("[DONE]").build()); }); }3.3 分布式限流的精度与性能权衡
限流是网关的必备能力。但分布式限流的精度和性能是一对矛盾:要精确,就得所有实例都去Redis做原子操作,Redis压力大;要性能,就得本地计数,但本地计数不准。
我的方案是两级限流:
- 第一级:本地令牌桶。每个实例维护一个本地令牌桶,令牌数量是全局配额除以实例数。比如全局每秒1000次,有10个实例,每个实例本地桶每秒100个令牌。请求先过本地桶,过了再走第二级。
- 第二级:Redis全局计数。本地桶过了之后,再去Redis做一次INCR,如果超过全局配额,就拒绝。
这样大部分请求在本地就被拦截了(如果本地桶空了),只有本地桶有令牌的请求才会去Redis。Redis的压力降低了一个数量级。
但这里有个问题:实例数变化时,本地桶的配额要重新分配。比如从10个实例扩到20个,每个实例的本地配额要从100降到50。这个可以通过配置中心推送,或者让实例定期从Redis读取当前实例数来动态调整。
实操心得:本地桶的令牌数量不要设得太小,否则突发流量会被本地桶拦截,即使Redis全局配额还有余量。我一般把本地桶设为全局配额的1.5倍除以实例数,留一点缓冲。
3.4 模型健康检查与自动摘除
网关要对接多个模型厂商,如果某个厂商的API挂了,网关要能自动摘除,把流量切到健康的模型上。这就是“他化自在”的另一层含义:不执着于某一个模型,而是根据健康状态动态选择。
健康检查我做了两层:
- 主动检查:每隔30秒,网关向每个模型的健康检查端点发一个轻量请求(比如一个很短的prompt),看是否返回200。如果连续3次失败,标记为不健康。
- 被动检查:每次实际请求,如果失败(超时、5xx),就记录一次失败。如果某个模型在1分钟内的失败率超过50%,标记为不健康。
健康状态存在Redis的Hash里,所有实例共享。当一个模型被标记为不健康时,路由层会跳过它,选择下一个健康的模型。如果所有模型都不健康,就返回503,并触发告警。
这里有个细节:健康状态的更新要有防抖。如果一个模型只是偶尔失败一次,不应该立即标记为不健康。我用的是滑动窗口:记录最近N次请求的成功/失败,只有失败率超过阈值才标记。N一般取20,阈值取50%。
3.5 优雅停机的实现细节
K8s滚动更新时,旧Pod会被终止。如果直接kill,正在处理的SSE连接会断掉,用户会看到“stream disconnected”。所以必须实现优雅停机。
优雅停机的步骤:
- 收到SIGTERM信号:Spring Boot会触发
ContextClosedEvent。 - 停止接受新请求:把就绪探针(readiness probe)设为失败,K8s会把该Pod从Service的Endpoints里移除,新请求不再打到这个Pod。
- 等待正在处理的请求完成:给一个宽限期(比如30秒),让正在进行的SSE连接自然结束。如果超过宽限期还没结束,就强制关闭。
- 关闭资源:关闭Redis连接、WebClient连接池等。
这里的关键是就绪探针和宽限期的配合。就绪探针失败后,K8s需要一点时间更新Endpoints(通常几秒),所以宽限期要大于这个时间。我一般设宽限期为30秒,就绪探针的失败检测间隔为5秒。
# K8s Deployment中的优雅停机配置 spec: template: spec: terminationGracePeriodSeconds: 30 containers: - name: gateway readinessProbe: httpGet: path: /actuator/health/readiness port: 8080 periodSeconds: 5 failureThreshold: 1注意:Spring Boot的
server.shutdown=graceful要配合spring.lifecycle.timeout-per-shutdown-phase=30s使用,否则WebFlux不会等待请求完成。
4. 实操过程与核心环节实现
4.1 环境准备与依赖版本锁定
生产环境的第一个原则:版本锁定。所有依赖的版本必须明确,不能用LATEST或者范围版本。我用的版本组合是:
| 组件 | 版本 | 说明 |
|---|---|---|
| JDK | 21 | 虚拟线程,LTS |
| Spring Boot | 3.2.x | 支持虚拟线程,WebFlux成熟 |
| Spring WebFlux | 随Boot | 响应式Web框架 |
| Lettuce | 6.3.x | Redis客户端,支持异步 |
| Reactor Netty | 随Boot | 底层HTTP客户端 |
| Micrometer | 1.12.x | 指标采集 |
| K8s Java Client | 18.x | 操作K8s API |
JDK 21是必须的,因为虚拟线程在JDK 21才正式可用。Spring Boot 3.2对虚拟线程的支持已经很好,只需要在配置里开启:
spring: threads: virtual: enabled: true但注意:WebFlux本身不需要虚拟线程,因为它是非阻塞的。虚拟线程主要用在那些不得不阻塞的地方,比如Redis的同步操作、文件IO等。在WebFlux里,如果某个操作是阻塞的,可以用Mono.fromCallable(...).subscribeOn(Schedulers.fromExecutor(virtualThreadExecutor))把它切换到虚拟线程上执行。
4.2 Redis集群的部署与连接配置
生产环境的Redis必须是集群模式,否则单点故障会导致整个网关不可用。我用的是Redis Cluster,3主3从,跨3个可用区。
Lettuce连接Redis Cluster的配置:
spring: data: redis: cluster: nodes: - redis-0.redis:6379 - redis-1.redis:6379 - redis-2.redis:6379 lettuce: pool: max-active: 16 max-idle: 8 min-idle: 4 max-wait: 2000ms shutdown-timeout: 200ms这里有几个参数要解释:
max-active: 16:每个实例最多16个Redis连接。这个数字是根据QPS算的。假设单实例QPS是1000,每个请求平均1次Redis操作,每次操作1ms,那么需要的连接数是1000 * 0.001 = 1。但考虑到突发流量和连接复用,设16是留了很大余量。max-wait: 2000ms:获取连接的最大等待时间。超过2秒就报错,避免请求堆积。shutdown-timeout: 200ms:关闭时的等待时间,配合优雅停机。
实操心得:Redis Cluster的MOVED重定向会导致额外的网络往返。Lettuce会自动处理MOVED,但会增加延迟。如果对延迟敏感,可以用Redis的hash tag把相关key放到同一个slot,减少重定向。
4.3 K8s部署清单的关键字段
K8s的Deployment清单里,有几个字段是生产环境必须的:
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 resources: requests: cpu: "1" memory: "2Gi" limits: cpu: "2" memory: "4Gi" env: - name: JAVA_OPTS value: "-XX:MaxRAMPercentage=75 -XX:+UseZGC" livenessProbe: httpGet: path: /actuator/health/liveness port: 8080 initialDelaySeconds: 30 periodSeconds: 10 readinessProbe: httpGet: path: /actuator/health/readiness port: 8080 initialDelaySeconds: 10 periodSeconds: 5关键点:
maxUnavailable: 0:滚动更新时不允许有不可用的Pod,保证零中断。requests和limits:requests是调度依据,limits是硬限制。CPU的limits设为2,意味着最多用2核。内存limits设为4Gi,JVM的MaxRAMPercentage设为75,即3Gi堆内存,留1Gi给堆外和系统。UseZGC:ZGC在JDK 21里已经成熟,停顿时间在毫秒级,适合网关这种低延迟场景。livenessProbe和readinessProbe分开:liveness失败会重启Pod,readiness失败会从Endpoints移除。readiness的initialDelaySeconds要小于liveness,因为readiness只是表示“能不能接流量”,不需要等完全启动。
4.4 HPA自定义指标配置
HPA默认只支持CPU和内存指标。要基于活跃SSE连接数扩缩容,需要用自定义指标。我用的是Prometheus Adapter + HPA。
首先,网关通过Micrometer暴露指标:
@Component public class SseConnectionMetrics { private final AtomicInteger activeConnections = new AtomicInteger(0); public void increment() { activeConnections.incrementAndGet(); } public void decrement() { activeConnections.decrementAndGet(); } @EventListener public void handleRequestStarted(RequestStartedEvent event) { increment(); } @EventListener public void handleRequestCompleted(RequestCompletedEvent event) { decrement(); } }然后,Prometheus Adapter把指标转换成HPA能识别的格式。HPA的配置:
apiVersion: autoscaling/v2 kind: HorizontalPodAutoscaler metadata: name: llm-gateway-hpa spec: scaleTargetRef: apiVersion: apps/v1 kind: Deployment name: llm-gateway minReplicas: 3 maxReplicas: 20 metrics: - type: Pods pods: metric: name: sse_active_connections target: type: AverageValue averageValue: "500"意思是:当平均每个Pod的活跃SSE连接数超过500时,触发扩容。minReplicas为3,保证高可用;maxReplicas为20,防止无限扩容。
注意:HPA的扩容有延迟(默认15秒检查一次),所以对于突发流量,HPA可能来不及。我的做法是:在网关前面加一层负载均衡(比如Nginx Ingress),Ingress层做连接数限制,超过就排队或者拒绝,给HPA争取时间。
4.5 完整请求链路与traceId贯穿
一个请求从进入网关到返回,要经过多个环节。为了排查问题,必须有一个traceId贯穿始终。
链路:
- Ingress:Nginx Ingress生成或透传
X-Request-Id。 - 网关Filter:从Header里读
X-Request-Id,如果没有就生成一个UUID,放到MDC(Mapped Diagnostic Context)里。 - 日志:所有日志都带上traceId。
- Redis操作:Redis的key里带上traceId(可选,用于排查)。
- 上游请求:把traceId放到请求Header里,传给模型厂商。
- 响应:把traceId放到响应Header里,返回给下游。
// WebFilter中设置traceId @Component public class TraceIdFilter implements WebFilter { @Override public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) { String traceId = exchange.getRequest().getHeaders().getFirst("X-Request-Id"); if (traceId == null) { traceId = UUID.randomUUID().toString().replace("-", ""); } MDC.put("traceId", traceId); exchange.getResponse().getHeaders().add("X-Request-Id", traceId); return chain.filter(exchange) .doFinally(signal -> MDC.clear()); } }这样,从Ingress到网关到上游,整个链路的日志都可以用traceId串起来。排查问题时,只要拿到用户提供的traceId,就能看到完整的请求路径。
5. 常见问题与排查技巧实录
5.1 SSE连接频繁断开:idle timeout的坑
这是最常见的问题。用户反馈“stream disconnected before completion: idle timeout waiting for sse”。原因通常是某个环节的超时设置太短。
排查思路:
- Ingress层:Nginx的
proxy_read_timeout默认是60秒。如果SSE连接超过60秒没有数据,Nginx会断开。解决:把proxy_read_timeout设为600秒或更长。 - 网关层:WebClient的读超时。如果设了60秒,而模型返回很慢,就会超时。解决:把读超时设为120秒,或者根据业务调整。
- 上游模型:有些模型厂商的SSE流本身就有超时,比如30秒没数据就断。这个只能通过选择更稳定的厂商或者做重试来解决。
实操心得:SSE的超时设置要遵循“下游 > 网关 > 上游”的原则。下游(Ingress)的超时最长,网关次之,上游最短。这样上游先超时,网关可以捕获并做重试,而不是下游直接断开。
5.2 Redis command timed out:连接池和网络的问题
“redis command timed out; nested exception is io.lettuce.core.RedisCommandTimeoutException”这个错误通常有两个原因:
- 连接池不够:并发请求多,连接池的max-active太小,请求排队等待连接,超过max-wait就报错。解决:增大max-active,或者优化Redis操作减少连接占用时间。
- 网络抖动:Redis Cluster的节点之间网络延迟高,导致命令执行慢。解决:检查网络,或者把Redis和网关部署在同一个可用区。
排查步骤:
- 看Redis的监控:连接数、QPS、延迟。
- 看网关的日志:是哪个Redis命令超时。
- 如果是某个特定命令超时,检查该命令的复杂度。比如
KEYS *这种O(n)命令在生产环境禁用。
5.3 K8s Pod频繁重启:OOM和探针配置
Pod频繁重启,通常是两个原因:
- OOMKilled:内存超过limits。解决:增大内存limits,或者优化代码减少内存占用。SSE转发时,如果背压没做好,内存会堆积。
- livenessProbe失败:探针检测失败,K8s重启Pod。解决:检查探针的路径和端口是否正确,initialDelaySeconds是否足够。
排查命令:
# 查看Pod重启原因 kubectl describe pod <pod-name> | grep -A 5 "Last State" # 查看Pod内存使用 kubectl top pod <pod-name> # 查看Pod事件 kubectl get events --field-selector involvedObject.name=<pod-name>5.4 模型健康检查误判:滑动窗口的阈值调整
健康检查误判是指:模型明明是健康的,但被标记为不健康,导致流量被切走。原因通常是滑动窗口的阈值太敏感。
比如,某个模型偶尔返回一次500,如果窗口大小是10,失败率阈值是10%,那一次失败就触发了。解决:增大窗口大小(比如20),提高失败率阈值(比如50%),或者增加连续失败次数要求(比如连续3次失败才标记)。
注意:健康检查的请求本身也会消耗资源。如果检查太频繁,会增加模型厂商的负担。我一般设30秒一次,而且用最轻量的请求。
5.5 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| SSE连接断开 | 超时设置太短 | 检查Ingress、网关、上游的超时 | 调整超时,遵循下游>网关>上游 |
| Redis超时 | 连接池不够或网络抖动 | 看Redis监控和网关日志 | 增大连接池,检查网络 |
| Pod重启 | OOM或探针失败 | kubectl describe pod | 增大内存,调整探针 |
| 健康检查误判 | 阈值太敏感 | 看健康状态变化日志 | 增大窗口,提高阈值 |
| 限流不准 | 本地桶和全局桶不一致 | 对比本地计数和Redis计数 | 调整本地桶配额,定期同步 |
| 请求延迟高 | Redis操作慢或上游慢 | 看traceId链路耗时 | 优化Redis命令,选择更快的模型 |
6. 生产上的一些经验之谈
6.1 灰度发布:别一次性全量
网关的每次变更都可能影响所有流量,所以灰度发布是必须的。我的做法是:
- 第一批:1个Pod,只接1%的流量。观察30分钟,看错误率、延迟、Redis指标。
- 第二批:10%的Pod,接10%的流量。观察1小时。
- 第三批:50%的Pod,接50%的流量。观察2小时。
- 全量:所有Pod更新。
灰度期间,如果发现错误率上升,立即回滚。K8s的kubectl rollout undo可以快速回滚。
6.2 容量规划:从QPS到Pod数
容量规划的核心是算清楚一个Pod能扛多少QPS。我的经验公式:
单Pod QPS = (1000 / 平均请求延迟ms) * 并发系数比如平均请求延迟是500ms(LLM请求通常很慢),并发系数是0.8(考虑CPU和Redis的开销),那么单Pod QPS = (1000/500) * 0.8 = 1.6。也就是说,一个Pod每秒只能处理1.6个请求。如果要扛100 QPS,需要63个Pod。
但这个公式对SSE不适用,因为SSE是长连接,一个连接可能持续几十秒。对于SSE,应该按活跃连接数来算。一个Pod能维持多少活跃SSE连接?实测下来,用WebFlux + 虚拟线程,一个Pod(2核4G)可以维持2000-3000个活跃连接。所以如果预计峰值有10000个活跃连接,需要4-5个Pod。
6.3 监控告警:哪些指标最重要
生产环境的监控指标很多,但真正需要告警的没几个。我设了以下几个:
- 错误率:5xx错误率超过1%,告警。
- P99延迟:超过10秒,告警。
- Redis连接池使用率:超过80%,告警。
- 活跃SSE连接数:超过单Pod容量的80%,告警(触发扩容)。
- 模型健康状态:所有模型都不健康,告警。
其他指标(CPU、内存、GC)可以看,但不需要告警,因为K8s和JVM会自己处理。
6.4 成本优化:别让网关成为烧钱机器
网关本身不烧钱,但网关背后的模型调用烧钱。优化成本的关键是:
- 缓存:对于相同的请求(相同的prompt和参数),可以缓存结果。但LLM的请求通常不同,缓存命中率低。不过对于健康检查这种固定请求,可以缓存。
- 限流:防止恶意用户刷接口。限流不仅保护网关,也保护钱包。
- 模型路由:把简单请求路由到便宜的模型,复杂请求路由到贵的模型。这需要在网关层做请求分类,可以用一个轻量模型来做分类。
6.5 后续扩展:还能往哪走
这个网关目前做到了“仙帝境”,但还有扩展空间:
- 多模态支持:目前只支持文本,未来可以支持图片、音频。
- Agent编排:网关可以不只是转发,还可以做简单的Agent编排,比如根据用户请求自动选择工具。
- 边缘部署:把网关部署到边缘节点,减少延迟。
但这些都是后话。先把当前的生产环境稳住,比什么都重要。
我个人在实际操作中的体会是:网关这个东西,越简单越稳定。每加一个功能,就多一个故障点。所以能不加就不加,能外置就外置。Redis外置状态、K8s外置运维、模型外置计算,网关只做它该做的事——转发和状态协同。这就是“他化自在”的真正含义:不执着于自己拥有,而是借助外部力量,达到自在的境界。