客户端该判成功还是失败?
——从“状态码优先”到“双层判定”,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 验证步骤
- 构造用例:用 WireMock 或 MockWebServer 模拟 200+error、429、500、200+空 choices 四类响应,断言抛出的异常类型与 retryable 值。
- 验证 body 回填:对 200 正常响应断言下游能拿到完整 body,防止拦截器吞流。
- 验证重试:模拟前两次 429、第三次 200,断言总调用 3 次且最终成功。
- 验证熔断:连续 10 次 ServerException 后断言熔断器打开,且 InvalidRequestException 不触发熔断。
- 验证流式:模拟 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 等契约测试工具
微信扫一扫分享
打开微信「扫一扫」,扫描二维码后在微信中分享给好友或朋友圈。
💬 评论 (0)
评论功能已关闭

