你的 Agent 端点是一个伪装成函数调用的分布式系统
现代 AI 应用中最危险的一行代码看起来完全无害:
result = await agent.run(user_query)
它读起来就像一个函数调用。它有名称,接受参数,返回数值。你的 IDE 会自动补全它。你的类型检查器也觉得没问题。然而,就在这个单一的 await 背后,隐藏着一个远程的、多跳的、部分失 效的分布式系统,而它却套着本地过程的语法外壳。代码看起来的样子与它实际表现出的行为之间的鸿沟,正是大多数生产环境 Agent 事故发生的地方。
几十年的肌肉记忆告诉我们,一个函数调用要么返回结果,要么抛出异常。它在我们的机器上运行,速度很快,而且如果失败了,它会立即且大声地报错。但对于 Agent 端点来说,这些都不成立。在那个 await 之下,潜伏着网络往返、拥有自定义速率限制和停机时间的远程服务、可能产生重复副作用的重试、可能在中途断掉的流,以及扇出到更多远程服务的工具调用。SDK 正在通过把这一切伪装成 len(my_list) 这种简单的操作,悄悄地坑了你。
这并不是什么新鲜的教训。这只是换了新装的分布式计算的误区。网络并不可靠,延迟并非为零,远程端点也并非始终可用。我们在微服务时代就学到了这一点,并为每一个跨服务调用封装了超时、重试和熔断机制。然而,随着 AI SDK 带来了它们友好的 .run() 和 .create() 方法,整整一代的应用代码在接触到它们的一瞬间就忘记了这个教训。
SDK 方法是伪装的网络边界
把每一次模型调用和每一次工具调用都看作其本质:跨越网络边界的 RPC。一旦你接受了这一设定,你原本就拥有的清单就变成了必选项。
一个指向内部微服务的常规 RPC 会获得超时预算、有界重试策略、熔 断器和指标监控。你的 agent.run() 调用理应获得完全相同的待遇,但通常它什么都得不到,因为它看起来不像 RPC。它看起来就像你导入的某个对象上的一个方法。
破绽就在于失败模式。本地函数不会返回 HTTP 429。本地函数的 p99 延迟不会是其 p50 的十倍。本地函数不会因为别人的流量激增而变慢。但 Agent 端点这三样全占了。大多数 HTTP 栈、代理和负载均衡器中的默认客户端超时时间是 30 秒——这往往比一个串联了多个工具调用和长补全的真实 Agent 响应要短。因此,这种伪装让你付出的第一个代价就是超时在错误的地方触发,杀掉了一个模型本可以在 45 秒内完成的请求。
解决方法并不高明。它就是你在开发支付服务时绝不会省略的那些乏味的基础设施:
- 每一笔调用都有明确的超时预算,根据实际延迟分布确定大小,而非使用库的默认值。
- 带指数退避和抖动的有界重试策略,这样供应商的波动就不会演变成所有实例同时发起的同步重试风暴。
- 熔断器,停止轰炸明显已宕机的供应商,转而快速失败。
- 每个调用点的指标监控,因为你无法优化你未衡量的延迟。
这些都不是什么创新。它是任何网络调用的基本要求,而它被忽视的唯一原因就是 SDK 让调用看起来像是在本地。
“失败”与“我不知道”的区别
这是一个被函数调用思维模型完全抹杀 的区别,也是代价最高昂的一个。
函数调用有两个结果:返回或抛出。而网络调用有三个:成功、失败,或者你不知道。第三种状态——超时的请求、在你发送了字节但在收到响应前断掉的连接——才是造成真正损害的状态,因为你的代码无法区分“模型从未看到我的请求”和“模型处理了我的请求,运行了三个工具,但在返回确认信息时丢失了”。
这就是两军问题,而且它是不可解的。再多的确认消息也无法弥合这一鸿沟。当 Agent 调用超时时,你真的无法知道工作是否已经完成。你只能决定在“不知道”的情况下该怎么做。
对于只读调用——比如“总结这份文档”——不确定的代价很低。重试即可;最坏的情况无非是付两次补全的钱。但对于执行了操作的调用——比如 Agent 已经发送了邮件、扣了款、提交了退款或删除了记录——“不知道”就是核心问题所在。盲目重试,你可能会把不可逆的操作执行两次。不重试,你可能根本什么都没做。
函数调用模型没有为此提供任何词汇。它告诉你调用“失败了”,而“失败”悄悄暗示着“什么都没发生”。对于一个调用具有副作用的工具的 Agent 来说,“失败”更多时候意味着“事情已经办了,但我弄丢了收据”。
重试是 Agent 悄悄重复扣款的地方
Agent 的重试比人为触发的重试更频繁,也更隐蔽,这使得 它们更具危险性。
更频繁,是因为 Agent 非常“啰嗦”。一次简单的用户查询可能演变成数十个工具调用,而每一次调用都是一次触发重试的瞬态故障的独立机会。更隐蔽,是因为没有人在那儿点击“提交”按钮——重试发生在编排循环内部,位于你的应用程序代码之下三层,唯一的证据就是一周后有人注意到的重复记录。
典型的事故在各团队中惊人地一致。Agent 调用了一个向客户收费的工具。下游支付服务由于其自身的外部依赖而变慢。Agent 的超时机制启动了。重试逻辑——正确且出于好意的重试逻辑——触发了第二次扣款。而实际上第一次扣款自始至终都是成功的,只是 Agent 没被告知。客户被双倍计费,系统的每一层都完全按照设计运行。
解决方法是让工具调用具备可重复执行的安全性,这意味着幂等键(idempotency keys)必须贯穿每一个修改状态的工具。当 Agent 第一次决定采取行动时,生成一个稳定的键。将其传递给工具。工具的后端在第一次执行时记录该键,并且在收到任何带有已见过的键的调用时,直接返回原始结果,而不再重新运行副作用。重放变成了空操作。重试风暴变得无害。
幂等性必须是你工具层的首要需求,而不是后续的优化项。经验法则:
- 只读工具(搜索、获取、计算)按原样进行积极重试是安全的。
- 修改类工具必须在允许 Agent 调用之前,先接受并遵守幂等键。
- 确实无法做到幂等性的工具——没有去重支持的第三方 API、物理操作——需要被限制在明确的人工确认之后,而不是交给自动化的重试循环。
设计动作层,使“重新运行”总是安全的。这样重试就不再可怕,而是变成了它应有的样子:对你已知的不稳定网络环境的常规响应。
可靠性是具有复合效应的,而 Agent 延长了链路
有一个算术题是函数调用框架完全掩盖了的。当你链式调用远程服务时,它们的可靠性是相乘的。五个可用性均为 99.9% 的服务组合在一起,大约只能给你 99.5% 的综合可用性——链条的可靠性总是低于其最薄弱的环节,而且通常比所有环节都差。
Agent 是一条在运行时自我构建的可靠性链条。你无法预知一次查询会产生一个工具调用还是四十个。每一次跳转都为结果增加了更多的故障概率。在一个中位数请求稳如磐石的工作负载中,仍可能存在长尾情况,即由于跳转次数足够多,导致某处发生故障——而且由于 Agent 是动态决定链条的,你无法通过查看代码来直观判断。
这就是为什么单点调用监测对 Agent 而言比普通服务更重要。你需要看到每一次跳转:哪个工具、耗时多久、成功、失败还是未知。如果没有这些,性能下降的 Agent 看起来只是“有时很慢”,而不是“日历工具的 P99 增长了三倍,且它出现在了我们 80% 的链路中”。模型并不是系统中唯一的不可靠下游依赖——它能触达的每一个工具也同样如此。
