4. 工具执行引擎:同步调用与异步调用、超时与重试机制、错误处理与异常传播

工具执行引擎,说白了就是整个编排系统的「心脏」。你前面把任务拆得再漂亮,工具定义得再规范,最后落地执行时如果引擎拉胯,一切都白搭。我这些年见过太多系统,流程图画得天花乱坠,一跑就超时、一报错就崩溃,问题全出在执行引擎上。

这一章,我们就把引擎的核心机制掰开揉碎。同步还是异步?超时怎么设?重试几次合适?异常怎么传?这些都是实战中每天都要面对的问题。

4.1 同步调用 vs 异步调用

先解决最基础的选择题:你的工具调用,到底该同步还是异步?

特性 同步调用 异步调用
调用方式 发起后阻塞等待结果 发起后立即返回,通过回调/轮询获取结果
适用场景 耗时短、依赖强、需要即时结果 耗时长、可并行、结果不紧急
资源占用 线程/协程被占用等待 释放调用方资源
错误处理 直接抛出异常 通过回调或状态机传递
编排复杂度 低,线性流程 高,需要状态管理

我个人习惯的选型原则很简单:如果这个工具调用是下一个步骤的前置条件,且执行时间在 100ms 以内,用同步。否则,考虑异步。

举个例子,一个用户注册流程:

  • 同步调用:校验手机号格式、检查用户名是否重复。这些操作毫秒级完成,必须拿到结果才能继续。
  • 异步调用:发送欢迎邮件、同步到第三方 CRM。这些操作耗时不确定,而且失败也不影响主流程。

核心原则:同步保证强一致性,异步换取高吞吐。不要为了「省事」把所有调用都做成同步,也不要为了「炫技」把简单调用搞成异步。

4.2 超时机制:别让系统死等

没有超时的系统,就像没有刹车的车。我曾经接手过一个线上事故,一个第三方天气 API 挂了,结果整个编排流程卡在那里 30 分钟,所有请求都在等它超时。嗯,那个 API 的默认超时是 30 分钟。

超时设置有几个关键参数:

  • 连接超时:建立 TCP 连接的最大等待时间。通常 3-5 秒。
  • 读取超时:从连接建立到收到完整响应的最大时间。根据工具特性来定。
  • 总超时:整个调用从开始到结束的最大时间。包含重试时间。

我建议的配置模板:

// 工具调用超时配置示例
{
  "tool_id": "weather_api",
  "timeout": {
    "connect": 5000,      // 5秒连接超时
    "read": 15000,        // 15秒读取超时
    "total": 30000        // 30秒总超时(含重试)
  },
  "retry": {
    "max_attempts": 3,
    "backoff": "exponential",
    "initial_delay": 1000
  }
}

避坑指南:我曾经把连接超时设成 1 秒,结果用户网络波动时频繁失败。后来改成 5 秒,配合重试,成功率从 92% 提升到 99.7%。超时不是越小越好,要结合你的网络环境和工具响应分布来调。

4.3 重试机制:优雅地处理失败

重试不是简单的「失败了再来一次」。不加控制的重试,就是雪崩的导火索。

常见的重试策略:

  1. 固定间隔重试:每次等待相同时间。简单,但容易造成「惊群效应」。
  2. 指数退避:每次等待时间翻倍。比如 1s、2s、4s、8s。最推荐的方式。
  3. 抖动退避:在指数退避基础上加随机偏移。防止多个请求同时重试。
  4. 增量退避:每次增加固定时间。比如 1s、3s、5s。

你想想看,为什么指数退避最常用?因为大部分临时故障(网络抖动、服务重启)都会在几秒内恢复。指数退避既给了恢复时间,又不会在故障持续时浪费太多资源。

重试还需要注意:

  • 幂等性检查:只有幂等的工具才能重试。扣款接口重试两次,用户就多付了一笔钱。
  • 最大重试次数:通常 2-3 次。超过 5 次基本说明问题不是临时的。
  • 重试风暴防护:全局限制同一工具的重试并发数。

我的经验:对于非幂等操作,不要自动重试。改为「标记失败,人工介入」或者「进入死信队列」。我在金融项目里就是这么干的,宁可慢一点,不能错一笔。

4.4 错误处理与异常传播

错误处理是执行引擎最容易写烂的部分。很多人的做法是:try-catch 一包,打印个日志,返回个「系统错误」。这等于把问题藏起来了。

好的错误处理应该分层:

层级 职责 处理方式
工具层 捕获原始异常,转换为标准错误码 返回错误对象,不吞异常
编排层 根据错误类型决定重试、跳过还是终止 更新 DAG 状态,触发补偿
业务层 处理业务语义错误,通知用户 生成友好提示,记录审计日志

异常传播的路径:

// 异常传播示例
工具A 调用失败
  → 工具层抛出 ToolExecutionException(code=503, message="服务暂时不可用")
  → 编排层判断:503 错误,可重试
  → 重试 2 次后仍失败
  → 编排层标记工具A 为 FAILED
  → 检查 DAG:工具A 是可选依赖,跳过
  → 继续执行后续工具
  → 最终返回部分成功状态

这里有个关键设计:异常一定要携带足够的上下文。光说「调用失败」没用,要告诉上层:哪个工具、什么错误码、耗时多久、重试了几次、原始异常是什么。

异常传播三原则:

  • 不吞异常:除非你知道怎么处理,否则往上抛
  • 不丢上下文:原始异常、调用链、参数快照都要带上
  • 不泄露敏感信息:数据库密码、用户隐私不要传到上层

4.5 执行引擎核心架构

说了这么多,我们把执行引擎的整体架构画出来。这张图我每次做系统设计都会先画一遍,它决定了整个编排系统的可靠性。

工具执行引擎核心架构 编排请求入口 任务调度器 同步执行器 阻塞等待结果 异步执行器 回调/轮询获取结果 超时控制器 连接/读取/总超时 重试管理器 指数退避 / 抖动 错误处理器 异常传播 / 补偿 结果聚合与返回 同步/异步执行器可混合编排 重试与超时协同工作

这张图里,我特别想强调两个点:

  • 重试管理器是独立组件:它不耦合在同步或异步执行器里。这样你可以统一配置重试策略,不管是同步调用还是异步调用,都走同一套重试逻辑。
  • 错误处理器在重试之后:先重试,重试耗尽再进错误处理。顺序不能反,否则重试机制就形同虚设了。

4.6 实战中的几个坑

最后分享几个我踩过的坑,希望能帮你省点时间:

坑一:异步调用的回调丢失

我曾经用消息队列做异步调用,结果消费者挂了,回调消息丢了,整个流程卡在「等待中」状态。后来加了回调超时检测和死信队列,才算解决。

坑二:重试导致资源耗尽

有一次线上故障,所有工具都超时,重试管理器疯狂重试,把线程池打满了。正常请求反而进不来。后来加了全局重试限流和熔断,才避免这种「自我攻击」。

坑三:异常吞没

最隐蔽的坑。有些开发者在工具层 catch 了异常,打印一行日志就返回 null。上层判断 null 就跳过,结果数据对不上,查了半天才发现是异常被吞了。我的原则:要么处理,要么传播,不要沉默。

执行引擎是编排系统的基石。同步异步选型、超时重试配置、错误处理策略,这三块做好了,你的系统至少能扛住 90% 的异常场景。剩下的 10%,靠监控和人工兜底。