提出在Agent运行顶层设置单一单调截止时间,下游各步骤的超时和重试退避统一从中派生,避免各步骤独立超时导致的累计等待超出用户预期且仍被计费。
Give an agent run a single monotonic deadline, then lease every per-attempt timeout and backoff sleep out of what is left of it.
一个 Agent 运行时只给定一个单调截止时间,然后从剩余时间中按需分配每次尝试的超时时长和退避休眠。
An agent answering one question rarely makes one web call. It searches, fetches a few pages, extracts fields, and sometimes drives a browser to confirm something. Each of those steps usually arrives with its own configuration: a per-request timeout here, three retries there, an exponential backoff somewhere else.
一个回答问题的 Agent 很少只发起一次 Web 调用。它会搜索、抓取多个页面、提取字段,有时还要驱动浏览器来确认某些内容。这些步骤通常各自带着自己的配置:这里有一个单次请求超时,那里是三次重试,另一个地方是指数退避。
Configured that way, the worst case is a sum nobody ever computed. Four steps at ten seconds each, times three attempts, is two minutes of patience before the backoff sleeps are counted, and no caller agreed to it. The user gave up long before. Depending on the provider, some of those abandoned attempts are still charged, and each of them still consumed a slot in whatever rate limit applies.
这样配置下来,最坏情况是一个没人算过的总和。四个步骤各十秒,各三次尝试,光是累加起来就超过两分钟,还不算退避休眠,而调用方根本不同意这种耐心程度。用户早就放弃了。有些提供商还会对那些被放弃的请求计费,而且每个请求仍然占用着相应的速率限制配额。
The fix is small and structural: decide the deadline once, at the top of the run, and make every step downstream derive its own limits from what remains.
解决方法很小但很结构化:在运行顶层只确定一次截止时间,然后让下游每个步骤从剩余时间中自行推导自己的限制。
A deadline is not a timeout
截止时间不是超时
A timeout is a duration attached to one operation. A deadline is an instant shared by everything the run touches. That difference matters because durations do not compose. Two operations with five-second timeouts can take eleven seconds together, and neither one violated its contract.
超时是附着在单个操作上的时长。截止时间则是运行所触及的一切共享的一个时刻。这个区别很重要,因为时长不会自动组合。两个五秒超时的操作可以一起花费十一秒,而它们各自的合约都没有被违反。
An instant composes trivially. Every step asks the same question, how much time is left, and every step gets an answer that already accounts for what earlier steps spent.
时刻组合起来则轻而易举。每一步都问同一个问题:还剩多少时间?每一步得到的答案已经包含了前面步骤消耗的时间。
Store that instant on a monotonic clock. Python's time.monotonic() returns a value whose reference point is undefined, so only differences between two readings are meaningful, and it cannot go backward when the system clock is adjusted. A wall-clock deadline can be moved by an NTP correction in the middle of a run, which is a rare failure that is very hard to reproduce.
把这个时刻存储在单调时钟上。Python 的 time.monotonic() 返回一个参考点未定义的值,因此只有两次读数之间的差值才有意义,而且当系统时钟被调整时它不会倒退。挂钟截止时间可能会在运行期间被 NTP 校准移动,这是一个非常难以复现的罕见故障。
The undefined reference point has one consequence worth planning for. A monotonic instant is meaningful only inside the process that read it. When the deadline crosses a process or a service boundary, send remaining milliseconds instead, and have the receiver convert it back into a local instant on arrival. Each side then keeps its own clock, and the only thing on the wire is a duration that both ends can interpret without agreeing on a reference point.
未定义的参考点有一个值得规划的后果。单调时刻只有在读取它的进程内部才有意义。当截止时间跨越进程或服务边界时,改为发送剩余毫秒数,并让接收方在到达时将其转换回本地时刻。每一方都保留自己的时钟,线路上传输的只是一个双方无需约定参考点就能解析的时长。
Rule one: the per-attempt timeout is a lease
规则一:每次尝试的超时是一个租约
Treat the configured per-attempt timeout as a ceiling, not a value. What a step actually receives is the smaller of that ceiling and what the budget has left:
将配置的每次尝试超时视为上限,而非固定值。步骤实际获得的是该上限与预算剩余量的较小值:
granted = min(configured, remaining - floor)
The subtraction is the interesting part. If a call is allowed to consume the last microsecond, the run ends with a timeout and nothing to show for it. Reserving a floor leaves room to record the outcome, release resources, and return a partial answer.
减法才是关键所在。如果允许调用耗尽最后一微秒,运行时将以超时告终,什么结果都没有。预留一个底值可以为记录结果、释放资源并返回部分答案留出空间。
Rule two: refuse calls that cannot finish
规则二:拒绝无法完成的调用
When the remaining budget drops below the floor, the correct action is to raise locally rather than to start a remote call. A call given far less budget than the endpoint usually needs to answer is very likely to time out, and a doomed attempt still occupies a connection and a slot in whatever rate limit applies. On metered APIs it may be charged as well, since some providers bill on the request rather than on a successful response.
当剩余预算降到低于底值时,正确做法是在本地抛出异常,而不是发起远程调用。如果给一个调用的预算远低于端点通常所需的回答时间,那它极有可能超时,而一个注定失败的尝试仍然占用着一个连接和速率限制中的一个配额。对于按量计费的 API,有些提供商还会对请求本身而非成功响应计费。
Refusing early converts a guaranteed slow failure into an immediate one, which is what the caller wanted anyway.
提前拒绝将一个必然的缓慢失败转化为即时失败,而这正是调用方本来想要的。
Rule three: sleeping is spending
规则三:休眠也是消耗
Backoff is a common place where budgets leak, because a sleep looks like inactivity rather than consumption. It is not: a retry sleep spends the same seconds a request would.
退避是预算泄露的常见地方,因为休眠看起来像是静止而非消耗。其实不是:重试休眠消耗的秒数和请求一样多。
So bound the backoff twice. Cap its exponential growth at a maximum, and check the chosen sleep against the remaining budget before committing to it. If the sleep would consume what is left, stop retrying and surface the failure while there is still time to report it.
所以要双重约束退避。将其指数增长封顶到一个最大值,并在确认之前将选定的休眠时间与剩余预算进行比较。如果休眠将耗尽剩余时间,就停止重试,并在仍有时间报告时将失败暴露出来。
Use jitter on the sleep. The AWS Builders' Library discussion of timeouts, retries, and backoff argues for randomizing the wait so that clients which failed together do not retry together; full jitter picks uniformly between zero and the current cap. Without it, a provider recovering from an outage can be hit by a synchronized wave of the same clients that just failed.
对休眠使用抖动。AWS Builders' Library 关于超时、重试和退避的讨论主张将等待随机化,以避免同时失败的客户端同时重试;完全抖动在零到当前上限之间均匀选取。没有它,从故障中恢复的提供商可能会被刚失败的那批客户端的同步浪潮再次冲击。
Rule four: retry only what is safe to retry
规则四:只重试可以安全重试的
A budget check answers whether there is time to retry. It does not answer whether retrying is correct. RFC 9110 defines which request methods are safe and which are idempotent, and idempotency is the property that makes an automatic retry harmless. A repeated GET on a search endpoint is usually safe to retry, because the method is defined as read-only, though the specification constrains the method and not the implementation behind it, and the second request still costs quota. A repeated POST that queues a browser job may run the job twice unless the API accepts a client-supplied idempotency key.
预算检查回答的是否有时间重试,而不是重试是否正确。RFC 9110 定义了哪些请求方法是安全的、哪些是幂等的,幂等性是使自动重试无害的属性。在搜索端点上重复 GET 通常可以安全地重试,因为该方法被定义为只读的,尽管规范约束的是方法而非其背后的实现,第二次请求仍会消耗配额。重复 POST 来将浏览器任务排队可能会运行该任务两次,除非该 API 接受客户端提供的幂等性键。
The same specification defines Retry-After, the field a server sends to say how long to wait: RFC 9110 describes it on 503 and on redirects, and servers commonly send it with 429 as well. When you get one, it should beat your local backoff calculation, and it should still be checked against the deadline. A Retry-After longer than the remaining budget is a signal to give up, not to wait.
同一规范定义了 Retry-After,这是服务器发送的用来告知要等待多长时间的字段:RFC 9110 对 503 和重定向进行了描述,服务器通常也会在 429 时发送它。当你收到一个 Retry-After 时,它应该优先于你本地的退避计算,并且仍需与截止时间核对。如果 Retry-After 长于剩余预算,那就是放弃的信号,而非等待。
The following is a synthetic, self-contained example. The timings, failures, and outputs are fabricated to make the control flow observable, and they are not measurements of any real provider.
下面是一个综合的、自包含的示例。其中的时间、失败和输出均为伪造,用于使控制流可观察,不代表任何真实提供商的实际测量。
"""Synthetic deadline-budget demo. Fabricated timings, not provider measurements."""
import asyncio
import random
import time
BASE_BACKOFF_S = 0.05
MAX_BACKOFF_S = 0.40
FLOOR_S = 0.02 # below this, no remote attempt is worth starting
class DeadlineExceeded(Exception):
pass
class Transient(Exception):
pass
class Budget:
"""One monotonic deadline, shared by every step of a single agent run."""
def __init__(self, total_s: float) -> None:
self.expires_at = time.monotonic() + total_s
def remaining(self) -> float:
return self.expires_at - time.monotonic()
def lease(self, step: str, want_s: float) -> float:
"""Largest timeout worth granting, or a refusal if nothing useful is left."""
left = self.remaining()
if left <= FLOOR_S:
raise DeadlineExceeded(f"{step}: {left:.3f}s left, below floor")
return min(want_s, left - FLOOR_S)
async def call_step(budget, step, operation, per_attempt_s, max_attempts, rng, trace):
last_error = None
for attempt in range(1, max_attempts + 1):
timeout_s = budget.lease(step, per_attempt_s)
trace.append((step, attempt, round(timeout_s, 3)))
try:
return await asyncio.wait_for(operation(attempt), timeout_s)
except (asyncio.TimeoutError, Transient) as exc:
last_error = exc
if attempt == max_attempts:
break
cap = min(MAX_BACKOFF_S, BASE_BACKOFF_S * 2 ** (attempt - 1))
sleep_s = rng.uniform(0.0, cap) # full jitter
if sleep_s >= budget.remaining() - FLOOR_S:
raise DeadlineExceeded(f"{step}: backoff outlasts the deadline") from exc
await asyncio.sleep(sleep_s)
raise last_error
def quick(label: str):
async def operation(attempt: int) -> str:
await asyncio.sleep(0.01)
return f"{label}(attempt={attempt})"
return operation
def hangs_first(n: int):
async def operation(attempt: int) -> str:
if attempt <= n:
await asyncio.sleep(10.0) # cut short by the per-attempt timeout
await asyncio.sleep(0.01)
return f"body(attempt={attempt})"
return operation
async def run_plan(total_s: float, rng: random.Random):
budget = Budget(total_s)
trace: list = []
results: dict = {}
plan = [
("search", quick("results"), 0.25, 2),
("fetch", hangs_first(1), 0.10, 3),
("extract", quick("fields"), 0.25, 2),
]
for step, operation, per_attempt_s, max_attempts in plan:
results[step] = await call_step(
budget, step, operation, per_attempt_s, max_attempts, rng, trace
)
return results, trace, budget.remaining()
if __name__ == "__main__":
results, trace, left = asyncio.run(run_plan(1.0, random.Random(7)))
assert results["fetch"] == "body(attempt=2)"
assert [(s, a) for s, a, _ in trace] == [
("search", 1), ("fetch", 1), ("fetch", 2), ("extract", 1),
]
assert all(granted <= 0.25 + 1e-9 for _, _, granted in trace)
assert left > 0.0 # the whole plan finished inside the one deadline it was given
try:
asyncio.run(run_plan(0.01, random.Random(7)))
except DeadlineExceeded as exc:
print(f"refused before dialing out: {exc}")
print(f"plan complete, {left:.3f}s unused")
Three properties are worth reading off that code. expires_at is written once and never extended, so no step can grant itself more room by asking twice. Every timeout passed to asyncio.wait_for comes from lease, so the ceiling and the remaining budget are enforced in one place. And the backoff sleep is checked against the budget before it happens, so a retry cannot outlive the run it belongs to. The second run never reaches that check: its budget starts below the floor, so lease refuses before the first request is dialed.
从这段代码中可以读出三个重要特性。expires_at 只写入一次,从不扩展,因此没有步骤可以通过两次请求来为自己争取更多时间。传递给 asyncio.wait_for 的每个超时都来自 lease,因此上限和剩余预算在一个地方得到强制执行。退避休眠在执行前会与预算进行核对,因此重试的寿命不会超过它所属的运行。第二次运行永远不会到达那个检查:它的预算从一开始就低于底值,因此 lease 在第一次请求拨出之前就拒绝了。
A budget makes limits explicit, which immediately raises the question of what the limits should be. Percentiles from your own traffic are the best source. Where you have none yet, published evaluations give a starting point: NativePort, for example, publishes run-dated latency and error-rate figures per web capability, alongside quality and a cost metric, so that a search timeout and a browser timeout are not chosen from the same number. Whatever the source, keep the run date attached, because provider behavior drifts.
预算使限制变得明确,这立刻引出了一个问题:限制应该是什么。自己流量的百分位数是最好的来源。在还没有数据的地方,发布的评估可以给出一个起点:例如 NativePort 会按 Web 能力发布运行日期的延迟和错误率数据,以及质量指标和成本指标,这样搜索超时和浏览器超时就不是从同一个数字中选取的。无论来源是什么,都要保留运行日期,因为提供商的行为会漂移。
Finally, record what the budget did. Log the granted timeout and the remaining budget on every attempt, not just the final outcome, so that a slow run can be explained without rerunning it.
最后,记录预算做了什么。记录每次尝试授予的超时和剩余预算,而不仅仅是最终结果,这样慢速运行就可以在不重新运行的情况下得到解释。
OpenTelemetry's HTTP semantic conventions already cover part of this: retries and redirects produce more than one physical request for one logical call, and each resend carries http.request.resend_count. Add the budget fields beside it and a trace answers the question that matters during an incident, which is whether the deadline was too small or the provider too slow.
OpenTelemetry 的 HTTP 语义约定已经覆盖了部分内容:重试和重定向会为一个逻辑调用产生多个物理请求,每次重发都带有 http.request.resend_count。在旁边添加预算字段,一个 trace 就能回答事故期间真正重要的问题:是截止时间设得太小,还是提供商太慢。
Coordinating Concurrent Tasks: Timeouts and time.monotonic, Python standard library documentation, on asyncio.wait_for and on the monotonic clock whose reference point is undefined.
协调并发任务:超时与 time.monotonic,Python 标准库文档,关于 asyncio.wait_for 和参考点未定义的单调时钟。
Timeouts, retries, and backoff with jitter, Amazon Builders' Library, on capping retries and randomizing waits so clients do not retry in unison.
超时、重试和带抖动的退避,Amazon Builders' Library,关于限制重试和随机化等待以避免客户端同步重试。
RFC 9110: HTTP Semantics, IETF, on safe and idempotent methods and on the Retry-After header field.
RFC 9110:HTTP 语义,IETF,关于安全方法和幂等方法以及 Retry-After 头字段。
Semantic conventions for HTTP spans, OpenTelemetry, on representing resends with http.request.resend_count.
HTTP 跨度的语义约定,OpenTelemetry,关于用 http.request.resend_count 表示重发。
We're the NativePort team, whose public leaderboards are cited once above as one possible source of published latency figures. AI assisted with drafting this article. During preparation, the code example was run and every cited URL was checked; editorial approval of this exact copy remains separate. All timings, failures, and outputs shown here are synthetic and describe no real provider.
我们是 NativePort 团队,上文引用了我们的公开排行榜作为发布延迟数据的一个可能来源。AI 协助了本文的起草。准备过程中,代码示例已运行完毕,每个引用的 URL 都已检查;对此确切副本的编辑批准仍是独立的。本文所示的所有时间、失败和输出均为综合生成,不描述任何真实提供商。