跳到主要内容

你的智能体需要的是监督者,而不是重试循环

· 阅读需 12 分钟
Tian Pan
Software Engineer

你的 Agent 在一个包含十二个步骤的任务的第七步挂掉了。框架捕获了异常,经过指数退避(exponential backoff)后进行了重试。它重试了该步骤 —— 但使用的是同样的上下文窗口(context window),其中已经堆积了三次失败的工具调用、一段解析了一半的错误消息,以及一个模型已经放弃的计划。当然,重试也失败了,因为重试是一场赌注,赌的是世界发生了变化,而那个 Agent 的世界没有任何改变。需要改变的是 Agent 的状态 —— 而任何 Agent 框架中的重试策略都不会做出这种决定。

Erlang 的 OTP 库在三十年前就为必须运行数十年的电话交换机制定了这项决策。主管树(supervisor trees)背后的洞察力从来不是“在崩溃时重启”。而是“如何恢复”是一个与“执行工作”完全解耦的关注点,它由一个独立的进程负责,并排列在一个层级结构中,每一层都对恢复的含义有更深一点的理解。现如今的大多数 Agent 框架将重试强加到单个调用上,这就像在电话交换机的每一行代码周围都套上 try/catch 一样。它们真正需要的是这种层级结构。

重试是针对调用的策略,主管是针对进程的策略

目前大多数 Agent 技术栈中提供的重试逻辑只回答了一个问题:这个 API 调用是否应该再次运行?这个问题的答案很明确 —— 带着退避机制重试 429s 和 503s 错误,对认证错误快速失败,并限制预算。网关和客户端库对此处理得很好,它就应该待在它所在的地方:调用点。

但 Agent 循环不是一个调用。它是一个带有累积状态的长运行进程:上下文窗口、临时记录本(scratchpad)、工具执行结果、部分提交的副作用。当循环本身中断时 —— 模型陷入死角、某个工具返回了污染上下文的内容、进程在第七步发生内存溢出(OOM) —— 重试“调用”是在回答错误的问题。正确的问题是 OTP 所问的:这个进程失败了;我们该如何处理它的“状态”?

这里有一个值得借鉴的机制,最近的研究也证实了原因。当一个 Agent 在原位失败并重试时,失败会留在其历史记录中 —— 早期错误的尝试会污染模型随后产生的所有内容。实践者经常看到这种情况:一个已经把自己绕进错误计划的 Agent,仅仅通过被告知再试一次是无法恢复的。它的恢复需要通过以一个更清晰的世界观重启来实现。这是一个主管决策,而不是重试决策,因为发生故障的组件不能是决定如何从自身故障中恢复的组件。

OTP 制定的三项决策

剥离 Erlang 特有的机制,主管会对每个失败的子进程做出恰好三种选择,而每一种都能直接映射到 Agent 上:

  • 以干净的状态重启。 这是 OTP 的默认设置。进程会回到初始状态,其理论依据是大多数故障都是暂时的,而状态损坏是疾病本身,而非症状。对于 Agent 而言:使用全新的上下文重新运行该步骤 —— 包括原始任务、持久化的事实,但不包含任何失败的残余。这就是“让它崩溃”(let it crash)的哲学,它之所以奏效,正是因为重启是“干净”的。
  • 以保存的状态重启。 进程从出错前写入的检查点恢复。对于 Agent,这就是持久化执行(durable-execution)浪潮 —— Temporal、Inngest、DBOS、Restate、LangGraph 的检查点机制 —— 所提供的:记录每个已完成的步骤,并在恢复时从日志回放,而不是重复工作。已完成的工具调用返回缓存的结果;崩溃的步骤重新运行。
  • 上报。 主管本身放弃并向上报错,交给具有更广泛权限的父级主管 —— 或者在 Agent 系统的根部,交给人类。OTP 通过重启强度(restart intensity)将其量化:如果在 max_seconds 内发生超过 max_restarts 次失败,主管将停止重启并停止运行,将故障沿树向上传播。

这第三点是 Agent 框架最显眼缺失的部分。重试策略有一个最大尝试次数,但当次数耗尽时,任务就……失败了。耗尽的重试预算不包含关于接下来需要采取“哪种类型”干预的信息。

在主管树中,耗尽本身就是一个信号,它会传递到某个地方:传递给更粗粒度的恢复策略、不同的模型、不同的任务分解方式,或者一个人。重启强度就是你的人工介入上报预算,它以配置的形式存在,而非凭感觉。

OTP 甚至区分了“哪些”退出值得重启:permanent 子进程总是重启,temporary 子进程从不重启,transient 子进程仅在异常退出时重启。Agent 需要同样的分类法。一个监听队列的监控 Agent 是永久性的(permanent)。一个即发即弃的摘要任务是临时性的(temporary) —— 如果它挂了,就丢弃它。一个代码迁移步骤是瞬态的(transient):如果它崩溃了就重启,但如果它在判定文件不需要更改后正常退出,则不重启。将所有故障一视同仁的框架,会重启那些本该彻底结束的任务,并抛弃那些本该恢复的任务。

一对一,其余重启:流水线的重启拓扑

OTP 的第二个贡献在于,重启决策不仅取决于失败的进程,还取决于它的 关系。监督者(Supervisor)会为整个组声明一种策略:

  • 一对一 (One-for-one):一个子进程崩溃,仅重启该子进程。当子进程之间相互独立时,这是正确的。
  • 全对一 (One-for-all):任何一个子进程崩溃,所有子进程都重启。当子进程共享某些可能被失败破坏的状态时,这是正确的。
  • 其余重启 (Rest-for-one):一个子进程崩溃,该子进程以及所有在它 之后 启动的子进程都会重启。当后面的子进程依赖于前面的子进程时,这是正确的。
加载中…
References:Let's stay in touch and Follow me for more thoughts and updates