☰
Spring Cloud Gateway构建AI服务网关:统一鉴权限流与熔断实践
2026/10/15 23:50:36 网站建设 项目流程

1. 为什么要给AI服务再做一层网关

先说清楚一个背景。我们团队负责公司内部的基础能力平台,今年接了两个大模型服务商、一个图像生成平台、一套语音识别引擎。一开始大家图省事,各业务线直接在自己的应用里塞SDK、调HTTP接口,结果三个月之后问题彻底压不住了。核心原因很简单:AI服务的调用模式跟传统REST接口差很多——密钥不能乱放、费用要按部门核算、响应可能是流式的、单次请求还可能跑几十秒,这些全都放在业务代码里,迟早出事。

于是我们决定做一层独立的网关节点,把所有AI服务的出入口收敛到网关这一层。项目代号就叫Spring AI Gateway,基于Spring Cloud Gateway扩展了一些AI场景专用能力。这篇文章就是把这套方案的选型过程、落地细节和踩过的坑一次讲清楚,适合正在做AI平台统一入口、或者准备给公司内部多个AI服务做治理的开发者。

我能给出的最直接建议是:如果你手里已经有两家以上的AI服务商要对接,别再让下游业务线直接碰他们的API Key了,网关这层治理早晚要做。这篇文章不会照着官方文档复读,而是以我们真实落地时的思路为主线,从为什么选Spring Cloud Gateway一直讲到生产环境的排查方法。

1.1 直连AI服务带来的四个高频痛点

最早的时候,某个内部商城团队接了大模型做商品文案生成,他们为了方便,直接在配置中心写上服务商提供的密钥。没过两周,公司的日志平台就检测到有异常外调,后来查明是某位开发者的密钥被带到了公共代码库。这不是个案,而是直连模式下最容易发生的事。

具体整理下来,直连模式最痛的几件事分别是:

  • 密钥管理完全失控。每个服务各存一份API Key,轮换密钥的时候要通知所有人改配置,漏一个就埋雷。
  • 费用无法按调用方维度统计。AI服务基本按Token或按次数计费,每个业务线的用量混在一起,财务对账的时候全靠Excel手工拉明细。
  • 故障隔离完全依赖业务方自觉。服务商A的接口超时10秒,各业务线各自设置超时,有的设3秒,有的设30秒,服务商一旦抖动,下游跟着全乱。
  • 限流和降级没有统一标准。有的业务线疯狂重试,直接触发服务商的限流,导致其他业务线也受影响。

这些问题的本质是:调用AI服务需要的不只是「发起一个HTTP请求」,而是「一个共享的、可控的、可观测的入口」。网关联动处理的就是这个入口问题。

1.2 自研网关、云厂商网关还是Spring Cloud Gateway

确定要上网关之后,我们内部做了一个简单的选型对比。市面上有现成的云上网关产品,也有自研派系,还有开源网关,考虑到我们的技术栈基本都是Java / Spring Boot,选择面其实很窄。

综合对比下来,表格如下:

对比维度云上网关产品基于OpenResty自研Spring Cloud Gateway
接入成本低,但内外网打通麻烦高,需要维护一套周边设施中,复用现有微服务体系
Java生态可扩展性受平台限制需要写Lua扩展天然支持Java Filter
流式响应支持部分产品支持一般需要底层处理原生对流式友好
团队维护成本较低很高可控
与注册中心/配置中心集成需要适配需要额外开发原生集成

我们在实际选型中最终选了Spring Cloud Gateway,原因是我们的服务治理体系本来就在Spring生态里,Nacos做注册配置中心、Prometheus做监控,网关直接使用同一套基础设施,省掉大量适配时间。而且Spring Cloud Gateway底层是Spring WebFlux + Reactor Netty,不是传统的Servlet模型,对流式响应和异步IO的处理天然更接近AI服务的调用场景。

1.3 网关内部模块划分

在搭项目之前,我们先把网关的职责边界画清楚。AI服务网关至少要具备以下能力:

  • 统一入口:所有AI服务调用统一走一个域名/路径前缀,对外只暴露网关地址。
  • 路由分发:根据请求路径或请求参数,把流量转发到对应服务商的真实接口。
  • 认证鉴权:校验调用方身份和权限,确保只有申请了Key的内部应用才能使用。
  • 流控保护:按调用方、按接口维度做限流,防止某个应用拖垮整体出口。
  • 熔断降级:当某个AI服务商持续异常时,快速返回降级结果,而不是让调用方阻塞等待。
  • 成本观测:记录每个请求的Token消耗、耗时、调用方来源,形成每日费用报表。

这六个模块不是一次性做完的,我们分了三期。第一期先把统一入口、路由和鉴权做完,解决密钥混乱和入口不一的问题;第二期补限流熔断和缓存,解决稳定性和成本问题;第三期才做完整的指标报表。下文主要讲前两期的核心实现细节。

2. 环境准备:搭出一个能跑通的Gateway骨架

2.1 工程基础与版本选择

代码基础我们用的是Spring Boot 3.2.5和Spring Cloud 2023.0.1,Java版本要求17。选这个组合没有特别激进,纯粹是因为Spring Cloud Gateway在WebFlux体系下的适配比较完善,同时Spring Boot 3.x已经全面切换到Jakarta命名空间,后续维护成本低。如果你还在用Spring Boot 2.x,也可以跑,但注意部分过滤器API有差异。

依赖方面,核心pom片段如下:

<dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-dependencies</artifactId> <version>2023.0.1</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-gateway</artifactId> </dependency> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-loadbalancer</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis-reactive</artifactId> </dependency> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-circuitbreaker-reactor-resilience4j</artifactId> </dependency> </dependencies>

这里有两个关键点。第一,不能引入spring-boot-starter-web,Spring Cloud Gateway的Netty模型和Tomcat会冲突。第二,要显式引入spring-cloud-starter-loadbalancer,否则在用服务名而不是IP地址配置路由时,网关会找不到实例。

2.2 第一版静态路由配置

我们最开始先做了最朴素的静态路由。application.yml里长这样:

server: port: 8000 netty: connection-timeout: 3000 spring: application: name: ai-gateway cloud: gateway: routes: - id: llm-text-route uri: https://model-api.example.com predicates: - Path=/api/ai/llm/** filters: - StripPrefix=2

这里解释一下/api/ai/llm/**的含义。我让下游业务方统一走/api/ai/{providerType}/这种路径,网关根据第一段providerType决定转发到哪个服务商。StripPrefix=2会把/api/ai这两段前缀去掉,再把剩余路径拼到目标uri上。这是最简单的路由策略,但配合统一的路径约定之后,新增一个服务商只需要加一段路由配置,业务方不用记多个域名。

2.3 把静态路由升级成动态路由

静态路由只适用于上线初期。AI服务商经常需要临时调整出口IP、切换模型版本,每次改路由都要重启网关,完全不可接受。所以我们二期就把路由定义搬到了Nacos配置中心,利用Spring Cloud Gateway自带的RouteDefinitionRepository做扩展。

实现思路是:监听配置中心的路由配置变更,解析成RouteDefinition列表,然后调用ApplicationEventPublisher.publishEvent(new RefreshRoutesEvent(this))触发网关刷新路由。下面这段代码是我们的核心逻辑:

@Component public class NacosRouteDefinitionRepository implements RouteDefinitionRepository { private final ApplicationEventPublisher publisher; private final NacosConfigService configService; private volatile String lastConfig = ""; @Override public Flux<RouteDefinition> getRouteDefinitions() { return Flux.fromIterable(parseRouteDefinitions(getConfigFromNacos())); } @Override public Mono<Void> save(Mono<RouteDefinition> route) { return Mono.empty(); } @Override public Mono<Void> delete(Mono<String> routeId) { return Mono.empty(); } @EventListener(classes = RefreshRoutesEvent.class) public void onRefresh() { // 配置变更后重新加载 } }

这个方案的本质是绕开内存中的InMemoryRouteDefinitionRepository,把路由信息的唯一数据源放到Nacos。做完整套之后最明显的感受是:上生产调服务商地址时,只需要去配置中心改一行URL,网关秒级生效,不再需要深夜发布重启。

3. 核心功能落地:鉴权、限流、熔断、缓存

3.1 用全局过滤器实现统一鉴权

网关的第一道关是鉴权。我们设计了调用方Key的机制:每个内部业务线申请一对AppKey和AppSecret,访问时在Header里带上X-App-Key和签名后的X-App-Sign。这样服务商的真实Key永远不会暴露给下游。

具体的全局过滤器代码如下:

@Component public class AuthenticationFilter implements GlobalFilter, Ordered { private final AppKeyService appKeyService; @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { ServerHttpRequest request = exchange.getRequest(); String appKey = request.getHeaders().getFirst("X-App-Key"); String sign = request.getHeaders().getFirst("X-App-Sign"); if (appKey == null || sign == null) { return reject(exchange, "missing credentials"); } return appKeyService.validateAppKey(appKey, sign) .flatMap(valid -> { if (!valid) { return reject(exchange, "invalid sign"); } exchange.getAttributes().put("appKey", appKey); return chain.filter(exchange); }); } @Override public int getOrder() { return -100; } }

注意两点。第一,Order要设置成负数,确保它在大多数过滤器之前执行。第二,不要在这里做数据库同步查询,否则高并发下网关性能会被拖垮。我们的做法是启动时把AppKey列表加载进Caffeine缓存,鉴权时只做缓存查询和签名比对,签名算法用HMAC-SHA256。

3.2 限流按调用方和接口维度分别配置

限流是整个网关里最容易「看似简单实则翻车」的部分。Spring Cloud Gateway内置的RequestRateLimiter过滤器用的是令牌桶算法,配合Redis实现分布式限流。

我们上线第一版后,发现一个问题:如果只按调用方限流,某个业务方把AI能力封装成自己的接口卖给外部用户,流量一大,其他业务方的额度就被抢走了。所以最终方案是「调用方 + 接口维度」双维度限流:KeyResolver返回一个字符串,把AppKey + 路由ID拼在一起作为Redis限流Key。

配置如下:

spring: cloud: gateway: default-filters: - name: RequestRateLimiter args: redis-rate-limiter.replenishRate: 60 redis-rate-limiter.burstCapacity: 120 key-resolver: "#{@callerRouteKeyResolver}"

对应的KeyResolver实现:

@Component public class CallerRouteKeyResolver implements KeyResolver { @Override public Mono<String> resolve(ServerWebExchange exchange) { String appKey = exchange.getAttribute("appKey"); String routeId = exchange.getAttribute(ServerWebExchangeUtils.GATEWAY_ROUTE_ATTR) == null ? "unknown" : ((Route) exchange.getAttribute(ServerWebExchangeUtils.GATEWAY_ROUTE_ATTR)).getId(); return Mono.just(appKey + ":" + routeId); } }

关于令牌桶参数replenishRate: 60和burstCapacity: 120,含义是每秒补充60个令牌,桶最大容量120。放在AI场景里,这大概是单调用方单接口每秒60次调用的上限。如果模型响应特别慢,比如一次要20秒,那60 QPS就会产生大量排队,需要配合后面讲的熔断和超时一起看。

3.3 服务商故障时的熔断降级

AI服务商的稳定性并不总靠得住。我们会遇到某个服务商因为机房抖动导致连续5分钟超时率50%的情况。如果没有网关这层隔离,业务方的应用会全部卡在等待响应上,线程池被打满,事故就会扩大。

我们用Spring Cloud CircuitBreaker + Resilience4j实现熔断。这一层不是网关自动集成好的,需要手动封装一个Filter,或者在路由的Filter链里加CircuitBreaker。

一个可用的配置长这样:

@Bean public CircuitBreakerFactoryCustomizer<Resilience4JCircuitBreakerFactory, Resilience4JConfig> customization() { return factory -> factory.configureDefault( id -> new Resilience4JConfig(id) .timeLimiterConfig(TimeLimiterConfig.custom() .timeoutDuration(Duration.ofSeconds(30)) .build()) .circuitBreakerConfig(CircuitBreakerConfig.custom() .failureRateThreshold(50) .waitDurationInOpenState(Duration.ofSeconds(30)) .slidingWindowSize(20) .build()) ); }

这里面timeoutDuration=30秒很关键。大模型接口的非流式调用经常要十几秒,不能拿普通接口的3秒超时来套,但也不能无限等。30秒是一个折中值。当熔断器进入OPEN状态后,网关不再把请求转发到真实服务商,而是走降级逻辑返回一个固定的JSON提示,让业务方快速感知而不是无限等待。

3.4 给幂等请求做缓存,省下真金白银

AI服务是按调用量计费的,同一段文案让模型重复生成两次,就是双份费用。我们观察后发现,内部的很多调用是高度幂等的,比如测试环境的联调请求、保活请求、重复拉取的周报生成,参数完全相同。

我们在网关里加了一层「精确请求缓存」:只有GET请求和部分带Idempotent-Key头的POST请求才会被缓存。缓存键由请求路径 + 请求体内容的Hash组成,值就是真实服务商返回的响应体。

实现时直接用Caffeine + Redis双层缓存:

@Component public class ResponseCacheFilter implements GlobalFilter, Ordered { private final Cache<String, byte[]> localCache = Caffeine.newBuilder() .maximumSize(10000) .expireAfterWrite(Duration.ofMinutes(10)) .build(); private final ReactiveStringRedisTemplate redisTemplate; @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { // 判断是否可缓存 // 生成缓存键 // 查缓存,命中直接返回 // 不命中则先缓存Body到exchange attribute,转发完成后写入缓存 } @Override public int getOrder() { return 100; } }

实际测试效果很好,某条周报生成路径的缓存命中率达到40%,一个月省下来的调用费用覆盖了网关本身的服务器成本,还多出来不少。但要注意,千万不要缓存流式SSE响应,缓存会把一整段流截断后存入Redis,既浪费空间又破坏长连接体验。

4. 接入AI服务商时的几个关键细节

4.1 SSE流式响应不能按普通HTTP来配置

大模型场景最常遇到的是SSE(Server-Sent Events)流式返回。普通的HTTP转发在网关拿到完整响应体后一次性返回,而SSE要求客户端与服务商之间建立一个持续的数据流通道。Spring Cloud Gateway底层是Netty的异步模型,对流式转发的支持比传统Servlet网关好很多,但依然有几个坑beta。

最大的坑是Response压缩。我们给网关开了全局的压缩过滤器,结果SSE流被压缩后,数据不是按行到达,客户端等半天收不到完整结果。排查后把SSE路由的Content-Encoding相关配置排除掉,同时关闭了该路由的压缩过滤。另一个问题是网关的HttpClient默认对请求有响应超时,但SSE的正常响应时间可能很长,所以需要单独给流式路由配置更长的超时时间,有时甚至需要把读取空闲超时调高到120秒。

4.2 请求体太大导致的内存飙升

图像生成服务接受base64编码的图片输入,一张大图可能有好几MB。网关默认的MaxInMemorySize是256KB,超过就会抛异常。有人第一反应是把这个值调大,但如果直接调到10MB,高并发下每路请求都在内存里缓存完整请求体,网关内存立刻就会被吃爆。

我们的做法是区分场景:文本类模型的请求体通常很小,保持默认值;图像类路由单独配置一个更大的MaxInMemorySize,并且只给该路由所在服务分配独立的网关实例组,从物理上隔离大请求。

配置参考:

spring: codec: max-in-memory-size: 2MB

如果你的服务商接口需要更大的图片输入,建议把网关侧的max-in-memory-size与服务商限制对齐,不要盲目放大。

4.3 服务商真实密钥的集中管理

网关存在的最大价值之一,就是把服务商Key从下游业务代码里彻底移除。运维在网关环境变量里配置真实Key,业务方只需要提交自己应用的AppKey和业务参数,具体请求转发时由网关把真实Key注入到请求头,业务方完全看不到。

这里有个容易被忽视的细节:如果你在网关配置里把真实Key放在普通请求头中,再转到服务商,服务的访问日志可能直接把整个Header打出来,等于密钥还是泄露了。我们最终的方案是:

  • 真实Key只存在网关环境变量里,不在Nacos明文配置里存。
  • 网关转发前把Key写入请求头,但同时在日志输出过滤器里对该Header做脱敏。
  • 下游业务方传过来的自定义Header统一清理,防止伪造服务商身份。

5. 生产落地:日志、指标与部署方案

5.1 一套能定位AI调用问题的访问日志格式

普通网关日志记requestURI、status、timeTaken就够了,AI网关远远不是。我们排查线上问题时最常遇到的是「这个请求到底用了哪个模型、花了多少Token、是谁调的」。所以日志格式专门做了扩展。

线上一条标准访问日志大致如下:

appKey=biz-crm model=gpt-3.5-turbo service=llm route=/v1/chat/completions status=200 tokens=1526 cost=3250ms target=model-api-02

为了搜集这些字段,我们手写了一层AccessLogGlobalFilter,从exchange attribute里读出appKey、targetUri、tokenCount、startTime等字段,再统一输出。注意WebFlux环境下不要直接用ThreadLocal存变量,要放在ServerWebExchange.getAttributes()里。

Token消耗字段是特别有价值的。因为网关代理的返回体里有usage字段,我们在转发完成或流式数据接收完成后解析出来,写进日志。这个字段后来成了财务账单的数据源,省了一个专门做账单解析的模块。

5.2 用Micrometer暴露真实业务指标

访问日志适合排障,做告警和容量规划还是得靠指标。我们引入micrometer-registry-prometheus,在网关里自定了一组Counter和Timer指标:

  • ai_gateway_request_total:按服务商、调用方、接口维度计数。
  • ai_gateway_request_duration_seconds:按成功失败分桶的耗时直方图。
  • ai_gateway_cost_tokens_total:累计Token消耗量,按模型维度。

代码如下:

static final Counter REQUESTS = globalRegistry.counter("ai_gateway_request_total", "service", "llm", "caller", "crm");

Prometheus每15秒从网关抓取一次指标,配合AlertManager设置两个核心告警规则:成功率连续3分钟低于95%告警;平均响应时间超过20秒告警。上线后第一次真实故障就是靠成功率告警发现的,某个服务商断连了将近7分钟,业务方还没反馈,我们这边已经拉起了排查。

5.3 无状态部署与压测容量评估

Spring Cloud Gateway本身是无状态的,这让我们在Kubernetes里的部署变得很简单。多副本部署后,路由状态都在Redis和配置中心,任意一个Pod下线都不会影响整体。

压测结果可以分享一个参考值。我们使用4C8G的容器跑网关,纯转发场景下能稳定承接大约每秒1200次简单文本请求;启用限流和鉴权后,由于有Redis和签名计算的开销,下降到约每秒800次。在同样的模型调用场景下,流量并不会真的达到这个量级,因为模型本身响应慢,出口QPS不会太高,所以网关通常不是瓶颈。

部署时还有一个小建议:不要给网关配置很高的单副本资源,而是把资源分摊到更多副本上。流式请求和令牌桶限流都需要一些内存,多副本可以降低单点内存压力,同时让K8s的滚动发布更平滑。

6. 高频故障排查与性能调优实录

6.1 Spring Web和WebFlux冲突导致并发启动失败

不少第一次用Spring Cloud Gateway的开发者会踩这个坑。工程里引了spring-boot-starter-web,然后发现网关无法启动,或者启动后有的接口返回404。原因就是Tomcat和Netty两个容器冲突了。

排查方法很简单:看启动日志有没有出现Spring MVC found on classpath的警告。因为Spring Cloud Gateway依赖限定了WebFlux,两者同时存在会使得路由匹配逻辑被Spring MVC抢先注册,导致网关根本不走Gateway的路由链。

解决办法是我们团队最常用的一句话:Spring Cloud Gateway项目不要加入starter-web依赖。如果确实有需要暴露普通HTTP接口,也要拆成独立服务而不是塞进网关。

6.2 SSE流式中途被网关截断

这个问题的典型表现是:客户端可以收到前面几行数据,然后连接中断,网关日志没有任何异常。查了一圈发现是网关的HttpClient在空闲一段时间后主动关闭了连接,原因是服务商两次发送数据之间的间隔超过了Netty的空闲超时设置。

我们最后在路由过滤链里加了一个自定义的SseTimeoutFilter,把该路由上Netty的读空闲时间从默认值调到了120秒,同时确保该路由不走全局压缩过滤器。如果你也碰到类似问题,优先检查两处:压缩过滤器和HTTP Client的空闲超时。

6.3 多个过滤器想读取请求体时读不到内容

如果同时需要日志过滤器、缓存过滤器、鉴权过滤器都读取请求Body,就会遇到一个经典问题:ServerHttpRequest的Body是流式的,读过一次就没了。我们在做缓存和日志同时读取时,发生过下游收到空Body的情况。

解决方案是把Body缓存到Gateway的cachedRequestBodyObject属性中。定义一个全局的CacheRequestBodyFilter,用DataBufferUtils.join把请求体完整读出来存入exchange attribute,后续过滤器统一从这里取。但要注意,Body缓存意味着请求会在内存中驻留一份,这对大请求是很重的负担,所以只对真的需要读Body的路径开启。

6.4 限流策略误杀所有请求

上线限流后遇到过一个诡异问题:所有调用方的全部请求都被返回429。查RedisKey发现所有请求都用了同一个Key,问题出在KeyResolver的返回值抛了异常,导致限流过滤器拿不到真正的Key,直接按默认Key处理。

正确的做法是在resolve方法里做好空值兜底,并在Filter级捕获异常,杜绝因校验失败导致全局限流。我建议在压测阶段就把错误Key、空Key、重复Key场景都测一遍,否则上线第一阶段就会被各路同事轰炸。

6.5 压测发现的连接池与线程瓶颈

最后说一个压测相关的问题。网关到后端AI服务之间会创建HTTP连接池,默认每个路由的最大连接数是500。压测时间一长,发现大量请求卡在等待连接上,错误率上升。我们把连接池配置调大,并增加了等待队列长度。

spring: cloud: gateway: httpclient: max-connections: 1000 connect-timeout: 3000 response-timeout: 30000 pool: max-connections: 1000 max-pending-connection-acquire: 500

这里的max-pending-connection-acquire表示如果没有空闲连接,最多有多少请求在队列里等待获取连接。配置调大之后的压测数据比之前稳定很多,但也要结合后端服务商能承受的并发量来看,并不是网关设置越大越好,否则会把压力全部抛给上游。

一点最后的体会

这套Spring AI Gateway从最初的路由转发,慢慢演进成集鉴权、限流、熔断、缓存、计费观测于一体的完整入口,前前后后花了大概两个迭代周期。我个人最大的体会是,网关真正的难点从来不是配置Spring Cloud Gateway,而是搞清楚你接入的AI服务有哪些独特行为——有没有流式返回、请求体有多大、费用怎么统计、上游能承受多大并发。把这些行为摸透了,再用网关的过滤器链去做针对性处理,项目推进就会顺畅得多。

最后再分享一个小技巧:线上的网关日志和指标不要只盯平均值,一定要分调用方、分接口、分模型去看。AI场景下,常年掉链子的是那1%的长尾异常请求,它们不占总流量,却会吃掉绝大部分排查精力。把可观测性粒度做细之后,很多问题在告警阶段就能定位,而不是等到业务方截图来问。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询