# 每次调用超时 30 秒，用户却要等 90 秒

> 每一步都重新计时，加上重试最坏要等 270 秒；给整个请求设一个截止时间并向下传递即可解决。

原文: https://fdetimes.net/zh/guides/agent-timeout-budget-deadline-propagation/

一位客户提交工单：团队刚部署的客服助手有时会转圈 90 秒才报错。打开代码一看，每个 API 调用都设了 30 秒超时，完全符合检查清单的要求。没有哪一行写错，用户的等待时间却是纸面数字的三倍。

这个 bug 能通过代码评审，正是因为每一行看起来都没问题。把 Agent 部署到生产环境的前线部署工程师（FDE），应该在上线那一周之前就弄懂它，而不是等它出现在客户工单里。原因很简单：每个调用都有自己的超时，但用户只关心整个请求要花多久。

修复它不需要新的基础设施，只需要换一个问题来思考时间：不是“每个调用可以等多久？”，而是“整个请求还剩多少秒？”

## 三次 30 秒等待，加起来就是 90 秒

设想助手在回答“我的订单到哪了？”。Agent 先调用客户关系管理（CRM）系统查找客户，再调用订单系统查询状态，最后调用大模型（LLM）撰写回复。每个调用各有 30 秒超时。

某天订单系统变慢了。最坏情况下三步都触达上限，30 加 30 加 30，一共 90 秒。

Tianpan.co 在一篇讨论 Agent 调用链的文章里描述了这一机制：Agent 安排下一次工具调用时，会给它一个全新的超时，就好像时钟刚刚被重置。

整条链上没有任何环节记录总耗时。

重试会让情况更糟。如果每一步最多可以尝试三次，最坏情况就是九次 30 秒的等待，即 270 秒。而且这些等待对谁都没有帮助。Google 的站点可靠性工程（SRE）手册说得很直白：截止时间一过，客户端就不再关心服务端在做什么。

## 截止时间属于请求，而不属于每一步

Google SRE 手册指出，给远程过程调用（RPC）设置截止时间通常是明智的，并且更进一步：服务端调用后端时，不应自己另设一个新的截止时间，而应把收到的截止时间传下去。这叫作截止时间传递（deadline propagation）。

tianpan.co 的文章把同样的原则用到了 Agent 上：截止时间以绝对时间点的形式只设定一次，然后把同一个时间点（而不是一段时长）传给后续每一次调用。

**要点:** 截止时间属于整个请求，而不属于每一次调用。

为什么用时间点而不是时长？“还剩 20 秒”这样的数字只在计算出来的那一刻是准确的，它没有算上请求随后在队列里或 Agent 推理循环中花掉的时间。而“10:00:30 到期”这样的截止时间始终准确，每一步都可以自己算出剩余时间。

gRPC 文档表明，这两种做法在实践中都存在：有些 API 接收超时（一段时长），有些接收截止时间（一个时间点），而且只有部分实现会自动把传入 RPC 的截止时间传递给传出的 RPC。请检查自己使用的库，不要想当然地认为它已经替你处理好了。

## 示例：给 Agent 循环加上预算

回到客服助手。假设客户能接受的完整回答时间最多 30 秒。下面这段 Python 代码足以在单个进程内修复这个 bug：

```python
import random
import time

class Budget:
    def __init__(self, seconds):
        # 只设定一次截止时间；使用单调时钟，避免系统时间调整造成偏差
        self.deadline = time.monotonic() + seconds

    def remaining(self):
        return self.deadline - time.monotonic()

class TransientError(Exception):
    pass

def call_tool(fn, budget, reserve=2.0, max_attempts=2):
    for attempt in range(max_attempts):
        left = budget.remaining() - reserve  # 预留时间用于回复用户
        if left <= 0:
            raise TimeoutError("预算已耗尽，不再调用")
        try:
            return fn(timeout=left)  # 每次调用只拿到剩余的时间
        except TransientError:
            wait = min(0.2 * 2 ** attempt, left) * random.random()
            time.sleep(wait)  # 带随机抖动的指数退避
    raise TimeoutError("重试次数已用完")

budget = Budget(30)
customer = call_tool(crm_lookup, budget)
order = call_tool(order_status, budget)
answer = call_tool(llm_summarize, budget)
```

现在重放那个糟糕的场景：CRM 在 3 秒后响应，订单系统卡住。第二次调用最多只能等大约 25 秒：30 减去 3，再减去 2 秒预留。

这个示例假设 `order_status` 会在 25 秒后抛出自己的客户端超时错误。这个错误不是 `TransientError`，所以 `call_tool` 不会捕获它，它会直接沿调用栈向上抛出，不做重试。

即使有重试循环，`call_tool` 也会停下来，因为此时剩余预算已经是零。用户大约在第 28 秒收到错误，而不是第 90 秒。

对 Agent 来说，还要多加一步：在让大模型规划下一次工具调用之前，先检查 `budget.remaining()`。如果剩余时间比一步通常所需的时间还短，就返回部分答案并附上说明。这比启动一项注定无法按时完成的工作要好。

## 每一层都重试，负载成倍增加

重试给了调用方又一次成功的机会，但服务端要为此多花时间。正因如此，AWS Builders' Library 称重试是“自私的”。如果每一层都各自重试，最底层的负载就会成倍放大。在 AWS 的例子里，数据库的负载增加了 243 倍，而且很可能再也恢复不过来。

不妨为自己的系统算一算：三层、每层尝试三次，意味着一个用户请求可能变成对某个慢服务的 27 次调用，而且恰恰发生在它最脆弱的时候。

Google SRE 建议限制每个请求的重试次数，并使用带随机化的指数退避。上面的代码两者都做到了，而且重试只在预算之内进行。

## 在客户现场落地

首先，把调用链画在纸上。写下每一步的超时和重试次数，再乘出最坏情况下的耗时。这个数字常常让客户吃惊，也是争取修复许可的最好理由。

其次，询问客户整个请求的预算应该是多少。AWS 也承认，选择超时值往往是最难的部分，所以这个数字应该来自用户体验，而不是拍脑袋。

然后，在唯一的入口（API 网关或第一个处理函数）设定截止时间，并传递到所有地方。服务之间使用 gRPC 截止时间，或用一个携带时间点的请求头；进程内部使用类似 `Budget` 的对象。最后，每一步在开始昂贵的工作之前都必须检查预算。

## 常见错误

最常见的错误是忘了默认值可能是无限的。gRPC 文档明确指出，gRPC 默认不设截止时间，所以如果客户端不设置，调用可能永远等下去。第二个错误是把“30 秒”传给每一层，而不是传剩余时间，这正是本文开头那个 90 秒的 bug。

第三个错误更隐蔽：上层已经放弃，下层却还在继续运行。一个在第 40 秒才完成的数据库查询，服务的却是第 30 秒就已过期的请求，这纯属浪费资源。

上面的 `Budget` 类只能阻止后续调用。还需要把 `budget.remaining()` 作为查询本身或数据库驱动的超时传下去，这样请求过期时，下层会自行取消。

第四个错误是不留预留时间，结果截止时间恰好在回复还需要格式化和发送时到期。

## 把这条经验写进简历

把这项技能写进简历时，要带上具体数字。比如“通过截止时间传递，把客服 Agent 的最坏等待时间从 270 秒降到 30 秒以内”，远比“具备可靠性相关经验”更有分量。

如果某个 FDE 招聘启事提到“生产环境 Agent”或“与客户系统集成”，就为系统设计轮面试准备好这个答案：能讲清 90 秒是怎么算出来的，并说明如何为整个请求设定单一截止时间。

快的 Agent 和慢的 Agent 可能用着同样的模型、同样的工具。区别在于，调用链上有没有某个环节在记录这个请求还剩多少秒。

**本周动手试试:**

- 挑一个正在部署的 Agent 或服务，列出每个对外调用的超时和重试次数，再乘出最坏情况下的等待时间。
- 在 Agent 循环中加入 Budget 类，当剩余时间不足以完成下一次工具调用时，停止并返回部分结果。
- 找出代码库中所有没有设置截止时间的 gRPC 客户端，并逐一修复。

## 信源

- [Addressing Cascading Failures (Google SRE Book, Chapter 22)](https://sre.google/sre-book/addressing-cascading-failures/)

- [Deadlines (gRPC guides)](https://grpc.io/docs/guides/deadlines/)

- [Timeouts, retries, and backoff with jitter](https://builder.aws.com/content/3EumjoZascWd1oZiEgL8ORlv3qE/timeouts-retries-and-backoff-with-jitter)

- [Deadline Propagation in Agent Chains: What Happens to Your p95 SLO at Hop Three](https://tianpan.co/blog/2026/04/17/deadline-propagation-agent-chains-slo)
