
超时不等于没执行Agent 工具调用为什么不能盲目重试给外部调用加超时超时就重试——这是写代码的人的本能反应我原来也这么想。直到我在自己的 Agent 项目里给工具调用加了超时看到报错就顺手重试回头一看同一个文件被写了两遍。这篇就把这个坑讲清楚future.result(timeout)的超时到底意味着什么、为什么写操作超时不能盲目重试、以及怎么用只读才重试 连续超时熔断在框架层把这件事兜住。文中的复现脚本只用标准库clone 下来一条命令就能跑。一、现象明明报了超时文件却被写了当时的场景大概是这样我注册了一个导出报告类的工具落盘要 0.5 秒而我给这次调用设的超时是 0.1 秒。一轮跑完控制台报了超时我的第一反应是最自然的那一种失败了那就重试。重试之前想确认一下它真的没执行于是先写了个最小复现连 Agent 都不用请出来# 复现 A超时返回了活儿照样干完了poolThreadPoolExecutor(max_workers1)futurepool.submit(slow_write)# 这个函数 0.5s 后才会写文件try:future.result(timeout0.1)# 主线程只愿意等 0.1sexceptFutureTimeout:print(超时了我当它没执行)time.sleep(0.6)print(target.exists())# True ——跑一下输出是这样的原因不复杂但我以前真没细看过future.result(timeout)的超时参数只管主线程愿意等多久。到点之后主线程拿着异常走人了工作线程对此一无所知——没人给它发停止信号它也不检查就照常把活干完了。也就是说超时那一刻副作用虽然还没发生但它在路上。后来我在自己的工具执行层ToolRuntime里给这段代码补了条注释# src/envy_agent_cli/tools/runtime.pydef_run_with_timeout(self,tool,args):⚠️ 超时只是放弃等待线程仍在跑——所以超时配了熔断。withThreadPoolExecutor(max_workers1)aspool:futurepool.submit(lambda:tool.handler(**args))returnstr(future.result(timeouttool.timeout))二、读操作重试没事写操作重试就是干两遍回到失败就重试这个反应。有意思的是它在大多数时候是对的工具是只读的read_file、list_dir、grep 这类超时说明没拿到结果再跑一遍毫无负担顶多费点时间。HTTP 里重试 GET 也是这个道理大家都习惯了。但写操作是另一回事。我又补了个盲目重试的复现工具往一个文件追加一行数据每次执行要 0.3 秒超时 0.1 秒就是上面截图的后半段两次调用都报了 TIMEOUT——从我的视角看是失败了两次文件里却诚实地躺着两行数据。第一次的线程把数据写进去了重试的那次又写了一遍。这个场景大家其实都见过下单接口超时了你敢不查状态直接自动重试吗多数人不敢因为你分不清订单没建成和订单建成了但响应没送回来。工具调用一模一样。这事儿有个正式的名字叫幂等性翻译成人话就是重试之前先问一句这事万一已经办成了再办一遍会怎样。三、所以 ToolRuntime 里写死了两条纪律想明白之后我在工具执行层里落了两条纪律写死在流程里不靠调用方自觉。第一条只有只读工具才允许重试。写操作超时就原样上抛交还编排层去决定换工具、问人或者干脆放弃执行层不擅自做主# src/envy_agent_cli/tools/runtime.py_invoke 节选exceptFutureTimeout:self._timeout_streak[name]1ifself._timeout_streak[name]self.timeout_breaker:self._disabled.add(name)# 连续超时 2 次熔断lastToolError.from_code(ErrorCode.TIMEOUT,...)iftool.read_onlyandself.retry_policy.should_retry(last,attempt):time.sleep(self.retry_policy.delay_for(attempt))continue# 只有只读工具才重试returnNone,last,attempt,dispatched# 写操作原样上抛第二条连续超时 2 次就熔断。这条防的是个更隐蔽的问题——主循环是要跑很多轮的每轮都可能再调一次这个卡住的工具。如果每次超时都只是放弃等待任务最后会正常结束但背后躺着几个还在跑的工作线程占着资源甚至还在偷偷写东西。所以连续超时 2 次之后这个工具在本次会话里就被拉黑了后续同名调用连线程都不提交直接返回 TIMEOUT# src/envy_agent_cli/tools/runtime.py_authorize_tool 节选ifnameinself._disabled:returnToolError.from_code(ErrorCode.TIMEOUT,f工具 {name} 连续超时{self.timeout_breaker}次本次会话已熔断不再执行)熔断的报错文案是特意写明白的让模型能看出不是这一次调用慢是这个工具被停用了它还有机会换个工具、换个思路而不是对着同一堵墙反复撞。计数在下一个任务开始时重置单次任务的失败不影响下一次。连续调三次的实际输出长这样——第三次是真没执行实际执行次数停在 2整个流程画出来是这样四、顺手记一个审计里的 executed 字段还有个副产品值得记一笔。每次工具调用我的审计日志会多记一个executed字段用来区分执行了但失败和根本没执行。下面两条都是statuserror含义完全不同节选trace_id 和时间戳太长掐了{tool:hang_read,status:error,error_code:TIMEOUT,executed:true,attempt:1,duration_ms:297.0}{tool:hang_read,status:error,error_code:TIMEOUT,executed:false,attempt:1,duration_ms:0.0}第一条是真把 handler 跑了然后超时副作用可能已经发生第二条压根没派发被熔断拦下了。没有这个字段两条在统计里长得一模一样——都叫失败但前者提示你去查卡顿和幂等后者提示你去查熔断策略处理方向完全两回事。说实话这个字段就是被超时≠没执行这件事逼出来的既然超时不代表没执行那就得有个地方诚实地记下到底执行了没。总结一下future.result(timeout)只表示主线程放弃等待工作线程还在跑——超时不等于没执行重试是幂等操作的特权写操作超时只能上抛让上层决定超时要配熔断不然循环每跑一轮就多泄一个永远不会收工的线程。