Agent 调工具卡住?3 种超时与取消策略实测对比

0 阅读3分钟

先给结论:「超时返回」不等于「取消」。我用三种策略调同一个 5 秒的慢工具做了实测——阻塞等 5.03 秒、副作用发生 1 次;超时 1.01 秒就返回,但后台活儿没停,总耗时仍是 5.04 秒、副作用照样发生 1 次;只有真正的取消策略,1.01 秒就叫停,副作用 0 次。放到 AI Agent 场景里,这意味着:你给工具调用设了超时并重试,很可能把同一件事做了两遍。

为什么超时不等于取消

这是很多 Agent 故障的根:超时只是调用方不再等了,不代表被调用方停了。线程还在跑、协程还在调度、HTTP 请求还在服务端执行。调用方拿到 Timeout 就去做别的事,真正的副作用(下单、写库、发消息)却可能在几秒后才落地。

一旦加上「失败就重试」的 Agent 逻辑,问题被放大:

场景调用方看到实际发生
只读查询超时超时请求可能完成,但无所谓
有副作用操作超时超时副作用仍然发生
超时 + 重试最终成功 1 次实际执行了 2 次

所以排障时先别急着调大超时,先确认:你的"超时"到底停没停活儿。

三种策略的最小实现

我用手写线程模拟"Agent 调用慢工具",工具本身是 5 秒,每 0.1 秒检查一次取消信号:

def slow_tool(cancel_event, side_effects):
    for _ in range(50):
        if cancel_event.is_set():
            return "cancelled"
        time.sleep(0.1)
    side_effects.append("executed")   # 副作用:真正落地
    return "done"

策略一:阻塞等待(不设超时)——最老实,也最容易把调用方拖死:

t.join()                     # 一直等,直到工具自己结束

策略二:超时返回(最常见,也最坑)——到点就不等了,但工具还在跑:

t.join(timeout=1.0)          # 1 秒后调用方继续
# 注意:这里没有 ev.set(),后台线程会继续跑到结束

策略三:协作式取消——到点后发出取消信号,工具在检查点自行退出:

t.join(timeout=1.0)
cancel_event.set()           # 关键:真正通知工具停止
t.join()                     # 等它干净退出

差别全在最后两行:有没有把取消信号传进去,并且工具愿不愿意检查。

实测数据

环境:Windows + Python 3.15.2,工具耗时固定 5.0 秒,超时阈值 1.0 秒。

策略调用方等待(ms)总耗时(ms)副作用次数工具结果
阻塞等待5031.05031.01done
超时返回1014.15036.21done
协作式取消1004.61009.30cancelled

三个数字最值得记住:

  1. 超时返回只省了调用方的等待,没省系统资源:调用方 1.01 秒返回,但总耗时 5.04 秒,和阻塞几乎一样。
  2. 超时后副作用照样发生:副作用次数 = 1,说明活儿干完了。
  3. 只有取消能真正止损:总耗时 1.01 秒,副作用 0 次。

Agent 场景怎么选

工具类型推荐策略补充
只读查询(搜索、读文件)超时返回即可重试安全
有副作用(下单、发消息、写库)取消 + 幂等键只取消不够,还要防重复
外部不可取消(第三方接口)超时 + 幂等/去重表接受它可能仍在执行

一句话总结:能取消的都加上取消,不能取消的一定要幂等。 超时只是兜底,不是止损。

怎么复现

单个文件、零第三方依赖,直接跑:

python -X utf8 timeout_strategies.py

输出就是上面的三行 JSON 结果,可自行改工具耗时和超时阈值对比。

结论与下一步

  • Agent 调工具失败,先分清是「慢」还是「没停」:超时只解决前者。
  • 有副作用的工具,必须同时具备取消信号和幂等键,否则重试就是重复执行。
  • 下一步可以做的:给工具协议加 cancel 通知;把副作用操作统一带幂等键;在 Agent 日志里记录「超时但副作用已发生」这类事件,避免误判成功。

把「超时」当「取消」用,是 Agent 最隐蔽的一类重复执行 bug,值得在工具层就堵住。