摘要:本文系统阐述了大模型API调用中的容错与高可用设计。核心思路是将大模型API视为不可靠的外部依赖,通过四层防护机制保障系统稳定性:1)线程池隔离,避免慢响应拖垮业务线程;2)智能重试,针对超时和限流进行指数退避重试;3)熔断降级,当失败率过高时自动切断调用链路;4)多Provider切换,在主模型不可用时自动降级到备用模型。文章结合Spring生态的@Retryable、Resilience4j CircuitBreaker等工具,给出了完整的Java代码实现,并提供了面试场景的标准回答模板。

这篇聊一个面试必问、但大部分人准备不足的话题。

你调大模型 API 的时候,考虑过它挂了怎么办吗?

很多人没想过。Demo 阶段嘛,调一次成功一次,没出过问题。但我问你个场景:

你的知识库上线了,用了 GPT-4o。某天下午三点,OpenAI 的某个节点出问题了——不是你一个人的问题是全球性的。你的用户正在往系统里问问题,突然全部返回超时错误。

你怎么办?

有人说:我代码里没处理超时,默认 30 秒连接超时。 用户等 30 秒,拿到一个白屏,刷新一下再等 30 秒。然后老板的微信来了:"你的系统坏了。"

有人说:我用 try-catch 包了一下,超时了返了个"服务繁忙"。 比上一位好点,但用户在你这拿不到答案,转头就去问别的同事了,你的系统慢慢被弃用。

这两种情况我都见过。而且说句实话,第二种已经算不错了——至少没让用户看到异常堆栈。

我打个比喻你们感受一下:

调大模型 API,跟你在美团点外卖一模一样。 你下单了,等着骑手把饭送来。但这个骑手可能路上摔跤了(请求超时)、可能拿错了餐(返回乱码)、可能点了个已取消的商家(API 挂了)、可能堵路上了(响应特别慢)。

你是点餐的人,你能怎么办?

你等一会儿再点一次——重试。你换一家店点——降级到另一个模型。你设置最长等待时间——超时。你觉得今天这家店不行直接不吃了——熔断。

一模一样。没有任何本质区别。你平时调支付宝、调微信支付、调短信通道,怎么做的兜底策略,调大模型 API 就怎么做。


先说核心:线程池独立

这是 90% 的人踩的第一个坑。

大模型 API 的响应时间是不可控的。好的时候 1 秒,慢的时候 10 秒,挂了的时候 30秒超时才回错误。如果不做隔离,你的业务线程会被大模型的慢响应拖死。

举个真实例子:

你的系统是个 Web 服务,Tomcat 默认 200 个线程。突然来了一波用户,每人问一个很长的 Prompt。大模型卡住了,200 个线程全卡在等待 API 返回上。

这时候新请求来了,线程池满了,Tomcat 拒绝连接。你的整个系统挂了——不是因为你的业务逻辑有问题,是因为调大模型 API 把线程池占完了。

这不是假设。我亲耳听过一个案例,他们用默认的 RestTemplate 调 OpenAI,生产上 400 并发,直接全部 503。

解决方案:大模型的 API 调用必须走独立的线程池。

@Configuration
public class LlmThreadPoolConfig {

    @Bean("llmTaskExecutor")
    public ThreadPoolTaskExecutor llmTaskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        // 核心线程:大模型慢,并发不宜太高
        executor.setCorePoolSize(10);
        // 最大线程:最多允许 20 个并发请求同时等大模型
        executor.setMaxPoolSize(20);
        // 队列容量:最多排队 100 个请求
        executor.setQueueCapacity(100);
        // 线程名前缀,方便排查
        executor.setThreadNamePrefix("llm-worker-");
        // 任务拒绝策略:直接抛异常,让调用方感知到限流了
        executor.setRejectedExecutionHandler(
            new ThreadPoolExecutor.CallerRunsPolicy()
        );
        executor.initialize();
        return executor;
    }
}

注意几个关键点:

corePoolSize = 10 —— 核心线程数。为什么设这么小?因为大模型 API 的瓶颈通常不在你的机器上,在远端 API 的并发限制上。很多大模型 API 对单个 IP 有 QPS 限制,你起 100 个线程去请求,95 个被限流返回 429。

queueCapacity = 100 —— 队列容量。超过 100 个就在入口处拒绝,不要让你的系统死在大模型手上。

CallerRunsPolicy —— 当线程池满了,让调用者线程自己跑。这个策略很聪明:Web 请求的线程被卡在大模型调用上,相当于它自己替大模型扛了一部分并发。比 AbortPolicy(直接抛异常)更温和。


@Retryable 重试——别一次失败就放弃

有时候大模型 API 挂了不是真挂了,是短暂波动。过几秒又好了。

比如网络抖了一下、API Gateway 重启、你 API Key 的配额刚好到期需要刷新。这些情况,重试一次就能搞定。

重试策略流程图

Spring 的 @Retryable 注解一行就搞定了:

@Service
public class LlmRetryService {

    @Retryable(
        retryFor = {TimeoutException.class, HttpClientErrorException.TooManyRequests.class},
        maxAttempts = 3,
        backoff = @Backoff(delay = 1000, multiplier = 2)
    )
    public String callLlm(String prompt) {
        ResponseEntity<String> response = restTemplate.postForEntity(
            openAiUrl, buildRequest(prompt), String.class
        );
        return response.getBody();
    }

    @Recover
    public String recover(Throwable e, String prompt) {
        // 三次重试都失败后做兜底
        return "系统暂时繁忙,请稍后再试";
    }
}

retryFor —— 什么异常触发重试?超时和限流(429)值得重试。4xx 其他错误(比如 401 认证失败)不值得,重试一百次也没用。

backoff = @Backoff(delay = 1000, multiplier = 2) —— 重试间隔。第一次等 1 秒,第二次等 2 秒。为什么递增?因为对端如果正在压力恢复中,你疯狂重试反而会加重对端负载。指数退避是对双方都友好的策略。

@Recover —— 三次全失败后调这个方法。你在这里做降级:返回一个友好的提示、或者走缓存、或者调一个更便宜的备用模型。


Resilience4j 熔断——别让错误连锁反应

重试对短时波动有效。但如果大模型真的挂了(比如 API 提供商宕机了),重试只会让你的线程池雪上加霜。

这时候需要熔断。一句话解释:你连续失败了很多次之后,就别再试了,直接走降级。

熔断器状态转换图

Resilience4j 的 CircuitBreaker 是 Spring Cloud 生态里的标配:

@Bean
public CircuitBreaker llmCircuitBreaker() {
    CircuitBreakerConfig config = CircuitBreakerConfig.custom()
        // 10 秒窗口内,超过 50% 的请求失败就熔断
        .failureRateThreshold(50)
        .slidingWindowSize(10)
        // 熔断后等待 30 秒再尝试恢复
        .waitDurationInOpenState(Duration.ofSeconds(30))
        // 半开状态允许 3 个请求试探
        .permittedNumberOfCallsInHalfOpenState(3)
        .build();

    return CircuitBreakerRegistry.of(config)
        .circuitBreaker("llm-api", config);
}

参数含义:

failureRateThreshold(50) —— 失败率阈值 50%。窗口内 10 个请求有 5 个失败就熔断。

slidingWindowSize(10) —— 滑动窗口大小 10 个请求。注意是最后一个请求开始往前数 10 个,不是固定的时间窗口。这样更能反映当前状态。

waitDurationInOpenState(30) —— 熔断后等 30 秒才能进入半开状态。给对端足够时间恢复。

permittedNumberOfCallsInHalfOpenState(3) —— 半开状态只放 3 个请求去试探。如果这 3 个都成功了,电路关闭恢复正常。如果还有失败的,继续熔断。

使用方式:

@Service
public class LlmApiService {

    @Autowired
    private CircuitBreaker llmCircuitBreaker;

    private final RestTemplate restTemplate;

    public String callWithCircuitBreaker(String prompt) {
        return llmCircuitBreaker.executeSupplier(() -> {
            // 熔断状态下,这一行不会被执行
            // 会直接抛出 CircuitBreakerOpenException
            return restTemplate.postForEntity(
                openAiUrl, buildRequest(prompt), String.class
            ).getBody();
        });
    }
}

当熔断器打开时,executeSupplier 不会真的发起 HTTP 请求,而是直接抛出异常。你可以在这个异常的地方捕获,走降级。


多 provider 切换——别在一棵树上吊死

靠模型提供商活着的系统,最怕的是它的 API 挂了。但你只有一个 API Key。

正确答案是:多备几个 provider,自动切换。

多Provider切换架构

@Component
public class LlmRouter {

    private final List<LlmProvider> providers;
    private final CircuitBreaker circuitBreaker;

    public String call(String prompt) {
        for (LlmProvider provider : providers) {
            if (!provider.isAvailable()) {
                log.warn("{} 不可用,切换到下一个", provider.name());
                continue;
            }
            try {
                return circuitBreaker.executeSupplier(
                    () -> provider.call(prompt)
                );
            } catch (Exception e) {
                log.warn("{} 调用失败,切换到下一个", provider.name(), e);
            }
        }
        // 所有 provider 都挂了,最后的降级
        return fallback(prompt);
    }

    private String fallback(String prompt) {
        // 你可以选择走本地小模型,或者返回缓存
        return localModel.call(prompt);
    }
}

LlmProvider 接口抽象了模型调用:

public interface LlmProvider {
    String name();
    boolean isAvailable();
    String call(String prompt);
}

你可以给 OpenAI 一个实现、给通义千问一个实现、再给本地部署的 Ollama 一个实现。排好优先级,失败了自动按顺序往下走。

fallback 方法调用 localModel —— 这可以是你本地部署的一个小模型,比如 Qwen2.5 7B。性能不如 GPT,但至少不会让用户空手而归。


综合:完整的调用链路

把上面的东西串在一起,实际的调用链路是这样的:

@Service
public class LlmResilientService {

    @Autowired
    @Qualifier("llmTaskExecutor")
    private ThreadPoolTaskExecutor executor;

    @Autowired
    private LlmRouter router;

    public CompletableFuture<String> ask(String prompt) {
        // 异步提交到独立线程池,不阻塞业务线程
        return CompletableFuture.supplyAsync(() -> {
            try {
                return router.call(prompt);
            } catch (Exception e) {
                log.error("所有模型调用都失败了", e);
                return "系统繁忙,请稍后重试";
            }
        }, executor)
        // 给整个调用设置超时:最多等 15 秒
        .orTimeout(15, TimeUnit.SECONDS)
        .exceptionally(ex -> {
            log.warn("LLM 调用超时", ex);
            return "回答超时,请简化后重试";
        });
    }
}

这个类做的事情:

1. 把请求丢到独立的 LLM 线程池,不占 Tomcat 的线程

2. 用 LlmRouter 自动切换多个 provider,A 不行换 B

3. 每个 provider 调用都有 Resilience4j 熔断器保护

4. 设置了全局超时 15 秒

你给前端返回 CompletableFuture,前端可以展示 loading 图标,等结果回来再刷新。不会让用户干等。


🎯 面试官视角的标准回答

如果面试官问:"大模型 API 挂了,你怎么保证系统可用性?"

我把它当作一个外部依赖来处理,和调支付宝没区别。从四个层面做:<br><br>第一是线程隔离。大模型 API 的响应时间不可控,必须走独立的线程池。我设了 10 个核心线程、100 个排队上限,超过的直接拒绝,不占 Tomcat 的 worker 线程。<br><br>第二是超时和重试。必须显式设置连接超时和读取超时,默认的 infinite 是生产大忌。超时后用 @Retryable 做指数退避重试,最多 3 次。<br><br>第三是熔断降级。用 Resilience4j 的 CircuitBreaker,50% 失败率触发熔断,30 秒后自动恢复。熔断期间走降级逻辑,比如返回缓存数据或友好提示。<br><br>第四是多 provider 切换。主备模型策略,OpenAI 挂了自动切到通义千问,再不行切到本地 Ollama。保证不管怎么出问题,用户都不会拿到白屏。<br><br>核心就一句话:任何外部依赖都不可靠,你要做的不是防它不挂,是它挂了你的系统还能跑。

下一篇是这个系列的最后一篇:RAG 调优。chunk 到底切多大?top_k 设多少?怎么评估你的 RAG 效果好不好?

私信回复「666」,一次性领走:

面试宝典:Java 高频考点速查表、HashMap/ConcurrentHashMap 源码笔记、JVM 调优案例、Spring Boot 面试 50 问

AI 编程工具箱:Cursor/Copilot/Codex 六工具对比表、10 个 Prompt 模板、Debug 万能公式、Cursor 速查手册、AI 图片生成入门、30+ 效率工具包

一份资料包,两个专栏都能用。「唠点键盘之外的」,只讲干货。

更多推荐