1. 为什么要在 Spring Cloud Gateway 上做 AI 服务网关
如果你正在用 Spring Cloud Gateway 做微服务入口,现在业务方要求接入大模型能力,最直接的做法是在每个业务服务里各自写一套 HTTP 客户端去调模型接口。我见过不少团队一开始就是这么干的,结果三个月后代码库里散落着七八份 API Key、五套重试逻辑、三套计费统计,改一个超时参数要发五个服务。
Spring AI Gateway 要解决的就是这个问题:把 AI 调用收敛到网关层,业务服务只面向一个统一的/api/ai/**入口,由网关负责选模型、算成本、做缓存、限流、熔断。它和传统 API 网关的区别在于,AI 请求的“路由依据”不只是 URL 和 Header,还包括请求体里的模型名、token 数量、问题复杂度,甚至语义相似度。
这篇要交付的东西很具体:一套能跑起来的 Spring Cloud Gateway 路由配置、一套语义缓存的键规则、一份settings.json/config.toml骨架,以及用 curl 验证“路由命中”和“缓存生效”的实际动作。适合已经在用 Spring Cloud 体系、想把多模型调用统一管起来的后端同学。核心检索词就三个:Spring AI Gateway、AI 服务网关、智能路由与语义缓存。
先说清楚一个前提:网关本身不生产模型能力,它需要一个稳定的上游通道。我这边统一用 TaoToken 作为模型接入层,一个 Key 打通多个模型,网关侧只认一个 Base URL,省掉了在网关里维护多厂商鉴权差异的麻烦。下面所有配置都围绕这个前提展开。
2. TaoToken 前置准备:统一 Key 与通道接入
在写路由之前,先把上游通道固定下来。TaoToken 在这里扮演的角色是“模型接入层”:网关不需要知道背后是哪个厂商的接口格式,只需要按 OpenAI 兼容协议发请求,由接入层完成转发。这样做的好处是网关代码里不会出现任何厂商特有的字段处理逻辑。
你需要准备三样东西:Base URL、API Key、Model ID。Base URL 用https://taotoken.net/api,注意这个地址不带任何查询参数,直接作为spring.cloud.gateway里uri的前缀。API Key 在控制台的 API Keys 页面生成,生成后只显示一次,建议直接写进环境变量而不是配置文件。
export TAOTOKEN_API_KEY="sk-你的key" export TAOTOKEN_BASE_URL="https://taotoken.net/api"Model ID 这块要注意,网关路由里用的模型名要和接入层支持的名称一致。常见的对话模型、代码模型都可以通过同一个 Key 调用,切换模型只需要改请求体里的model字段,不需要换 Key、不需要换 Base URL。这是统一 Key 最实际的价值:网关的智能路由策略可以纯粹基于业务规则来写,不用掺杂鉴权分支。
如果你用的是 Claude Code 这类客户端,配置骨架长这样,放在~/.claude/settings.json:
{ "env": { "ANTHROPIC_BASE_URL": "https://taotoken.net/api", "ANTHROPIC_AUTH_TOKEN": "sk-你的key", "ANTHROPIC_MODEL": "claude-sonnet-4-5" } }如果是 Codex 类的 CLI,配置写在~/.codex/config.toml:
model = "gpt-5" model_provider = "taotoken" [model_providers.taotoken] name = "TaoToken" base_url = "https://taotoken.net/api" env_key = "TAOTOKEN_API_KEY" wire_api = "chat"这两个骨架的意义在于:网关下游的业务服务、开发同学本地的 CLI 工具,用的是同一套 Base URL 和同一批 Key,排查问题时不会出现“本地能跑线上不行”的割裂。网关侧只需要在application.yml里引用环境变量即可,不要把 Key 硬编码进代码仓库。
有一点要提醒:网关做统一接入后,Key 的轮换、额度控制、调用统计都集中在接入层,网关本身不需要实现复杂的配额算法,只需要在过滤器里读取响应头里的用量信息做记录。这样网关的职责更单一,也更容易测试。
3. 可复制的路由配置与缓存键规则
这一节是全文的核心,直接给能粘贴进项目的配置。先看application.yml里的网关路由部分,这里用声明式配置而不是 Java DSL,因为声明式更容易做多环境覆盖。
spring: cloud: gateway: routes: - id: ai-chat-route uri: https://taotoken.net/api predicates: - Path=/api/ai/chat/completions - Method=POST filters: - StripPrefix=2 - name: RequestRateLimiter args: redis-rate-limiter.replenishRate: 20 redis-rate-limiter.burstCapacity: 40 key-resolver: "#{@userKeyResolver}" - name: CircuitBreaker args: name: aiChatCB fallbackUri: forward:/fallback/ai - id: ai-embedding-route uri: https://taotoken.net/api predicates: - Path=/api/ai/embeddings filters: - StripPrefix=2 httpclient: connect-timeout: 5000 response-timeout: 60sStripPrefix=2是因为外部路径是/api/ai/chat/completions,剥掉两层后变成/chat/completions,拼上uri就是https://taotoken.net/api/chat/completions,正好是 OpenAI 兼容路径。这个细节很多人第一次配会搞错,导致 404。
智能路由的关键不在 YAML,而在一个自定义的GlobalFilter,它根据请求体内容改写目标模型。下面这段是核心逻辑,放在AiRoutingFilter里:
@Component public class AiRoutingFilter implements GlobalFilter, Ordered { private static final Set<String> COMPLEX_HINTS = Set.of("重构", "架构", "性能优化", "并发", "分布式"); @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { ServerHttpRequest request = exchange.getRequest(); if (!request.getPath().value().contains("/chat/completions")) { return chain.filter(exchange); } return DataBufferUtils.join(request.getBody()) .flatMap(buffer -> { byte[] bytes = new byte[buffer.readableByteCount()]; buffer.read(bytes); DataBufferUtils.release(buffer); String body = new String(bytes, StandardCharsets.UTF_8); String routed = routeModel(body); ServerHttpRequest mutated = request.mutate() .body(routed) .build(); return chain.filter(exchange.mutate().request(mutated).build()); }); } private String routeModel(String body) { boolean complex = COMPLEX_HINTS.stream().anyMatch(body::contains); String target = complex ? "gpt-5" : "gpt-5-mini"; return body.replaceFirst("\"model\"\\s*:\\s*\"[^\"]+\"", "\"model\":\"" + target + "\""); } @Override public int getOrder() { return -50; } }这段代码做了两件事:读请求体、按关键词把model字段替换成目标模型。实测下来,关键词命中率不需要很高,只要把最贵的模型留给真正复杂的请求,成本就能明显下降。注意getOrder()返回 -50,要排在限流过滤器之前,否则限流按旧模型算配额会不准。
语义缓存的键规则单独说。缓存键不能简单用请求体的 MD5,因为用户换个说法问同一个问题就会 miss。我的做法是:取最后一条 user message 的文本,做归一化(去空格、转小写、去掉标点),再拼上模型名做 MD5。这样“怎么退款”和“如何退款?”会命中同一个键。
public static String cacheKey(String userText, String model) { String normalized = userText.toLowerCase() .replaceAll("[\\p{Punct}\\s]", ""); String raw = model + "::" + normalized; return "ai:cache:" + DigestUtils.md5DigestAsHex( raw.getBytes(StandardCharsets.UTF_8)); }缓存写入用 Redis,TTL 设 24 小时,只缓存非流式请求。流式响应(stream: true)不缓存,因为分块响应没法整体复用。这个规则要写死在过滤器里,别让业务方自己决定,否则缓存命中率会被流式请求拖垮。
4. 验证路由命中与缓存生效
配置写完不验证等于没写。这一节给两组 curl 命令,分别验证路由和缓存。
先启动网关,确认 Redis 在跑。然后发第一个请求,故意用简单问题,看它是否被路由到轻量模型:
curl -s -X POST http://localhost:8080/api/ai/chat/completions \ -H "Content-Type: application/json" \ -H "X-User-Id: u1001" \ -d '{ "model": "gpt-5", "messages": [{"role": "user", "content": "今天天气怎么样"}], "stream": false }' | jq '.model, .usage'返回体里的model字段应该是gpt-5-mini,说明路由过滤器把简单问题降级了。如果返回的还是gpt-5,检查AiRoutingFilter的getOrder()是否生效、请求体是否被正确读取。这里有个坑:Spring Cloud Gateway 默认不缓存请求体,DataBufferUtils.join读完之后如果不重新构造 request,下游会拿到空 body。上面代码里request.mutate().body(routed)就是干这个的。
再发一个复杂问题,验证它走高质量模型:
curl -s -X POST http://localhost:8080/api/ai/chat/completions \ -H "Content-Type: application/json" \ -H "X-User-Id: u1001" \ -d '{ "model": "gpt-5", "messages": [{"role": "user", "content": "帮我做一次分布式架构的性能优化"}], "stream": false }' | jq '.model'这次应该返回gpt-5。两次请求的X-User-Id相同,方便后面看限流计数。
验证缓存要连发两次相同语义的请求,第二次看响应时间。第一次请求会 miss,第二次应该命中:
# 第一次 time curl -s -X POST http://localhost:8080/api/ai/chat/completions \ -H "Content-Type: application/json" \ -d '{"model":"gpt-5-mini","messages":[{"role":"user","content":"如何申请退款"}],"stream":false}' > /dev/null # 第二次,换个说法 time curl -s -X POST http://localhost:8080/api/ai/chat/completions \ -H "Content-Type: application/json" \ -d '{"model":"gpt-5-mini","messages":[{"role":"user","content":"退款怎么申请?"}],"stream":false}' > /dev/null第二次的耗时应该明显低于第一次,通常在几十毫秒级别。如果两次耗时差不多,去 Redis 里查一下键是否存在:
redis-cli --scan --pattern "ai:cache:*"能看到键说明写入成功,看不到就是缓存过滤器没生效。常见原因是过滤器顺序排在路由之后,或者isCacheableRequest判断把请求排除了。另外注意,缓存命中的响应要手动构造ServerHttpResponse,不能直接chain.filter,否则会真的打到上游。
5. 常见报错排查:401、local proxy failed 与 reading choices
这一节按真实报错来。第一个高频错误是 401:
{"error":{"message":"invalid api key","type":"authentication_error"}}网关侧看到 401,先确认三件事:环境变量TAOTOKEN_API_KEY是否被 Spring 读到(用System.getenv打日志)、请求头里的Authorization是否被网关过滤器误删、Base URL 是否写成了带路径的地址。我踩过的坑是网关的StripPrefix把/v1也剥掉了,导致上游路径不对,返回的却是 401 而不是 404,排查了半天。
第二个错误是local proxy failed或连接超时:
io.netty.channel.ConnectTimeoutException: connection timed out这个通常是httpclient.connect-timeout设太短,或者网关所在网络到上游的出口不稳定。把connect-timeout调到 5000ms 以上,response-timeout调到 60s,因为大模型首 token 延迟本来就高。如果用了流式,response-timeout要设得更长,或者干脆对 SSE 路由单独配置。
第三个错误是解析响应时抛reading choices相关异常:
com.fasterxml.jackson.databind.JsonMappingException: Cannot deserialize value of type `java.util.ArrayList` from Object value这说明网关在改写响应体时,把非标准格式的响应也当成 chat completion 处理了。检查你的响应过滤器是否对所有/chat/completions响应都做了choices字段解析。有些错误响应体里没有choices,直接解析就会炸。正确做法是先判断 HTTP 状态码,非 200 直接透传,不要碰 body。
第四个是 OAuth 或鉴权头冲突。如果你在网关里同时配了 Spring Security 和上游鉴权,可能出现Authorization头被覆盖。解决办法是在路由过滤器里显式设置上游鉴权头,别依赖默认透传:
exchange.getRequest().mutate() .header("Authorization", "Bearer " + apiKey) .build();排查顺序建议固定下来:先看网关日志里的 requestId,再查 Redis 里的限流计数和缓存键,最后用 curl 直连上游确认 Key 本身没问题。直连命令:
curl -s https://taotoken.net/api/chat/completions \ -H "Authorization: Bearer $TAOTOKEN_API_KEY" \ -H "Content-Type: application/json" \ -d '{"model":"gpt-5-mini","messages":[{"role":"user","content":"ping"}]}'直连能通、走网关不通,问题一定在网关配置;直连也不通,就是 Key 或额度的问题。
6. 把网关接入落到日常开发流
网关跑起来只是第一步,真正省事的是把它接进日常开发流。我现在的做法是:本地开发不直接连上游,而是把本地服务的 AI 调用指向本地网关,网关再指向 TaoToken。这样本地就能复现线上的路由和缓存行为,不会出现“本地调的是 A 模型、线上走的是 B 模型”的偏差。
具体操作是在本地application-local.yml里把上游地址改成http://localhost:8080/api/ai,网关的uri仍然指向https://taotoken.net/api。开发同学不需要各自申请 Key,统一用网关的环境变量即可。需要看某个请求走了哪个模型、有没有命中缓存,直接看网关日志里的 requestId 和 Redis 键。
对于长期跑 Agent 任务或批量代码生成的场景,建议单独走 Coding Plan 通道,和交互式请求分开限流,避免批量任务把交互请求的配额挤掉。验证模型能力是否正常,可以用模型对话页面直接发一条消息,确认 Key 和通道没问题,再去调网关。
最后给一个实用技巧:把缓存命中率和路由分布做成两个计数器,暴露在/actuator/metrics下。每周看一眼,如果缓存命中率低于 30%,说明缓存键规则太严,考虑放宽归一化策略;如果高质量模型占比超过 40%,说明路由关键词太宽,该收紧阈值了。这两个指标比任何监控大盘都直接。