模型调用的不可靠,是评测类平台稳定性设计里绕不开的前提。上线第一天,评测任务一多,超时、限流、5xx 一个接一个冒出来,用户反馈是”点了没反应,转圈半小时”。当时我对比过两条路:一条是加机器、多开线程硬扛,另一条是把不可靠性在调用层彻底兜住。前者只会把问题往后推,后者才能根治。最后我把方案收敛成”超时分级 + 异常分类 + 失败重试 + 用户可感”四件套,把外部模型的不可靠封装成用户眼里”看起来可靠”的服务。下面把每一件怎么落地写清楚。
一、超时分级
最开始我踩过一个坑:全平台共用一套超时。短对话 5 秒能出结果,长文评测要 30 秒,统一设 10 秒,长任务全被掐断;统一设 30 秒,短对话超时了用户要干等。后来才意识到,超时必须跟着业务场景走,而不是跟着 HTTP 客户端走。分级落地按三步来:
- 梳理所有调用场景,标出典型的输入长度与输出长度;
- 按场景拆出连接超时、读取超时、总超时三档参数;
- 参数放进配置中心,上线后按监控数据微调。
下面是平台在线上用的分级表:
| 场景 | 入口超时 | 连接超时 | 读取超时 | 总超时 |
|---|---|---|---|---|
| Prompt Lab 单次 | 5s | 3s | 10s | 12s |
| SxS 评测(单模型) | 8s | 3s | 25s | 30s |
| 评分员 | 6s | 3s | 18s | 20s |
| 批量后台任务 | 30s | 5s | 120s | 150s |
注意连接超时全部统一成 3 秒——连不上基本是网络或域名问题,等再久也没意义;真正拉开差距的是读取超时,得按场景来。落地到代码就是给 OkHttp/HttpClient 配一个 RequestConfig,直接复用到各调用入口:
RequestConfig config = RequestConfig.custom()
.setConnectTimeout(3_000)
.setSocketTimeout(25_000)
.setConnectionRequestTimeout(8_000)
.build();
这套配置在平台跑了一个季度,整体超时率从上线初的 8% 降到 1% 出头。提醒一句:连接超时和读取超时别写反,写反的结果是连不上时干等 25 秒,长文本读到一半反而被掐。
二、异常分类
超时只是表象,真正麻烦的是异常五花八门:限流 429、Key 失效 401、内容安全拦截 400、模型 5xx,还有流式解析失败。最初我把它们全塞进一个 catch 块打日志,重试逻辑根本没法写——429 应该等一会儿再试,AUTH_ERROR 试多少次都是白费。于是我做了一个分类枚举,把异常收敛成几大类,后续所有策略都挂在类型上。
先定义异常类型,把六种情况显式列出来:
public enum LlmErrorType {
TIMEOUT, // 超时
RATE_LIMIT, // 限流 429
AUTH_ERROR, // 401/403,API Key 失效
CONTENT_FILTER, // 内容安全拦截
SERVER_ERROR, // 5xx 模型服务挂了
PARSE_ERROR, // 流式 chunk 解析失败
UNKNOWN
}
有了类型,写分类器就顺了,按异常类与 HTTP 状态码直接映射:
public LlmErrorType classify(Throwable t) {
if (t instanceof SocketTimeoutException || t instanceof TimeoutException) {
return TIMEOUT;
}
if (t instanceof HttpStatusCodeException h) {
return switch (h.getStatusCode().value()) {
case 401, 403 -> AUTH_ERROR;
case 429 -> RATE_LIMIT;
case 400 -> h.getResponseBodyAsString().contains("content_filter")
? CONTENT_FILTER : PARSE_ERROR;
case 500, 502, 503, 504 -> SERVER_ERROR;
default -> UNKNOWN;
};
}
if (t instanceof JsonParseException) return PARSE_ERROR;
return UNKNOWN;
}
这里最容易漏的是 400 状态码要再拆一层:同一个 400 可能是内容被拦,也可能是参数解析失败,要靠响应体里是否带 content_filter 字样判断。分类做到这一步,后面的重试、告警、前端提示就都有了依据。
三、失败重试策略
分类做完,重试策略就水到渠成:能重试的才重试。这里我吃过一次亏——AUTH_ERROR 也进了重试队列,密钥过期后所有请求在 2 秒的重试里空转,还把告警埋没了。所以规则里明确三类不重试:认证失败、内容拦截、解析错误,前两者重试无意义,后者重试也救不回来。
各类型要不要重试、重试几次、退避怎么走,都在下面这张表里:
| 异常类型 | 是否重试 | 重试次数 | 退避 |
|---|---|---|---|
| TIMEOUT | 是 | 2 | 指数退避 2s/4s |
| RATE_LIMIT | 是 | 3 | 指数退避 5s/15s/45s(带 jitter) |
| SERVER_ERROR | 是 | 2 | 2s/8s |
| AUTH_ERROR | 否 | – | 立即失败,发告警 |
| CONTENT_FILTER | 否 | – | 标记”内容审核未通过” |
| PARSE_ERROR | 否(重试也无用) | – | 记录原始 chunk |
重试落地用 Resilience4j 或自研 RetryTemplate 都行,我们选的是 Resilience4j,配置直接挂在调用入口上:
RetryConfig cfg = RetryConfig.custom()
.maxAttempts(3)
.intervalFunction(IntervalFunction.ofExponentialBackoff(
Duration.ofSeconds(2), 2.0, 0.3)) // 30% jitter
.retryOnException(t -> t instanceof TimeoutException
|| t instanceof RateLimitException)
.build();
配置里 30% 的 jitter 别省——后面踩坑部分会看到,不加抖动的重试在批量任务里就是雪崩源头。
四、降级与备选模型
重试三次还失败,不能直接把错误抛给用户。评测场景里用户要的是”这次评分的结论”,而不是一个错误码。所以平台做了模型降级:主模型不行就换备选模型,把单点故障挡在业务层之外。这里的取舍是”宁可换一个风格略有差异的模型,也不让用户空手而归”,毕竟评测结果的可复现性比模型偏好更重要。
降级流程按优先级排列:
- 主模型重试耗尽,进入降级链路;
- 依次尝试备选模型,每次先判断异常是否可重试,不可重试立即退出;
- 全部失败才抛业务异常,由前端呈现”重新生成”入口。
实现上就是一个简单的 fallback 循环:
public ChatResult callWithFallback(ChatRequest req) {
List<String> models = req.getFallbackModels();
for (String m : List.of(req.getModelCode(),
models != null ? models : List.of())) {
try {
return llmClient.call(m, req);
} catch (Exception e) {
log.warn("模型 {} 失败 尝试备选 {} 原因 {}",
m, e.getMessage());
if (!isRetryable(e)) break; // 不可重试错误直接失败
}
}
throw new BusinessException("所有模型都失败了");
}
注意循环里的判断,isRetryable 为 false 的异常要直接 break,否则认证失败也会傻傻地轮一遍所有备选模型,白花时间。这套链路上线后,主模型故障时段平台的可用性保持在 99.9% 以上,用户基本无感。策略总结一句话:主模型 3 次重试都失败 → 切备选模型 → 备选也失败才返回错误。
五、流式响应的异常处理
上面的兜底都假设异常发生在请求结束前,但 SSE 流式是边收边发,异常可能出现在中段——前 30 个 token 正常,第 300 个 token 时连接断了。这种场景下把异常闷头吞掉,前端拿到的是一段残缺答案,用户根本不知道发生了什么。我们用的方案是:在 WebFlux 里用 doOnError 记录、onErrorResume 兜底,把异常包装成一个特殊 chunk 推给前端:
Flux<ChatChunk> stream = llmClient.stream(req)
.doOnError(e -> log.error("流式异常", e))
.onErrorResume(e -> {
// 把异常包装成一个特殊 chunk 推到下游
return Flux.just(ChatChunk.error(
classify(e).name(), e.getMessage()));
})
.timeout(Duration.ofSeconds(30))
.doOnNext(chunk -> forwardToClient(chunk)); // WebSocket 推
前端只要识别 chunk.type=error,就知道流中途挂了,展示”重新生成”按钮而不是傻等。这里额外配了 30 秒的 timeout,防止个别模型输出卡死、挂起整个连接。另外注意超时断流时,后端要补一条”已输出 X token”的元信息,方便前端展示已用 token 和预估费用。
六、超时与成本的平衡
超时参数不是越大越好。调大读取超时,长文是保住了,但单条请求占着连接和线程,并发一上来任务队列就堆积;调小则用户体验受损,长文输出被拦腰截断。我们把线上调用按输出长度分了四档,这是反复压测后定的经验值:
- 简单对话/短 prompt:8s 总超时;
- 代码生成(≤1000 token 输出):20s;
- 长文总结/翻译(>2000 token 输出):60s;
- 评分员(必须输出完整):30s,截断会算错分。
再配合让用户传 max_tokens 限制输出长度,等于让用户自己控制最长等待时间——超时与成本的博弈就转嫁成了产品选项,而不是后端写死。这里的经验是:评分员这类”必须拿到完整输出”的场景,宁可给足时间也不截断,截断一次算错分,返工成本远超等待成本。
七、用户感知的友好性
后端兜底做得再好,用户看不到就等于没做。最开始降级是静默的,用户只感觉”这次回答不太一样”,还以为是 bug。后来我们决定把降级、重试这些动作通过 meta 字段显式透出,用户和运营都能一眼看懂发生了什么:
{
"code": 0,
"data": {
"output": "这里是答案...",
"meta": {
"primary_model_failed": true,
"used_fallback": "claude-sonnet",
"retries": 2,
"fallback_reason": "RATE_LIMIT"
}
}
}
前端在答案下方渲染一行小灰字”由 Claude 提供(主模型限流,自动切换)”,用户从困惑变成理解,客服工单里”为什么答案变了”这类问题明显减少。透明度本身也是稳定性的一部分,别把它当成多余的装饰。
八、踩过的坑
把前面几套方案连起来跑,真正的问题才浮出来。挑几个印象最深的:
- 重试雪崩:用户提交 1000 个任务,限流时全部 2s 后重试,把限流时间拉长。jitter 一定要加(±30%)。
- 超时断流丢 token:在 25s 时被切断,输出只完成 60%,但 LLM 已经按完整 2000 token 计费。前端要展示”已用 X token / 估费 Y 元”。
- 备选模型用完同一个 Key 配额:主备模型用同一供应商,限流时一起挂。要分散到不同供应商。
- AUTH_ERROR 一定要告警:API Key 失效是运维事件,不是用户事件。发飞书/钉钉告警,开发者介入。
- 流式 chunk 不完整:”data: [DONE]” 没收到,可能是 TCP 半关。要给前端一个”流式超时”独立状态。
这些坑大多不在”怎么写”上,而在”何时触发”上——重试、降级、计费、前端状态四套逻辑互相耦合,任何一个环节的判断错位,都会把一个小抖动放大成线上事故。
九、监控指标
兜底逻辑多了,就必须能看见它每时每刻在干什么。平台对所有调用入口统一埋点,按模型和结果维度打计数,错误再细分类型:
@Timed(value = "llm.call", percentiles = {0.5, 0.95, 0.99})
public ChatResult call(ChatRequest req) {
// ...
metrics.counter("llm.call",
"model", m, "result", "success").increment();
// 失败
metrics.counter("llm.call",
"model", m, "result", "fail", "error_type", type.name())
.increment();
}
Grafana 看板固定盯三张图:P99 延迟曲线、异常类型分布柱状图、备选模型切换率。某模型 5xx 突增时自动触发告警,值班同学能在用户反馈前介入。到这里,超时与异常的兜底链路就完整了——分级、分类、重试、降级、流式、成本、感知、监控,每一环都有明确的触发条件和出口。
常见问题(FAQ)
Q1:用户主动取消任务怎么处理?
WebSocket 断连后后端用 CancellationException 取消 Flux,前端不用展示重试按钮。SSE/WS 都要监听 disconnect 事件。
Q2:限流该不该让用户感知?
要。429 Too Many Requests + Retry-After 头让前端倒计时。前端体验远比”按钮没反应”好。
Q3:能不能让用户自选超时时间?
能,在评测设置里加”最长等待 30/60/120 秒”选项,默认 30 秒。代码生成场景给默认值偏长,Prompt Lab 偏短。