摘要:本文系统阐述了大模型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,自动切换。
@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+ 效率工具包
一份资料包,两个专栏都能用。「唠点键盘之外的」,只讲干货。