YAOTU INSIGHTS

Spring AI Gateway实战:统一大模型接入与流式转发

Spring AI Gateway实战:统一大模型接入与流式转发
最近做AI应用的同学应该都有同感模型调用越来越杂对接的渠道越来越多。这边一个业务要接大模型做对话那边一个服务要调向量化接口还有的老系统只是为了接一个翻译能力。各个业务方直接去申请模型服务的Key密钥散落一地调用量没人统计接口升级了下游全得跟着改。时间一长整个调用关系乱成一锅粥出了问题都不知道该找谁。所以我把这几年做API网关的经验搬过来结合Spring生态整理了一个专门面向AI服务场景的网关方案也就是标题里的Spring AI Gateway。它不是凭空发明轮子而是把传统API网关的路由、鉴权、限流、监控这些成熟能力重构到AI服务的语境下——多模型接入、流式响应转发、Token计量、上下文管理、按业务线分配额度。用一个统一入口承接所有AI能力业务方只对接一个地址剩下的路由、转发、熔断、统计都在网关层解决。这篇内容会从Spring AI Gateway的架构思路讲起落到依赖配置、路由规则、流式转发、Token统计、限流熔断这些核心环节再附上我自己实测遇到的坑和排查路径。适合正在做AI应用集成、想把模型调用统一收敛起来的技术同学参考。1. 整体设计思路为什么AI服务需要一个专用网关先聊清楚一个问题Nginx能反代Spring Cloud Gateway也能做路由为什么还要单独搞一个AI Gateway答案是AI服务的调用模式和普通HTTP API差别很大。传统网关处理的是短连接请求转发完就完事响应体通常是JSON大小几KB到几MB控制逻辑放在路由和鉴权层面就够了。但AI模型调用有四个显著不同的特征。第一个是流式响应。大模型生成内容是边生成边吐字一个请求可能持续十几秒甚至几十秒传统网关如果默认缓冲整个响应再返回客户端要等半天才能看到第一个字体验直接崩掉。所以网关必须支持流式透传也就是请求打进来之后把模型服务的SSE或WebSocket数据流原样转发给客户端中间不截断、不缓冲。第二个是多种模型协议并存。市面上能接的模型服务和开源自建模型接口规范不统一。有走OpenAI兼容格式的有走各家私有协议的有本地推理框架暴露非标接口的。业务方如果每个都适配一遍工作量非常大。网关的一个核心职责就是把多种协议归一化成同一套对外接口上游随便怎么变下游不用改。第三个是Token计量和成本归属。大模型接口不是免费的按Token计费。企业里不同部门、不同项目用模型成本要分摊。这个统计如果放在业务代码里每个项目都得写一遍还容易漏。放在网关层每个请求经过的时候都能实时累加Token消耗按项目、按渠道、按模型维度记账月底对账直接导出报表。第四个是超时、熔断和降级的复杂度。模型推理慢响应时长不稳定。如果网关没有针对流式请求的超时控制一个慢请求就可能把连接池占满。更麻烦的是模型服务一旦故障不能把错误直接抛给所有业务方最好有降级策略比如主模型超时就切到备用的轻量模型。对比一下传统API网关和Spring AI Gateway的分工差异传统API网关聚焦在请求路由、入口鉴权、灰度发布这些通用能力AI网关在保留这些能力的基础上还要处理流式转发、Token统计、模型路由、上下文传递这些AI业务特有的逻辑。我见过不少团队直接用Spring Cloud Gateway挂模型服务的地址结果碰到两个问题。第一网关默认配置下SSE流式响应会被压缩或缓冲客户端收不到增量内容。第二所有业务方共用一把密钥无法区分调用方鉴权形同虚设。这些问题的根源在于通用网关不知道AI请求长什么样自然没法针对性地处理。Spring AI Gateway的价值就是把AI服务的语义纳入网关的配置体系和过滤链里。所以我设计的整体架构分四层接入层负责统一对外暴露HTTP接口鉴权识别调用方路由层根据请求参数选择具体模型服务商和模型转发层处理流式代理、超时、重试观测层采集Token消耗、延迟、错误率输出指标和日志。Spring的响应式栈WebFlux Reactor Netty天然适合做流式转发这是选型时非常关键的一个原因。2. 环境准备与关键技术选型先列一下我用的环境版本都是当前生产环境跑过一段时间比较稳的组合JDK 17Spring Boot 3.x和Spring Framework 6.x的基线版本要求Spring Boot 3.2.x稳定版本兼容性最好Spring Cloud Gateway 4.1.x基于WebFlux的响应式网关专注路由和过滤Maven 3.9构建工具也支持Gradle看团队习惯Redis用于限流计数、Token累计的分布式存储为什么需要JDK 17Spring Boot 3.0之后就抛弃了JDK 8不是因为想折腾而是虚拟线程、Records、Switch模式匹配这些新特性对写网关过滤逻辑确实有帮助。尤其是虚拟线程在处理大量IO密集型请求时很有优势。当然如果你还在JDK 8上也可以考虑Spring Boot 2.7配合相关网关版本但AI流式这块的响应式支持不如3.x顺畅建议直接用新版本。Spring Cloud Gateway基于WebFlux这意味着我们的过滤器写起来和Servlet模型不一样。很多人第一次上手会被这个区别坑到——我明明写了过滤逻辑为什么对请求体没效果因为在WebFlux里请求体是一次性流不能被反复读取。要拿到请求体内容得先缓存再读取。这一点在后面的Token统计和鉴权环节会反复遇到。除了基础框架还需要引入几个组件spring-cloud-starter-gateway核心网关依赖包含路由、断言、过滤器机制spring-boot-starter-data-redis-reactive响应式Redis客户端用于限流和计数reactor-extraReactor的扩展工具包主要用于流式信号的统计和超时控制micrometer-registry-prometheus指标埋点把网关的调用数据暴露给监控大盘依赖版本不要随手写最新版最好用Spring Cloud的BOM统一管理不然容易碰到jar包冲突。我踩过一次spring-cloud-starter-gateway和spring-boot-starter-web同时存在的坑结果网关直接启动报错因为Spring Boot自动装配发现两个Web容器冲突了。网关项目里千万不要引入spring-boot-starter-web这是入门阶段最高频的一个错误。还有一个容易被忽视的点Spring AI Gateway这个名字和Spring官方出的Spring AI项目是两个东西。Spring AI是官方的大模型应用开发框架定位在应用层Spring AI Gateway是我们在网关层基于Spring Cloud Gateway自己搭建的AI服务路由方案。理解这一点很重要因为搜索资料时这两类内容混在一起初次接触容易绕晕。3. 核心实现与实操过程3.1 搭建基础网关工程首先创建一个空的Spring Boot项目只引入网关和Reactive相关的依赖。我的pom.xml关键依赖如下dependencies dependency groupIdorg.springframework.cloud/groupId artifactIdspring-cloud-starter-gateway/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis-reactive/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-actuator/artifactId /dependency /dependencies注意这里没有引入spring-boot-starter-web也没有引入spring-boot-starter-webflux。因为Spring Cloud Gateway本身已经带了WebFlux再显式引入反而可能造成版本冲突。这个工程就是个纯粹的响应式网关不走Servlet容器。配置文件里先把基础路由跑起来。假设我们要对外暴露一个统一的AI对话接口路径 /ai/v1/chat后端转发到模型服务商的实际地址server: port: 8080 spring: application: name: spring-ai-gateway cloud: gateway: routes: - id: ai-chat-route uri: http://api.model-provider.example.com predicates: - Path/ai/v1/chat filters: - RewritePath/ai/v1/chat, /v1/chat/completions这个配置的意思是客户端请求网关的 /ai/v1/chat网关把路径重写成模型服务商的 /v1/chat/completions然后转发过去。这时候网关已经能完成基础转发但和直接用Nginx反代没什么本质区别。接下来逐步往里面加入AI网关该有的能力。3.2 多模型服务商接入与统一路由策略实际的业务场景不会只对接一个模型服务可能有多个渠道一个主打对话的较大模型、一个走轻量快速路线的垂直模型、一个本地私有化部署的推理服务。它们的接口格式不同鉴权方式不同超时参数更不一样。Spring AI Gateway在这里采用两级路由策略。第一级是按业务方路由识别调用方身份第二级是按请求内容路由识别要调用的模型。先看二级路由的实现方案。我们要求客户端在请求头里带一个 x-model-provider 的头来指定渠道网关读取这个值动态选择目标地址。Spring Cloud Gateway自带RouteLocator只支持静态配置动态路由需要自定义一个过滤器在转发前决定URI指向哪里。实现方式是自定义一个GlobalFilter在请求进入路由匹配阶段后、真正发起转发前根据头信息替换目标URIComponent public class ModelRouterFilter implements GlobalFilter, Ordered { private static final MapString, String PROVIDER_MAP Map.of( provider-a, http://xxx-model-a.example.com, provider-b, http://xxx-model-b.example.com, local-llm, http://10.0.0.8:8000 ); Override public MonoVoid filter(ServerWebExchange exchange, GatewayFilterChain chain) { String provider exchange.getRequest().getHeaders().getFirst(x-model-provider); if (provider null || !PROVIDER_MAP.containsKey(provider)) { exchange.getResponse().setStatusCode(HttpStatus.BAD_REQUEST); return exchange.getResponse().setComplete(); } String targetUri PROVIDER_MAP.get(provider); URI newUri URI.create(targetUri); ServerWebExchange mutatedExchange exchange.mutate() .request(exchange.getRequest().mutate().uri(newUri).build()) .build(); return chain.filter(mutatedExchange); } Override public int getOrder() { return -1; } }这里有个细节Order值越小优先级越高把RouterFilter的Order设为-1保证它在其他业务过滤器之前执行因为后面的过滤器和转发逻辑都依赖路由结果。有些团队会问为什么不直接在配置文件里写多条Route、每个Route对应一个模型服务商那样当然也可以但客户端就必须知道所有上游地址的路径规则。通过Header动态路由客户端只认一个网关入口网关内部在做模型调度这种设计在模型服务商数量增加时改动面最小。3.3 流式响应转发与增量透传到了最关键的一环。AI对话基本都是流式输出客户端期望的效果是用户发一句话服务端不是十几秒后一次性吐出一整段而是打字机一样一个字一个字敲出来。Spring Cloud Gateway的默认转发行为是拿到上游完整响应后再发回给客户端。对于普通API没事但对于SSE流式响应这个行为会把实时性毁掉。需要显式启用流式转发。在Spring Cloud Gateway中可以配置spring: cloud: gateway: httpclient: response-timeout: 30000这个配置把上游响应的超时时间从默认的5秒拉长到30秒。真正让流式响应“流”起来的是要确保任何中间过滤逻辑不截断数据流并且响应头里正确设置SSE相关字段。在自定义过滤器中不能像Servlet里那样直接读取response.getWriter()去写。响应式网关里要操作的是一个ServerHttpResponse通过writeWith接收Publisher。以下是一段处理SSE的过滤器简化示例Component public class SSEHeaderFilter implements GlobalFilter, Ordered { Override public MonoVoid filter(ServerWebExchange exchange, GatewayFilterChain chain) { ServerHttpResponse response exchange.getResponse(); response.getHeaders().set(Content-Type, text/event-stream); response.getHeaders().set(Cache-Control, no-cache); response.getHeaders().set(X-Accel-Buffering, no); return chain.filter(exchange); } Override public int getOrder() { return 100; } }X-Accel-Buffering这个头值得说一下。如果网关前面还挂了NginxNginx默认对响应做缓冲这样即使网关已经把流式数据传下去Nginx也会攒一堆才发给客户端。设置X-Accel-Buffering为no就是告诉Nginx别缓冲实时转发。你如果整个链路还有CDN类似的缓存策略也要调否则前端看到的还是挤牙膏式的输出。实测下来流式转发的性能瓶颈往往不在网关本身而在连接池配置。网关默认的HTTP连接池是固定数量的如果某个大模型推理特别慢长时间占用连接不释放连接池很容易被打满。生产环境要把连接池调大一些并且设置合理的超时和老化时间spring: cloud: gateway: httpclient: connect-timeout: 3000 response-timeout: 30000 pool: type: elastic max-connections: 500 max-idle-time: 60stype用elastic而不是fixed让网关在高峰期能弹性创建连接避免流量尖刺把连接池压垮。这里要根据机器规格和上游服务的承压能力调不是越大越好。3.4 业务方鉴权与API Key管理网关作为统一入口首先解决的是“谁在调用”的问题。每家业务方不能直接持有模型服务商的密钥而是由网关颁发接入凭证。凭证类型一般有两种静态Token和动态签名。内部系统用静态Token就够外部客户接入用动态签名更安全。我实现Token校验的方式是自定义一个GlobalFilter从请求头里读取 Authorization解析出业务方身份然后用Redis里的缓存校验有效性。Component public class AuthFilter implements GlobalFilter, Ordered { private final StringRedisTemplate redisTemplate; public AuthFilter(StringRedisTemplate redisTemplate) { this.redisTemplate redisTemplate; } Override public MonoVoid 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(); } token token.substring(7); return redisTemplate.opsForValue() .get(app:token: token) .flatMap(appId - { exchange.getAttributes().put(appId, appId); return chain.filter(exchange); }) .switchIfEmpty(Mono.defer(() - { exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); return exchange.getResponse().setComplete(); })); } Override public int getOrder() { return -100; } }Token管理还有一个常见细节我们对接的模型服务商本身的API Key不是放在每个请求里透传的。网关收到下游请求后先按业务方身份查到这个业务方被分配的上游Key再把这个Key注入到转发的请求头里。这个查Key的过程要走缓存不能每次都查数据库。网关高并发场景下一次请求多一次DB交互整体吞吐会差很多。我见过一个团队最开始把所有服务商的Key写在配置文件里后来要按业务方维度分配不同上游账号又改成数据库存储每次请求实时查。上线后压测发现吞吐量掉了20%问题就出在每次转发都要查一次库。后来加了一层本地缓存加Redis两级缓存才把性能拉回来。从安全角度还要做好上游密钥的隔离网关转发的日志里不能明文打印上游Key。这一点容易混在大量调试日志里被忽略但踩过坑的人都知道——日志一旦泄露到第三方等于把模型服务的账单开放给所有人。3.5 Token计量与成本分摊AI网关和传统网关的最大区别就在这里。每调用一次大模型都是在烧钱。如果不计量财务月结的时候各个业务方都不认账最后只能按总额平摊。用网关做Token计量是实现成本归属的最简路径。难点在于Token消耗不像一句SQL执行时间那么好统计。模型服务的流式响应是分片返回的usage信息有的在响应末尾才给出来有的服务商干脆不给要我们自己估算。所以在Spring AI Gateway中我做了两套统计方案第一套从响应体中提取usage字段。针对走标准协议的服务商流式响应的最后一段data里通常会包含prompt_tokens、completion_tokens和total_tokens。我们在过滤器里捕获流式数据的末尾节点解析这段信息累加到Redis。实现方案是包装上游响应流在数据传递过程中扫描数据块private FluxDataBuffer wrapResponse(FluxDataBuffer body, String appId, String model) { return body.doOnNext(buffer - { byte[] bytes new byte[buffer.readableByteCount()]; buffer.read(bytes); extractUsageAndAccumulate(new String(bytes, StandardCharsets.UTF_8), appId, model); }); }这里有一个只能在实战中发现的坑DataBuffer是直接内存用完必须释放。如果doOnNext里只读不释放撑不了多久就会内存泄漏。我一开始写的时候只看内容忘了释放测试环境跑了半小时就报OutOfDirectMemoryError。后来改成在doOnNext里把buffer.retain()再读确保链路最终释放问题才解决。第二套针对不返回usage信息的模型用估算公式。中文字符平均约1.5到2个Token一个汉字英文约0.3到0.4个Token一个单词。网关拿到请求的输入文本和输出文本套入估算公式和真实计数做加权校准。这套方案精度虽然不如服务商自报的准确但用于成本分摊已经够了。Token数据要按多个维度汇总按业务方、按模型、按渠道、按天。Redis的Hash结构很合适key设计类似token:stats:{appId}:{model}:{yyyyMMdd}累加命令用HIncrBy一天的请求量几百万条也不会有性能问题。每天晚上通过定时任务把Redis里的数据落库生成账单报表。3.6 限流、熔断与降级策略面向AI服务的限流和普通限流不一样因为AI请求的消耗不是等价的。同样一次对话一个简单的你好和一个需要长文生成的问题Token消耗差了百倍。用传统的QPS维度限流根本控制不住成本。Spring AI Gateway里要按两个维度限制QPS维度和Token维度。QPS维度用Spring Cloud Gateway自带的RequestRateLimiter做。基于Redis的令牌桶算法配置如下filters: - name: RequestRateLimiter args: redis-rate-limiter.replenishRate: 100 redis-rate-limiter.burstCapacity: 200 key-resolver: #{appKeyResolver}replenishRate是每秒补充多少令牌burstCapacity是桶的容量key-resolver按业务方隔离每个业务方单独一套额度。Token维度的限流要自己在过滤器里做。在请求进来时先查询该业务方今天的剩余Token额度如果不足直接返回429并附带提示。实际处理时这个问题容易出错因为Redis的读写和网关的请求不是同一个线程多个并发请求同时扣减额度时可能会扣超。解决办法是用Redis的Lua脚本做原子扣减。这里贴一段Lua脚本示意local used redis.call(HGET, KEYS[1], ARGV[1]) if used and tonumber(used) tonumber(ARGV[2]) tonumber(ARGV[3]) then return -1 end redis.call(HINCRBY, KEYS[1], ARGV[1], tonumber(ARGV[2])) return 1传入key、业务方、本次预估Token数、上限额度脚本判断是否会超限超了就返回-1不再扣减没超就算上。这个脚本在并发下也能保证额度不超卖。熔断和降级我用的方案是给路由配置FallbackHeaders和自定义的FallbackController。当上游模型服务连续报错或超时就把请求降级到备用模型或者直接返回一个可读的兜底消息。以某个业务为例如果它依赖的大模型A服务不可用网关自动把流量切换到轻量模型B客户端无感知但回答质量会降一些。这种情况在日志里要有显眼的降级标识方便业务方知道为什么回复变简单了。3.7 日志与调用链追踪网关是流量的十字路口如果对日志没有统一规划排障的时候会非常痛苦。我在过滤器里给每个请求生成一个traceId放入请求头和MDC上下文。日志格式统一加上traceId和appId这样就能快速检索某个业务方某次调用的完整链路。注意在WebFlux环境下MDC的传递和传统Servlet不同——同一个请求的后续日志可能在不同线程中打印所以需要用Reactor的Context来传播traceIdString traceId UUID.randomUUID().toString().replace(-, ); exchange.getAttributes().put(traceId, traceId); return chain.filter(exchange) .contextWrite(ctx - ctx.put(traceId, traceId));打印日志时从Context中取traceId写入。如果你直接用传统MDC.put大概率会碰到日志里traceId为空或者串号的情况这也是响应式编程给网关排障带来的最大难点。我建议核心日志要打出四条请求进来时打一条记录业务方、路径、模型路由决策时打一条记录选择了哪个上游转发异常时打一条记录错误类型和耗时Token统计完成时打一条记录本次消耗。有了这四条日志线上问题基本能定位到具体环节。4. 常见问题与排查技巧实录4.1 流式输出到前端变成一次性返回症状前端打字效果消失等很久之后一次性显示全部内容。排查思路先看响应头的Content-Type是否被改成了application/json再看网络链路里是否有缓冲层。最常见的原因有两类一是自定义过滤器在修改响应时把Content-Type覆盖掉了二是Nginx或CDN对event-stream做了缓冲。逐层检查先绕过CDN直连网关测再直连上游测定位缓冲发生在哪一跳。我处理过的一个案例是网关下游接的某一个服务商SDK默认不是流式模式网关侧一切正常但SDK在发请求时带了streamfalse参数导致服务端把整段内容一次性返回。这种问题光看网关日志根本发现不了要抓请求的查询参数才能看出端倪。4.2 netty连接池被写满症状网关偶发超时错误率升高重启后恢复过一阵又复现。排查方法打开Actuator的metrics端点看httpClient的连接数和pending状态。大量请求处于pending state说明连接池都在忙。这时候要把连接池从fixed改成elastic或者把max-connections调大。另外确认上游服务的接受并发数如果上游只能扛20路并发网关连接池调再大也没用反而排队超时更严重。4.3 数据缓冲导致内存泄漏症状网关运行一段时间后内存居高不下频繁Full GC最终OOM。这类问题十有八九出在DataBuffer没有正确释放。排查时开启Netty的泄漏检测参数-Dio.netty.leakDetection.levelparanoid运行一段时间看日志里是否有LEAK警告。特别是我们自定义过滤器里处理响应体时对每个DataBuffer的操作都要谨慎只读不释放也一样会泄漏因为引用计数没有归零。4.4 Token统计缺失或重复症状账单数据对不上有时少统计有时多统计。原因是流式响应的usage信息并不一定只出现一次。有的服务商在中间提示信息里包含usage片段末尾又有一个总的usage。如果过滤器中只要看到usage字段就累加就会重复计数。解决方法是加一个解析状态只取流末尾的最终usage。还有一种情况是网关超时重发请求同一个语义请求被发送两次产生双份Token消耗。这个要在生成请求ID时做幂等控制上游支持的话把请求ID带上不支持的只能从日志层面去重。4.5 跨域问题前端页面直接调用网关接口浏览器报CORS错误。网关层面统一配置跨域即可spring: cloud: gateway: globalcors: cors-configurations: [/**]: allowedOrigins: http://console.example.com allowedMethods: * allowedHeaders: * allowCredentials: true注意allowedOrigins不要写*写*的话无法和allowCredentials同时使用。带上cookie或者Token的场景必须显式列出具体的来源地址。4.6 请求体读取与修改有业务方要求在网关层给每个请求追加一条system prompt或者统一改写输入格式。这就要在过滤器里读取请求体做修改后再放行。问题来了WebFlux的请求体是一次性的不能直接消费。我采用的方案是先把请求体缓存到内存或者本地临时文件然后构造一个新的Request对象继续传递public MonoVoid filter(ServerWebExchange exchange, GatewayFilterChain chain) { ServerHttpRequest request exchange.getRequest(); return DataBufferUtils.join(request.getBody()) .flatMap(dataBuffer - { byte[] oldBytes new byte[dataBuffer.readableByteCount()]; dataBuffer.read(oldBytes); DataBufferUtils.release(dataBuffer); String oldBody new String(oldBytes, StandardCharsets.UTF_8); String newBody injectPrompt(oldBody); byte[] newBytes newBody.getBytes(StandardCharsets.UTF_8); DataBuffer newBuffer exchange.getResponse().bufferFactory().wrap(newBytes); ServerHttpRequest mutatedRequest request.mutate() .headers(h - h.setContentLength(newBytes.length)) .build(); return chain.filter(exchange.mutate() .request(new ServerHttpRequestDecorator(mutatedRequest) { Override public FluxDataBuffer getBody() { return Flux.just(newBuffer); } }) .build()); }); }这段代码在Streaming场景下有个风险所有请求体都被完整缓存后才能继续处理大对话几万Token的请求体会占用较多内存。所以实测中我建议只对需要改写Prompt的特定路由启用这个过滤器而不是全局挂载。有的团队用nginx的sub_filter做简单替换但碰到JSON嵌套的场景还是用Java过滤器更可控。5. 几轮实战后的体会总结Spring AI Gateway这个方向从需求确认到落地跑通第一个生产链路差不多用了两个星期。大部分时间不是在写代码而是在处理各种响应式流式场景下的边界情况。这也提醒我一个经验AI网关的复杂度不在路由而在流量语义——流式、超时、计量、降级每一个环节都和数据流打交道如果一个环节的模型理解错了后面的表现一定走样。最初设计的时候我也考虑过要不要直接用现成的商业API网关产品后来对比下来还是Spring生态自建适合我们。原因有两个。一是技术栈统一团队本来就在Spring Boot体系里写自定义过滤器不需要引入新的语言和框架二是AI服务的路由策略变化太快今天接这个模型明天换那个模型自建网关改配置加代码就能跟上节奏商业产品反而要提工单等排期。还在考虑要不要把网关和模型评测打通。有了网关透传的数据其实天然就能获取到业务方的真实请求和响应可以用来做模型的线上评测和回归。这块后续打算单独整理一下。如果只是在做简单的模型调用对接完全可以先用直连方式跑通业务等模型服务接入超过三个、成本开始失控的时候再迁移到这个网关方案来改造量并不大——业务方改一下base_url把密钥换成网关签发的Token就行这是Spring AI Gateway这类方案的通用路径。