给 Agent 工具调用加超时预算时,我会先把 deadline 和重试账本写进协议
最近给一个 Agent 工作台接内部工具时,我遇到一个很真实的问题:模型已经给出计划,前端也开始展示进度,但其中一个工具服务偶发变慢,整条任务就卡在半完成状态。用户点了取消,后端还在等;系统自动重试了两次,日志里却看不出每次等待花在哪里。后来我把这类问题收敛成一套超时预算协议。

原创示意图:把总 deadline、单次工具超时、重试预算、失败分流和审计账本放在同一条链路里观察。 来源:Codex image generation
问题背景
Agent 工具调用的链路通常比普通接口更长。一轮任务可能先查知识库,再读取文件,随后调用外部 API,最后汇总结果。任何一步慢下来,都会影响用户对整个任务的判断。MDN 文档说明,AbortSignal.timeout() 可以创建会在指定时间后自动中止的 signal,并在超时时给出 TimeoutError;Fetch 也支持把 AbortSignal 放进请求的 signal 属性,让请求可以被取消。
真实项目里还会同时出现用户主动取消、页面切换、工具限流、后端队列等待和自动重试。所有这些信号如果没有合并,UI 只能看到一个笼统失败,服务端也只剩零散日志。
关键难点
第一个难点是 deadline 和 timeout 的边界。deadline 是整条 Agent 任务还能花多少时间,timeout 是某一次工具调用最多等多久。一个任务剩余 8 秒时,单个工具就不该再拿 20 秒超时去赌。
第二个难点是重试预算。网络抖动可以重试,参数错误和权限失败应直接终止。重试还会吃掉剩余 deadline,所以每次 retry 都要记录 attempt、等待时间、错误类型和是否仍有预算。Node.js timers promises 文档说明,promisified setTimeout 可以传入 AbortSignal,适合做可取消的 backoff 等待。
第三个难点是审计。MCP Tools 规范建议客户端实现工具调用超时,并记录工具使用用于审计;它也把协议错误和工具执行错误分成两类。未知工具或参数不合法属于协议层,外部 API 慢、限流或业务失败属于执行层,处理策略应分开。
解决思路
我现在会让每次 Agent 运行生成一个 runId 和一个总 deadline。每次工具调用再生成 callId、attemptId 和单次 timeout。真正传给工具执行器的 signal 由三部分合成:用户取消 signal、总 deadline signal、单次 timeout signal。MDN 的 AbortSignal.any() 正适合表达这种任一条件触发就中止的组合。
执行器只返回结构化结果:ok、timeout、cancelled、retryable_error、fatal_error。UI 不猜错误来源,调度层根据结果决定是否重试,审计层记录每次 attempt 的开始时间、结束时间、剩余预算和错误摘要。
关键步骤
落地时我先在任务入口计算 deadlineAt,并把它写进运行上下文。每次调用工具前计算 remainingMs,如果剩余时间小于最小可用窗口,直接返回可解释的 timeout。工具执行只接收受控参数和合成 signal,不允许自己新建无限等待。重试前先判断错误类型,再用可取消的 backoff 等待。最后把 attempt 事件写进同一张账本,包括 started、timed_out、aborted、retry_scheduled、succeeded 和 failed。
观测上我会沿用 OpenTelemetry 的错误记录思路:最终失败的 span 设置错误状态和 error.type,已被重试并成功恢复的中间错误留在 attempt 日志里,避免把一次已恢复的抖动统计成最终失败。
可复用经验
Agent 工具越多,越要把时间当成协议字段。用户取消解决操作权,总 deadline 保护整条工作流,单次 timeout 保护工具边界,retry budget 保护系统不被慢故障拖住。只要这些字段在前端、调度器、工具执行器和日志里保持同一套语义,超时问题就能从偶发现象变成可测试、可复盘、可调整的工程参数。
我还会把一次失败复盘固定成三问:是谁触发了中止,预算在哪里耗尽,下一次是否允许重试。能回答这三问,后续调参数或补降级策略就有证据。