超时与截止时间预算:那个没人选过的数字。
你的智能体里每一个超时值,都是由一个看不见其他超时值的人定下的;而你真正上线的最坏情况,是这些各自独立的选择的乘积——不是其中任何一个。三十秒的工具上限,放进一个二十步的循环、再加上客户端自带的两次重试,就是一趟合法地能跑掉大半个小时的运行,而这个数字没人写下来过,也没人同意过。把常量调小救不了它。出路是别再在调用点上配超时,改成把一份截止时间沿着这趟运行往下传——因为预算会复合,常量不会。
那道没人算过的算术。
请求/响应式的服务只有一跳、一个超时,所以那个超时就是最坏情况。而一个智能体循环的步数不由你决定,并且循环会把它内部的每一个数字乘起来。
拿一份现实的配置算一遍。二十步,每步一次模型调用加一次工具调用。工具客户端设成 30 秒。模型 SDK 保持默认——OpenAI 的 Python 库十分钟超时,并且在你的代码看到失败之前,自己就把某些错误重试两次。这趟运行的诚实上限既不是「三十秒」,也不是「十分钟」,而是二十乘以两者之和,再乘以重试倍数,单位是小时。
没人是故意的,因为这些默认值继承自三个对「多久算太久」各执一词的库:
- 压根没有超时。Python 的
requests默认不设;它自家的快速上手文档就警告说,不设「会让你的程序无限期挂起」。工具层建在它上面的智能体,有一个永远不会结束的步骤。 - 五秒。
httpx出厂的默认配置是Timeout(timeout=5.0)——对一次 API 调用很合理,对一个要跑测试套件的工具则是灾难。 - 十分钟,外加两次悄无声息的重试。模型 SDK 的尺码是按一次长推理请求裁的,不是按你的循环裁的。
你的智能体继承的是这三者中最先触发的那个,而那很少是你本来会选的那个。并且尾部比均值暗示的更糟,因为循环给了慢路径很多次机会:一个百分之一的概率走慢分支的步骤,在二十步上会产出大约六分之一概率的慢运行。这是平均不掉的。一趟运行的 p99 由其中最糟的那一步决定,而你抽了二十次。
在调任何参数之前,先把你实际上线的那个数字写下来。用你的单步上限乘以步数上限,加上模型调用上限乘以同一个步数上限,再乘以「一加上自动重试次数」。如果这个乘积大于你公布的 SLO,那你现在还不是有延迟问题——你有的是规格问题,而下面每一条修法都在「先把规格修好」的下游。
截止时间是你传下去的一个值。超时是你配在那儿的一个常量。
分布式系统十年前就把这场争论了结了,而智能体技术栈大多没读过那份会议记录。gRPC 自家的指南说得很直白:截止时间(deadline)是「一个时间点,过了它客户端就不愿再等」,超时(timeout)是「这次调用可以花掉的最长时长」,两者的关系是「在调用开始那一刻,把超时加到当前时钟上」。截止时间是那个原语,超时是导出量。
有两条机制值得原样照搬:
- 在成熟的实现里,传播是自动的。一台去调另一台服务器的服务器,会尊重最初那个客户端的截止时间——Go 与 Java 默认开启,C++ 需要显式开启。gRPC 的文档对「为什么你想要这个」毫不客气:手工来做「易出错」。
- 它以「剩余时间」而非时间戳的形式传输。gRPC 会把截止时间换算回一个已经扣除了已耗时的超时值,正是为了让两台机器上不同步的时钟没法把它弄脏。你这趟运行的预算也该这样移动。
移植到智能体上,就是四条规矩:
- 在铸出 run 的地方铸出截止时间。目标抵达,你分配一个 run ID,同一口气里分配一个绝对截止时间——它们属于同一条记录,理由相同,而且都需要抵达每一层。如果你已经为轨迹的缘故在传 run ID,那这份预算是搭顺风车的。
- 每次调用的超时都按
min(remaining, ceiling_for_this_step_type)导出。单步上限要留着——它是你拦住某个病态工具吃掉整趟运行的办法——但它只能把调用缩短,永远不能把它延过截止时间。 - 完不成的就别开工。如果剩余预算低于这类步骤的 p50,就别发这次调用。开一件你终将抛弃的工,会花钱、会留下一个你之后要去对账的副作用,且什么也买不到。gRPC 的服务端就是这么干的:一个已经过期的 RPC 它选择取消而不是服务;你的外壳应该在拨号之前就做掉。
- 子智能体继承的是一片切片,绝不是一份全新的额度。这是预算无声蒸发最常见的方式:一个还剩五分钟的父方派出三个子智能体,每个都自己造了一份默认十分钟的预算,于是父方的截止时间成了摆设。孩子的预算是父方剩余时间的一部分,由父方裁定;而且父方还得活着,才收得到那个答案。
超时是把活儿丢下,不是把活儿取消。
正是这一节,把一个延迟控制变成了一个正确性问题;也正是这一节,大多数智能体技术栈弄错了。gRPC 的文档把那句悄悄话直接说了出来:「服务端应用有责任停止它为服务这个 RPC 而派生出的任何活动。」你的超时触发,是一句关于你自己耐心的陈述,不是一句关于对方系统行为的陈述。
所以当一次工具调用在三十秒超时、而远端的处理器在三十一秒提交,你手上拿到的就不是一个失败,而是一个问题:它到底发生了没有?而智能体的本能——模型看到一个错误,提议把这次调用再来一遍——是可选答案里最糟的那个,因为模型根本无从知道第一次已经落地了。两张发票、两张工单、两封邮件。
那些控制手段都不光鲜,而且它们全都活在模型之下:
- 幂等键在第一次尝试之前铸出,不是在重试时铸。在重试时生成的键是一个新键,也就是同一个 bug 戴了顶帽子。完整契约见幂等与重试。
- 让超时那条路走对账,而不是走重试。一次写操作到期后,下一个动作是按键去读——「操作
k落地了吗?」——只有它的答案才决定要不要重发。对账是便宜的、有界的、安全的;盲目重试三样都不是。 - 永远不要让模型拥有写操作的重试决定权。读操作从循环里重来一遍没问题。写操作不行,因为那个决定需要模型没有的状态。外壳吞掉超时、去对账,然后交给模型一个已经尘埃落定的事实。这正是工具错误恢复在「该让模型看见的错误」与「绝不该给它看的错误」之间划下的那条界。
- 把这次丢弃记下来。每一次过期的写都是一个潜在的孤儿。把它按孤儿记录,连同那个键,好让修复智能体的副作用是一次查询,而不是一场考古。
取消是一项能力,不是一个假设。如果一个工具可能跑上几分钟——一次构建、一次迁移、一次长检索——它就需要一个显式的取消端点,而你的外壳需要在到期时去调它。没有这个,「超时」的意思就是:你不再盯着一个仍在运行、仍在花钱、仍有能力改你数据的进程了。持久化执行引擎之所以存在,很大程度上就是为了让这件事还能活下去;见持久化执行。
流式输出与思考摧毁了你的存活信号。
读超时问的是「最近有字节到达吗?」十年来这都是「它还在推进吗」的一个公允代理。在智能体技术栈里,它两样都不再是了。
- 保活把一趟已死的运行维持成活的。一条每十五秒发一个注释帧的 SSE 流,永远不会触发读超时——无论它背后那个东西已经多么彻底地停止了做有用功。你在传输层上配了一个存活检查,而它正在代表一具尸体作答。
- 推理令牌让「多久才有第一个有用产出」在设计上就是可变的。自适应的思考预算恰恰是一套「在任何可观察的事情发生之前,花掉难以预测的时长」的机制——见自适应思考与努力预算。任何紧到能抓住一次卡死请求的超时,也会杀掉一次货真价实的难题。
- 令牌到达不等于步骤推进。一个正在吐出一份冗长、笃定、错误的计划的模型,字节是满速产出的,而这趟运行哪儿也没去。
别只跑一个钟,跑两个,并且让第一个是语义的:
- 一个挂在状态变化上、而非挂在字节上的静默钟。只有当发生了一件「读轨迹的人会称之为事件」的事情时才重置它:发出一次工具调用、返回一个工具结果、完成一条消息、写下一个检查点。保活不算,令牌不算。
- 一堵硬墙,就是 STEP 2 里那个运行截止时间。绝对、无条件,且与「场面看上去多热闹」无关。
再给长时间运行的工具一套进度协议——一个带着单调递增的工作量单位的心跳,而不只是一次脉搏。没有它,你只剩两个选项:杀掉本来好好的活儿,或者在一件已经坏掉的活儿上永远等下去;而这两个方向你都会选错。
给着陆留出预算。
在一个有截止时间的智能体里,最浪费的失败是这样一趟运行:把 100% 的预算花在干活上,0% 花在作答上。截止时间在一次工具调用的半途抵达,外壳杀掉这趟运行,于是一件几近完成的工作以错误的形式交回——而价钱是全额付过的。
把「收尾」当成一个有拨款的步骤。留出一段尾巴——二十秒的定额,或预算的 10%,取其大者——除了终止之外任何事都不许动它。然后把剩下的花在一道阶梯上,而不是一处悬崖上:
- 大约消耗到 60% 时,停止加宽。不再新开并行探索,不再新开子智能体,不再做投机性检索。宽度是你用「手上还有的预算」买来的;到这一刻,你手上没有了。
- 大约 80% 时,只准放行装得下的调用。剩下的每一次工具调用,其 p95 都必须低于「剩余预算减去预留」。这就是 STEP 2 里那条「完不成就别开工」的规矩,换了一个更狠的阈值来用。
- 到了预留线,就停下来着陆。把哪些已经确立、哪些没有、以及预算耗尽时有什么还在飞行中,总结出来。
有两个细节决定这套做法是有用还是仅仅整洁。把剩余预算告诉模型。如果它能调整自己的努力程度,那预算就是这个决定的一个输入,而不是套在它外面的一层壳——一个知道自己还剩九十秒的模型,会挑一份不一样的计划,和一个「被杀掉才发现」的模型不同。以及把部分结果标注成部分结果,边界要看得见:「预算在第 14 步耗尽;账目对账已完成,供应商查询正在进行中、未包含在内。」把一趟被截断的运行当成完成的答案端出去,严格地比一次干净的失败更糟,因为它把一个未知洗成了一项声明——而这恰恰是评审队列抓不住的东西。这一步里的其余部分都是寻常的优雅降级;被人跳过的,是标注这件事。
把它运营起来:四个数字,其中一个不是你以为的那个。
超时策略在一块标准仪表盘上是隐形的,因为它做的每件事,呈现出来都是「看起来像别人的锅」的错误与延迟。
- 把「我们放弃了」和「他们失败了」分开。客户端的 deadline exceeded 和上游的 503 是同一根红柱子,却是两个相反的问题:一个是你的配置,一个是他们的可用性;把它们平均在一起,就会产出一个季度的、对着错误系统使的劲。这个区分在上图表之前,先属于你的故障分类法。
- 把耗尽归因到花掉预算的那一步,而不是抱着预算的那一步。这是反直觉的一条,弄错了会把团队送去优化无辜的代码。一趟运行死在第 18 步时,第 18 步很少是罪魁——第 3 步那次四分钟的检索才是。按整趟运行的消耗占比来归因,而这需要一份你必须刻意加上的逐步预算账。
- 盯预算利用率的分布,而不是超时的计数。如果 p50 利用率是 15%、p99 是 100%,那你的截止时间对中位用户毫无影响,而对尾部则是那个约束性条件——这告诉你该修的是尾部的成因,不是那个数字。超时计数什么都不会告诉你。把它和度量智能体延迟配着看,逐步分布住在那儿。
- 对「对账次数」告警,因为那才是你这套策略的真实成本。每一次你不得不回读的写操作,都是一次给你买来了不确定性的超时。如果这个速率在上升,说明你的上限收得太紧;而再收紧一点,会让延迟曲线更好看、让正确性更糟。
然后诚实地测它。关掉截止时间去跑的压力测试,量的是一个你并不运营的系统;真正有意思的行为,是当一整个机群的预算开始同时到期时它会怎么样——而那也正是你的并发假设与厂商的速率限制开始难看地相互作用的时刻。
在动任何一个常量之前先做这件事:往你已有的 trace 里打一个字段——每个步骤边界上的剩余预算——然后放一周。它花掉一个下午,而它回答了你此刻还在猜的三个问题:你的截止时间到底起不起约束作用、哪一步真正吃掉了这趟运行,以及你那些「错误」里有多少其实是你自家客户端对一件已经干完的活儿放了弃。几乎每一个做了这件事的团队都会发现,他们真正的上限是由一个屋里没人知道的库默认值定的。
相关:急停开关——用来停住那些还在预算之内、却已经跑错了的运行;持久状态与可恢复性——给那些该被挂起而不是被抛弃的运行;以及智能体成本控制——同一个步数所乘起来的另一份预算。