一句话总结:当大模型 API 从「技术尝鲜」变成「生产基础设施」,Java 后端必须有自己的 LLM 网关层------不是简单转发,而是统一接入、Token 感知限流、智能降级、全链路审计与成本治理。

一、为什么 Java 后端需要 LLM 网关
2026 年的 AI 工程已经过了一张 API Key 调用到底的阶段。企业里真实的 LLM 调用链路往往是这样的:
- 客服系统调用 Azure OpenAI;
- 代码助手接 Claude;
- 内部 RAG 用通义千问;
- 营销文案又接了 DeepSeek;
- 每个业务团队各自维护 API Key、限流、重试、日志。
结果就变成了「Key 泛滥、账单爆炸、故障互相影响、审计无从下手」。
LLM 网关(LLM Gateway / AI Gateway)解决的就是这个问题。它不是 OpenAI 的 SDK 包装,而是位于所有业务服务和模型提供商之间的一层统一治理平面,负责:
| 能力 | 说明 |
|---|---|
| Provider 抽象 | 业务代码只认网关,换模型不用改业务 |
| API Key 隔离 | 真实 Key 只在网关存储,业务侧用虚拟 Key |
| Token 感知限流 | 按输入/输出 Token 计费限速,而不是粗糙的 QPS |
| 智能降级 | OpenAI 挂了自动切 Anthropic,长上下文模型降级到短上下文 |
| 审计与合规 | 记录谁、在什么时间、调用了什么模型、花了多少 Token |
| 成本控制 | 租户级预算、模型路由到性价比最高的可用模型 |
对 Java 团队来说,Spring Cloud Gateway 是最自然的落地选择:它本来就是微服务体系里的入口,和 Spring AI、Spring Security、Micrometer、Resilience4j 都是一家人。本文就围绕一个完整的 Multi-Tenant LLM Gateway 项目,从源码原理讲到生产代码。
二、LLM 网关与传统 API 网关的差异
传统 API 网关假设:请求开销相近、后端无状态、响应一次性返回。但 LLM 调用完全不是这个模型:
- 请求成本差异巨大:一个 128K 上下文的请求和一个 1K 上下文的请求,GPU 消耗可能差两个数量级;
- 流式响应:SSE/Chunked 响应需要网关具备长连接透传能力,不能等响应体完整再转发;
- Token 计费:按请求数限流会误导容量规划,必须按 Token 计费;
- 模型降级:当 GPT-4 不可用时,能否自动降到 GPT-3.5 或国产模型,并保持响应格式一致;
- 上下文亲和性:多轮对话最好打到同一推理实例以复用 KV Cache,否则首 Token 延迟会飙升。
这些差异决定了:传统网关的「按 QPS 限流 + 轮询负载均衡」在 LLM 场景下会失效。LLM 网关需要引入 Token 感知、模型路由、流式透传、语义缓存、成本审计 等新的抽象。
三、核心原理与源码解析
3.1 Spring Cloud Gateway 请求生命周期
Spring Cloud Gateway 基于 Project Reactor 和 Netty,一个请求进入后依次经过:
RoutePredicateHandlerMapping -> FilteringWebHandler -> GlobalFilter -> GatewayFilter -> 下游服务
关键源码在 org.springframework.cloud.gateway.handler.FilteringWebHandler:
java
public Mono<Void> handle(ServerWebExchange exchange) {
Route route = exchange.getRequiredAttribute(GATEWAY_ROUTE_ATTR);
List<GatewayFilter> gatewayFilters = route.getFilters();
List<GatewayFilter> combined = new ArrayList<>(this.globalFilters);
combined.addAll(gatewayFilters);
// 按 order 排序
AnnotationAwareOrderComparator.sort(combined);
return new DefaultGatewayFilterChain(combined).filter(exchange);
}
这意味着我们可以在 GlobalFilter 层统一做认证、限流、审计,在 GatewayFilter 层做路由转发、Provider 选择、Header 注入。
3.2 RouteDefinition 与 Predicate
RouteDefinition 是路由的配置抽象,核心字段:
java
public class RouteDefinition {
private String id;
private List<PredicateDefinition> predicates = new ArrayList<>();
private List<FilterDefinition> filters = new ArrayList<>();
private URI uri;
private Map<String, Object> metadata = new HashMap<>();
// ...
}
LLM 网关里,我们会让 Predicate 按路径匹配,例如 /ai/openai/** 转发到 OpenAI,/ai/anthropic/** 转发到 Claude。真正生产环境中,更推荐用统一入口 + Header 模型选择,而不是硬编码路由。
3.3 RedisRateLimiter 令牌桶实现
Spring Cloud Gateway 内置的 RequestRateLimiter 基于 Redis + Lua 脚本实现分布式令牌桶。核心 Lua 在 org.springframework.cloud.gateway.filter.ratelimit.RedisRateLimiter:
lua
local tokens_key = KEYS[1]
local timestamp_key = KEYS[2]
local rate = tonumber(ARGV[1])
local capacity = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local requested = tonumber(ARGV[4])
local fill_time = capacity / rate
local ttl = math.floor(fill_time * 2)
local last_tokens = tonumber(redis.call("get", tokens_key))
if last_tokens == nil then
last_tokens = capacity
end
local last_updated = tonumber(redis.call("get", timestamp_key))
if last_updated == nil then
last_updated = 0
end
local delta = math.max(0, now - last_updated)
local filled_tokens = math.min(capacity, last_tokens + (delta * rate))
local allowed = filled_tokens >= requested
local new_tokens = filled_tokens
if allowed then
new_tokens = filled_tokens - requested
end
redis.call("setex", tokens_key, ttl, new_tokens)
redis.call("setex", timestamp_key, ttl, now)
return { allowed, new_tokens }
关键认知:默认 requestedTokens = 1,也就是每个请求只消耗 1 个令牌 。对于 LLM 网关,这远远不够。我们需要自定义 RateLimiter 或 GatewayFilter,让请求消耗与预估 Token 成正比的令牌数。
3.4 Resilience4j 熔断降级
Spring Cloud Gateway 通过 CircuitBreaker GatewayFilter 集成 Resilience4j。CircuitBreakerConfig 核心参数:
failureRateThreshold:失败率阈值,默认 50%;waitDurationInOpenState:开启状态持续时间;slidingWindowSize:统计窗口大小;permittedNumberOfCallsInHalfOpenState:半开状态允许请求数。
在 LLM 网关里,熔断需要区分错误类型:
- 429/503:provider 侧限流,适合立即 fallback;
- 400/401:请求或鉴权错误,fallback 无意义,应快速失败;
- 5xx:provider 内部错误,应触发熔断并切换模型。
四、项目实战:构建 Multi-Tenant LLM Gateway
4.1 项目结构
llm-gateway/
├── src/main/java/com/example/llmgateway/
│ ├── LlmGatewayApplication.java
│ ├── config/
│ │ └── GatewayConfig.java
│ ├── filter/
│ │ ├── ProviderRoutingFilter.java
│ │ ├── TokenAwareRateLimitFilter.java
│ │ ├── ApiKeyInjectionFilter.java
│ │ └── AuditLoggingFilter.java
│ ├── limiter/
│ │ └── TokenBucketRateLimiter.java
│ ├── model/
│ │ ├── LlmProvider.java
│ │ ├── LlmRequest.java
│ │ └── LlmResponse.java
│ └── util/
│ └── TokenEstimator.java
├── src/main/resources/
│ └── application.yml
└── pom.xml
4.2 Maven 依赖
xml
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</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>
<dependency>
<groupId>com.knuddels</groupId>
<artifactId>jtokkit</artifactId>
<version>1.1.0</version>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
</dependencies>
4.3 配置文件
yaml
spring:
application:
name: llm-gateway
redis:
host: localhost
port: 6379
timeout: 800ms
cloud:
gateway:
routes:
- id: openai-proxy
uri: https://api.openai.com
predicates:
- Path=/ai/openai/**
filters:
- StripPrefix=2
- name: CircuitBreaker
args:
name: openai-cb
fallbackUri: forward:/fallback/openai
- id: anthropic-proxy
uri: https://api.anthropic.com
predicates:
- Path=/ai/anthropic/**
filters:
- StripPrefix=2
- name: CircuitBreaker
args:
name: anthropic-cb
fallbackUri: forward:/fallback/anthropic
default-filters:
- DedupeResponseHeader=Access-Control-Allow-Credentials Access-Control-Allow-Origin
resilience4j:
circuitbreaker:
instances:
openai-cb:
slidingWindowSize: 100
failureRateThreshold: 50
waitDurationInOpenState: 30s
permittedNumberOfCallsInHalfOpenState: 10
recordExceptions:
- org.springframework.web.reactive.function.client.WebClientResponseException
4.4 路由与 Provider 抽象
统一入口让业务只调用 /ai/chat/completions,由 Header 里的 X-AI-Provider 决定具体走哪家:
java
@Component
@Slf4j
public class ProviderRoutingFilter implements GlobalFilter, Ordered {
private static final String PROVIDER_HEADER = "X-AI-Provider";
private static final String MODEL_HEADER = "X-AI-Model";
private static final int ORDER = -100; // 最早执行
private final Map<String, ProviderConfig> providers = Map.of(
"openai", new ProviderConfig("https://api.openai.com", "/v1/chat/completions", "OPENAI_API_KEY"),
"anthropic", new ProviderConfig("https://api.anthropic.com", "/v1/messages", "ANTHROPIC_API_KEY"),
"deepseek", new ProviderConfig("https://api.deepseek.com", "/v1/chat/completions", "DEEPSEEK_API_KEY")
);
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
ServerHttpRequest request = exchange.getRequest();
String provider = request.getHeaders().getFirst(PROVIDER_HEADER);
if (provider == null || !providers.containsKey(provider)) {
return errorResponse(exchange, HttpStatus.BAD_REQUEST, "Missing or invalid X-AI-Provider");
}
ProviderConfig config = providers.get(provider);
String targetPath = config.baseUrl() + config.path();
ServerHttpRequest mutated = request.mutate()
.uri(URI.create(targetPath))
.header("Authorization", "Bearer " + resolveApiKey(config.keyEnv()))
.header("X-Resolved-Provider", provider)
.build();
exchange.getAttributes().put("llm.provider", provider);
exchange.getAttributes().put("llm.model", request.getHeaders().getFirst(MODEL_HEADER));
return chain.filter(exchange.mutate().request(mutated).build());
}
private Mono<Void> errorResponse(ServerWebExchange exchange, HttpStatus status, String msg) {
exchange.getResponse().setStatusCode(status);
byte[] bytes = ("{\"error\":\"" + msg + "\"}").getBytes(StandardCharsets.UTF_8);
exchange.getResponse().getHeaders().setContentType(MediaType.APPLICATION_JSON);
return exchange.getResponse().writeWith(Mono.just(bytes)
.map(b -> exchange.getResponse().bufferFactory().wrap(b)));
}
private String resolveApiKey(String envName) {
String key = System.getenv(envName);
if (key == null || key.isBlank()) {
throw new IllegalStateException("Missing API key env: " + envName);
}
return key;
}
@Override
public int getOrder() { return ORDER; }
record ProviderConfig(String baseUrl, String path, String keyEnv) {}
}
4.5 Token 感知限流器
这是 LLM 网关与普通网关最大的区别。我们用 jtokkit 估算请求体里的 Token 数,然后让 Redis 令牌桶按 Token 数扣减。
java
@Component
public class TokenAwareRateLimitFilter implements GlobalFilter, Ordered {
private static final int ORDER = -50;
private final ReactiveRedisTemplate<String, String> redisTemplate;
private final Encoding encoding;
public TokenAwareRateLimitFilter(ReactiveRedisTemplate<String, String> redisTemplate) {
this.redisTemplate = redisTemplate;
this.encoding = Encodings.newDefaultEncodingRegistry().getEncoding(EncodingType.CL100K_BASE);
}
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String tenantId = exchange.getRequest().getHeaders().getFirst("X-Tenant-Id");
if (tenantId == null) tenantId = "anonymous";
String key = "llm:tenant:" + tenantId + ":tokens";
return exchange.getRequest().getBody()
.collectList()
.flatMap(buffers -> {
String body = buffers.stream()
.map(b -> b.toString(StandardCharsets.UTF_8))
.collect(Collectors.joining());
int estimatedTokens = estimateTokens(body);
exchange.getAttributes().put("llm.estimatedTokens", estimatedTokens);
return acquireTokens(key, estimatedTokens)
.flatMap(allowed -> {
if (allowed) {
// 重新把 body 写回流,避免下游读不到
DataBuffer newBuffer = exchange.getResponse()
.bufferFactory().wrap(body.getBytes(StandardCharsets.UTF_8));
ServerHttpRequest mutated = new ServerHttpRequestDecorator(exchange.getRequest()) {
@Override
public Flux<DataBuffer> getBody() { return Flux.just(newBuffer); }
};
return chain.filter(exchange.mutate().request(mutated).build());
} else {
exchange.getResponse().setStatusCode(HttpStatus.TOO_MANY_REQUESTS);
exchange.getResponse().getHeaders().set("Retry-After", "10");
return exchange.getResponse().setComplete();
}
});
});
}
private int estimateTokens(String json) {
// 生产级建议解析 messages 数组,这里简化:按文本内容估算
try {
int charCount = json.length();
// 中文场景:1 token ≈ 0.75 中文字符(保守按 0.6)
return Math.max(1, (int) (charCount * 0.6));
} catch (Exception e) {
return 1000; // 失败时给一个保守默认值,避免误判通过
}
}
private Mono<Boolean> acquireTokens(String key, int tokens) {
// 实际生产建议用 Lua 脚本保证原子性,这里用 RedisTemplate 演示
String lua = """
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local requested = tonumber(ARGV[3])
local now = tonumber(ARGV[4])
local ttl = math.floor(capacity / rate * 2)
local current = redis.call('get', key)
local last = current and tonumber(current) or capacity
local allowed = last >= requested
local new = allowed and (last - requested) or last
redis.call('setex', key, ttl, new)
return allowed and 1 or 0
""";
RedisScript<Long> script = new DefaultRedisScript<>(lua, Long.class);
return redisTemplate.execute(script, List.of(key), "100000", "1000", String.valueOf(tokens),
String.valueOf(System.currentTimeMillis() / 1000))
.next()
.map(r -> r == 1)
.onErrorReturn(true); // Redis 故障时 fail-open,避免网关雪崩
}
@Override
public int getOrder() { return ORDER; }
}
说明:上面用字符数估算 Token 是为了演示。生产环境应集成
jtokkit按模型对应编码(CL100K_BASE / O200K_BASE)精确计算 messages 数组。
4.6 审计与成本监控
审计需要记录请求模型、租户、Token 数、耗时、是否命中缓存、是否降级。这里用 GlobalFilter 在响应完成后写日志 + Micrometer 指标。
java
@Component
@Slf4j
public class AuditLoggingFilter implements GlobalFilter, Ordered {
private static final int ORDER = Ordered.LOWEST_PRECEDENCE - 100;
private final MeterRegistry meterRegistry;
public AuditLoggingFilter(MeterRegistry meterRegistry) {
this.meterRegistry = meterRegistry;
}
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
long start = System.currentTimeMillis();
String tenantId = exchange.getRequest().getHeaders().getFirst("X-Tenant-Id");
String provider = exchange.getAttributeOrDefault("llm.provider", "unknown");
String model = exchange.getAttributeOrDefault("llm.model", "unknown");
Integer estimatedTokens = exchange.getAttributeOrDefault("llm.estimatedTokens", 0);
return chain.filter(exchange)
.doFinally(sig -> {
long duration = System.currentTimeMillis() - start;
int status = exchange.getResponse().getStatusCode() != null
? exchange.getResponse().getStatusCode().value() : 0;
log.info("LLM_AUDIT tenant={} provider={} model={} status={} " +
"estimatedTokens={} durationMs={}",
tenantId, provider, model, status, estimatedTokens, duration);
meterRegistry.counter("llm.requests.total",
"tenant", tenantId,
"provider", provider,
"model", model,
"status", String.valueOf(status)).increment();
meterRegistry.counter("llm.tokens.estimated",
"tenant", tenantId,
"provider", provider).increment(estimatedTokens);
meterRegistry.timer("llm.request.duration",
"provider", provider,
"model", model).record(duration, TimeUnit.MILLISECONDS);
});
}
@Override
public int getOrder() { return ORDER; }
}
4.7 降级 Controller
当 OpenAI 熔断打开时,网关把请求 forward:/fallback/openai,由下面 Controller 返回统一降级响应:
java
@RestController
@RequestMapping("/fallback")
public class FallbackController {
@PostMapping("/{provider}")
public Mono<ResponseEntity<Map<String, Object>>> fallback(
@PathVariable String provider,
@RequestBody(required = false) Map<String, Object> body) {
Map<String, Object> resp = new HashMap<>();
resp.put("provider", provider);
resp.put("fallback", true);
resp.put("message", "Provider " + provider + " is currently unavailable. " +
"Please retry or switch to another model.");
resp.put("suggested_models", List.of("deepseek-chat", "qwen-turbo"));
return Mono.just(ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(resp));
}
}
五、生产踩坑清单
1. 按请求数限流等于「没限流」
很多团队直接把 Spring Cloud Gateway 的 RequestRateLimiter 配成 100 QPS,结果一个长上下文请求就把后端打爆。LLM 场景必须做 Token 感知限流 ,建议按 (输入 Token + 预估输出 Token) / 分钟 配置租户配额。
2. Token 估算不准导致限流失效
用字符数估算中文 Token 误差很大。生产环境务必用 jtokkit 或对应厂商的 tokenizer,并按模型选择编码:
- GPT-4 / GPT-3.5 →
CL100K_BASE - GPT-4o →
O200K_BASE - Claude → 需要调用 Anthropic 官方 tokenizer 或近似估算
3. 流式响应被网关「憋大招」
默认 ModifyResponseBodyGatewayFilter 会等完整响应再转发,LLM 流式场景下会彻底破坏 SSE 体验。Gateway 转发流式响应时不要做任何需要消费完整 body 的 filter,只读 Header 和元数据。
4. Redis 限流器故障导致全站不可用
Redis 挂掉时如果 fail-closed,所有请求都会被 429。生产级限流必须 fail-open :Redis 异常时允许流量通过,并告警,而不是阻断。代码里 onErrorReturn(true) 就是这个意图。
5. API Key 在日志里裸奔
网关会打印下游请求 Header,一定要把 Authorization 加入日志脱敏列表。同时建议用虚拟 Key + 网关真实 Key 映射,业务侧只持有虚拟 Key,即使泄露也能随时吊销。
6. X-Forwarded-For 被伪造导致限流绕过
按 IP 限流时,恶意客户端可以伪造 X-Forwarded-For。必须只信任经过负载均衡追加的最后一跳 IP,或者按 X-API-Key 限流。
7. 模型降级时响应格式不一致
OpenAI 和 Anthropic 的响应格式不同,直接 fallback 会让上游解析失败。建议在网关层做响应格式归一化 ,把 Anthropic 的 content 数组转成 OpenAI 的 choices 结构,或者让业务统一按 OpenAI 兼容格式调用。
8. 审计日志写磁盘导致网关 IO 被打满
高频 LLM 调用下,同步写文件会把网关拖垮。审计日志应走 异步队列(Kafka / MQ) 或采样记录(例如只记录 10% 完整 prompt),并用结构化日志(JSON)方便后续分析。
9. 多租户配额互相影响
如果没有按租户隔离 Redis Key,一个租户刷流量会把全局桶打空。Key 设计必须是 llm:tenant:{tenantId}:tokens,并且为每个租户单独配置 capacity 和 rate。
10. 忽略 provider 侧 429 的 retry-after
OpenAI 返回 429 时通常带 retry-after 头,网关应把这个头原样透传或主动根据它做指数退避,而不是无脑重试。
六、总结
LLM 网关是 Java 后端把大模型能力「工程化」的关键一层。它不是可有可无的代理,而是企业生产 AI 的入口:
- Provider 抽象让业务与模型解耦;
- Token 感知限流让容量规划贴合真实成本;
- 智能降级让单点故障不拖垮业务;
- 统一审计让成本、合规、安全可控。
Spring Cloud Gateway + Spring AI + Redis + Resilience4j 这套栈,对已经熟悉 Spring 生态的 Java 团队来说,几乎是零额外学习成本的落地路径。再一次印证了那个核心判断:Java 工程师做 AI,不需要转 Python,工程化能力才是真正的护城河。
完整示例代码已按模块化结构给出,生产部署时建议再补上:Nacos 动态路由、Sentinel 集群流控、Micrometer + Prometheus + Grafana 监控、Kafka 异步审计、以及 Provider 响应格式归一化中间件。网关这一层做厚了,后面的 Agent、RAG、代码助手才能真正跑得稳。