Skip to Content
Hi-Agent v1.0 · 全新上线 · 一门关于 Agent 工程的系统课程
课程01 · Chat1.4 Hi-Agent异常处理框架

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:错误来自哪个 Provider
  • statusCode:如果未来接 HTTP,可以挂上响应码
  • cause:底层原始异常

其中,当前阶段真正会用到的是:

  • code
  • message
  • retryable

providerstatusCode 虽然现在还没正式接入 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:

  • IllegalArgumentException
  • IllegalStateException

如果现在突然切成 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 是空值

这些错误以前分别抛 IllegalStateExceptionIllegalArgumentException

现在它们都归到:

AgentException.configError(...)

从业务语义上说,它们都不是“运行时偶发错误”,而是配置问题
把它们统一进 CONFIG_ERROR 之后,后面上层就可以非常稳定地识别:

这是配置错了,不是应该重试的故障。

OpenAiChatClient 是这一节最值得改的地方,因为它正处在“内部参数校验”和“外部模型调用”之间。

当前这节里,我们先处理它的本地主动抛错部分:

  • client == null -> INVALID_ARGUMENT
  • model 为空 -> INVALID_ARGUMENT
  • userPrompt 为空 -> INVALID_ARGUMENT
  • messages 为空 -> INVALID_ARGUMENT
  • onDelta == null -> INVALID_ARGUMENT
  • role 不支持 -> 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")

这一步很小,但很关键。

因为统一异常模型最怕的不是“有些地方还没细分”,而是:

有些地方根本没进入这套语言。

Last updated on