每个框架出厂时都只带了一个步数上限作为它唯一的终止条件——于是你的系统分不出「做完了」和「被截断了」,你自己也分不出。
LangGraph 默认在 25 步停下,在 ReAct 循环里大约是十二次工具调用,然后抛出 GraphRecursionError;其他框架的差别只在数字和异常名字上。那个上限量的是系统花了多少力气,不是任务完成了没有,所以它在「差一步就做完」和「已经在原地打转」上触发得一模一样——而上限是一个在两种相反失效之间来回拧的旋钮,不是一道安全网:你为了减少过早停止而往上拧的每一格,都会增加那些永不停止的运行。解法不是一个更好的数字。解法是一份终止契约:三个可区分的出口、一本共享预算账(而不是按智能体的上限),以及一个由「自称干完了的那个智能体」之外的东西做出的完成判定。
默认上限比团队以为的低,而且它回答的是一个没人问过的问题。
LangGraph 的 recursion_limit 默认是 25,其中一步是一次节点执行。一个标准 ReAct 循环每次工具调用烧两步——模型节点,然后工具节点——所以默认预算在 GraphRecursionError 之前大约是十二次工具迭代。对一个聊天智能体来说这很宽裕。对一个要分派四个工人、每个工人各读三个文件的主管来说,它在第一次汇总之前就耗尽了。这个上限还有一个配置陷阱,踩的人多到已经出现在论坛里:把它嵌在 configurable 里,这个键就会被忽略,你安静地拿回 25,而你看到的那条报错报的正是那个你以为自己已经覆盖掉的默认值。
不过先把数字放到一边,因为它是其中较不有趣的那个问题。结构性的问题在于:这个上限是谁的函数。它数的是系统自身的活动量。而完成,是任务的一个属性。这两件事只通过「你本来想量的那个东西」相关,也就是说:无论这次运行距离做完只剩一次工具调用,还是已经把同一个文件反复读了九轮,上限都在同一刻触发。
真正露馅的是下游收到了什么。上限耗尽表现为图这一层抛出的一个异常,而不是一个结果——没有部分输出,没有「已完成哪些」的陈述,也看不出四个工人里哪些跑完了。拿它跟你本来想要的信号比一比:四个子任务完成了三个,第四个卡在一个一直返回 503 的 API 上,这是我们现在手上的东西。几乎所有生产系统都得事后从链路追踪里把这份信息重建出来,因为框架的终止路径把它丢掉了。
一个对自家系统好用的检查:故意把上限撑爆,然后看看调用方收到了什么。如果答案是「一个异常,外加 state 对象里碰巧剩下的东西」,那么你的终止路径是一次带可配置触发条件的崩溃。这篇文章其余的部分,都是在把它变成一个决定。
一次运行有三种结束方式,把它们揉成一种,正是失效变得安静的原因。
一次运行结束,可能因为任务做完了,可能因为任务做不了,也可能因为你愿意花的东西花完了。这不是同一个结果的三个变体——它们要求下游采取不同的动作,而一个分不清它们的系统,一定会采取错的那个。
- 完成(complete)。一个完成判据被求值并且成立。结果可用,这份工作不该重试。
- 放弃(abandoned)。有人确认了任务按当前说法做不成:某个必需系统宕了、请求自相矛盾、信息根本不存在。重试纯属浪费;正确的反应是把阻塞点抛上来。这正是无法满足的任务所论证的、大多数智能体从未被给予的那个出口——所以它们选择打转,而不是谢绝。
- 耗尽(exhausted)。步数、token、墙上时钟或金钱在任务仍然活着时用完了。带着更大的预算重试是理性的;带着同样的预算重试不是。
把「放弃」并进「耗尽」,你就会重试一次永远不会成功的运行,这就是重试放大,只不过由一个主管来付账。把「耗尽」并进「完成」——这是更常见的方向,因为一次被截断的运行照样会吐出一句看起来合理的收尾——你就会把一份残缺答案当成成品发出去。后面这种合并才是会抵达用户的那种,而且它抵达时底气十足,因为那句收尾并不知道自己是被腰斩的。
补救办法是机械的,也很便宜:每一次运行都返回一个结构化结果,带上退出原因、完成判据的判定、已用预算对允许预算,以及任何已有的部分结果。把退出原因做成一个必填字段,而不是从异常类型里推断出来的东西,这三种情形就会一路保持可区分,直到调用方手里。
MAST 给两种终止失效都起了名字,而它们方向相反——所以拧那个上限,只是在二者之间换。
伯克利的 MAST 分类法(Cemri 等,arXiv 2503.13657)从七个开源多智能体框架的 200 条轨迹里导出了十四种失效模式,标注者间一致性为 Cohen's κ = 0.88。其中两种与「停下」有关,而它们指向相反:FM-1.5「不知道终止条件」属于系统设计类——智能体不知道「做完」长什么样,所以它们不停;FM-3.1「过早终止」属于任务核验与终止类——工作没做完它们就停了。
把这两者同时放在眼前,步数上限扮演的角色就清楚了。上限是多数系统拥有的唯一工具,而它只作用在其中一个上:它用蛮力框住了 FM-1.5。它对 FM-3.1 什么也没做,更糟的是,它制造了 FM-3.1——一次在十二个工具调用处被切断的运行,按定义就是过早终止。所以上限是一个两端各有一种失效的旋钮。往下拧以控制失控成本,你就把「长但健康」的运行变成了被截断的运行。往上拧以让正当工作跑完,你就让病态的运行烧到新天花板。不存在正确的设定值,因为这个旋钮量的根本不是那个能区分两种情形的东西。
那个区分是有名字的,它叫「进展」。一次在推进的运行——每轮有新信息、状态在变、子目标在收口——应当被允许继续;一次没在推进的运行应当立刻停下,远在任何上限之前。便宜的进展信号是存在的,而几乎没人把它们接进来:参数完全相同的重复工具调用、三轮以来状态哈希没变过的轨迹、最近五步没产出任何新制品的工人。它们中任何一个都比步数计数更适合当停止触发器,因为它们各自都是关于这次运行的证据,而不是关于预算的证据。把它们接成通往放弃出口的显式转移,并把那个循环记录下来——那个循环正是轨迹评测所需要、而上限耗尽会摧毁的东西。
干了这份活的那个智能体,是最不该来判定它干完了的那个。
这是全文杠杆率最高的一条结构性规则,而且有现场证据。IBM 与加州大学伯克利分校把 MAST 套用在 310 条 ITBench SRE 轨迹上的研究发现,对整体失败最强的单一预测因子是 FM-3.3「错误核验」——智能体一贯地「宣布胜利」,却没有对照实际状态去核查。不是停得太早,也不是不肯停:是在停下的同时断言一个从未被核查过的成功。同一份分析还看到某个模型的过早终止相对其对照高出 46 个百分点,这是配套的那一半结果:当自评就是那道闸门时,闸门会随模型一起移动。
为什么这是结构性的而不是提示词没写好:智能体对「是否完成」的判断,和它所判断的那份工作,出自同一个过程、同一份上下文、同一套激励。一个把任务读错了的工人,会认为那个读错了的任务已经完成。一个悄悄丢掉了某个子目标的工人,手上根本没有那个子目标的记录可供对照。这是生成器—核验器鸿沟作用在「停下」这个决定上,也正是为什么一个诚实版本的主管是核查者而不是归并者——拓扑那篇从另一个方向抵达了同一个结论。
该怎么做,按强度从高到低:
- 机器可判的判据。测试通过、那行记录存在、文件能解析、发票金额对得上。只要任务允许,没有别的做法能与之竞争:便宜、确定、且对工人的叙述免疫。
- 一个有自己世界视角的独立核验器。不是工人的总结——是工人的制品,从记录系统里重新读出来。一个拿到报告的核验器,是在给散文打分。
- 人工闸门,用于不可逆和不可核验的那些。每个决定都很贵,因而要省着用,但在没有判据可言之处,它是唯一诚实的答案。
还有一个看起来像解法、实则不是的:让工人给自己的信心打分。它产出的那个数字与流畅度相关,而不是与正确性相关,并且会让失败隐瞒变得更硬——因为你给那份隐瞒加了一个数字背书。
按智能体的上限无法组合成一个系统级界限,而扇出正是算术崩掉的地方。
每个框架的上限都作用在一个智能体或一张图上:这边一个递归上限,那边一个最大迭代数,另一边一个最大轮次。每一个都是局部界限。没有一个是对这次运行的界限,而在一个动态拓扑里,它们之间的关系是相乘而不是相加。
把形状推一遍。一个上限为 25 的主管去分派工人,每个工人也各有自己 25 的上限。如果一次工人分派花掉主管一步,那么天花板既不是 25 也不是 50——在框架所知的任何东西被触发之前,它在 25 × 25 这个量级上。再允许一个工人去生成工人,指数就又动了。这条路径上没有任何一处配置有误;每个组件都在它写明的上限之内,而这个系统根本没有上限。这就是多智能体失效模式里所记录的成本爆炸背后的机理:不是某个智能体里的 bug,而是它们之间那一层的缺席。
修好它的构件是一本共享预算账,而它是一小段代码:
- 每次运行一本账,在入口处创建,记录 token、墙上时钟、金钱和工具调用次数。不是按智能体,也不是按图——是按用户可见的任务,那才是预算唯一有意义的单位。
- 按实际开销扣减,在每次调用返回时记账,这样账面反映的是发生了什么,而不是打算发生什么。
- 在分派之前查,不是之后。一个正要扇出到四个工人的主管,先问剩余预算够不够四个工人,不够就只扇出两个。事后查,告诉你的是一笔你已经付过的超支。
- 并发下要预留,不能只是查。四个并行工人若各自读到同一个剩余余额,就会各自认为自己花得起。
- 让智能体看得见。一个被告知「本次运行预算还剩 20%」的工人,与一个靠「被杀掉」才发现自己预算情况的工人,行为完全不同——前者会收束、会收窄,或者走放弃出口,而这些都比被截断更好。
按智能体的上限也保留着;它们作为对单个失控组件的兜底依然有用。只是别再把它们当成系统的预算,并让这本账的记账口径与你已经在做的成本归属对齐,这样「框住一次运行的那个预算」和「出现在账单上的那个预算」才是同一个数字。
把终止契约写下来。
上面这一切就是一件产物,按任务类型来写,由编排器而不是提示词来强制执行。它短到可以评审,具体到可以测试。
TERMINATION CONTRACT :: task_type = "resolve_support_ticket"
completion predicate : ticket.status in {resolved, escalated}
AND reply_sent == true
checked_by : orchestrator, re-reading the CRM
(NEVER the worker's own report)
abandon triggers : required system unavailable after 2 attempts
| state hash unchanged for 3 iterations
| identical tool call + args seen 3x
| worker invokes give_up(reason)
result : exit=abandoned, blocker recorded, NO retry
budget ledger : shared, per run. tokens | usd | wall | calls
checked : before every dispatch, reserved not peeked
at 20% left: workers notified; fan-out degrades to serial
at 0% : exit=exhausted, partial results RETURNED
exits complete | abandoned | exhausted # required field
every exit returns: partials, budget_used, trace_id
这份草稿里有三个细节承担了大部分工作。完成判据由编排器重新读取记录系统来核查,这是把 STEP 4 落到实处。give_up(reason) 是一个显式工具,因为一个没有办法谢绝的工人,会自己发明一种办法来显得已经干完了。而每一个出口都返回部分结果——这是对多智能体系统在压力下的表现改善最大的那一项改动,因为它把一次预算超支从「一次丢掉的运行」变成了「一次更短的运行」。
如果这篇只做一件事,那就把退出原因做成每一次运行结果上的必填字段,别再从异常里推断它。它花掉一个下午,纯属增量改动,而且会立刻暴露出你一直蒙着眼在飞的那个分布——头一回测这件事的团队,多数会发现自己「成功」的运行里有五分之一到三分之一其实撞上了上限,并且这个比例在恰恰是当初引入多智能体拓扑所要应付的那类长周期任务上高得多。然后加 give_up,再加共享账本,再加循环检测器。而调高步数上限——所有人都从这儿开始——是这份清单上唯一一项除了账单金额之外什么都不改变的干预。关于「停下」这个问题的单智能体版本,见规划与终止。