某团队 AI 代理连续 11 轮设计评审都无法出设计文档,根源是「有异议就跳过计数」豁免条款导致循环计数形同虚设,耗资 136 美元。
一张工单。十一次设计轮次。$136 的模型消耗。零行代码。
我们的 AI 开发编排器已经连续数周端到端地处理工单——阅读工单、草拟方案、设计、编码、评审、发起 PR——而在这张工单上,它从未离开设计阶段。每一轮,作者都在修订设计文档。每一轮,评审者都发现了新的反对意见。每一轮,文档都变得更长,而更长的文档意味着更多可以被反对的表面。 我们编写的循环上限从未触发。
原因如下。上限的逻辑是:除非评审者提出了阻塞性问题,否则在 N 轮设计轮次后停止。这个豁免听起来合理——你不想让一个计数器压制真正的异议。但评审者是新鲜出炉的进程,每轮都会输出一批新的发现。总是存在一个阻塞性发现。不是同一个——每次都是新的。豁免是无界的,而它所守护的计数器形同虚设。
这次事件是这篇文章存在的原因。这也是我们的系统唯一一次腐化的地方,现在我们有了数据支撑这个结论。
这种失败模式有一个名字——上下文腐化(context rot),或"迷失在中间",或"打补丁惯性"——以及一个简单的机制。
语言模型在生成每个 token 时,都会以其窗口内的所有内容为条件。假设第 1 轮读取了一个 4k token 的提示并生成了 1k token,其中部分是错误的。在一个单一的长会话中,第 2 轮的输入是全部 5k token。模型没有办法给错误的 token 分配零注意力——它们像其他所有内容一样作为条件输入。所以第 2 轮的输出继承了它们的错误概率,第 3 轮继承了第 2 轮的。错误的输出变成错误的输入,不断累积。
举例来说,假设第一轮的错误率为 12%,且每次都会重新输入全部历史:
相比之下,一个全新 worker 的每一轮都读取相同的大约 1.2k token(设计片段加上大约 0.1k 的精简裁决),而原始输出——包括错误的 token——则被丢弃。上下文永远不会增长,错误风险停留在第一轮的位置。(这些数字是效果方向的说明,而非实际测量。)
本能的修复是告诉模型要注意它:"如果你注意到自己在兜圈子,就停下来重新思考。"这不起作用,原因在于结构性——这条指令是由同一个注意力已经停留在被污染的前缀上的模型执行的。被降级的上下文无法被信任来判断自己是否已被降级。
更大的上下文窗口使情况更糟,而非更好。一个百万 token 的窗口能容纳百万 token 的污染。
我们的编排器将模型视为无状态 worker,并将真正的状态保存在它之外。五个机制,按重要性排序:
每轮一个全新的进程。每个作者轮次和每个评审者都是一次新的模型调用,上下文为空。没有会话会在轮次之间恢复。第 8 轮不可能纠结于第 3 轮的糟糕草稿,因为它从未见过它。
状态存在磁盘上。工作区目录包含状态文件、活的设计文档、决策日志,以及每轮的产物堆栈。任何轮次——或任何机器,因为工作区是一个 git 仓库——都可以从文件中重建完整图景。第 15 轮的提示与第 2 轮的提示大小相同。
在提示中声明的有界读取集。worker 被明确告知要读取什么:
重新水化一个有界的读取集:批准的设计文档命名了要读取的文件和插入点——读取那些文件,加上对你所触及的符号的一些定向 grep;不要爬取整个模块。同时读取上一轮的裁决文件,了解你本轮必须解决的未解决发现。
注意它没有说的话:不要读取上一轮的 transcript、推理过程或输出。那些已经不复存在。
精简的交接——由代码生成,而非模型。每个评审者必须将其发现写入一个结构化的、经 schema 检查的文件:id、严重程度、是否阻塞、位置、主张。一个编排器函数——普通的 Python,不调用模型——将这些合并为一份裁决文件:下一轮必须处理的发现,除此之外别无他物。以下是这很重要的原因。
确定性的循环上限。在 $136 事件之后,我们用两个任何东西都无法绕过的计数器替换了那个有豁免的计数器。主力机制:连续三份非终结性的评审裁决(既不是 ready 也不是 ship)则运行停止,附加开放发现路由给人类。一个对总设计轮次的硬上限作为后盾;每次人类 override 之后,该上限增加一个固定增量,这样上限不会在后续每一轮都再次触发并骚扰守门人。两个计数器都不向模型询问任何事情。(初版计算相同裁决并在一个连续三次说"ready"的设计上触发——因为设计不断处于 ready 状态而升级——非终结性裁决才可。)
限制 agent 上下文的标准方式是压缩:当窗口填满时,请模型总结历史,然后从摘要继续。大多数 agent 框架都这么做。这比什么都没有好,但它仍然是一个腐化向量。
摘要器是一个以被污染文本为条件的模型。无论错误的 token 造成了什么偏差,这些偏差同样会偏差摘要——现在以压缩的、听起来权威的形式呈现,并叠加了摘要器自身的幻觉。你只是清洗了污染,而非移除了它。
我们的交接中没有模型参与。链条是:全新的作者 → 全新的评审者 → 纯代码 → 全新的作者。模型接触交接的唯一环节是生成结构化发现,而该输出在被接受之前会经过 schema 验证——输出无法解析的评审者会被重新派遣,而不是被总结。
这是我们最愿意捍卫的设计决策,也是看起来最不常见的一个。
关于腐化的说法很廉价;我们存储了 22 次完成的生产运行,每轮都有事件日志,所以我们去验证了。经典的腐化特征——断路器中间件存在的理由——都是"同样的东西在下一轮再次出现"。在 22 次运行和 38 个代码轮次中:
每轮引入的测试失败都在下一轮作者轮次中被清除。促使"上下文清理"中间件存在的乒乓球循环并未出现——因为没有受污染的上下文需要清理。
这个检查只有十行代码。如果你的 agent 每轮都写入一个结构化事件,在你自己的日志上运行它:
import json, hashlib
prev = {} # run -> last turn's failure fingerprint
for e in map(json.loads, open("events.jsonl")):
if e["event"] != "tests_run" or not e.get("failing_tests"): continue
fp = hashlib.sha256("\n".join(sorted(e["failing_tests"])).encode()).hexdigest()
if prev.get(e["run"]) == fp:
print(f'{e["run"]}: same failures again at turn {e["turn"]}')
prev[e["run"]] = fp
如果它打印出了任何内容,你就有了这个循环——而对它使用每轮计数器比更大的上下文窗口要便宜得多。
被测量为不存在不等于不可能。二十二次运行是一个小型语料库。上限在真正激烈的情况下只触发过一次——在 $136 工单的继任运行上——而上限后运行中的第二次事件是我们再次审视的触发点。
还有一种循环模式被观察到但有界:有一次运行 drew 了连续三次阻塞性代码评审,每次都提出了新的发现。这是评审者更替,不是上下文腐化——每个 worker 都是全新的——而每次运行的轮次预算包含了它。我们考虑为它添加一个代码阶段连胜上限,但决定不做:二十二次运行中的一例,且该运行的设计阶段早于上限的存在,不值得再增加一个旋钮。仓库自身的历史表明,从不触发的上限才是悄悄腐化的那个。
模型无法注意到自己的循环;计数器可以。
状态应该存在磁盘上,每轮都应该从一个空上下文开始。
不要让模型总结历史——用代码合并结构化输出。
然后测量它。检查只有十行代码,答案可能会让你大吃一惊——朝任何一个方向。