有一种 Agent 漏洞,当你孤立地观察任何单个组件时,它都不会出现。模型没问题,工具没问题,重试策略也没问题。纸面上的超时值甚至可以说很慷慨。然而,一个通常在 8 秒内完成的工具,却总是在一个已经在 7.9 秒时将其宣告为失败的 Agent 面前折戟。Agent 围绕一个从未发生过的“错误”重新规划,并启动了第二次调用,而第一次调用的结果即将与其发生碰撞。
漏洞不在任何一个框框里。它存在于两个没人同意应该同步的时钟之间的缝隙中。
这是 Agent 工程领域中相当于分布式系统经典问题——协作节点之间的时钟漂移 (clock drift)——只不过这两个节点位于同一个进程中,且漂移不是以 NTP 偏差的毫秒为单位衡量的。它是以每一方决定称为“t-zero”的事件来衡量的。Agent 的预算往往从 LLM 发出第一个 token 开始计时。工具的预算往往从工具进程实际收到调用的那一刻开始计时。在这两个时刻之间,隔着整个 prefill 阶段、整个流式传输管道、整个工具路由器 (tool-router) 跳转,以及工具工作线程 (tool worker) 前的任何排队。这两个秒表之间没有任何时间是共享的。它们都认为自己在为同一件事计时。
两个没人同意应该同步的时钟
对 Agent 步骤的传统看法就像时间轴上的一个条形图:提示词输入,模型思考,工具运行,结果返回。对超时的传统看法是,你在该条形图的某个地方画一条垂直线,并称之为截止日期。
现实情况是至少有四个时钟在并发运行,且几乎从未同步:
- Agent 框架 (harness) 时钟,通常在请求发送给模型时开始计算预算。
- 模型的有效时钟,框架通常只有在第一个流式 token (TTFT) 出现时才能感知到。在 2026 年,对于对话式生成,首字时间被报告在数百毫秒到几秒不等,对于长上下文可能更长。这整个间隔可能算作,也可能不算作你的“Agent 预算”,具体取决于你使用的库。
- 工具客户端时钟,从框架发出工具调用开始,到工具返回结束。大多数框架将其公开为每个工具的超时。
- 工具服务端时钟,仅在工具进程实际收到请求时开始。它前面的任何东西——队列、MCP 路由器、具有自身空闲超时的反向代理——都会增加工具进程看不见且无法计入自身的延迟。
在表现良好的 RPC 栈中,这些时钟通过截止日期传播 (deadline propagation) 来协调。调用方计算一个绝对截止日期,将其作为墙钟瞬间 (而非持续时间) 附加到请求中,每个下游跳转都会继承它。经典的 gRPC 公式对此非常明确:截止日期是以绝对时间戳表示的“从现在起 5 秒内”,并且上下文会通过每个子调用进行传播,以便整个子树在同一时刻终止。像 userver 这样的框架也描述了同样的想法——链接截止日期,这样缓慢的上游就不会消耗掉下游仍期望拥有的预算。
在实践中,Agent 栈并不这样做。Agent 的预算是在框架进程中计算的持续时间。工具的预算是在工具进程中计算的另一个独立的持续时间。没有共享的截止日期。没有传播。
因此,当模型的第一个 token 终于到达时,框架时钟已经过去了 1100 毫秒。当工具调用经过 MCP 服务器路由时,又消失了 300 毫秒。当工具工作线程出队请求并开始其自身的 8 秒预算时,框架已经在针对一个 1.4 秒前就开始的 8 秒预算进行计时。从框架的角度看,工具还有 6.6 秒。从工具的角度看,它有 8 秒。数字看起来相等,实则不然。
为什么这看起来像是一个 Agent 拒绝使用的成功工具调用
病理性的情况并不是 Agent 在工具启动前就放弃了。那种情况非常明显——你会看到一个被取消的调用和一个愤怒的日志。病理性的情况是 Agent 在 7.9 秒时放弃,工具在 8.0 秒时完成,且双方都在各自的追踪 (trace) 中写入了结构化的成功日志。
Agent 的追踪显示:启动调用,等待,超时在 7.9 秒过期,重新规划。从 Agent 的角度来看,工具失败了。它进入了一个恢复分支——选择一个不同的工具,向用户询问澄清问题,或者最糟糕的是,重试相同的调用。
工具'的追踪显示:收到调用,执行,在 8.0 秒返回并带有干净的 200 状态码。从工具的角度来看,一切正常。它甚至还因为这项工作向用户收了费。
这种竞争在下游表现为三种不同的症状,它们看起来都像是不同的漏洞:
- 一个没有任何东西在等待的“成功”工具结果。Agent 的协程已被取消。结果落入了一个关闭的通道或孤儿 future 中。一些框架将其记录为“tool_use without matching tool_result”——如果双方对发生的事情达成一致,这种形式本应是不可能的。
- 一个自信地报告“工具失败了,我将尝试不同的方法”的模型,而一个完全正确的答案正坐在工具的响应队列中。
- 重复调用,因为 Agent 的重新规划选择了具有相同参数的相同工具——而现在工具工作线程正在做两次相同的工作,第二次调用正与第一个过时的结果赛跑,进入一个不知道该信任哪一个的状态机。
今年早些时候,在一个流行的 Agent 框架的 GitHub Issue 中出现了一个真实的案例,其表现正是这种形式:tool_use 块与 tool_result 不匹配,在并行批处理负载下刷新过晚且乱序。这类问题的事后分析几乎总是如出一辙:框架的中断 token 在一个甚至在工具还没听说过调用之前就开始计时的预算上触发了。
复合故障:重新规划触发的重复调用演变成惊群效应
两个时钟之间的单次竞争令人恼火。但重试策略会将其转变为一种自我放大的故障模式。
这种模式通常是这样的:智能体(agent)的预算是 8 秒。工具的预算也是 8 秒。在工具进程开始前,线路延迟和预填充时间(prefill time)消耗了 1.4 秒。工具在 7.8 秒内完成了自身的工作,也就是说,从调度器(harness)的角度来看,总共耗时 9.2 秒。调度器已经在 8 秒时超时并进行了重新规划。重新规划策略是“使用相同的参数重试相同的工具,因为故障看起来是瞬态的”。
现在有两个在途调用。每个调用都在工具进程中启动了一个新的 8 秒时钟。每个调用都将在大约 8 秒内完成。第一个调用在第二个调用执行到一半时结束。智能体收到了第一个结果,但其状态机现在正在等待第二个调用。第一个结果变成了“孤儿”。第二个结果到达后,智能体才对其进行推理——但这是重复操作的结果。如果工具不是完全幂等的(例如文件写入、支付、向量库 upsert、日历邀请等任何有状态的操作),那么现实世界中已经发生了重复操作。
现在扩大规模。智能体同时运行数百个会话。每一个会话都在相同的时钟不匹配情况下运行。每一个都在重试。每一次重试都会消耗另外 8 秒的工具工作负载能力。工具的队列深度增加。队列深度减慢了工具的启动速度。工具启动越慢,意味着越多的智能体超时并重新规划。更多的重新规划意味着更多的重复调用。更多的重复调用意味着更深的队列。
这是典型的惊群效应(thundering herd),只不过触发因素不是缓存未命中或服务重启。触发因素是一个从未达成共识的时钟。经典的缓解措施——抖动重试、请求合并、指数退避——都无济于事,因为重试之间并非相互竞争。它们是在与一个在系统中任何地方都不作为共享对象存在的截止日期(deadline)赛跑。
真正的解决方案
修复方法不是“增加超时时间”。增加超时时间只是在两个时钟碰撞前争取更大的间隙。它并不能消除碰撞。
修复方法是停止使用两个时钟。分布式系统社区在几十年前就达成共识的模式——并且 gRPC、userver 和类似的堆栈默认内置的模式——是绝对截止日期传播(absolute-deadline propagation):在智能体步骤开始时,计算一个墙上时钟时刻(now + budget)。将其附加到每一个下游调用中,包括工具调用、任何子智能体调度,以及任何 LLM 流式调用。每个组件都根据自己的单调时钟检查同一个时刻。操作结束的时刻是唯一的,并且每个参与者都对此达成共识。
对于目前尚未实现此功能的智能体调度器,以下是一些具体的改变:
- 工具预算不再是一个时长。 调度器不再说“工具有 8 秒时间”。而是说“工具在 14:03:21.400 之前有效”。如果调用花了 1.4 秒才到达工具工作进程,该进程会看到它还剩下 6.6 秒,而不是 8 秒,并据此采取行动——取消无法及时完成的工作,或返回部分结果。
- 模型调用共享相同的截止日期。 如果智能体总共有 8 秒,而仅 TTFT(首个 Token 延迟)就花了 1.5 秒,模型知道它还有 6.5 秒——而不是 8 秒——来完成生成。支持服务端
max_time 或类似功能的流式服务商可以遵循这一点。不支持的服务商至少应该通过客户端的绝对截止日期得到通知。
- 重新规划策略读取相同的截止日期。 当预算到期时,调度器不会为重新规划启动一个新的 8 秒时钟。它要么干净利落地放弃,要么结转剩余时间。在新的预算中重新规划正是制造重复调用级联的原因。针对接近零的剩余预算进行重新规划是短暂且防御性的,它迫使调度器在询问用户和中止任务之间做出选择。
- 工具结果具有感知截止日期的确认通道。 当工具在智能体截止日期之后完成时,调度器仍然可以将结果记录为“已完成但被遗弃”,而不是默默丢弃。这对于有状态的工具至关重要——你需要知道即使智能体放弃了结果,副作用也已经发生了。这里的修复方案正是为了解决目前被掩盖的孤儿
tool_result 类问题。
这个修复方案更难的版本是没人愿意做的,即:停止将 LLM 调用和工具调用视为预算中唯一的内容。完整的智能体步骤包含规划阶段、序列化阶段、到模型的网络跳数、流式传输管道、工具路由跳数、队列、工具工作进程启动、工具本身、响应跳数以及返回调度器的过程。每一个环节都消耗墙上时钟时间。如果预算只考虑“模型时间”加上“工具时间”,那么竞争将会消耗掉大量未被计算在内的延迟。
需要内化的模式
每当你在两个不同的进程中各有一个计时器并试图测量同一次操作时,请自问:它们共享同一个 t-zero 吗?它们共享同一个 t-end 吗?如果其中任何一个问题的答案是否定的,那么你拥有的并不是超时策略(timeout policy),而是一个两端各拿着一块秒表的竞态条件(race condition)。
这不是一个新问题。它是分布式系统中最古老的问题——协作节点之间的时钟偏移(clock skew)——只是披上了 LLM bug 的外衣。解决方法一如既往:只计算一次截止时间(deadline),并将其传播到各处,让每个参与者根据自己的单调时钟(monotonic clock)检查同一个时刻。达不到这种程度,你并不是在防止超时,而是在预定超时。
下次当某个工具在源端的成功率指标显示正常,而 Agent 在测试框架(harness)端的成功率却在崩溃时,不要去看工具,也不要去看模型。去观察它们各自认为时钟开始计时的那个时间点之间的差距。Bug 就在那里。
会员专享
余下内容仅对会员开放。
会员可读完整内容 —— 每一个公开发布的观点背后,那些框架、决策与推理的全貌。
- —完整文章,包含未公开存档的部分
- —可落地的工作框架,附带权衡与决策依据
- —新文章抢先看,先于公开发布
随时取消 · 一次订阅,畅读全部