跳到主要内容

你的 Agent 端点是一个伪装成函数调用的分布式系统

· 阅读需 10 分钟
Tian Pan
Software Engineer

现代 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% 的链路中”。模型并不是系统中唯一的不可靠下游依赖——它能触达的每一个工具也同样如此。

像对待不可靠的下游一样对待模型,因为它确实如此

这种心态的小小转变会改变一切。不要再把 Agent 端点看作一个智能函数。开始把它看作一个你不拥有、不控制且无法完全信任的远程服务——它有频率限制、停机故障、延迟激增,以及一定比例永远不会到达的响应。

这种重新定义会明确告诉你该构建什么,因为你以前为每个其他不可靠的依赖项都构建过这些:

  • 为每个模型和工具调用封装一个符合实际的超时预算(timeout budget)
  • 使重试有界、增加抖动(jittered),并仅保留给幂等操作
  • 幂等键穿透到每一个触碰状态的工具中。
  • 增加熔断器(circuit breakers),使供应商停机时能快速失败,而不是产生级联反应。
  • 监测每一次跳转,使动态构建的链条可见。
  • 将超时视为**“我不知道”,而不是“它失败了”**——并慎重决定如何处理这种“不知道”。

这些都不是什么新鲜事。它是任何跨网络边界 RPC 的标准规范,Agent 代码之所以经常缺乏这些,唯一的原因是简洁的 SDK 方法说服了你。await 是令人宽慰的。但这种宽慰本身就是 bug。你的代码越早承认 agent.run() 是披着函数外衣的分布式系统,它在生产环境中带给你的意外就越少。

References:Let's stay in touch and Follow me for more thoughts and updates