跳到主要内容

停不下来的 Agent:作为运行时故障模式的范围蔓延

· 阅读需 9 分钟
Tian Pan
Software Engineer

你让智能体修复一个不稳定的测试。第三分钟,测试通过了。第四分钟,智能体正在读取相邻文件。第九分钟,它“改进”了一个测试从未触及的辅助函数,为了清晰起见重命名了一个无关的参数,并开始对 fixture 构建器进行重构。最终提交的 diff 涉及 12 个文件和 400 行代码。原始 Bug 修复了,一些原本没坏的代码也顺便被“修复”了。

这不是模型感到困惑,而是模型完全按照指令留下的空间在行事。任务要求“修复 Bug”,但并没说“修复后就停止”。大多数智能体循环都有明确的起点和成功标准,但对第三个问题却含糊其辞:你什么时候结束?在聊天会话中,“结束”是由用户决定的。在自主循环中,“结束”是由停止条件决定的,如果你没写停止条件,那停止条件就是“模型失去了兴趣”。这不属于你可以调试的故障模式,而是一种你必须通过设计来消除的故障模式。

成本维度在正确性维度之前就揭示了真相。一个简单的智能体循环,在 5 个步骤时,成本大约是相同结果单次推理的 3 倍;在 50 个步骤时,超过 30 倍;超过 200 个步骤时,成本则会跨过 100 倍。其中大部分是上下文开销,每次工具调用都会重新发送,并呈单调增长,直到某种机制将循环踢出。一个不知道何时停止的模型不仅仅是在做更多的工作——它在每一轮都在做更昂贵的工作,每一轮新迭代都在为之前的所有内容买单。

乐于助人即故障模式

我们从聊天机器人中继承的直觉是,答案越详尽越好。进一步探索,考虑边缘情况,让代码比你发现它时更整洁。在代码审查中,这些都没有错。但当智能体决定工作范围时,这一切都是错的。

智能体不知道代码库中的哪些文件属于另一个团队。它不知道它“改进”的辅助函数被你本季度无法更改的序列化格式所依赖。它不知道被重命名的参数是公共 API 的一部分。它也看不到三周后的 Slack 讨论,届时有人会问为什么部署开始失败,而答案最终是一个从未被要求进行的重构。从循环内部看,智能体看到了一个提供帮助的机会并抓住了它。从循环外部看,你得到了一个不受控的爆炸半径。

这就是作为运行时故障的范围蔓延:它不会抛出异常,不会返回错误,测试依然是绿色的,而且输出结果看起来甚至比你要求的还要好。日志中不会有一行写着“智能体认为任务比你描述的更宏大”。唯一的信号是 diff 的规模和账单的金额,而这两者都在损害造成后才送达。

“完成”必须写进提示词

模型不会通过阅读任务来弄清楚任务在哪里结束。“修复 auth/session_test.py 中失败的测试”对于一个能力强的智能体来说,读起来像是一个会话的开始,而非全部。一旦测试变绿,智能体手里仍有工具,仍有上下文窗口,系统提示词仍在鼓励它保持高质量和严谨。它理所当然地会继续做下去。

解决方法是在任务中写明停止条件,就像写明成功条件一样。不要只说“修复 Bug”,而要说“修复 Bug,然后停止——不要修改包含失败测试及其直接 fixture 之外的任何文件,一旦测试通过,不要运行测试运行器以外的任何工具”。是的,这很啰嗦。之所以啰嗦,是因为自然语言版本的“做完就停”包含了很多未指明的工作,而智能体会用自己的判断来填补这个真空。

一个有用的模式:每个智能体任务都应在提示词中明确回答三个问题——什么算成功、什么算超出范围,以及在观察到成功的瞬间智能体必须做什么。第三点是团队最容易忘记的。如果你不说“输出 DONE 并退出”,智能体就会继续在已经完成的工作中做一个“好公民”。

在生产环境中有更深层次的做法:将成功本身设计为智能体发出的信号,而不是智能体推断出的结论。一个 final_answer() 工具,一个 done() 动作,或者一个包含 complete: true 字段的结构化输出模式。模型随后被训练为能够识别“我现在应该调用这个工具”,就像识别任何其他工具调用一样。终止变成了一种模型决定去“做”的事情,而不是在没有任何后续动作时发生的一种状态。

步骤预算和 Diff 限制是遏制,而非优化

人们往往倾向于将每步的 Token 使用量视为性能问题。精简提示词,修剪上下文,路由到更小的模型。这些固然重要,但都忽略了更难的核心问题:一个不停止的智能体会耗尽你给它的任何预算。在不限制步骤总数的情况下优化每步成本,只不过是为相同数量的无尽回合支付更少的单价。

步骤预算——对循环允许迭代次数的硬性限制——不是一个调节旋钮,而是一个断路器。代码编辑智能体的合理生产默认值通常在 15–25 步之间,有时甚至更低。上限的意义不在于任务总能在此范围内完成,而在于当任务无法完成时,由其他机制来决定下一步该做什么——是缩小范围重试、移交给人工,还是优雅地报错。如果没有这个上限,“下一步”就是“智能体继续烧钱”。

Diff 大小限制是应用在写入面上的相同思路。如果任务是“修复此函数中的 Bug”,而智能体提出了跨越 9 个文件、共 600 行的更改,那么这个 diff 本身就是一个停止信号。每个单独的修改是否合理并不重要,总量证明了智能体已经重新定义了任务。要么 diff 被拒绝并给智能体一个更紧凑的任务简报,要么由人工接管。你不能一边持续批准小任务带来的大 diff,一边假装自己还在掌控工作范围。

将这些联系在一起的模式是:每种遏制机制——步骤预算、diff 限制、实际耗时超时、工具调用配额——都是针对同一个根本原因的防御,即智能体内在缺乏“足够”的概念。你不是从中挑选一个来部署,而是要分层使用它们,因为任何单一机制都可能被一个善于寻找“出路”继续工作的智能体所击败。

产品化版本:一个用户可以信任的“完成”信号

在研究工具或一次性脚本中,你或许可以通过设置硬限制(hard ceilings)来草草了事。但在产品中,停止问题(stopping problem)就变成了一个 UX 问题。用户看不到你的步数预算(step budget)。他们只看到一个东西在不停运转,却无法判断它是在取得进展、陷入了死循环,还是已经完成但不知道如何退出。

解决停止问题的产品化方案,是让“完成”成为用户可以看见并信任的状态,而不仅仅取决于 Agent 继续工作的热情。UI 中需要有清晰的完成信号。一份关于已完成工作和刻意未触碰部分的总结。一份 Agent 考虑过但认为超出范围(out of scope)的任务清单,这些内容应作为建议呈现,而不是直接付诸行动。Agent 应该说:我修复了测试,我注意到另外三个地方可能存在同样的模式问题,但我没有改动它们,如果你需要我处理,它们就在这里。

这种形式——“我停止了,这是我选择不去做的事情”——正是本文开头提到的失败模式的反面。不停手的 Agent 会在 diff 中隐藏其范围的扩张。而表现良好的 Agent 会呈现它没有采纳的每一个选项。用户来决定是将临时方案(workaround)演变为重构(refactor)。Agent 在两个方向上都发挥了作用:完成了被要求的受限任务,并报告了它注意到的不受限情况。

这改变了你给 Agent 下达任务简报的方式

你可能已经猜到了结果。经历过这种事故的团队,哪怕只有一次,也不会再像以前那样编写 Agent 任务了。任务中增加了“不要做”部分。系统提示词(System prompts)增加了一条明确指令,要求在观察到成功标准的那一刻发出结构化的 done 信号,并且除非提示词授予了相应范围,否则绝不修改指定集合之外的文件。每个 Agent 任务都有了步数预算和 diff 上限,并根据任务类型而非 Agent 类型进行配置。平台团队开始将“范围扩张”(scope expansion)作为一项指标进行记录——diff 大小相对于简报大小的比例、触碰了指定集合之外的文件、检测到成功后的工具调用——并像审查错误率一样对其进行审查。

这些做法并不罕见。这与你对任何有权操作共享系统的进程所施加的约束纪律(containment discipline)是一样的。唯一的新情况是,这里讨论的进程是一个概率性的进程,它原生并没有“我现在应该停止”的概念,而在这种语境下,默认的“乐于助人”行为反而成了 bug。那个完全按照你的要求执行并随后停止的 Agent,才是可以安全赋予更多权限的 Agent。而那个总是做得比你要求的多一点的 Agent,会悄悄地让你的团队学着少提要求。

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