挂钟时间截止日期漂移:为什么你的智能体认为它还有时间但实际上没有
用户点击发送。智能体被配置了 30 秒的时间配额。规划器(planner)检查任务,发现一条耗时约 12 秒的“深度研究”路径和一条耗时 3 秒的“快速查询”路径,并自信地选择了深度路径,因为“我们有充足的时间”。28 秒后,响应返回,比团队上季度发布的 SLA 晚了 2 秒。仪表盘显示,智能体的推理是正确的,重试逻辑是正确的,工具调用也成功了。没有人能解释为什么用户的加载动画转了 46 秒。
这个 bug 不在任何单一组件中。它存在于组件之间的缝隙中,存在于一个系统从未想过要刷新的值里:智能体对于还剩多少时间的认知。在请求受理与模型的下一个规划步骤之间,发生了一次透明重试,挂钟时间在流逝,但截止时间的元数据却没有更新。模型现在正根据它在 15 秒前就已经花掉的预算进行推理,而它自己对此一无所知。
这种失败模式在分布式系统中有一个名字:截止时间漂移(deadline drift)。gRPC 已经与之抗争了十年,其解决方案已非常成熟——将 截止时间作为基于挂钟时间的值进行透传,在每一跳扣除已用时间,并拒绝预计耗时超过剩余时间的任务。智能体编排框架正在以不同的名称悄悄地重建同样的底层管道,而且其中许多框架在早期 gRPC 用户犯过错的地方也重蹈覆辙。不同之处在于,LLM 与强类型的 RPC 客户端不同,它会兴高采烈地将一个过时的数字当作新鲜数字进行推理,并产生一个自信满满却错误的计划,而不是抛出一个清晰的错误。
重试与预算的分离
这个陷阱是结构性的。大多数智能体编排框架将工具调用失败视为底层关注点:捕获瞬时错误、退避、重试,然后将结果返回给规划器,就像第一次尝试只是多花了一点时间一样。在边界处,这是一个干净的抽象,对于它所设计的初衷来说运行良好。规划器无需关心不稳定的网络路径。模型永远不会看到这些噪音。
但当你要求规划器对时间进行推理时,这种抽象就会发生泄漏。规划器被告知它有 30 秒时间。它正在根据这个数字做出决策——我是该走廉价路径还是深度路径,我是该并行化,还是该放弃并道歉。与此同时,编排框架在规划器毫不知情的重试尝试中,默默地消耗着那 30 秒中的大把时间。
每一层在孤立状态下都是正确的。重试层按设计隐藏了瞬时故障。规划器按设计根据给定的截止时间分配时间。它们结合在一起,产生了一个系统:在有重试的请求上统一延迟,在没有重试的请求上统一准时——这是一种双峰 SLA 模式,在客户提交工单之前,它不会出现在任何汇总指标中。
更深层次 的问题在于,“截止时间”从来不是一个单一的概念。它至少包含三个:
- 挂钟截止时间,锚定在用户点击发送的时刻。
- 当前尝试的截止时间,锚定在本次重试开始的时刻。
- 模型认知的截止时间,锚定在放入其上下文中的任何内容。
如果这三个数字发生偏离,智能体就会在错误的参考系下做出“正确”的决策。偏离越大,决策就越错,而且这种偏离会随着重试次数的增加而单调递增。
gRPC 的经验教训
gRPC 在几十年前就处理过这个问题,其设计值得全盘借鉴。截止时间是一个挂钟时刻(instant),而不是一个时长(duration)。当一台服务器代表一个请求调用另一台服务器时,它不会传递一个新的 30 秒预算。它会计算 deadline - now() 并传递这个值。下游服务继承原始截止时间减去上游已经消耗的时间,包括任何重试。
这种设计产生了一些值得关注的特性:
- 时间是单调递增的,且对请求是全局的。只有一个唯一事实来源——原始截止时间——每个子系统都根据“还剩多少时间”来推理。
- 重试消耗的是时间,而不是机会。一次耗时 8 秒的重试消耗了请求预算中的 8 秒,链条中的下一个调用者看到的预算就会减少 8 秒。不存在在每一跳都会重置的“重试计数器”。
- 如果子系统的预期耗时超过剩余预算,它可以拒绝开始工作。与其开始一个耗时 6 秒但在 2 秒后就会超时的操作,不如直接返回
DEADLINE_EXCEEDED快速失败。
智能体编排框架需要其中的每一项。但它们目前大多一项都没有。
如何停止对模型撒谎
修复方案分为四个部分,跳过其中任何一个都会让失败模式依然存在。
将截止时间锚定在请求上,而不是尝试上。 当编排框架接受请求时,它会标记 request_received_at 和 deadline = request_received_at + budget。每一次重试、每一次工具调用、每一次模型调用都要针对 deadline 进行评判,而不是针对每次尝试都会重置的时长。重试层负责不隐瞒其自身重试所消耗的时间。如果用户的挂钟预算是 30 秒,而编排框架已经在一个失败后重试的尝试上花费了 18 秒,那么下一个规划步骤看到的应该是 12 秒,而不是 30 秒。
在每个决策点向模型展示 time_remaining。 如果规划器唯一的信号是开始时给定的静态预算,它就无法做出具备预算意识的选择。它需要一个字段——随你命名,但必须是真实的、动态的——写着“截至本轮,你还有 N 秒”。这个字段是在调用规划器的瞬间计算的,而不是请求到达的时刻。如果模型在深度研究路径和快速查询路径之间做出选择,它应该根据 time_remaining = deadline - now() 进行选择,而不是根据原始预算。教会模型对时间进行推理的最廉价方法就是告诉它真相。
拒绝无法完成的工作。 工具和计划应该声明一个预期耗时——一个大致的 p95 就足够了。编排框架在分发任务前检查预计耗时与 time_remaining。如果预计耗时超过预 算,请求应立即转入备用路径或优雅地宣告失败,而不是抱着侥幸心理开始,最终在完成时违反 SLA。这就是智能体版的 gRPC 服务器入口截止时间检查:当没有剩余时间时,聪明的做法是拒绝工作,而不是开始工作并在 3 秒后超时完成。
在可观测性中区分两种延迟信号。 “智能体认为它运行了多久”和“用户实际上等了多久”是不同的数字,你两者都需要。第一个数字告诉你智能体的计划是否合理。第二个数字告诉你用户是否得到了承诺的服务。如果两者发生偏离,你就遇到了截止时间漂移,而其中的差额正是编排框架花在规划器不知情的重试上的时间。只显示其中一个数字的仪表盘将永远掩盖这个 bug。
双峰 SLA 模式
这种现象之所以会被大多数团队忽略,是因为其症状看起来就像是噪声。中位数延迟正常,p95 也正常。聚合指标中没有任何迹象在提示“截止日期错误(deadline bug)”。接着,当客户抱怨等待时间过长时,团队深入调查单个追踪(trace),发现一个请求运行了 40 秒,而内部所有计数器却都显示它只运行了 28 秒。
寻找模式,而非量级。“截止日期漂移(deadline drift)”的特征是:用户感知的延迟呈现出双峰分布(bimodal distribution),而智能体内部延迟中却没有这种现象。按重试次数切分你的延迟数据。零次重试的请求应该有一套延迟分布;重试一次的请求应该有另一套向右偏移的分布;重试两次的请求则应进一步向右偏移。如果从智能体的视角看,这些分布完全重合,但从用户的视角看却是散开的,那么说明测 试框架(harness)向规划器(planner)隐瞒了重试行为,而你正面临着这个 Bug。
另一个特征是:SLA 违规与工具的不稳定性(flakiness)相关,而不是与任务复杂度相关。如果那些慢请求是因为遇到了第三方工具的瞬时 503 错误,而不是因为处理真正困难的工作,那么延迟就损耗在了不可见的重试中。规划器在批准深度研究路径时,根本无法得知预算已经超支了。
准时性随着可靠性的提高而下降
接下来的部分可能会让工程主管感到不安。当重试机制生效时,典型的本能是让它们变得更激进。增加尝试次数、更长的退避时间(backoff)、更宽泛的错误分类。其中的每一项改变都会使重试层更可靠,但也会让截止日期漂移变得更糟——因为每一次成功的重试,都在规划器毫不知情的情况下消耗了用户的时间预算。
一个致力于提高工具调用持久性、却不同时让规划器感知墙钟时间(wall-clock)预算的团队,最终交付的系统会表现出:可靠性在提高,而准时性却在下降。99 分位成功率在爬升,用户可见的 99 分位延迟也在爬升,而团队无法将这两个趋势联系起来,因为在不同的仪表盘上,它们看起来都是利好。
解决办法不是停止重试,而是停止让重试层对模型撒谎。
