指出 Agent 快速入门演示中无界循环的设计本质,模型停止判断是启发式heuristic而非保证,需叠加显式边界才能兜底。
无界限 agent 并不是粗心大意的疏忽。它是一个 agent 之所以令人印象深刻这一事实的直接后果:一个 agent 的价值恰恰在于你不必指定任务需要多少步。这就是它的全部卖点。固定步数的 workflow 就是 workflow,你自己也能写。
所以设计直觉——保持循环开放,模型会知道何时完成——并不愚蠢。这是特性。错误在于把“模型决定何时停止”和“没有其他停止条件”当作同一件事。模型决定是一种启发式方法;边界是一种保障。两者你都想要,而当启发式方法本身出问题的时候,只有其中一个可以依赖。
还有第二个原因,与 agent 的测试方式有关。Agent 是针对开发者已知可完成的任务开发的。失控发生在不可完成的任务上——文件不存在、API 返回的格式 agent 无法解析、目标描述不足——而这些任务不会出现在任何人的手动测试集中,因为尝试它们没有意义。
“无限循环”是一个过于粗略的描述。有三种不同的失控模式,需要三种不同的边界,因为每一种都察觉不到另外两种的检测器。
消耗模式值得花点篇幅,因为对于那些正确添加了步数限制的人来说,这结果出乎意料。每轮对话都会重新发送迄今为止的完整对话历史,所以如果每轮增加的长度大致恒定,那么在 n 步运行中,总输入 token 随 n 的平方增长而非线性增长。将步数限制从十加倍到二十,并不会让最坏情况的账单翻倍;而是让其中输入部分大约翻两番。这就是为什么一个看似慷慨的步数限制,实际上是一个比看起来大得多的财务承诺;为什么预算必须以金钱而非迭代次数来计算。
失控不会把自己标榜为错误,这让它代价高昂:agent 正在工作。日志在写入,工具在调用,什么都没有抛出。值得监控的信号如下,它们可以从你已经收集的轨迹中廉价计算出来。
步数分布呈肥右尾。大多数运行在几步内完成;平均值不是问题所在。要关注 p99 和达到上限的运行占比。不断上升的触顶率意味着上限现在承担了负载,这意味着上游某处出了问题。
单次运行中重复的工具调用指纹。对工具名及其参数做哈希。同一哈希在一次运行中出现三次就是振荡,这可以在循环中检测到,而非事后在仪表板上查看。
每完成任务的成本,而非每次调用的成本。单次调用的数字在整个失控过程中看起来都很正常。每任务的数字才是会变动的,而且它是唯一能映射到单位经济学的指标。
状态无变化的运行。如果一个 agent 应该产生一个产物,而运行结束却没有,那就是消耗了预算却什么都没产生。跟踪这个比率;它是返回空 body 的 200 请求的 agent 等价物。
三个计数器加一个进度检查。进度检查是通常缺失的那个,因为它是唯一能捕获“在向虚无迈进但有可衡量进度”的 agent 的机制。
type Budget = {
maxSteps: number;
maxCostCents: number;
maxWallMs: number;
maxRepeats: number; // 相同工具调用指纹的容忍次数
};
class RunBudget {
private steps = 0;
private cents = 0;
private seen = new Map<string, number>();
private readonly startedAt = Date.now();
constructor(private readonly limits: Budget) {}
/** 在每步之前调用,所以负担不起的步永远不会开始。 */
admit(estimateCents: number) {
if (this.steps >= this.limits.maxSteps) throw new Halt("steps");
if (this.cents + estimateCents > this.limits.maxCostCents) throw new Halt("budget");
if (Date.now() - this.startedAt > this.limits.maxWallMs) throw new Halt("time");
this.steps++;
}
/** 在之后调用,传入真实费用和该步实际做了什么。 */
settle(actualCents: number, fingerprint: string) {
this.cents += actualCents;
const n = (this.seen.get(fingerprint) ?? 0) + 1;
this.seen.set(fingerprint, n);
if (n > this.limits.maxRepeats) throw new Halt("no-progress");
}
spent() { return this.cents; }
}
两个细节承载了全部重量。admit 在调用之前运行并接收一个估计值,所以负担不起的步永远不会开始——这与截止日期拒绝开始它无法完成的工作是同样道理。而且预算是任务的属性,不是 agent 的属性:如果生成了子 agent,它收到同一个对象,而不是全新的。如果在扇出时每个 agent 各自有预算,五十美分的上限就会变成五十美元的运行。
halt 必须是一个独立的结果,而不是错误。被预算阻止的任务与失败的任务不同,把它们混为一谈意味着你无法区分基础设施问题和过于困难的任务。记录哪个限制触发了;在 steps、budget、time 和 no-progress 上的分布告诉你每种情况下的不同信息。停止条件总体而言——包括行为良好的 agent 用来决定自己何时完成的进度启发式方法——本身是一个独立的主题;上面给出的是它们下面的地板。
上面的边界是一张安全网,而频繁触发的安全网是一个设计问题而非已解决的问题。三个属性使一个任务首先可以绑定。
可检查的完成定义。不是“模型说它完成了”,而是你可以评估的谓词:测试通过、schema 验证通过、文件存在、记录已写入。没有可检查完成的任务没有自然的停止点,总是被计数器终止。
外部可见的进度。如果每一步都应该改变 transcript 之外的东西,那么什么都没改变的一步可以在没有语义的情况下检测到。唯一产物是自己上下文窗口中的文本的 agent 无法用这种方式检查,而这正是让它们写到某个真实地方的理由。
幂等、可逆的工具。Halt 按定义落在运行中途,所以每个工具必须把世界留在你可以恢复或回滚的状态。这就是 claim-before-call 模式不再只是基础设施的 nicety,而成为让你能够取消的前提。
在构建之前的有用设计练习:写下当 agent 已经消耗了百分之八十的预算且未完成时它应该做什么。如果诚实答案是“继续”,那这个任务没有边界,你不应该无人值守地运行它。如果答案是“把它交给人”,你就刚刚指定了一个审查队列,而 agent 是一个初稿生成器而非自主工作者——这通常也是两者中更有价值的那个。
Pattern: Deterministic Rails Around a Stochastic Core
Anti-Pattern: One Model for Everything
A Checklist Before You Ship Anything AI