Agent 应用的异常处理,和普通接口的异常处理不是一回事。一个 Agent 调用链路上可能同时出现模型超时、工具执行失败、返回格式解析错误、上下文超限、异步任务中断等问题,如果只在入口写一个统一的 try-catch,很难把异常恢复、重试、兜底消息这些逻辑放到正确的位置。这篇内容会围绕 Agent 异常处理的三种方式展开:同步调用中的显式异常处理、异步编排中的 CompletableFuture 异常回调、以及 Agent 执行器层面的超时、重试和兜底。理解这三种方式的边界之后,再去看具体的 Agent 框架,异常处理 API 的作用就很容易对号入座。
1. 先把 Agent 异常分成三份,才能设计处理策略
1.1 Agent 运行链路的异常来源
一个典型的 Agent 任务,执行链路通常包含四类节点:接收用户输入、规划下一步动作、调用模型、执行工具。每一类节点都有各自的失败方式。
模型调用失败最典型。可能是模型服务超时、限流、上下文超长,也可能是返回内容不符合 JSON 结构,导致解析失败。工具执行失败也很常见,比如 HTTP 接口返回 500、数据库连接超时、文件路径不存在、权限不足。另外还有 Agent 框架自身的问题,例如规划器循环次数过多、运行器超过执行时长、异步任务被中断,或者父子任务之间异常传播断裂。
这些异常如果混在一起处理,会出现两个问题。第一个问题是定位困难:日志里只有一个外层异常,用户无法知道是模型超时还是工具失败。第二个问题是恢复策略无法差异化:重试一个不可重试的解析错误没有意义,对限流错误不重试又会白白浪费可用额度。
所以在设计异常处理之前,先要把异常分类。按照“在哪里抛出、由哪一层处理”来划分,比按照“对用户展示什么”来划分更实用。
1.2 三种处理方式的职责边界
Agent 异常处理的三种方式,并不是三个等价方案,而是三个层次。
第一层是同步调用中的显式异常处理。在代码中直接调用模型接口、工具方法、解析函数时,使用 try-catch、自定义异常、异常包装等手段,把原始异常转换成语义清晰的业务异常。这一层解决的问题是“这个异常是什么、出在哪一步”。
第二层是异步编排中的异常处理。Agent 任务经常并行执行多个工具,或者把大任务拆成多个子任务,这就会用到 CompletableFuture 或类似的异步编程模型。此时异常不会主动出现在当前线程,而是挂在 Future 链上,需要用 exceptionally、handle、whenComplete 等方法处理。这一层解决的问题是“异步失败后如何恢复、如何传递、如何感知”。
第三层是 Agent 执行器层面的保护。无论同步还是异步,最终都需要一个运行器把任务整体跑起来。执行器负责设置总超时、重试策略、熔断降级、兜底回复。这一层解决的问题是“任务整体卡死或失败时,如何给用户一个确定结果”。
三者不是替代关系。只有第一层,异步失败会漏掉;只有第二层,同步入口的异常会污染外层逻辑;只有第三层,具体错误信息会丢失。生产环境通常是三层叠加使用。
1.3 三种方式与异常类型的对应关系
| 异常处理方式 | 针对场景 | 典型异常 | 处理目标 |
|---|---|---|---|
| 同步显式异常处理 | 模型调用、工具调用、解析过程 | 格式错误、连接异常、状态码错误 | 明确异常语义,保留现场 |
| CompletableFuture 异常处理 | 并行工具、异步子任务、结果合并 | 执行异常、取消异常、超时 | 恢复默认值、传播异常、记录结果 |
| 执行器层保护 | 整个 Agent 任务 | 总超时、循环过深、不可恢复错误 | 限时、重试、熔断、兜底 |
可以先在纸上把你项目里的 Agent 调用链路画出来,标出哪些节点是同步方法,哪些节点是异步任务,哪个对象是总执行器。标完之后,三种方式的落点自然就清楚了。
2. 方式一:用显式异常处理管住同步调用
2.1 在模型调用点捕获异常
最简单的 Agent 流程,是用户输入后直接调用一次模型,然后把结果返回。此时模型调用是同步方法,异常可以用 try-catch 处理。
下面是一段通用示意代码,不绑定某个具体 Agent 框架。在实际项目中,把modelClient.chat(...)换成你所使用的框架 API 即可。
public class AgentService { private final ModelClient modelClient; public AgentService(ModelClient modelClient) { this.modelClient = modelClient; } public String chat(String userInput) { try { // 调用模型,返回可能是普通文本,也可能是结构化 JSON String raw = modelClient.chat(buildPrompt(userInput)); return parseResult(raw); } catch (ModelTimeoutException e) { // 超时通常可以重试,也可以直接降级 return "当前模型响应较慢,请稍后重试。"; } catch (ModelParseException e) { // 返回内容无法解析,重复尝试大概率还是失败 log.error("模型返回内容解析失败: {}", rawLog(e)); return "模型返回内容无法理解,已记录日志。"; } catch (Exception e) { // 最后兜底,避免异常穿透到接口层 log.error("Agent 调用失败", e); return "服务暂时不可用,请稍后再试。"; } } }这段代码有几个关键点。
第一,catch (ModelTimeoutException e)和catch (ModelParseException e)必须放在catch (Exception e)前面,因为子类异常先捕获,父类最后兜底。第二,虽然这是一个同步方法,但异常类型必须由模型客户端或者 Agent 框架明确抛出,否则catch永远接不到对应分支。第三,兜底返回的字符串要分场景设计,超时提示、解析失败提示、系统异常提示不能完全一样,否则用户无法判断是自己输入问题还是系统问题。
2.2 用自定义异常包装工具调用错误
Agent 执行工具时,异常信息往往包含底层细节,比如第三方接口的原始报文、HTTP 状态码、超时时间。直接把这些内容抛给上层,既不安全,也不方便做策略判断。
推荐的做法是定义少量业务异常,在工具执行处做一次包装。
public class AgentToolExecutionException extends RuntimeException { private final String toolName; private final String action; // 例如 QUERY_ORDER、CALL_API private final boolean retryable; public AgentToolExecutionException(String toolName, String action, boolean retryable, String message, Throwable cause) { super(message, cause); this.toolName = toolName; this.action = action; this.retryable = retryable; } public boolean isRetryable() { return retryable; } public String toolName() { return toolName; } }这里最重要的不是异常类的数量,而是retryable这个标记。同样是工具失败,网络超时可能是可重试的,参数校验失败是不可重试的。把是否可重试放在异常类上,后续做重试策略时就不需要去解析字符串,也不需要用“异常类型名是否包含 Timeout”这种脆弱判断。
包装时要注意保留原始异常。比如下面的写法。
public String callExternalApi(String param) { try { return httpClient.post("/api/query", param); } catch (SocketTimeoutException e) { throw new AgentToolExecutionException( "externalApi", "QUERY", true, "外部接口查询超时", e ); } catch (IllegalArgumentException e) { throw new AgentToolExecutionException( "externalApi", "QUERY", false, "外部接口参数错误", e ); } }使用cause把原始异常传入,日志里可以看到完整的堆栈链路;同时对外保留了一个稳定的业务异常。好的异常设计通常不是“异常越少越好”,而是“异常类型有限、语义清晰、携带足够上下文”。大量用new RuntimeException("失败了")会抹掉所有排查线索,后续定位成本很高。
2.3 对不可变结果使用 fail-fast
同步异常处理里还有一个经常被忽略的问题:有些错误发生之后,重试没有意义,但代码却没有快速失败,反而继续往下执行。比如模型返回的 JSON 缺少必要字段、工具返回的数据结构不对,这些属于“数据契约错误”。
建议在解析层做显式校验,校验失败直接抛异常。
public AgentResult parseResult(String raw) { JsonNode node = JsonUtils.parse(raw); if (!node.has("intent")) { throw new ModelParseException("模型返回缺少 intent 字段", raw); } if (!node.get("intent").isTextual()) { throw new ModelParseException("模型返回 intent 字段类型错误", raw); } return new AgentResult(node.get("intent").asText(), node); }这样的代码虽然看起来多,但把错误从“后续某个地方空指针”提前到了“解析这个节点就报错”,错误位置离问题根源更近。排查一个 NPE 比排查一个明确的字段校验异常要慢得多。
2.4 同步方式常见的三个坑
第一个坑是捕获范围过大。有人把整个 Agent 执行流程放在一个 try-catch 里,所有异常都变成“系统异常”。结果是模型超时、工具失败、解析错误、用户输入错误全部混在一起,后续无法做重试判断。推荐做法是:在异常源头附近做捕获和包装,入口只做兜底。
第二个坑是吞掉异常。catch (Exception e) { return "失败"; }且不写日志,是最危险的写法。异常发生后系统仍然“正常”返回,日志里没有任何记录,问题只能靠用户投诉发现。无论哪种异常,都要至少记录一条日志,并保留异常堆栈。
第三个坑是异常类爆炸。每个工具类都建一个独占异常类,最后维护成本很高。推荐做法是围绕 Agent 的少数关键边界设计异常类型,用toolName、action、retryable等字段区分具体场景,而不是为每个方法单独建异常。
3. 方式二:用 CompletableFuture 处理异步编排中的异常
3.1 Agent 异步编排为什么需要专门处理异常
当 Agent 需要并行调用多个工具、同时查询多个数据源、或者把一个大任务拆成多个子任务时,同步 try-catch 就不够用了。因为异常发生在线程池中的某个子任务里,当前主线程不会立刻感知到。如果处理不当,异常会被 Future 包装,并在线程池中“安静”地结束,调用方拿到的是一个ExecutionException,但内部失败细节常常丢失。
CompletableFuture 是 Java 中常用的异步编排工具。它提供了三种典型的异常处理方法:exceptionally、handle、whenComplete。三者都能拿到异常,但语义不同。理解差异才能真正用好。
exceptionally:只在异常发生时执行,返回一个恢复值。handle:无论成功还是失败都执行,可以同时拿到结果和异常,并返回一个新结果。whenComplete:无论成功还是失败都执行,但返回结果由原 Future 决定,不能改变结果。
3.2 用 exceptionally 在失败后给出默认结果
exceptionally适合“失败后给一个默认值”的场景。比如并行查询多个工具时,某个工具失败不应该导致整个 Agent 失败,而是用缓存值或默认值补齐。
CompletableFuture<Double> queryScore(String userId) { return CompletableFuture.supplyAsync(() -> { // 模拟远程查询 return riskService.queryScore(userId); }, agentExecutor) .exceptionally(ex -> { log.warn("查询 {} 评分失败,使用默认分值", userId, ex); return 0.0D; }); }这里的agentExecutor是自定义线程池。使用supplyAsync时如果不指定线程池,会走公共 ForkJoinPool,在 Web 应用里容易和业务线程互相影响。建议异步任务都要显式传入线程池。
exceptionally方法的返回值类型必须与上游的 CompletableFuture 结果类型一致。如果上游是CompletableFuture<Double>,恢复值也必须是Double。这会在编译期检查,是最安全的异常恢复方式。
3.3 用 handle 同时处理结果和异常
handle能同时拿到正常结果和异常对象。适合需要根据“成功但结果不满足条件”和“直接失败”做不同处理的场景。
CompletableFuture<String> executeToolAsync(ToolCall call) { CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> invokeTool(call), agentExecutor); return future.handle((result, ex) -> { if (ex != null) { Throwable cause = (ex instanceof CompletionException) ? ex.getCause() : ex; if (cause instanceof AgentToolExecutionException toolEx && toolEx.isRetryable()) { return "TOOL_RETRYABLE"; } return "TOOL_FAILED"; } if (result == null || result.isBlank()) { return "TOOL_EMPTY"; } return result; }); }这里要注意CompletionException的拆解。CompletableFuture 在内部会把异常包装成CompletionException,直接判断异常类型会失真。建议先看future.isCompletedExceptionally(),或者对ex做一次拆包装,再判断真实类型。
handle返回的 CompletableFuture 永远不会以异常结束,因为内部已经处理了 ex。如果你想保留异常给后续链使用,就不要在 handle 里覆盖异常状态,而是要重新抛出或者传递。
3.4 用 whenComplete 记录状态,不改变结果
whenComplete适合“只观测不修改”的场景。比如异步任务结束后记录耗时、记录成功失败状态、写审计日志,但最终结果仍然沿用上游值。
CompletableFuture<AgentResult> future = CompletableFuture .supplyAsync(() -> agentRunner.run(userInput), agentExecutor) .whenComplete((result, ex) -> { if (ex != null) { log.error("Agent 异步执行失败,input={}", userInput, ex); } else { log.info("Agent 异步执行成功,cost={}ms", result.costMs()); } });这里最容易被绕过的地方是:whenComplete所在的上游 Future 如果异常结束,那么这个CompletableFuture仍然是异常结束的。也就是说,whenComplete不等于“捕获异常”,它只是“看一眼异常”。要真正处理异常,后面还需要接exceptionally或handle。
如果只是希望失败时记录日志,正确姿势就是whenComplete之后再接exceptionally。
CompletableFuture<AgentResult> resultFuture = CompletableFuture .supplyAsync(() -> agentRunner.run(userInput), agentExecutor) .whenComplete((result, ex) -> { if (ex != null) { log.warn("异步失败,准备降级", ex); } }) .exceptionally(ex -> AgentResult.fallback("系统繁忙"));3.5 异步链异常传播和 get 超时
CompletableFuture 的异常传播有一个让新手困惑的点:如果链上某个节点异常,后续节点默认不会执行,异常会沿着链继续向后传递,直到被某个处理节点捕获,或者最终被join()/get()抛出。
比如下面这个链:
CompletableFuture .supplyAsync(() -> toolA(), executor) .thenApply(a -> toolB(a)) .thenAccept(r -> save(r)) .exceptionally(ex -> { log.error("执行失败", ex); return null; });如果toolA()失败,thenApply和thenAccept都不会执行,异常会直接跳到exceptionally。这个设计符合“失败即短路”的语义,但如果你期待每个节点都有日志,就必须在每个节点内部自行 try-catch,或者在每个节点后面接单独的异常处理。
阻塞获取结果时,也容易踩坑。get()会抛出ExecutionException,而join()会抛出CompletionException。很多代码直接捕获ExecutionException,但对join()无效。更稳妥的方式是把异常拆开再判断。
try { AgentResult result = future.get(5, TimeUnit.SECONDS); return result; } catch (TimeoutException e) { future.cancel(true); return AgentResult.timeout(); } catch (ExecutionException e) { Throwable cause = e.getCause(); if (cause instanceof AgentToolExecutionException toolEx) { return AgentResult.retryable(toolEx.isRetryable()); } return AgentResult.error(cause.getMessage()); }用get(timeout, TimeUnit)是异步任务最基础的兜底,否则一个永远不结束的工具调用会让整个 Agent 卡死。
3.6 常见异步异常处理误区
CompletableFuture 的异常处理有一个常见误区:以为whenComplete能改变结果。它不能。如果whenComplete内想返回其他值,需要用handle。
另一个误区是:使用completeExceptionally之后没有消费异常。比如:
CompletableFuture<String> f = new CompletableFuture<>(); f.completeExceptionally(new IllegalStateException("boom"));如果后续没有人调用get()、join()或exceptionally,这个异常相当于被吞掉。Java 不会自动记录它。生产环境建议在任务结束点统一注册whenComplete或exceptionally,确保异常都有消费者。
还有一个误区是混合使用orTimeout和completeOnTimeout。orTimeout会让 Future 以TimeoutException结束,之后可以接exceptionally恢复;completeOnTimeout则直接给一个默认值,Future 会以正常状态结束。两者语义不同,不要混用。orTimeout和completeOnTimeout需要 JDK 9 及以上,使用时注意项目 JDK 版本。
4. 方式三:在执行器层做超时、重试和兜底
4.1 执行器层为什么不能省
同步异常处理和异步异常处理,解决的是“调用点”的问题。但 Agent 任务通常不是单次方法调用,而是一个有循环、有步骤、有模型推理、有工具执行的完整过程。循环可能失控,模型可能反复给出同一个错误动作,工具可能连续超时。这些情况需要有一个更高的层次来踩刹车。
执行器层做的就是这件事:对整体任务设置执行时长上限,对可重试异常做有限次数重试,对不可恢复错误提供兜底回复,同时把运行中的错误、状态、耗时记录下来。
学习阶段可以忽略执行器层,因为任务简单,跑一次就结束。但进入生产环境后,没有执行器保护的 Agent 会非常脆弱:一个模型调用的超时可能让用户请求挂 30 秒,一个工具重试可能让整体任务重复执行三次,导致订单接口被重复调用。
4.2用带超时的 get 拦截卡死
执行器层最简单的保护,是给整个 Agent 调用设置超时。先提交一个 Callable 到线程池,再用 future.get(timeout) 等待。
public AgentResult executeWithTimeout(Callable<AgentResult> task, long timeoutMs) { Future<AgentResult> future = threadPool.submit(task); try { return future.get(timeoutMs, TimeUnit.MILLISECONDS); } catch (TimeoutException e) { future.cancel(true); log.error("Agent 整体执行超时,已取消任务"); return AgentResult.timeout("任务执行超时,已中断"); } catch (ExecutionException e) { Throwable cause = e.getCause(); if (cause instanceof AgentToolExecutionException toolEx) { return AgentResult.retryable(toolEx.retryable()); } return AgentResult.error("Agent 执行失败: " + cause.getMessage()); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return AgentResult.error("当前线程被中断"); } }这段代码的三个 catch 都要写。TimeoutException意味着任务没有在限定时间内返回,需要取消任务。ExecutionException意味着任务内部抛出了异常,需要拆开getCause()才能看到真实异常。InterruptedException意味着当前线程被外部中断,需要恢复中断标记,不能让线程状态被抹掉。
future.cancel(true)并不是一定能杀死正在运行的线程。如果任务内部不响应中断,线程会继续执行,但至少不会再被主流程等待。生产环境还要注意线程池的回收策略,避免因任务卡死导致线程池线程耗尽。
4.3 对可重试错误做有限次数重试
重试不是越多越好。模型接口限流时,稍等一会儿重试可能成功;但如果是参数错误,重试一万次也失败。建议只在异常携带retryable=true时重试,并且设置最大次数和间隔。
public AgentResult executeWithRetry(Task task, int maxRetries, long delayMs) { int retryCount = 0; while (true) { try { return task.run(); } catch (AgentToolExecutionException e) { if (!e.isRetryable() || retryCount >= maxRetries) { throw e; } retryCount++; log.warn("任务第 {} 次执行失败,将在 {}ms 后重试", retryCount, delayMs); sleepQuietly(delayMs); } } }这里要注意两点。第一,重试间隔最好有退避,比如第一次间隔 200ms,第二次 500ms,第三次 1s。固定间隔在限流场景下容易再次触发限流。第二,重试必须保证幂等。Agent 执行工具时,如果工具本身不是幂等的,比如创建订单、发送短信、扣减库存,重试可能导致重复业务操作。这需要在工具设计时考虑,或者在重试前通过业务唯一 ID 去重。
4.4 对不可重试错误做 fallback
有些异常重试没有意义,比如模型返回内容无法解析、工具参数不符合规则、用户输入不合法。这时候执行器需要提供 fallback 逻辑,返回一个对用户有意义的兜底结果。
public AgentResult runWithFallback(String userInput) { try { return agent.run(userInput); } catch (AgentToolExecutionException e) { if (!e.isRetryable()) { log.warn("工具不可重试失败,tool={}", e.toolName()); return AgentResult.fallback("该操作暂时无法完成,请检查参数后重试"); } throw e; } catch (ModelParseException e) { log.error("模型输出无法解析", e); return AgentResult.fallback("模型输出格式异常,请重新提问"); } }fallback 的关键是“给用户一个确定结果,同时不隐藏问题”。兜底消息要友好,但也要区分场景,不能让所有失败都返回“系统繁忙”。用户输入问题、模型问题、工具问题、系统问题,应该有不同的提示。
4.5 框架内置异常策略与自定义扩展
很多 Agent 框架自带执行器和异常策略。有的提供 maxIterations,限制规划循环次数;有的提供 timeout 配置,控制执行总时间;有的提供 retry 模板,对模型调用自动重试。使用框架时,优先使用框架内置能力,不要重复造轮子。
但内置策略通常只能处理框架层异常。对于你自己工具里的业务异常,仍然需要沿用前面两种方式做好包装和语义化。框架内置异常策略适合作为第三层兜底,而不是替代第一层和第二层。
如果框架的兜底策略不符合要求,可以通过实现框架的执行器接口或异常处理接口扩展。自定义时要注意:不要覆盖框架已有的超时配置,也不要把所有异常都转成通用错误,否则日志可观测性会下降。
5. 一套代码把三种方式串起来
5.1 分层职责划分
一个可工作的 Agent 服务,建议按三层划分异常处理。
业务服务层负责同步异常处理。模型调用、工具调用、结果解析都在这一层做 try-catch 和异常包装,保证异常类型语义明确。
异步编排层负责处理并行任务。使用 CompletableFuture 的 exceptionally、handle、whenComplete 处理子任务失败,并保证异常链不中断。
执行器层负责总体兜底。设置整体超时,执行有限重试,提供 fallback,形成给用户的最终结果。
5.2 核心代码:AgentService 和 AgentExecutor
下面是一个简化的组合示例。该类先定义工具执行的异步编排,再由执行器统一控制超时。
public class AgentFacade { private final AgentService agentService; private final ExecutorService executor; public AgentFacade(AgentService agentService, ExecutorService executor) { this.agentService = agentService; this.executor = executor; } public AgentResult run(String userInput) { Future<AgentResult> future = executor.submit( () -> agentService.executeWithRetry(userInput, 2, 300L) ); try { return future.get(10, TimeUnit.SECONDS); } catch (TimeoutException e) { future.cancel(true); return AgentResult.timeout("任务处理超时"); } catch (ExecutionException e) { Throwable cause = e.getCause(); if (cause instanceof AgentToolExecutionException toolEx) { return AgentResult.toolError(toolEx.toolName(), toolEx.getMessage()); } return AgentResult.error("系统繁忙"); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return AgentResult.error("任务被中断"); } } }这段代码把执行器超时作为最外层保护。agentService.executeWithRetry内部再处理具体的同步异常、异步编排和重试逻辑。在生产项目中,这一层还会加入熔断、监控埋点、审计日志。
5.3 统一错误响应和恢复策略
为了让上层不关心异常细节,可以定义一个统一的错误码枚举。
public enum AgentErrorCode { SUCCESS("SUCCESS", "成功"), MODEL_TIMEOUT("MODEL_TIMEOUT", "模型调用超时"), MODEL_PARSE_ERROR("MODEL_PARSE_ERROR", "模型输出解析失败"), TOOL_ERROR("TOOL_ERROR", "工具执行失败"), AGENT_TIMEOUT("AGENT_TIMEOUT", "Agent 整体执行超时"), INTERRUPTED("INTERRUPTED", "任务被打断"), ERROR("ERROR", "系统异常"); private final String code; private final String desc; AgentErrorCode(String code, String desc) { this.code = code; this.desc = desc; } public String code() { return code; } public String desc() { return desc; } }错误码的好处是:上层可以根据 code 决定 HTTP 状态码、用户提示和是否需要重试,而不用依赖异常类的具体实现。日志系统也可以直接用 code 聚合统计,快速看出哪类错误最多。
5.4 运行验证与预期输出
假设创建一个简单的 Agent 服务,模型模拟正常返回,工具模拟超时,运行后可以在日志中看到如下输出:
[main] WARN AgentService - 工具 externalApi 第一次执行超时,2 秒后重试,retry=1 [main] WARN AgentService - 工具 externalApi 第二次执行超时,3 秒后重试,retry=2 [main] ERROR AgentFacade - Agent 整体执行超时,任务已取消如果只想快速验证异步异常处理,可以写一个最小单元测试。
@Test void testAsyncExceptionRecovery() { CompletableFuture<String> future = CompletableFuture .supplyAsync(() -> { throw new IllegalStateException("boom"); }) .exceptionally(ex -> "recovered"); assertEquals("recovered", future.join()); }验证时不要只看程序不崩溃,还要看异常是否被正确记录、恢复值是否符合预期、重试次数是否正确、超时是否真的取消了任务。只有这些行为都符合预期,异常处理才算是真正生效。
6. Agent 异常处理高频问题排查
6.1 现象与排查顺序表
遇到 Agent 异常时,先不要急着改代码,按下面表格从现象倒推原因。
| 问题现象 | 常见原因 | 检查方式 | 处理建议 |
|---|---|---|---|
| Agent 请求长时间不返回 | 未设置整体超时,模型或工具卡住 | 查看线程池状态、jstack | 执行器层增加 future.get(timeout) |
| 日志里没有异常,但结果错误 | 异常被吞掉,catch 后未记录 | 检查 catch 块是否只有 return | catch 中至少记录 error 日志 |
| 异步任务失败但主流程不知道 | 未接 exceptionally/handle/whenComplete | 检查 CompletableFuture 链是否有消费者 | 在链末尾加 whenComplete 或 exceptionally |
| 返回“系统繁忙”但无法定位 | 所有异常都进入统一兜底 | 检查异常是否在源头包装 | 在源头区分超时、解析、工具、系统异常 |
| 重试导致工具重复执行 | 工具非幂等,未做重试去重 | 检查工具调用是否传业务唯一 ID | 重试前加入幂等控制 |
| 线程池线程耗尽 | 大量任务卡死未取消 | 监控线程池活跃线程数 | 增加超时,合理设置队列和拒绝策略 |
6.2 日志关键字与定位手段
排查 Agent 异常时,日志中优先关注这些关键字:
AgentToolExecutionException:工具执行失败,重点看 toolName 和 retryable 字段。CompletionException/ExecutionException:异步包装异常,重点拆 getCause() 看真实原因。TimeoutException:要么是单次调用超时,要么是整体任务超时。ModelParseException:模型输出结构不符合预期,检查 prompt 和解析器。InterruptedException:线程被中断,检查是否有线程池关闭或任务取消。
排查时可以用 jstack 查看线程状态:
jcmd <pid> Thread.print如果发现大量WAITING状态的线程集中在某几个方法,说明任务在那个位置没有响应超时,优先给对应调用点补超时。
6.3 最容易忽略的幂等和资源释放问题
第一个容易忽略的是重试期间的幂等。Agent 重试不是单纯地重跑一个函数,而是可能重新执行模型调用、重新调度工具。如果工具是“发通知”“创建订单”,重试会造成重复操作。建议在工具执行前生成 traceId,并在下游接口使用该 id 做幂等校验。
第二个容易忽略的是线程池资源释放。使用 CompletableFuture 时如果没有指定线程池,会使用公共 ForkJoinPool。公共池被多个任务占用后,其他异步任务会被排队。生产环境要单独创建线程池,并在应用关闭时优雅关闭。
第三个容易忽略的是中断状态。捕获InterruptedException后如果不调用Thread.currentThread().interrupt(),线程的中断状态会被清除,后续调用方无法感知线程被中断。规范做法是捕获后立即恢复中断标记。
7. 生产环境落地建议与检查清单
7.1 生产环境不能只写 try-catch
学习环境的 Agent 示例通常是一次调用、一次返回,异常处理可以非常粗糙。生产环境至少要补齐五件事。
第一,配置外置。超时时间、重试次数、重试间隔、熔断阈值不要写死在代码里,放到配置中心或环境变量中,方便运维动态调整。
第二,日志与监控。每个 Agent 任务要有 traceId,核心节点都要打印耗时和状态。监控指标至少包括:任务成功率、平均耗时、P99 耗时、工具失败率、重试次数分布、超时次数。
第三,权限与安全。Agent 执行的工具通常涉及外部接口和数据库,异常信息中包含的原始报文不能直接返回给前端,防止内部信息泄露。
第四,回滚方案。如果模型服务或工具服务出现问题,异常处理策略要能快速降级。比如关闭某些高风险工具,或者把模型切到备用服务。
第五,异常审计。异常处理不只是“给用户一个提示”,还要沉淀成异常数据。每类错误码出现的次数、影响用户数、平均处理时长,都应该可以在报表中看到。
7.2 可复用的 Agent 异常处理检查清单
在代码合并前,可以按下面的清单逐项确认。
| 检查项 | 是否满足 |
|---|---|
| 模型并发地调用接口是否有超时兜底 | 是 / 否 |
| 每个工具执行异常是否包装为带 retryable 标记的业务异常 | 是 / 否 |
| CompletableFuture 链异常是否有消费者 | 是 / 否 |
| 是否区分 whenComplete 和 handle 的语义 | 是 / 否 |
| 获取异步结果是否使用 get(timeout) | 是 / 否 |
| 重试次数是否有限,重试间隔是否退避 | 是 / 否 |
| 可重试工具是否考虑幂等控制 | 是 / 否 |
| 整体 Agent 执行器是否设置总超时 | 是 / 否 |
| 异常日志是否保留原始 cause | 是 / 否 |
| 用户提示是否区分超时、解析、工具、系统异常 | 是 / 否 |
| 中断异常是否恢复线程中断标记 | 是 / 否 |
| 错误码是否可用于统计聚合 | 是 / 否 |
没有全部满足时,可以先上线最核心的两项:整体超时和异常日志。这两项能避免 Agent 服务在故障时无响应、无记录。
7.3 后续扩展方向
如果 Agent 异常处理已经形成稳定体系,下一步可以继续做几件事。
接入重试框架或熔断框架。通过注解或配置统一管理重试次数、退避策略、熔断阈值,减少手写 while 循环。
建立异常演练机制。定期模拟模型超时、工具 500、限流、线程池耗尽,验证异常处理链路是否真的按预期工作。
把异常分为“用户可恢复”和“系统可恢复”两类,分别设计交互逻辑。用户可恢复的异常,可以引导用户重新输入或修改参数;系统可恢复的异常,可以自动重试或降级。
Agent 应用越复杂,异常处理越要前置设计。不要等项目上线后才在入口堆 try-catch,那只能解决“看不到报错”,解决不了“报错定位慢、恢复策略混乱、重复执行风险高”这些本质问题。把三种方式按层落实,是让 Agent 服务具备基础稳定性的第一步。