第1696篇:大模型调用的超时策略设计——分级超时与快速失败的权衡
第1696篇:大模型调用的超时策略设计——分级超时与快速失败的权衡
我见过两种极端的超时配置。
一种是没有超时:代码直接用默认配置调用大模型API,没有设任何超时。出了问题以后,应用服务器里几百个线程全在等大模型返回,线程池满了,新请求全部拒绝,服务彻底瘫痪。事后复盘,大模型API那边只是在做限速,偶尔有几个请求慢了,但因为没有超时保护,这几个慢请求把整个服务压垮了。
另一种是超时设得太短:把连接超时、读超时统一设成5秒。大模型API的正常响应经常超过5秒,于是绝大部分请求都超时失败了。监控显示错误率100%,但其实大模型API本身一点问题都没有。
这两种问题我都碰到过,甚至在同一个项目里。
超时策略设计,是AI服务里最需要认真对待的工程问题之一。
大模型调用有哪些时间点需要控制
在设计超时之前,先搞清楚一次大模型调用里,时间花在哪些阶段。
这里涉及的超时维度:
- 连接超时(Connect Timeout):建立TCP连接的超时,通常几秒足够
- 请求发送超时(Write Timeout):发送Prompt到大模型API的超时,Prompt越长越慢
- 首Token时间(TTFT,Time to First Token):从发出请求到收到第一个Token的时间
- 流式传输超时(Read Timeout):整个流式传输过程中,两个相邻Token之间的最大间隔
- 总请求超时(Total Timeout):整个请求从开始到结束的最大时间
大多数HTTP客户端只有连接超时和读超时两个维度,但AI场景需要更精细的控制。
分级超时的设计思路
不同场景对超时的容忍度不同,要分级设计。
第一级:用户实时交互
- 场景:用户正在聊天界面等待回答
- 特点:用户感知强烈,即使响应内容很好,等太久也会离开
- 首Token超时:5-8秒(超过这个时间,用户体验极差)
- 总超时:60-120秒(流式场景下,只要字在蹦出来用户能接受)
第二级:后台批量处理
- 场景:批量给文档生成摘要、批量打标签
- 特点:用户不在线等,可以接受更长时间
- 首Token超时:30-60秒
- 总超时:300秒(5分钟)
第三级:异步任务
- 场景:定时分析报告、离线数据处理
- 特点:用户只关心最终结果,不关心单次调用时间
- 首Token超时:120秒
- 总超时:600秒(10分钟)
第四级:实时降级场景
- 场景:推荐系统、搜索增强等,如果AI慢可以降级到规则
- 特点:快速失败,立即降级,不等待
- 首Token超时:2秒(超时就走备用方案)
- 总超时:3秒
用Java实现分级超时
我用一个 TimeoutPolicy 的抽象来封装不同场景的超时配置:
public sealed interface TimeoutPolicy permits
TimeoutPolicy.Interactive,
TimeoutPolicy.Batch,
TimeoutPolicy.Async,
TimeoutPolicy.FastFail {
Duration connectTimeout();
Duration firstTokenTimeout();
Duration tokenIntervalTimeout(); // 两个相邻Token之间的最大间隔
Duration totalTimeout();
// 用户实时交互
record Interactive() implements TimeoutPolicy {
public Duration connectTimeout() { return Duration.ofSeconds(5); }
public Duration firstTokenTimeout() { return Duration.ofSeconds(8); }
public Duration tokenIntervalTimeout() { return Duration.ofSeconds(10); }
public Duration totalTimeout() { return Duration.ofSeconds(120); }
}
// 后台批量
record Batch() implements TimeoutPolicy {
public Duration connectTimeout() { return Duration.ofSeconds(10); }
public Duration firstTokenTimeout() { return Duration.ofSeconds(60); }
public Duration tokenIntervalTimeout() { return Duration.ofSeconds(30); }
public Duration totalTimeout() { return Duration.ofMinutes(5); }
}
// 异步任务
record Async() implements TimeoutPolicy {
public Duration connectTimeout() { return Duration.ofSeconds(10); }
public Duration firstTokenTimeout() { return Duration.ofSeconds(120); }
public Duration tokenIntervalTimeout() { return Duration.ofSeconds(60); }
public Duration totalTimeout() { return Duration.ofMinutes(10); }
}
// 快速失败(用于降级场景)
record FastFail() implements TimeoutPolicy {
public Duration connectTimeout() { return Duration.ofSeconds(2); }
public Duration firstTokenTimeout() { return Duration.ofSeconds(2); }
public Duration tokenIntervalTimeout() { return Duration.ofSeconds(2); }
public Duration totalTimeout() { return Duration.ofSeconds(3); }
}
}然后在LLM客户端里使用:
@Service
public class LLMClient {
public String chat(String prompt, TimeoutPolicy policy) {
OkHttpClient client = httpClientBuilder()
.connectTimeout(policy.connectTimeout())
.writeTimeout(Duration.ofSeconds(30))
.readTimeout(policy.tokenIntervalTimeout()) // OkHttp的readTimeout对应两次读之间的间隔
.build();
// 总超时用CompletableFuture.get(timeout)来控制
CompletableFuture<String> future = CompletableFuture.supplyAsync(() ->
executeRequest(client, prompt)
);
try {
return future.get(policy.totalTimeout().toMillis(), TimeUnit.MILLISECONDS);
} catch (TimeoutException e) {
future.cancel(true); // 取消底层请求
throw new LLMTimeoutException("LLM请求超时: policy=" + policy.getClass().getSimpleName());
} catch (ExecutionException e) {
throw new LLMException("LLM请求失败", e.getCause());
}
}
// 流式版本,支持首Token超时
public Flux<String> streamChat(String prompt, TimeoutPolicy policy) {
return Flux.<String>create(sink -> {
// ... 流式调用逻辑
})
// 首Token超时控制
.timeout(
policy.firstTokenTimeout(), // 第一个元素的超时
policy.tokenIntervalTimeout() // 后续元素间隔的超时
)
.timeout(policy.totalTimeout()); // 总超时(再套一层)
}
}区分首Token超时和流式传输超时
这是AI流式场景特有的需求,Reactor的 timeout 操作符支持两种timeout:
// timeout(Duration) - 对每个元素的间隔超时
// timeout(Duration firstTimeout, Publisher fallback) - 只对第一个元素超时
Flux<String> tokenStream = llmClient.rawStream(prompt)
.timeout(
Duration.ofSeconds(8), // 等待第一个Token最多8秒
Duration.ofSeconds(10) // 后续每个Token间隔最多10秒
);但这有个问题:Reactor的 timeout(Duration, Duration) 接口并不是这么工作的。实际上需要自己实现:
public Flux<String> streamWithFirstTokenTimeout(
Flux<String> source,
Duration firstTokenTimeout,
Duration subsequentTimeout) {
AtomicBoolean firstTokenReceived = new AtomicBoolean(false);
return source
.doOnNext(token -> firstTokenReceived.set(true))
.timeout(token -> {
// 根据是否已收到第一个Token来决定超时时间
if (!firstTokenReceived.get()) {
// 还没收到第一个Token,用首Token超时
return Mono.delay(firstTokenTimeout);
} else {
// 已经开始流式输出,用相邻Token间隔超时
return Mono.delay(subsequentTimeout);
}
});
}快速失败与降级的设计
超时之后做什么,是比超时本身更重要的问题。
有几种策略:
策略一:直接返回错误
最简单,适合没有降级方案的场景:
public ChatResponse chat(String prompt) {
try {
return llmClient.chat(prompt, TimeoutPolicy.interactive());
} catch (LLMTimeoutException e) {
throw new ServiceException("AI服务响应超时,请稍后重试");
}
}策略二:降级到备用模型
超时后自动切换到响应更快的模型:
public ChatResponse chatWithFallback(String userMessage) {
// 先尝试高质量模型(3秒超时)
try {
return gpt4Client.chat(userMessage, new TimeoutPolicy.FastFail());
} catch (LLMTimeoutException e) {
log.warn("GPT-4超时,降级到快速模型");
// 降级到响应更快但质量略低的模型
return gpt35Client.chat(userMessage, new TimeoutPolicy.Interactive());
}
}策略三:返回缓存的历史回答
对于一些固定问题(FAQ、常见问题),超时时返回缓存:
public ChatResponse chatWithCache(String question) {
String cacheKey = hashQuestion(question);
// 先试缓存
String cached = cache.getIfPresent(cacheKey);
if (cached != null) {
return new ChatResponse(cached, true); // 标记是来自缓存的
}
// 实时调用
try {
String response = llmClient.chat(question, TimeoutPolicy.interactive());
cache.put(cacheKey, response);
return new ChatResponse(response, false);
} catch (LLMTimeoutException e) {
// 超时时尝试找语义相似的缓存答案
String similar = findSimilarCached(question);
if (similar != null) {
return new ChatResponse(similar + "\n\n(注:当前AI响应慢,以上为参考答案)", true);
}
throw e;
}
}策略四:部分响应(已有流式内容的情况)
流式场景下,可能已经输出了一半内容,超时后可以优雅地结束:
public Flux<String> streamWithGracefulTimeout(String prompt) {
StringBuilder accumulated = new StringBuilder();
return llmClient.rawStream(prompt)
.doOnNext(accumulated::append)
.timeout(
Duration.ofSeconds(8), // 首Token
Duration.ofSeconds(15), // 后续Token间隔
// 超时时的fallback:返回一个截断提示
Flux.just("\n\n[响应已超时,以上为部分内容]")
);
}熔断器:超时的高阶版本
超时只处理单次请求,但如果大模型API持续慢,每次都等到超时才失败,积累的等待时间还是很大。
熔断器(Circuit Breaker)能解决这个问题:当失败率超过阈值,直接快速失败,不等到超时。
用Resilience4j实现:
@Configuration
public class CircuitBreakerConfig {
@Bean
public CircuitBreaker llmCircuitBreaker() {
io.github.resilience4j.circuitbreaker.CircuitBreakerConfig config =
io.github.resilience4j.circuitbreaker.CircuitBreakerConfig.custom()
// 滑动窗口:最近20次请求
.slidingWindowType(SlidingWindowType.COUNT_BASED)
.slidingWindowSize(20)
// 失败率超过50%时打开熔断器
.failureRateThreshold(50)
// 慢调用:超过5秒算慢调用
.slowCallDurationThreshold(Duration.ofSeconds(5))
// 慢调用率超过30%时打开熔断器
.slowCallRateThreshold(30)
// 熔断器开启后,等待10秒进入半开状态
.waitDurationInOpenState(Duration.ofSeconds(10))
// 半开状态下,允许5个请求通过测试
.permittedNumberOfCallsInHalfOpenState(5)
.build();
return CircuitBreaker.of("llm-api", config);
}
}
@Service
public class RobustLLMClient {
private final CircuitBreaker circuitBreaker;
private final LLMClient rawClient;
public String chat(String prompt) {
// 用熔断器包装调用
Supplier<String> decoratedCall = CircuitBreaker.decorateSupplier(
circuitBreaker,
() -> rawClient.chat(prompt, TimeoutPolicy.interactive())
);
try {
return decoratedCall.get();
} catch (CallNotPermittedException e) {
// 熔断器开启,快速失败
log.warn("LLM API熔断器开启,快速失败");
throw new ServiceUnavailableException("AI服务暂时不可用,请稍后重试");
}
}
}熔断器的状态转换很关键,要通过监控暴露出来:
// 监听熔断器状态变化
circuitBreaker.getEventPublisher()
.onStateTransition(event -> {
log.warn("熔断器状态变更: {} -> {}",
event.getStateTransition().getFromState(),
event.getStateTransition().getToState());
// 发送告警
alertService.sendAlert("LLM API熔断器状态变更: " + event.getStateTransition());
});超时配置的常见误区
误区一:把连接超时设得很长
// 错误:连接超时30秒意味着每次连接失败都要等30秒
.connectTimeout(30, TimeUnit.SECONDS)
// 正确:连接超时应该在3-5秒,因为TCP连接本身应该很快
.connectTimeout(5, TimeUnit.SECONDS)误区二:用统一的readTimeout控制所有超时
很多代码把readTimeout设成120秒,期望覆盖所有场景。问题是:
- 非流式请求:用户要等到120秒才知道超时,体验差
- 流式请求:120秒的相邻Token间隔超时太宽松,僵尸连接可能持续很久
误区三:超时后不取消HTTP连接
// 错误:超时后没有取消HTTP请求,连接还在后台运行
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> llmClient.call(prompt));
try {
return future.get(5, TimeUnit.SECONDS);
} catch (TimeoutException e) {
// 只捕获了异常,但future还在后台运行,连接还在占用!
throw new TimeoutException();
}
// 正确:超时后取消future,底层HTTP库会收到中断信号并关闭连接
} catch (TimeoutException e) {
future.cancel(true); // true表示尝试中断正在执行的任务
throw new LLMTimeoutException();
}误区四:流式输出不设总超时
// 问题:只设了首Token超时,没有总超时
// 如果大模型输出非常非常多的内容(意外情况),可能永远不结束
streamFlux.timeout(Duration.ofSeconds(8)); // 只控制首Token
// 正确:首Token超时 + 总超时双重保障
streamFlux
.timeout(Duration.ofSeconds(8)) // 首Token超时
.take(Duration.ofMinutes(3)); // 总时间不超过3分钟超时配置的动态调整
不同时间段、不同用户类型、不同模型,超时配置可能需要动态调整。用配置中心(如Nacos、Apollo)管理超时参数:
@Configuration
@RefreshScope // Spring Cloud动态刷新
public class DynamicTimeoutConfig {
@Value("${llm.timeout.firstToken:8000}")
private long firstTokenTimeoutMs;
@Value("${llm.timeout.total:120000}")
private long totalTimeoutMs;
@Value("${llm.timeout.fastFail:2000}")
private long fastFailTimeoutMs;
public TimeoutPolicy getInteractivePolicy() {
return new DynamicInteractivePolicy(firstTokenTimeoutMs, totalTimeoutMs);
}
public TimeoutPolicy getFastFailPolicy() {
return new DynamicFastFailPolicy(fastFailTimeoutMs);
}
}这样可以在不重启服务的情况下,根据大模型API的实时状态动态调整超时参数。
总结
大模型调用的超时策略,核心要点:
- 分级设计:不同场景用不同超时策略,不能一刀切
- 区分各阶段超时:连接超时、首Token超时、Token间隔超时、总超时,每个都有意义
- 超时后要有动作:降级、快速失败、返回缓存,不能只抛异常
- 取消底层连接:超时后必须取消HTTP连接,否则连接泄漏
- 熔断器升级:在超时的基础上加熔断器,防止慢API拖垮整个服务
- 监控驱动:通过实际数据调整超时参数,不要靠拍脑袋
超时是个看起来简单、实际上细节很多的工程问题。花时间把它设计好,能避免很多生产事故。
