ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Spring AI Gateway实战:从零搭建AI服务统一网关

Spring AI Gateway实战:从零搭建AI服务统一网关

做AI应用调试的时候,你有没有遇到过这种场景:项目里接了好几个大模型服务,每个服务各自保存一份密钥,调用方时不时把请求打到不同的模型上,限流基本靠运气,出了问题只能翻日志一点点找。我去年在给某跨平台系统做AI接口治理时,被这个问题折腾得够呛。后来用Spring AI Gateway这套思路,把整个模型调用层收拢成一个统一网关,才算真正理顺。这篇文章想把这套方案的从零搭建过程、选型思路和踩过的坑整理出来,给正在琢磨AI服务网关怎么做的人一个参考。

这套东西解决的核心问题很直接:AI服务不能裸奔式接入。当你的业务里同时存在多模型调用、多团队接入、多端访问时,网关层能把认证、限流、审计、模型切换这些事统一收口。而且Spring AI本身把各家模型的差异抹平了,配合Spring Cloud Gateway做入口转发,效果相当于“入口统一+出口统一”。适合正在做Java后端AI应用、想给团队搭建AI服务接入层的人看。

1. 为什么AI服务需要一个网关

1.1 直连模型时我踩过哪些坑

早期我做AI功能时图省事,直接在业务代码里调用模型接口。业务少的时候没什么感觉,等接入方一多,问题就冒出来了。

首先是密钥管理失控。每个服务各自维护API Key,有人把Key写死在配置文件里,有人直接放在前端代码里调。结果某天一个Key被刷爆,账单瞬间涨了一截,才意识到密钥暴露是个大问题。其次是模型切换成本高。今天用A模型效果好,明天要换B模型,每个调用方都得跟着改代码、改配置、重新发版。再就是完全没有统一的调用视图,哪个团队调了多少次、哪些请求超时、哪个模型报错,根本看不出来。

这些问题如果只是偶尔一次调用无所谓,一旦AI成为产品核心链路,就必须有个中间层把这些事管起来。网关的价值就在这里:把模型调用从“手工作坊”变成“标准化流水线”。

1.2 网关在AI调用链里的位置

我理解的AI服务网关,不是简单在模型接口前加一层代理,而是把整条调用链分成三段。

第一段是客户端到网关。网关负责鉴权、限流、参数校验、日志记录,这一段解决的是“谁在调、能不能调、调了多少”。第二段是网关到AI应用服务。这个服务负责组装Prompt、管理上下文、处理结构化输出,甚至做一些业务相关的后处理。第三段是AI应用服务到具体模型服务商。这里用Spring AI统一对接多模型,模型升级、切换都在这一层处理,业务方无感知。

这种三段式的拆法,核心逻辑是让每一层只操心一件事。网关不用关心模型内部细节,AI服务不用操心流量控制,模型服务商更不用知道你内部的组织结构。Spring AI Gateway这个名字,本质就是“Spring AI应用 + 网关”的组合架构,只不过把网关能力前置了。

1.3 三种接入方式的取舍

我在决定方案前,对比过三种路线:业务直连模型、只封装SDK、独立网关。这里直接放一张对比表,方便你看差别。

维度业务直连模型封装SDK(统一客户端)独立AI网关(Gateway + Spring AI)
鉴权与密钥管理分散在各服务,风险高集中在SDK,但SDK分发仍麻烦统一在网关,密钥不出核心区域
限流能力基本没有依赖SDK内部实现,力度弱网关层集中限流,支持令牌桶等算法
模型切换改动各业务代码改SDK配置,仍需发版改网关/AI服务的策略配置
可观测性很难统一有基础日志统一审计日志、调用链跟踪、指标采集
接入成本最低中初期较高,但长期收益明显
适合规模单体、演示Demo中等规模、团队单一多团队、多模型、生产级系统

我的建议是:如果只是写个个人Demo,直连模型就行,没必要上网关;如果是一个产品线有多个端接入,或者团队里好几个项目都在用AI,那直接上网关,后面省心得多。

2. 方案选型:为什么是Spring Cloud Gateway + Spring AI

2.1 网关层不等于模型SDK层

很多人会问:Spring AI本身不是已经统一了模型接入吗?为什么还要单独加一个网关?

这里要分清楚:Spring AI解决的是“怎么调模型”的问题,它把不同模型服务商的协议差异抽象成统一的ChatClient、ChatModel接口,这是出口统一。但网关解决的是“谁能调、调得是否合规、流量是否可控”的问题,属于入口统一。打个比方,Spring AI像是一个翻译官,能把不同语言的外卖菜单翻译成标准菜单;网关则像餐厅门口的服务台,先确认你有没有预约、排队号是多少、一次能点几个菜。

两者不是替代关系,而是分层配合。没有网关,Spring AI再方便,调用入口依然散落在各个服务里;没有Spring AI,网关就算把请求转过去了,上游对接不同模型时还是会写出大量if-else。

2.2 调用链路的关键节点

我们这套方案里,请求链路是这样的:

客户端进入Spring Cloud Gateway,先走全局过滤器链,做鉴权和基础校验。校验通过后匹配路由,进入具体路由过滤器,比如限流、路径重写。转发到AI应用服务后,服务内部通过Spring AI的ChatClient组装Prompt、调用模型、处理返回结果。模型响应原路返回。

一条链路里,最容易被忽略但必须想清楚的两个节点是执行顺序和错误处理。Spring Cloud Gateway的过滤器有order概念,全局过滤器默认在路由过滤器之前执行,所以我通常把鉴权放到order值最小的全局过滤器里,保证未经认证的请求到不了路由层。限流放在路由过滤器里,因为限流必须针对具体路由算流量,不能所有请求混在一起。

2.3 路由、过滤器、限流这三个基础概念

不常接触网关的话,这三个概念容易混。我用实际场景讲。

路由(Route)就是一张“请求地图”。比如客户端请求路径是/api/ai/chat,网关根据Path断言匹配到对应的AI服务地址,相当于快递分拣中心根据面单上的地址把包裹送到不同站点。

过滤器(Filter)就是在路由执行前后插入的一系列处理动作。比如我要在所有请求头上加一个内部标识,或者把请求体里的某些敏感字段脱敏,都可以用过滤器完成。网关里有GlobalFilter和GatewayFilter两种,前者对所有请求生效,后者只对绑定的路由生效。

限流(RateLimiter)是控制流量的阀门。Spring Cloud Gateway内置的RequestRateLimiter基于令牌桶算法,配合Redis实现分布式限流。我后面会详细讲参数怎么算。

3. 从零搭建一个Spring AI Gateway实战

3.1 初始化工程与依赖版本组合

我是基于Spring Boot 3.2.x来做的,网关用Spring Cloud Gateway 4.x,AI部分用Spring AI的starter。这里要注意,Spring AI的版本和Spring Boot的版本兼容性很关键,用错版本会出现类找不到或者配置不生效的问题。

pom.xml核心依赖这样配:

<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.2.5</version> <relativeParent/> </parent> <dependencies> <!-- 网关核心 --> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-gateway</artifactId> </dependency> <!-- WebFlux,Gateway基于它运行 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-webflux</artifactId> </dependency> <!-- 限流需要Redis --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis-reactive</artifactId> </dependency> <!-- Spring AI 统一模型接入 --> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-spring-boot-starter</artifactId> <version>1.0.0-M6</version> </dependency> </dependencies>

需要说明的是,Spring AI当时还处于里程碑版本阶段,API变化比较快。我写这篇文章时用的1.0.0-M6,后面发布正式版后,部分类名可能调整,但整体思路不变。

注意:Gateway基于WebFlux,所以不要在同一工程里引入spring-boot-starter-web,否则启动会冲突报错。AI服务作为被转发的后端,可以单独用一个Spring Boot工程,也可以在同一工程里拆模块,但不要把Web MVC和WebFlux混在一个可执行应用里。

3.2 用YAML配置路由与模型提供方

网关配置文件里,我把路由指向一个本地的AI应用服务,端口是18080。客户端请求路径是/api/ai/**,网关转发时把前两段路径去掉,这样AI服务只看到/chat这种相对路径。

server: port: 8080 spring: application: name: ai-gateway cloud: gateway: routes: - id: ai-core-route uri: http://localhost:18080 predicates: - Path=/api/ai/** filters: - StripPrefix=2 - name: RequestRateLimiter args: redis-rate-limiter.replenishRate: 20 redis-rate-limiter.burstCapacity: 40 redis-rate-limiter.requestedTokens: 1 data: redis: host: localhost port: 6379

AI应用服务这边,用Spring AI抽象对接模型服务商。配置里base-url和api-key都从环境变量读取,避免密钥明文入库。

spring: ai: model: base-url: ${AI_MODEL_BASE_URL:https://api.example-model.com} api-key: ${AI_MODEL_API_KEY:sk-your-key} chat: options: model: ${AI_MODEL_NAME:chat-model-name} temperature: 0.7

这段配置的核心用意是:AI应用服务不要写死具体模型服务商,切换模型时只改环境变量,业务代码一行不用动。

3.3 实现一个带鉴权的全局过滤器

鉴权是所有服务入口的第一道门。我在网关里写了一个全局过滤器,检查Authorization请求头,并简单做一个Token校验。

import org.springframework.cloud.gateway.filter.GlobalFilter; import org.springframework.cloud.gateway.filter.GatewayFilterChain; import org.springframework.core.Ordered; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Component; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; @Component public class AuthGlobalFilter implements GlobalFilter, Ordered { @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { String token = exchange.getRequest().getHeaders().getFirst("Authorization"); if (token == null || !token.startsWith("Bearer ")) { exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); return exchange.getResponse().setComplete(); } String realToken = token.substring(7); // 这里调用内部认证服务校验Token,通常用Redis缓存结果,减少鉴权耗时 if (!validateToken(realToken)) { exchange.getResponse().setStatusCode(HttpStatus.FORBIDDEN); return exchange.getResponse().setComplete(); } return chain.filter(exchange); } private boolean validateToken(String token) { // 模拟校验逻辑 return "internal-token-123".equals(token); } @Override public int getOrder() { return -100; } }

这里有一个容易忽略的点:getOrder()返回-100,是为了让这个全局过滤器在Spring Cloud Gateway内置的过滤器之前执行,确保未认证请求被尽早拦截。如果order设置得太大,请求可能先被内置的转发逻辑处理,鉴权过滤器反而变成了事后检查。

实操心得:生产环境不要把Token校验逻辑写在网关里,应该让网关把Token透传给统一认证服务,缓存认证结果。我这次只是为了演示,所以用了最简单的模拟校验。另外,校验失败时状态码建议区分:未携带Token返回401,Token无效返回403,语义更清楚。

3.4 给网关加上令牌桶限流并讲清参数计算

限流这部分,我一开始以为配置上就完事了,后来发现参数算不明白的话,限流要么形同虚设,要么误伤正常请求。

令牌桶算法的原理可以这么理解:有一个桶,桶里最多放burstCapacity个令牌,每秒钟往桶里放replenishRate个令牌。请求来了必须拿到令牌才能放行,没令牌就等或者直接拒绝。配置里设了三个参数:

  • replenishRate=20:每秒补充20个令牌,也就是正常情况下每秒最多放行20个请求。
  • burstCapacity=40:桶的容量是40,允许短时间内突发40个请求。
  • requestedTokens=1:每个请求消耗1个令牌。

参数计算公式不复杂,核心是确定两个值:业务平均每秒请求量和峰值每秒请求量。比如我做过的一个内部AI服务,日调用量约100万,平均每秒大概12次,峰值每秒约30次。那我设replenishRate=20留一点余量,burstCapacity=40能覆盖短时峰值。

但如果你的模型服务商本身有每分钟调用数限制,比如每分钟60次,那每秒补1个令牌就够了,burstCapacity设成5到10更合理,别让突发流量把模型服务商打爆。

filters: - name: RequestRateLimiter args: redis-rate-limiter.replenishRate: 20 redis-rate-limiter.burstCapacity: 40 redis-rate-limiter.requestedTokens: 1

注意:RequestRateLimiter必须配合Redis使用,这是分布式限流的基础。如果只用本地内存,网关多实例部署时每个实例各自计数,总流量会成倍增长,等于没限住。

3.5 在网关后面的AI服务里编排模型调用

AI应用服务是网关后面的核心业务逻辑层。它的职责是用Spring AI把模型调用编排好,对外只暴露一个简单的Chat接口。

import org.springframework.ai.chat.client.ChatClient; import org.springframework.web.bind.annotation.*; import reactor.core.scheduler.Schedulers; import reactor.core.publisher.Mono; @RestController @RequestMapping("/chat") public class ChatController { private final ChatClient chatClient; public ChatController(ChatClient.Builder builder) { this.chatClient = builder.build(); } @PostMapping public Mono<String> chat(@RequestBody ChatRequest request) { return Mono.fromCallable(() -> chatClient.prompt() .user(request.getMessage()) .call() .content() ).subscribeOn(Schedulers.boundedElastic()); } }

这里有个小细节:ChatClient的call()方法是同步阻塞的,所以我在WebFlux环境里用Mono.fromCallable包了一层,并指定了boundedElastic线程池,避免阻塞事件循环线程。如果不包这一层,网关转发没问题,但AI服务自身在高并发下容易把线程耗尽。

如果要用流式响应,可以把接口改成返回Flux:

@PostMapping("/stream") public Flux<String> stream(@RequestBody ChatRequest request) { return chatClient.prompt() .user(request.getMessage()) .stream() .content(); }

流式返回在网关层也要做适配,Spring Cloud Gateway转发响应时本身支持流式,但如果中间加了过滤器去读响应体,就要小心缓冲问题。我当时踩过一个坑:为了记录完整响应日志,在全局过滤器里读了response body,结果把流式响应变成了全量缓冲,前端等模型一个字一个字蹦的效果全没了。后来改用只记录响应状态码和耗时,不碰body,问题才解决。

4. 把网关用到生产环境:上下文、观测、模型兜底

4.1 会话上下文该放哪里

对话类AI场景绕不开上下文管理。刚开始我把聊天历史直接放在AI服务内存里,用ConcurrentHashMap存,测了几个请求没问题,一压测就发现内存涨得飞快,而且网关转发到不同实例时上下文不共享。

后来我用了Spring AI的MessageChatMemoryAdvisor,把聊天记忆存进Redis。这样多个网关实例和AI服务实例都能读到同一份会话上下文,水平扩展时才不会出现“上一句你说了什么”这种尴尬。

ChatMemory chatMemory = new RedisChatMemory(redisTemplate); ChatClient chatClient = ChatClient.builder(chatModel) .defaultAdvisors(new MessageChatMemoryAdvisor(chatMemory)) .build();

上下文存储要考虑token长度。每轮对话都全量塞给模型,很快就超过模型上下文窗口。我给MessageChatMemoryAdvisor配了窗口大小,只保留最近6轮对话,超过的旧消息自动丢弃。这里的取舍是:对话连贯性够用就行,别把模型上下文窗口撑爆。

4.2 审计日志与调用链跟踪

做AI网关,日志这事必须提前想好,不然后面排查问题全靠猜。我加了一个审计过滤器,把每次请求的关键信息记录下来:调用方标识、请求路径、模型名称、消耗的令牌数、响应耗时、状态码。

这里不贴完整代码了,核心思路是在GlobalFilter里往exchange的attributes里写入开始时间,等响应结束后在filter的then阶段读取这些数据,打印结构化日志。字段之间用KV形式,方便日志系统解析。

requestId=xxx userId=yyy model=chat-model-name tokens=128 latency=2341ms status=200

如果公司有全链路追踪系统,可以把网关生成的traceId下传到AI服务,AI服务再传给模型调用链路。这样用户问了一句“为什么这么慢”,你能从网关一路追到模型服务商,定位到是网络延迟还是模型本身推理慢。

4.3 多模型路由与超时兜底

AI服务一个很现实的需求是模型降级。主模型服务商不稳定时,要能快速切到备用模型。我在AI服务里写了多模型配置,主备两个ChatModel都注入进来,调用主模型失败或超时时自动切备用。

@Service public class ModelRouter { private final ChatModel primaryModel; private final ChatModel fallbackModel; public ModelRouter(ChatModel primaryModel, ChatModel fallbackModel) { this.primaryModel = primaryModel; this.fallbackModel = fallbackModel; } public String chat(String prompt) { try { return primaryModel.call(prompt); } catch (Exception e) { // 这里记录一条告警日志 return fallbackModel.call(prompt); } } }

这只是最基础的兜底方案。更完善的可以在网关层做:网关配置了多个路由,分别指向使用不同模型的AI服务,通过条件路由或权重的形式做灰度。比如新模型先在10%流量上试运行,表现稳定再逐步调高权重。这个能力Spring Cloud Gateway的Weight路由谓词能直接支持。

4.4 这套网关上线后带来的实际变化

这套方案上线后,客观影响是看得见的。首先是密钥不再散落各处,模型服务商的密钥只在AI服务环境变量里存在,业务方完全接触不到。新项目接入AI,只需要申请网关的Token,不用关心后端用的什么模型。其次是模型升级变得很轻,某次模型服务商发布新版本,我在AI服务里改了模型名配置,刷新后全部调用方自动切换到新模型,业务端无感知。

对团队管理的影响更明显。原来每个组自己管AI调用的时候,账单汇总困难,不知道哪条业务线花钱多。网关统一收口后,按调用方维度统计token消耗和费用,预算和成本拆分都有据可查。

5. 常见问题与排查技巧实录

5.1 过滤器不生效、路由404这类基础问题

问题现象可能原因排查与解决
自定义GlobalFilter没执行过滤器没注册为Spring Bean检查类上是否有@Component注解,启动日志里是否出现该Bean
请求返回404路由断言路径写错核对Path断言与实际请求路径是否匹配,注意通配符写法
StripPrefix后路径不对对StripPrefix数量理解错误StripPrefix=2表示去掉前2段,比如/api/ai/chat变成/chat
网关启动报Web兼容错误工程里引入了spring-boot-starter-web删除web依赖,网关必须基于WebFlux

5.2 限流误伤与参数调优

有一次我把replenishRate设成5,结果业务方反馈时不时报429。查了监控发现,模型调用本身有突发性,用户上午十点集中提问,流量秒级冲到10以上。5个令牌每秒根本不够。后来把replenishRate调到20,burstCapacity调到50,误伤消失。

限流参数调优没有一劳永逸的答案。我现在的做法是先观察两周线上流量,取P95和P99的QPS,再反推参数。如果限流触发率超过1%,说明参数偏严;如果从不触发,说明设得太宽,失去限流意义。

实操心得:限流之后一定要做好合理的错误提示。网关默认返回429状态码,但响应体是空的。我加了一个全局异常处理,返回统一的JSON结构,告诉调用方“触发限流,请在X秒后重试”,否则前端拿到429也不知道发生了什么。

5.3 流式响应与模型超时的几个坑

流式响应最大的坑是超时配置。网关默认转发响应有超时时间,模型推理本身耗时长,尤其是生成大段内容时,容易触发网关超时导致连接中断。我处理的办法是把路由级超时拉长,GatewayProperties里设置httpclient连接超时和响应超时:

spring: cloud: gateway: httpclient: connect-timeout: 3000 response-timeout: 60s

另一个坑是模型服务商返回的报错信息被网关吞掉,调用方只看到500。我的处理是在AI服务里增加统一异常处理,把所有模型层的异常转成结构化错误码,网关再根据错误码返回给调用方。比如模型超时返回504、模型余额不足返回403、模型内容审核拒绝返回422。这样调用方能直接根据状态码定位问题,不用每次来找后端看日志。

5.4 版本兼容问题速查

Spring AI迭代快,版本兼容是我遇到过最多的一类问题。出现过Spring Boot版本不同导致自动配置类加载失败、ChatClient的包路径变化导致编译错误等。我的建议是:以Spring AI对应版本的官方文档为准,锁定Spring Boot版本、Spring Cloud版本,不要混用太新的Spring AI和太旧的Spring Cloud。如果项目里只能用稳定版Spring Boot,就先确认Spring AI有没有对应的稳定版,再决定是否升级。

场景建议
Spring Boot 3.2.x + Spring AI M系列可以正常工作,但API可能后续调整
Spring Boot 3.4.x + Spring AI 1.0正式版正式版发布后优先选这个组合
Spring Boot 2.x不建议,Spring AI基于Spring Boot 3构建,网关注册和WebFlux都依赖新版本

整个项目从路由配置到限流、鉴权、模型编排,最花时间的地方不是写代码,而是把“请求经过每个环节时应该检查什么、记录什么、如果失败怎么办”想清楚。我个人的体会是,网关的上线并不等于一劳永逸,它只是把混乱的调用关系变成了有秩序的管理面。你后续如果想继续扩展,可以在网关层加缓存策略,命中相同问题时直接返回缓存结果,减少模型调用成本;也可以接上多租户隔离,让不同业务线之间的Token、限流配额互不影响。先把基础链路跑通,再去想这些事也不迟。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进