每次调用超时 30 秒,用户却要等 90 秒
每一步都重新计时,加上重试最坏要等 270 秒;给整个请求设一个截止时间并向下传递即可解决。
要点速览
- 如果每次工具调用各有 30 秒超时,三个调用串行最多可能等 90 秒,因为没有任何环节记录总耗时。
- 在请求开始时只设定一次截止时间,并把这个时间点(而不是一段时长)传给每一次调用。
- 重试要计入预算,并且只放在一层。否则上面每多一层,最底层的负载就成倍增加。
一位客户提交工单:团队刚部署的客服助手有时会转圈 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:
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 可能用着同样的模型、同样的工具。区别在于,调用链上有没有某个环节在记录这个请求还剩多少秒。
这篇文章对你有帮助吗?
感谢你的反馈!