大模型服务集成:Java 后端的流量管控与容错架构设计

一、Token 燃烧与响应延迟:大模型后端集成的双重挑战

将大语言模型(LLM)集成到企业级 Java 后端系统中,远不是调一个 HTTP 接口那么简单。在生产环境中,大模型服务暴露出两个核心工程痛点。

第一个痛点是成本失控。大模型按 Token 计费,一次典型的 GPT-4 级别调用,输入 2000 Token 加输出 800 Token 的成本约 0.12 美元。如果一个面向 C 端用户的智能客服系统日均调用量 50 万次,月度 API 费用将超过 15 万美元。更危险的是,恶意用户可以通过构造超长 Prompt 或发起高频请求,在短时间内制造巨额账单。

第二个痛点是延迟不可控。大模型的推理延迟通常在 2-15 秒之间波动,且具有长尾特性——P99 延迟可能达到 P50 的 5 倍以上。对于习惯了毫秒级响应的 Java 后端系统,这种延迟波动会引发线程池耗尽、连接超时、级联故障等一系列连锁反应。

这两个痛点本质上都指向同一个架构需求:在 Java 后端与大模型服务之间,必须构建一层具备流量管控、成本约束和容错能力的中间层。

二、LLM 网关的架构设计与核心机制

LLM 网关是解决上述痛点的核心架构组件。它不是简单的反向代理,而是融合了限流、熔断、缓存、降级等多维能力的智能中间层。

graph LR
    A[客户端请求] --> B[LLM Gateway]
    B --> C[Token 预算校验]
    C --> D[语义缓存查询]
    D -->|缓存命中| E[直接返回缓存结果]
    D -->|缓存未命中| F[请求队列与限流]
    F --> G[模型路由选择]
    G --> H[Provider A<br/>GPT-4]
    G --> I[Provider B<br/>Claude]
    G --> J[Provider C<br/>本地模型]
    H --> K[响应处理与缓存写入]
    I --> K
    J --> K
    K --> L[Token 计量与预算扣减]
    L --> M[返回响应]

    style B fill:#f9f,stroke:#333
    style D fill:#bbf,stroke:#333
    style G fill:#bfb,stroke:#333

上图展示了 LLM 网关的请求处理流程。其中三个机制尤为关键:语义缓存、Token 预算控制和模型路由降级。

语义缓存不同于传统的 KV 缓存,它基于向量相似度匹配而非精确键值。当用户提问"Java 如何实现单例模式"和"Java 单例模式的写法"时,语义缓存能识别两者意图一致,直接返回缓存的回答,避免重复调用大模型。实测中,语义缓存在客服场景的命中率可达 40%-60%,显著降低 API 调用量。

Token 预算控制为每个租户或用户设置日/月度 Token 消耗上限,在请求发出前进行预算校验,超限则拒绝或降级。这是防止成本失控的最后一道防线。

模型路由降级根据请求复杂度和当前负载,动态选择不同能力的模型。简单问题路由到低成本模型,复杂问题才调用高成本模型;当高成本模型不可用或延迟过高时,自动降级到备用模型。

三、基于 Spring Boot 的 LLM 网关生产级实现

3.1 语义缓存的核心实现

/**
 * 语义缓存服务:基于向量相似度匹配的缓存层。
 * 为什么不用传统的 Redis String 缓存?
 * 因为用户对同一问题的表述千差万别,
 * 精确匹配的命中率极低(实测不到 5%)。
 * 语义缓存通过 Embedding 向量相似度匹配,
 * 将命中率提升到 40%-60%。
 */
@Service
public class SemanticCacheService {

    private final EmbeddingClient embeddingClient;
    private final VectorStore vectorStore;

    // 相似度阈值:低于此值认为问题不相关,不返回缓存
    // 为什么设为 0.92 而非更高?
    // 0.95 以上命中率骤降,0.90 以下误匹配率上升,
    // 0.92 是在客服场景下实测的平衡点
    private static final double SIMILARITY_THRESHOLD = 0.92;

    public SemanticCacheService(EmbeddingClient embeddingClient,
                                VectorStore vectorStore) {
        this.embeddingClient = embeddingClient;
        this.vectorStore = vectorStore;
    }

    /**
     * 查询语义缓存。
     * 返回 Optional.empty() 表示缓存未命中,需要调用大模型。
     */
    public Optional<String> query(String userQuestion) {
        float[] queryEmbedding = embeddingClient.embed(userQuestion);

        List<Document> similarDocs = vectorStore.similaritySearch(
            SearchRequest.builder()
                .query(userQuestion)
                .topK(1)
                .similarityThreshold(SIMILARITY_THRESHOLD)
                .build()
        );

        if (similarDocs.isEmpty()) {
            return Optional.empty();
        }

        Document matched = similarDocs.get(0);
        // 校验缓存时效性:超过 24 小时的缓存可能过时
        long cacheAge = Duration.between(
            matched.getMetadata().getInstant("createdAt"), Instant.now()
        ).toHours();

        if (cacheAge > 24) {
            return Optional.empty();
        }

        return Optional.of(matched.getContent());
    }

    /**
     * 写入语义缓存。
     */
    public void put(String question, String answer) {
        Document doc = new Document(
            answer,
            Map.of(
                "originalQuestion", question,
                "createdAt", Instant.now()
            )
        );
        vectorStore.add(List.of(doc));
    }
}

3.2 Token 预算控制与限流

/**
 * Token 预算控制器:防止大模型调用成本失控。
 * 为什么在网关层做预算控制,而不是依赖 Provider 的配额?
 * 1. Provider 配额是全局的,无法按租户/用户粒度控制
 * 2. Provider 配额触发后是硬熔断,无法实现优雅降级
 * 3. 自建预算控制可以实现更灵活的策略(如按天重置、超额降级)
 */
@Component
public class TokenBudgetGuard {

    private final RedisTemplate<String, String> redisTemplate;

    public TokenBudgetGuard(RedisTemplate<String, String> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    /**
     * 校验 Token 预算是否充足。
     * 使用 Redis 的 INCRBY 原子操作,保证并发安全。
     * 为什么用 INCRBY 而非 GET+SET?
     * 因为 GET+SET 不是原子操作,并发场景下会导致预算超支。
     */
    public BudgetCheckResult checkAndReserve(String tenantId,
                                              int estimatedTokens) {
        String dailyKey = "token:budget:" + tenantId + ":" +
            LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE);

        // 原子递增已使用量
        Long usedTokens = redisTemplate.opsForValue().increment(
            dailyKey, estimatedTokens
        );

        // 设置过期时间,防止 Key 永久残留
        if (usedTokens.equals((long) estimatedTokens)) {
            redisTemplate.expire(dailyKey, Duration.ofDays(2));
        }

        int dailyLimit = getTenantDailyLimit(tenantId);

        if (usedTokens > dailyLimit) {
            // 超限:回滚刚才的预占
            redisTemplate.opsForValue().increment(dailyKey, -estimatedTokens);
            return BudgetCheckResult.exceeded(dailyLimit, usedTokens - estimatedTokens);
        }

        return BudgetCheckResult.ok(dailyLimit, usedTokens);
    }

    private int getTenantDailyLimit(String tenantId) {
        // 从配置中心获取租户的日预算上限
        // 默认 500K Token/天,约对应 100 美元
        return 500_000;
    }
}

3.3 模型路由与降级策略

/**
 * 模型路由器:根据请求特征和系统负载选择最优模型。
 * 为什么需要模型路由?
 * 因为不同复杂度的问题用同一模型是巨大的浪费:
 * - 简单问答用 GPT-4 成本是 GPT-3.5 的 15 倍
 * - 复杂推理用 GPT-3.5 质量不达标,需要人工兜底
 * 模型路由的核心价值是在成本和质量之间找到最优解。
 */
@Service
public class ModelRouter {

    private final List<ModelProvider> providers;
    private final CircuitBreakerRegistry circuitBreakerRegistry;

    public ModelRouter(List<ModelProvider> providers,
                       CircuitBreakerRegistry circuitBreakerRegistry) {
        this.providers = providers;
        this.circuitBreakerRegistry = circuitBreakerRegistry;
    }

    /**
     * 路由选择:根据请求复杂度和模型可用性选择 Provider。
     */
    public ModelProvider route(ChatRequest request) {
        ComplexityLevel complexity = assessComplexity(request);

        // 按复杂度排序候选模型
        List<ModelProvider> candidates = providers.stream()
            .filter(p -> p.supportsComplexity(complexity))
            .sorted(Comparator.comparingInt(p -> p.getCostWeight(complexity)))
            .toList();

        // 遍历候选模型,选择第一个熔断器处于关闭状态的
        for (ModelProvider candidate : candidates) {
            CircuitBreaker cb = circuitBreakerRegistry
                .circuitBreaker(candidate.getName());
            if (cb.getState() == CircuitBreaker.State.CLOSED) {
                return candidate;
            }
        }

        // 所有首选模型不可用,降级到最低成本的可用模型
        return providers.stream()
            .filter(p -> circuitBreakerRegistry
                .circuitBreaker(p.getName())
                .getState() != CircuitBreaker.State.OPEN)
            .min(Comparator.comparingInt(ModelProvider::getBaseCost))
            .orElseThrow(() -> new ServiceUnavailableException(
                "所有大模型服务不可用,请稍后重试"));
    }

    private ComplexityLevel assessComplexity(ChatRequest request) {
        // 基于 Prompt 长度和关键词评估复杂度
        // 简单策略:超过 500 Token 或包含推理关键词视为复杂
        if (request.estimatedInputTokens() > 500 ||
            containsReasoningKeywords(request.getMessage())) {
            return ComplexityLevel.HIGH;
        }
        return ComplexityLevel.LOW;
    }
}

四、LLM 网关的架构代价与适用边界

引入 LLM 网关并非没有代价,以下几个方面的成本需要在架构决策时充分权衡。

语义缓存的准确性代价。 向量相似度匹配存在误判风险——两个语义不同但表述相似的问题可能被错误匹配。例如"如何优化 Java 内存"和"如何优化 Python 内存"的向量相似度可能超过阈值,但答案完全不同。在高准确性要求的场景(如医疗、法律咨询),语义缓存需要配合人工审核机制,或者干脆禁用。

Token 预算预占的精度问题。 大模型的实际 Token 消耗在响应完成前无法精确获知,预算控制只能基于估算值预占。如果估算偏低,可能导致预算超支;如果估算偏高,会浪费预算配额,降低系统吞吐。目前主流的做法是按输入 Token 的 1.5 倍预占,响应完成后按实际值调整,但这引入了额外的 Redis 操作开销。

模型路由的冷启动问题。 新上线的模型 Provider 缺乏历史负载数据,路由策略难以做出最优决策。在冷启动阶段,可能将过多请求路由到新 Provider,导致延迟波动。建议对新 Provider 设置流量上限(如不超过总流量的 10%),逐步放量。

适用边界:LLM 网关适合日均调用量超过 1 万次、有多模型混用需求、或对成本控制有严格要求的场景。对于调用量极低(日均百次以内)的内部工具,直接调用大模型 API 即可,引入网关反而是过度设计。

禁用场景:对实时性要求极高的场景(如实时对话中的流式响应),语义缓存的向量计算延迟(约 50-200ms)可能不可接受。此时应跳过缓存层,直接透传请求,或采用异步预计算缓存的方式。

五、总结

大模型后端集成的核心挑战不是"能不能调通 API",而是如何在成本、延迟和质量三者之间建立可控的平衡。LLM 网关通过语义缓存降低调用量、Token 预算控制防止成本失控、模型路由实现成本与质量的最优匹配,为 Java 后端系统与大模型服务之间构建了一层可靠的中间层。

落地路线建议:第一步,先实现 Token 预算控制和基础限流,这是成本安全的底线保障,实现复杂度低、收益明确。第二步,引入模型路由和熔断机制,实现多 Provider 之间的故障隔离和负载均衡。第三步,根据业务场景的缓存命中率数据,决定是否引入语义缓存——如果用户提问的重复率低于 20%,语义缓存的投入产出比可能不理想。第四步,建设可观测性体系,对每次 LLM 调用的延迟、Token 消耗、缓存命中率进行细粒度监控,为后续的架构优化提供数据支撑。

架构决策的关键在于认清当前阶段的主要矛盾:如果成本是首要问题,优先建设预算控制和缓存;如果稳定性是首要问题,优先建设熔断和降级。不要试图一次性解决所有问题,而是根据实际痛点逐步迭代。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐