Java教程

Java与AI:大模型 HTTP 200 但响应体里带了 error 字段,Java 客户端该把它当成功还是失败,统一异常分类(LlmException)到底该按 HTTP 状态码还是按业务错误体来切?

👤 为我痴狂 👁 57 阅读 ❤ 0 点赞 ➦ 0 分享 📅 2026-10-23
首页› 理学› Java教程› 正文
工程手记 · 接口契约
Java与AI:HTTP 200 却带 error 字段,
客户端该判成功还是失败?

——从“状态码优先”到“双层判定”,LlmException 统一异常分类的工程落地路径

摘要

调用大模型服务时,最迷惑人的故障形态不是 500,而是 HTTP 200 + 响应体 {"error": {...}}。很多 Java 客户端把它当成功解析,结果在业务层抛出 NullPointerException 或静默返回空串,监控上看不到任何错误率。本文围绕一条主线展开:传输层判定(HTTP 状态码)与语义层判定(业务错误体)必须分层,异常分类以“可重试性 + 归因”为第一原则,而不是以状态码或错误体任何单一维度为第一原则。文中给出 LlmException 的分类树、判定顺序、Spring RestClient 拦截器实现、重试与熔断参数,以及一套可复现的验证清单。

目录

一、问题现场:一次“成功”的失败调用

先还原一个真实感很强的场景。某 Java 服务通过 HTTP 调用大模型网关,代码大致如下:

// 典型“只看状态码”的写法
HttpResponse<String> resp = client.send(req, BodyHandlers.ofString());
if (resp.statusCode() == 200) {
    ChatResult result = objectMapper.readValue(resp.body(), ChatResult.class);
    return result.getChoices().get(0).getMessage().getContent(); // 这里炸了
}
throw new RuntimeException("llm call failed: " + resp.statusCode());

服务端返回的报文可能是这样的(结构示意,字段名参考主流厂商公开文档):

HTTP/1.1 200 OK
Content-Type: application/json

{
  "error": {
    "type": "rate_limit_exceeded",
    "code": "rate_limit_exceeded",
    "message": "Rate limit reached for requests",
    "param": null
  }
}

此时 choices 为 null,反序列化不报错(Jackson 默认忽略未知字段、缺失字段为 null),于是 getChoices() 返回 null,NPE 在业务层爆开。更糟的是:如果业务代码对空结果做了兜底(返回空字符串),这次限流就被彻底吞掉,监控上错误率为 0,而用户侧体验在持续劣化。

本文评述:这不是“解析没写好”的小问题,而是接口契约的判定权归属问题。HTTP 状态码是传输层契约,error 字段是应用层契约。把两者混在一个 if 里判断,等于让两个不同抽象层次的信号争夺同一个布尔值,冲突是必然的。

1.1 为什么大模型 API 特别容易出现这种形态

传统 REST 服务通常用 4xx/5xx 表达失败,200 就是成功。但大模型 API 有三个特殊性:其一,流式输出(SSE)一旦开始推送,HTTP 头早已发出,中途出错只能靠事件流里的 error 事件表达,状态码无法回改;其二,网关/代理层(如各类 API 聚合网关、企业自建 LLM Gateway)为了统一计费与日志,常把上游错误“包装”成 200 返回,避免客户端 SDK 直接抛错;其三,部分厂商在配额、内容审核等场景选择 200 + error 体,以兼容只认状态码的老客户端。本文认为,这三种动因在 2024—2026 年只会增多不会减少,因此“200 带 error”不是异常,而是需要被正式建模的常态。

二、协议层事实:HTTP 200 到底承诺了什么

RFC 9110(HTTP Semantics,2022 年发布,取代 RFC 7231)对状态码的定义是“服务器对请求的理解与处理结果的机器可读摘要”。200 OK 的语义是“请求成功”,但“成功”指的是服务器成功接收、理解并处理了请求,并不承诺“业务语义成功”。这一点在 RFC 9110 §15.3.1 的措辞中很清楚:响应载荷的语义由具体方法与应用协议定义。

换句话说,HTTP 层只保证“这次对话完成了”,应用层完全可以在 200 的载荷里说“我拒绝服务”。这在协议上是合法的。真正的问题在于:大量客户端库与开发者心智模型把 200 等同于业务成功,形成了事实上的“隐式契约”。

层次 信号来源 承诺内容 失败时的典型表现
传输层 HTTP 状态码、连接状态 请求是否被接收、路由、处理 4xx/5xx、连接超时、TLS 失败
应用层 响应体 error 字段、业务码 业务是否真正完成 200 + error、200 + 空 choices
语义层 内容审核标记、finish_reason 输出是否可用、是否被截断 finish_reason=content_filter

表 1:三层判定信号对照(据 RFC 9110 与主流厂商公开 API 文档整理)

笔者认为:把这三层压成一个布尔值,是绝大多数“200 带 error”事故的根因。正确的做法不是选边站(状态码派 vs 错误体派),而是承认三层各自独立,按固定顺序逐层判定,并把判定结果映射到同一个异常类型体系。这就是本文后续所有设计的出发点。

三、错误体谱系:主流大模型 API 的错误结构对比

要设计统一的 LlmException,先得知道各家错误体长什么样。下表基于各厂商公开 API 文档整理(字段名以文档为准,此处为结构归纳,不逐字复制原文):

厂商/协议 错误容器 关键字段 是否常见 200+error
OpenAI 风格 {"error":{...}} type / code / message / param 网关层常见
Anthropic 风格 {"type":"error","error":{...}} type / message 较少,多用 4xx/5xx
国内云厂商 A {"code":...,"message":...} code / message / request_id 偶见
国内云厂商 B {"error_code":...,"error_msg":...} error_code / error_msg 偶见
自建网关 自定义 envelope success / errno / data 很常见

表 2:主流错误体结构归纳(据各厂商公开 API 文档整理,2025 年 12 月核对)

可以看到,错误体的容器名(error / 无容器 / envelope)和字段名(type / code / error_code)都不统一,但语义维度高度收敛:几乎都能归到“限流、鉴权、参数、内容审核、服务端故障、超时”这几类。这为统一分类提供了现实基础。

本文评述:统一异常分类的目标不是“把各家字段名映射成一张大表”——那张表会随厂商版本无限膨胀。目标是提取语义维度:可重试性、归因方(客户端/服务端/第三方)、是否计费、是否需要人工介入。字段名只是提取这些维度的线索。

四、核心主线:双层判定模型与“可重试性优先”原则

4.1 双层判定模型

本文提出的判定模型分两层、四个判定点,顺序固定:

第 1 层 · 传输层判定(看状态码与连接)

① 连接/超时/IO 异常 → 直接归为 TransportException,可重试。

② 状态码 2xx 以外的,按 4xx/5xx 分流;但不立即定终局,先看响应体有没有结构化错误信息用于细化。

第 2 层 · 语义层判定(看响应体)

③ 状态码 2xx 时,检查响应体是否含 error 容器或业务失败标志;含则按错误体细化分类。

④ 状态码 2xx 且无 error 时,再检查业务不变量(choices 非空、finish_reason 合法),不满足则归为 ContractViolationException。

这个顺序的关键在于:状态码先判,但不独占判定权;错误体后判,但可以覆盖状态码给出的“成功”结论。两者不是二选一,而是“先粗后细”的两级过滤。

4.2 为什么“可重试性优先”而不是“状态码优先”

异常分类的最终消费者是重试策略、熔断器、告警路由、计费对账。这四者关心的第一属性都是“这次失败能不能自动恢复”。状态码与这个属性只是相关,不是等价:

  • 429 通常可重试(退避后),但 429 + “账户余额不足”不可重试;
  • 500 通常可重试,但 500 + “请求体超长”重试一万次也没用;
  • 200 + rate_limit_exceeded 可重试,200 + invalid_api_key 不可重试。

所以分类树的第一层分叉应当是“可重试 / 不可重试 / 需人工”,第二层才是“归因”。状态码和错误体都是特征,可重试性才是标签。这是本文与“按状态码切”或“按错误体切”两种流行做法的根本分歧。

笔者认为:业界关于“异常该按什么切”的讨论,常陷入“状态码派 vs 错误体派”的二元对立。但从控制论角度看,异常分类是一个特征到动作的映射问题:输入是 (状态码, 错误体, 上下文),输出是动作(重试/降级/告警/拒绝)。用动作空间来组织分类,比用信号来源来组织分类,更贴近工程目标,也更稳定——因为动作空间变化慢,信号格式变化快。

五、LlmException 分类树设计(含完整代码)

5.1 分类树

LlmException (abstract, 携带 requestId / provider / retryable / category)
├── TransportException            连接、DNS、TLS、读超时、IO
│   ├── ConnectTimeoutException
│   └── ReadTimeoutException
├── RateLimitException            限流/配额(可重试,带 retryAfter)
├── AuthException                 鉴权/权限(不可重试)
├── InvalidRequestException       参数/格式/上下文超长(不可重试)
├── ContentPolicyException        内容审核拦截(不可重试,需业务提示)
├── ServerException               5xx/上游故障(可重试)
├── ContractViolationException    200 但结构不合法(不可重试,需修客户端)
└── UnknownLlmException           兜底(默认不可重试,触发告警)

5.2 基类与枚举

public enum LlmErrorCategory {
    TRANSPORT, RATE_LIMIT, AUTH, INVALID_REQUEST,
    CONTENT_POLICY, SERVER, CONTRACT, UNKNOWN
}

public abstract class LlmException extends RuntimeException {
    private final LlmErrorCategory category;
    private final boolean retryable;
    private final String provider;
    private final String requestId;
    private final Integer httpStatus;
    private final String rawCode;

    protected LlmException(LlmErrorCategory category, boolean retryable,
                           String provider, String requestId,
                           Integer httpStatus, String rawCode,
                           String message, Throwable cause) {
        super(message, cause);
        this.category = category;
        this.retryable = retryable;
        this.provider = provider;
        this.requestId = requestId;
        this.httpStatus = httpStatus;
        this.rawCode = rawCode;
    }
    // getters 省略
}

5.3 关键子类:RateLimitException 带 retryAfter

public class RateLimitException extends LlmException {
    private final Duration retryAfter; // 来自 Retry-After 头或错误体

    public RateLimitException(String provider, String requestId,
                              Integer httpStatus, String rawCode,
                              Duration retryAfter, String message) {
        super(LlmErrorCategory.RATE_LIMIT, true, provider, requestId,
              httpStatus, rawCode, message, null);
        this.retryAfter = retryAfter == null ? Duration.ofSeconds(1) : retryAfter;
    }
    public Duration getRetryAfter() { return retryAfter; }
}

关键参数解释:retryable 是给重试器看的唯一开关;category 是给告警路由与看板看的;requestId 必须从响应头(如 x-request-id)或错误体透传,否则排障时无法与厂商对账;rawCode 保留原始错误码字符串,便于事后统计厂商码分布。

5.4 分类器:从 (状态码, 响应体) 到异常

public final class LlmErrorClassifier {

    public static LlmException classify(int status, String body,
                                        HttpHeaders headers, String provider) {
        String requestId = firstNonNull(headers.getFirst("x-request-id"),
                                        headers.getFirst("request-id"));
        // 第 1 层:传输层
        if (status >= 200 && status < 300) {
            ErrorBody err = ErrorBodyParser.tryParse(body); // 兼容多种容器
            if (err != null) {
                return fromErrorBody(err, status, requestId, provider, headers);
            }
            if (!ContractChecker.isValidChatResponse(body)) {
                return new ContractViolationException(provider, requestId, status,
                        "2xx but body violates chat contract");
            }
            return null; // 真正成功
        }
        // 非 2xx:先按状态码粗分,再尝试用错误体细化
        ErrorBody err = ErrorBodyParser.tryParse(body);
        if (status == 401 || status == 403) {
            return new AuthException(provider, requestId, status,
                    err == null ? null : err.code(), msg(err, "auth failed"));
        }
        if (status == 429) {
            return new RateLimitException(provider, requestId, status,
                    err == null ? null : err.code(),
                    parseRetryAfter(headers), msg(err, "rate limited"));
        }
        if (status >= 500) {
            return new ServerException(provider, requestId, status,
                    err == null ? null : err.code(), msg(err, "server error"));
        }
        return new InvalidRequestException(provider, requestId, status,
                err == null ? null : err.code(), msg(err, "invalid request"));
    }

    private static LlmException fromErrorBody(ErrorBody e, int status,
                                              String requestId, String provider,
                                              HttpHeaders headers) {
        String code = e.code() == null ? "" : e.code().toLowerCase();
        if (code.contains("rate_limit") || code.contains("quota")
                || code.contains("throttl")) {
            return new RateLimitException(provider, requestId, status, e.code(),
                    parseRetryAfter(headers), e.message());
        }
        if (code.contains("auth") || code.contains("api_key")
                || code.contains("permission")) {
            return new AuthException(provider, requestId, status, e.code(), e.message());
        }
        if (code.contains("content") || code.contains("safety")
                || code.contains("moderation")) {
            return new ContentPolicyException(provider, requestId, status, e.code(), e.message());
        }
        if (code.contains("invalid") || code.contains("context_length")
                || code.contains("too_long")) {
            return new InvalidRequestException(provider, requestId, status, e.code(), e.message());
        }
        if (code.contains("server") || code.contains("overload")
                || code.contains("unavailable")) {
            return new ServerException(provider, requestId, status, e.code(), e.message());
        }
        return new UnknownLlmException(provider, requestId, status, e.code(), e.message());
    }
}

操作 → 预期结果 → 常见错误:把上述分类器接到调用链后,200 + rate_limit_exceeded 应产出 RateLimitException(retryable=true);常见错误是把 code 匹配写成 equals 精确匹配,而厂商常在 code 里带后缀(如 rate_limit_exceeded_v2),导致漏判。本文建议用 contains 做粗匹配,再用白名单做精确覆盖。

本文评述:基于字符串 contains 的分类天然脆弱,但它是可解释、可热修的。相比之下,把错误码硬编码成枚举会在厂商加码时直接抛 IllegalArgumentException,故障面更大。工程折中是:contains 兜底 + 配置化映射表(见 6.3),把精确映射外置到配置文件,发版即可更新。

六、落地实现:Spring RestClient 拦截器与判定顺序

6.1 为什么用拦截器而不是在每个调用点判

判定逻辑必须单点收敛。散落在各业务方法里的 if 判断,必然出现“这个接口判了、那个接口忘了”的漂移。Spring Framework 6.1 起提供的 RestClient 支持 ClientHttpRequestInterceptor,是天然的收敛点。若仍用 RestTemplate,对应 ClientHttpRequestInterceptor 同样可用;WebClient 则用 ExchangeFilterFunction。

6.2 拦截器实现

@Component
public class LlmErrorInterceptor implements ClientHttpRequestInterceptor {

    private final String provider;

    public LlmErrorInterceptor(@Value("${llm.provider}") String provider) {
        this.provider = provider;
    }

    @Override
    public ClientHttpResponse intercept(HttpRequest request, byte[] body,
                                        ClientHttpRequestExecution execution) throws IOException {
        ClientHttpResponse response = execution.execute(request, body);
        int status = response.getStatusCode().value();
        String respBody = StreamUtils.copyToString(response.getBody(), StandardCharsets.UTF_8);

        LlmException ex = LlmErrorClassifier.classify(
                status, respBody, response.getHeaders(), provider);
        if (ex != null) {
            throw ex;
        }
        // 成功:把已读的 body 重新包装回去,避免下游读不到
        return new BufferedClientHttpResponse(response, respBody.getBytes(StandardCharsets.UTF_8));
    }
}

关键点:拦截器里读了一次 body,必须用 BufferedClientHttpResponse 包装回填,否则下游反序列化拿到空流。这是最常见的实现错误,表现为“加了拦截器后所有成功请求都解析成 null”。

6.3 配置化映射表

# llm-error-mapping.yml(示意,按实际厂商码补充)
mappings:
  - match: "rate_limit_exceeded"
    category: RATE_LIMIT
    retryable: true
  - match: "insufficient_quota"
    category: RATE_LIMIT
    retryable: false      # 余额不足,重试无意义
  - match: "invalid_api_key"
    category: AUTH
    retryable: false
  - match: "context_length_exceeded"
    category: INVALID_REQUEST
    retryable: false
  - match: "content_filter"
    category: CONTENT_POLICY
    retryable: false

注意 insufficient_quota 与 rate_limit_exceeded 都归 RATE_LIMIT 类,但可重试性相反。这正是“可重试性优先”原则的价值:同一 category 下允许 retryable 不同,重试器只看 retryable,看板只看 category,各取所需。

七、重试、退避与熔断:把分类结果用起来

7.1 重试策略

RetryPolicy<LlmException> policy = RetryPolicy.<LlmException>builder()
    .handle(LlmException.class)
    .maxAttempts(4)
    .exponentialBackoff(Duration.ofMillis(500), 2.0, Duration.ofSeconds(8))
    .jitter(0.3)
    .filter(e -> e.isRetryable())          // 唯一开关
    .onRetry(ctx -> log.warn("retry {} for {}",
            ctx.attempt(), ctx.lastException().getCategory()))
    .build();

参数解释:maxAttempts=4 是经验值,对应 500ms→1s→2s→4s 的退避序列,总耗时约 7.5s,低于多数网关 10s 超时;jitter=0.3 防止重试风暴同步化;filter 只认 retryable,不认状态码——这是本文主线的直接体现。若用 Resilience4j,等价配置是 RetryConfig.custom().retryExceptions(...) 配合自定义 Predicate。

7.2 熔断与降级

熔断器统计的应当是 ServerException 与 TransportException,而不应统计 InvalidRequestException 与 AuthException——后者是客户端问题,熔断只会让故障面扩大。这是分类树带来的直接收益:熔断器配置从“按异常类型猜”变成“按 category 白名单选”。

Category 重试 熔断计数 告警级别
TRANSPORT是是P2
RATE_LIMIT视 retryable是P3
SERVER是是P1
AUTH否否P1(立即)
INVALID_REQUEST否否P3
CONTENT_POLICY否否P3
CONTRACT否否P1(需修代码)

表 3:分类到运维动作的映射(本文设计,参数为工程经验值)

八、流式响应(SSE)的特殊判定

流式场景下,HTTP 200 在第一个字节发出时就已确定,之后任何错误都只能通过事件流表达。典型形态是:

event: error
data: {"error":{"type":"server_error","message":"upstream timeout"}}

event: done
data: [DONE]

此时拦截器已经放行了 200,错误发生在流读取阶段。判定逻辑必须下沉到 SSE 解析器:遇到 event: error 立即构造对应 LlmException 并中断流。注意:已经推送给用户的部分内容不可撤回,所以流式场景的“失败”是“部分成功 + 中断”,异常里应携带 partialContent 字段,供上层决定是重放还是提示用户。

笔者认为:流式场景是“状态码优先”论最站不住脚的地方。状态码在流开始时就已冻结,后续所有判定只能依赖事件流语义。这反过来证明:把判定权完全交给状态码,在架构上是不完备的;双层判定不是可选优化,而是流式时代的必需。

九、可观测性:让“200 里的错误”被看见

分类做完,还要让它在监控上可见。建议埋三个指标:

  • llm_call_total{provider,category,http_status}:http_status 维度保留原始状态码,这样能直接看出“200 但 category!=OK”的占比;
  • llm_retry_total{category}:验证 retryable 配置是否符合预期;
  • llm_contract_violation_total{provider}:CONTRACT 类异常单独计数,这类异常意味着厂商契约变更或客户端解析 bug,必须人工介入。

日志侧,每条 LlmException 必须打 requestId + provider + rawCode + category + retryable。缺 requestId 的日志在跨厂商排障时几乎无用——这是很多团队踩过的坑。

十、验证方法与排错清单

10.1 验证步骤

  1. 构造用例:用 WireMock 或 MockWebServer 模拟 200+error、429、500、200+空 choices 四类响应,断言抛出的异常类型与 retryable 值。
  2. 验证 body 回填:对 200 正常响应断言下游能拿到完整 body,防止拦截器吞流。
  3. 验证重试:模拟前两次 429、第三次 200,断言总调用 3 次且最终成功。
  4. 验证熔断:连续 10 次 ServerException 后断言熔断器打开,且 InvalidRequestException 不触发熔断。
  5. 验证流式:模拟 SSE 中途 error 事件,断言抛出异常且 partialContent 非空。

10.2 排错清单

现象 最可能原因 处置
成功请求解析成 null拦截器读了 body 未回填用 Buffered 包装
限流被当成功只判状态码启用语义层判定
余额不足被疯狂重试retryable 配置错误insufficient_quota 置 false
厂商加码后全量 Unknown精确匹配漏判contains 兜底 + 配置表
流式错误无异常SSE 解析器未处理 error 事件补事件分支

表 4:常见故障与处置对照(本文整理)

十一、进阶:契约测试与前沿预判

契约测试:把“200+error”纳入 CI。做法是把各厂商错误样例固化成 JSON fixture,每次发版跑一遍分类器,断言 category 与 retryable。这样厂商改码时,CI 会先于线上报警。Pact 等契约测试工具

🔒 复制本站文章内容需登录并达到 L3。当前:未登录

分享到

💬
微信
📷
朋友圈
🐧
QQ好友
🌐
QQ空间
👁
微博
📌
钉钉
🔗
复制链接
📑
复制图文

微信扫一扫分享

打开微信「扫一扫」,扫描二维码后在微信中分享给好友或朋友圈。

💬 评论 (0)

评论功能已关闭

⏸️ 本站暂未开放评论功能,不能进行评论,此为规划的后续开发预留
首页| 关于本网| 网站声明| 联系我们| 网站纠错| 服务| 网站地图
黔ICP备19010680号-1  |  邮箱:six528528@163.com
贵公网安备 52010302001819号
Copyright 2019-2026 http://www.databrush.com/ All rights reserved.
QQ
QQ扫一扫
Logo
DBN数据刷