Java 后端企业级 LLM 网关实战:Spring Cloud Gateway 构建统一大模型接入层

一句话总结:当大模型 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 调用完全不是这个模型:

  1. 请求成本差异巨大:一个 128K 上下文的请求和一个 1K 上下文的请求,GPU 消耗可能差两个数量级;
  2. 流式响应:SSE/Chunked 响应需要网关具备长连接透传能力,不能等响应体完整再转发;
  3. Token 计费:按请求数限流会误导容量规划,必须按 Token 计费;
  4. 模型降级:当 GPT-4 不可用时,能否自动降到 GPT-3.5 或国产模型,并保持响应格式一致;
  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、代码助手才能真正跑得稳。

相关推荐
denlaku1 小时前
JDK 13 新特性详解
java·开发语言
今年下半年1 小时前
【防重放】Spring Boot 通过自定义注解实现接口防重放
java·spring boot·自定义注解·防重放
WeiXin_DZbishe1 小时前
基于Node.js的马拉松赛事管理系统的设计与实现-计算机毕设 附源码【课程设计】90211
java·javascript·vue.js·python·php·课程设计
Wang's Blog1 小时前
Java 项目部署之 Docker工具快速入门: 使用 Docker 镜像命令练习
java·docker
企业数字化笔记1 小时前
多租户系统从哪里开始设计?租户身份、数据隔离、权限与套餐的整体方案
java·spring boot·后端·junit
tiger从容淡定是人生2 小时前
从 Gemini 的任务示例出发:AI 能否从知识中推导工作流?
java·人工智能·aigc·知识图谱·创业创新·零知识证明
Nebula_g2 小时前
JavaSE高级:动态代理
java·开发语言·算法·javase
高级程序源2 小时前
springboot校园外卖平台13676-计算机课程设计、毕业设计
java·vue.js·spring boot·后端·tomcat·maven·课程设计
怦然丶心动2 小时前
用 Java 把干支历法算清楚:从节气切年到可读摘要
java·算法·vue·独立开发·spring boot / spring