1.3 Hi-Agent异常处理框架
前面的理论部分已经讲清楚了一件事:
在 Agent 系统里,错误处理的第一步不是立刻重试,也不是一出错就降级,而是先判断: 这个错误到底是什么错误。
如果连错误都没有统一表达方式,那么后面的重试、切换 Provider、自动降级,其实都无从谈起。
所以这一节的目标非常明确:先给项目建立一套统一的异常语言。
在这次改造之前,当前项目里的异常大致是这种状态:
- 参数不合法时,直接抛
IllegalArgumentException - 状态不对时,直接抛
IllegalStateException null校验时,有些地方用Objects.requireNonNull- 上层只能通过异常 message 猜测到底发生了什么
这在 Demo 阶段没什么问题,因为代码还少,链路也短,但一旦后面开始接:
- HTTP 状态码分类
- SDK 异常映射
- 多 Provider
- 重试
- 自动降级
这样显然是不行的,因为对于上层来说:
IllegalArgumentException只是告诉你“参数错了”IllegalStateException只是告诉你“状态不对”
但它不会告诉你:
- 这是配置错误,还是运行时错误?
- 这是流式阶段的问题,还是普通聊天阶段的问题?
- 这个错误值不值得重试?
- 这个错误将来是否可以关联到某个 Provider?
所以,我们首先需要 把项目里分散、模糊、靠 message 识别的异常,提升成结构化错误模型。
1. 让异常变成可判断的数据
如果异常只有一段文本 message,上层就只能靠字符串判断,这是非常脆弱的。
所以这一节我们让异常至少具备这些信息:
code:错误码message:给开发者看的错误说明retryable:是否值得重试provider:错误来自哪个 ProviderstatusCode:如果未来接 HTTP,可以挂上响应码cause:底层原始异常
其中,当前阶段真正会用到的是:
codemessageretryable
而 provider 和 statusCode 虽然现在还没正式接入 HTTP/SDK 映射,但这次先把槽位留出来,下一节接起来会更顺。
接下来我们首先实现AgentException:
public final class AgentException extends RuntimeException它内部保存了这些字段:
private final AgentErrorCode code;
private final boolean retryable;
private final String provider;
private final Integer statusCode;同时还提供了静态工厂方法:
AgentException.invalidArgument(...)
AgentException.invalidState(...)
AgentException.configError(...)
AgentException.streamError(...)
AgentException.internalError(...)这样写,如果你在代码里看到:
throw AgentException.invalidArgument("messages must not be empty");那么这行代码表达的信息非常明确:
- 这是参数错误
- 错误码是
INVALID_ARGUMENT - 默认不重试
相比直接抛:
throw new IllegalArgumentException("messages must not be empty");语义会清楚很多。
AgentException
public final class AgentException extends RuntimeException {
private final AgentErrorCode code;
private final boolean retryable;
private final String provider;
private final Integer statusCode;
public AgentException(AgentErrorCode code, String message, boolean retryable) {
this(code, message, retryable, null, null, null);
}
public AgentException(
AgentErrorCode code,
String message,
boolean retryable,
String provider,
Integer statusCode,
Throwable cause) {
super(requireNonBlank(message, "message"), cause);
this.code = Objects.requireNonNull(code, "code must not be null");
this.retryable = retryable;
this.provider = normalize(provider);
this.statusCode = statusCode;
}
public static AgentException invalidArgument(String message) {
return new AgentException(AgentErrorCode.INVALID_ARGUMENT, message, false);
}
public static AgentException invalidState(String message) {
return new AgentException(AgentErrorCode.INVALID_STATE, message, false);
}
public static AgentException configError(String message) {
return new AgentException(AgentErrorCode.CONFIG_ERROR, message, false);
}
public static AgentException streamError(String message) {
return new AgentException(AgentErrorCode.STREAM_ERROR, message, false);
}
public static AgentException internalError(String message) {
return new AgentException(AgentErrorCode.INTERNAL_ERROR, message, false);
}
public AgentErrorCode code() {
return code;
}
public boolean retryable() {
return retryable;
}
public String provider() {
return provider;
}
public Integer statusCode() {
return statusCode;
}
private static String normalize(String value) {
if (value == null || value.isBlank()) {
return null;
}
return value.trim();
}
private static String requireNonBlank(String value, String fieldName) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(fieldName + " must not be blank");
}
return value.trim();
}
}AgentException 被设计成了运行时异常,而不是受检异常。当前项目的风格本来就是 unchecked:
IllegalArgumentExceptionIllegalStateException
如果现在突然切成 checked exception,那么很多方法签名都会被迫加上 throws,不仅改动大,而且会让当前这套 Chat / Streaming 链路显得很重。
而对于我们后面要做的:
- 流式调用
- 多 Provider 路由
- 自动降级
运行时异常也更适合做统一拦截。
接下来,我们将异常码也抽象出来:
AgentException
public enum AgentErrorCode {
INVALID_ARGUMENT,
INVALID_STATE,
CONFIG_ERROR,
STREAM_ERROR,
INTERNAL_ERROR
}这 5 类分别对应:
INVALID_ARGUMENT参数不合法,比如空字符串、空列表、null输入INVALID_STATE当前状态不合理,比如响应结构存在,但没有 assistant 内容CONFIG_ERROR配置缺失或配置值非法STREAM_ERROR流式路径上的结果异常,比如流式响应没有有效内容INTERNAL_ERROR预留给后续更泛化的内部错误
有了我们自定义异常之后,把当前项目里“我们自己主动抛出的本地异常”统一替换掉。
在 OpenAiConfig 里,有两类错误:
.env里缺少必填配置baseUrl/apiKey/model是空值
这些错误以前分别抛 IllegalStateException 和 IllegalArgumentException。
现在它们都归到:
AgentException.configError(...)从业务语义上说,它们都不是“运行时偶发错误”,而是配置问题。
把它们统一进 CONFIG_ERROR 之后,后面上层就可以非常稳定地识别:
这是配置错了,不是应该重试的故障。
OpenAiChatClient 是这一节最值得改的地方,因为它正处在“内部参数校验”和“外部模型调用”之间。
当前这节里,我们先处理它的本地主动抛错部分:
client == null->INVALID_ARGUMENTmodel为空 ->INVALID_ARGUMENTuserPrompt为空 ->INVALID_ARGUMENTmessages为空 ->INVALID_ARGUMENTonDelta == null->INVALID_ARGUMENTrole不支持 ->INVALID_ARGUMENT
这些都属于“调用者传参错了”。
但还有两类错误不是简单参数错误:
普通聊天响应里没有 assistant 内容
阻塞式 chat(...) 如果响应对象存在,但没有 assistant 文本内容,这说明请求已经走到响应解析阶段了,只是结果状态不符合预期。
所以这里归入:
AgentException.invalidState(...)流式响应最终没有任何有效文本
流式 streamChat(...) 如果整个流跑完了,但最终一个有效文本都没收到,这不是简单参数错,而是流式过程上的异常结果。
所以这里归入:
AgentException.streamError(...)这个区分是有价值的。因为后面接重试和降级时:
INVALID_ARGUMENT基本不该重试INVALID_STATE多半要重点排查STREAM_ERROR可能会成为流式恢复和降级判断的一部分
OpenAiChatSession 之前在构造函数里还保留着:
Objects.requireNonNull(client, "client must not be null")从 Java 角度它没问题,但从“统一错误语言”的目标来看,它会抛出 NullPointerException,这就和项目内其他错误体系脱节了。
所以这里也改成了:
AgentException.invalidArgument("client must not be null")这一步很小,但很关键。
因为统一异常模型最怕的不是“有些地方还没细分”,而是:
有些地方根本没进入这套语言。