AI 调用超时与异常兜底方法详解(AI 大模型评测平台的稳定性设计)

模型调用的不可靠,是评测类平台稳定性设计里绕不开的前提。上线第一天,评测任务一多,超时、限流、5xx 一个接一个冒出来,用户反馈是”点了没反应,转圈半小时”。当时我对比过两条路:一条是加机器、多开线程硬扛,另一条是把不可靠性在调用层彻底兜住。前者只会把问题往后推,后者才能根治。最后我把方案收敛成”超时分级 + 异常分类 + 失败重试 + 用户可感”四件套,把外部模型的不可靠封装成用户眼里”看起来可靠”的服务。下面把每一件怎么落地写清楚。

一、超时分级

最开始我踩过一个坑:全平台共用一套超时。短对话 5 秒能出结果,长文评测要 30 秒,统一设 10 秒,长任务全被掐断;统一设 30 秒,短对话超时了用户要干等。后来才意识到,超时必须跟着业务场景走,而不是跟着 HTTP 客户端走。分级落地按三步来:

  1. 梳理所有调用场景,标出典型的输入长度与输出长度;
  2. 按场景拆出连接超时、读取超时、总超时三档参数;
  3. 参数放进配置中心,上线后按监控数据微调。

下面是平台在线上用的分级表:

场景 入口超时 连接超时 读取超时 总超时
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 别省——后面踩坑部分会看到,不加抖动的重试在批量任务里就是雪崩源头。

四、降级与备选模型

重试三次还失败,不能直接把错误抛给用户。评测场景里用户要的是”这次评分的结论”,而不是一个错误码。所以平台做了模型降级:主模型不行就换备选模型,把单点故障挡在业务层之外。这里的取舍是”宁可换一个风格略有差异的模型,也不让用户空手而归”,毕竟评测结果的可复现性比模型偏好更重要。

降级流程按优先级排列:

  1. 主模型重试耗尽,进入降级链路;
  2. 依次尝试备选模型,每次先判断异常是否可重试,不可重试立即退出;
  3. 全部失败才抛业务异常,由前端呈现”重新生成”入口。

实现上就是一个简单的 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 偏短。

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 qiqicto@qq.com 举报,一经查实,本站将立刻删除。
赞 (0)
其AI的头像其AI普通用户

相关推荐

返回顶部