为什么工具调用会拖垮整个 Agent 运行时

Agent 主循环通常接近串行:模型返回 tool_call,运行时执行工具,把结果写回上下文,再进入下一轮。这个链路上任何一次工具调用没有超时边界,整个循环就停在那里。典型表现有三类:单次请求长时间挂起(没有读取超时);重试风暴把连接池占满,后续请求全部卡在排队;失败被当作成功返回给模型,导致后面几轮全部跑偏。

多数故障不是工具慢,而是没有人替“慢”定义边界。

分层超时:四种时间不要共用一个值

只配一个总超时是最常见的误区。建议至少分四层:

  1. 连接超时:与目标建立连接的时间。通常设得很短,秒级以内,它只反映网络可达性,不随业务复杂度变化。
  2. 首字节超时:连接建立后到收到第一个响应字节的时间。对同步接口而言,这一层最能反映对端是否还活着。
  3. 传输/总超时:从请求发出到读完响应体的时间,需要按工具的真实耗时分布来定,而不是所有工具共用一个值。
  4. 编排超时(Agent 级 deadline):一次 tool_call 从进入队列到写回上下文的墙上时间,包含排队、重试与退避等待,是最终兜底。

关键约束是:编排超时必须大于“单次尝试超时 × 最大尝试次数 + 退避等待时间”。否则重试还没跑完就被上层强制中断,退避等于白等。

不同工具应给出不同档位:本地或内网只读查询用短超时;第三方读接口用中等超时;写操作或带副作用的调用可以放宽单次超时,但要把重试次数压到最低;批处理、导出这类长耗时任务不要同步等待,改为提交任务 ID 加轮询或回调,把长耗时从主循环里移出去。

错误分类:先判断能不能重试,再决定重试几次

超时本身不构成重试的理由。把错误分成三类处理:

  • 可重试:连接被拒、连接重置、DNS 临时失败、5xx 中的服务端错误、限流响应。前提是操作幂等,或调用方带了幂等键。
  • 不可重试:参数错误、鉴权失败、资源不存在、配额永久耗尽、schema 校验失败。重试只会浪费时间并放大下游负载。
  • 语义不明:写请求超时,请求已发出但结果未知。这类必须按幂等性判断,写入接口无法确认是否落地时默认不重试,改为查询确认。

常见错误是把所有异常统一 catch 后重试固定次数,结果是鉴权失败的请求也被重试三次,既慢又把错误日志刷满。

退避与重试预算

  • 使用指数退避加抖动。纯指数退避会让同一批失败请求在同一时刻再次涌向对端,形成同步重试尖峰。
  • 设置重试预算:按工具维度限制每分钟最大重试次数或最大重试比例,而不只是限制单次请求的尝试次数。当某工具错误率超过阈值时直接快速失败并上报。
  • 尊重对端返回的等待时间提示,优先按它等待,而不是按本地退避计算。
  • 只在一层做重试。SDK、网关、Agent 运行时三处都配重试,次数会相乘,3×3×3 就是 27 次。

连接池与并发限制

超时和并发是同一问题的两面。即使每个请求都有超时,并发上限过高时,慢请求仍会占满连接池,后续请求在排队阶段就超时。

  • 按目标隔离连接池,避免一个慢下游影响其他工具。
  • 为每个池设置最大连接数、最大等待队列长度,并配置获取连接的等待超时。
  • 单个工具的并发上限按下游容量设定,而不是按 Agent 并发数设定。
  • 队列必须有界,无界队列会把超时问题转化成内存增长与延迟累积。

可观测性埋点

没有指标就只能靠猜。建议按工具维度埋这些指标:各层超时的触发计数(连接、首字节、总超时、编排超时);尝试次数分布(成功、失败、超时、被拒绝);重试次数与退避总耗时;连接池的活跃连接、空闲连接、等待请求数与等待时长;端到端 tool_call 延迟直方图,并把工具执行时间与排队加重试时间分开统计;每个工具的失败率与快速失败触发次数。

日志中要带 trace id 与 attempt 序号,否则一次 tool_call 的多次尝试在日志里会看起来像多个不相关的请求。

落地清单

  1. 每个工具单独定义连接、首字节、总超时,并统一由 Agent 级 deadline 兜底。
  2. 把错误分类写成代码里的映射表,而不是注释里的约定。
  3. 只在一层重试,配抖动退避与全局限额。
  4. 按目标隔离连接池,设置并发上限与有界队列。
  5. 埋点覆盖尝试、超时、重试、连接池与端到端延迟。
  6. 长耗时任务改用任务化接口,不占用主循环。

超时配置的目标不是让请求一定成功,而是让每次失败都快速、可解释、可观测,并且不占用其他工具的资源。