[控场AI]
· 6 分钟阅读· 3,152 字

Coder与Reviewer代理陷入死循环,步数限制为何失效

Coder与Reviewer代理陷入死循环,步数限制为何失效

步数限制拦不住内容重复型循环,负载指纹检测是轻量必要的防线。

一位开发者搭建的多代理协作循环在无人值守时连续运行一整夜,Coder 与 Reviewer 反复交换同一组消息而不触发任何步数上限。文章剖析了原因:步数计数器只能看到每一步都合法完成,无法识别内容的字节级重复。解决方案是转向内容指纹——对每条消息负载做哈希并监控重复频率与 ABAB 交替模式,约十五行代码即可实现。文章特别警示了 JSON 序列化时的键序陷阱:不加 sort_keys=True 会让同一字典产生不同哈希,导致检测静默失效。最后诚实地划定了指纹方案的边界,指出换词不换意等场景需要嵌入向量补齐,并强调生产环境中没有负载指纹就只剩步数限制或账单兜底。

一个简单的循环实验,变成了三天的拉锯战

一位开发者上周搭建了一个经典的多代理协作循环:Coder 代理写代码,Reviewer 代理给反馈,Coder 根据反馈修改。他设定好流程后就去睡觉了。

第二天醒来,两个代理还在对话。没有崩溃,没有报错,只是持续地"交流"——Coder 反复修复同一个问题,Reviewer 反复提出同一个修改意见。双方都严格按照指令执行。

最终终止这一切的,是他手动杀掉了进程。

Coder agent and Reviewer agent looping

这个看似荒诞的案例,暴露了当前多代理框架中一个被普遍忽视的漏洞。

步数限制为什么拦不住这种循环

几乎所有主流代理框架都内置了步数上限。LangGraph、CrewAI,以及这位开发者自己搭建的框架,都有这个机制。但它没有起作用。

原因在于步数计数器的视角:消息 47 是一个全新的步骤,因为消息 46 已经执行完毕并产生了输出,计数器正常递增。计数器无法识别消息 47 与消息 45 在字节层面完全相同。

换句话说,步数限制能拦截那些永不停止的代理,却拦截不了那些每一步都正常结束、然后立刻用相同内容重新开始的代理。

从计数器的角度看,每一步都是合法的、新的、有输出的。它没有能力判断内容的重复性。

这就是问题的核心:步数限制是一个数量指标,而循环的本质是内容重复。用数量去检测内容层面的问题,方向本身就错了。

多代理框架中的步数上限(step limit)本质上是一种资源配额机制,它的设计初衷是防止代理陷入无限调用工具或无限等待模型响应的死循环,而非检测语义层面的重复行为。在 LangGraph 中,这个参数通常叫 recursion_limit;在 CrewAI 中则体现为 max_iter 或 max_rpm。它们共同的假设是:只要每一步都在产生新的输出,流程就在推进。但这个假设在多代理对话场景下并不成立——两个代理可以永远在"提出问题—给出相同回答—再次提出相同问题"的闭环里高效地消耗 token,而每一步都完美通过计数器的检查。这类循环在分布式系统中常被称为活锁(livelock):系统并未死锁,进程仍在活跃运转,但整体状态不再向前推进。理解活锁与死锁的区别,是理解为什么步数限制在方向上就错了的关键。

用内容指纹替代步数计数

这位开发者做出了一个关键转变:停止计数步骤,转而计数指纹。

具体做法是:对每条消息进行哈希处理,将哈希值存入每个会话的小窗口中。如果同一个哈希值出现次数过多,就判定为循环。如果出现 A→B→A→B 这种短模式交替,则判定为乒乓循环。

整个检测逻辑大约只需要十五行代码。

这个方案的精妙之处在于,它直接对准了循环的本质——重复内容。无论两个代理之间的消息间隔多少步,只要内容指纹相同,就能被捕获。

内容指纹的核心是哈希函数,通常使用 MD5、SHA-1 或更轻量的 xxhash、fnv1a。对代理间传递的消息负载做哈希后,会得到一个固定长度的摘要字符串,内容相同则哈希必然相同,内容哪怕只差一个字符哈希也会截然不同。实际实现中,检测器会维护一个滑动窗口或计数字典(如 Python 的 collections.Counter 或固定长度的 deque),记录最近 N 条消息的哈希值。当某个哈希的出现频次超过阈值(例如同一指纹在 10 步内出现 3 次),或检测到 ABAB 这类周期为 2 的交替模式时,即触发终止。这套方案的时间复杂度是 O(1) 每步,内存开销也极小,因此非常适合放进生产环境的监控路径。需要注意的是,哈希碰撞在这类场景中几乎可以忽略,但哈希的稳定性严重依赖序列化过程,这正是后文要讨论的键序问题。

一个静默杀手:键序问题

指纹检测有一个容易踩的坑,一旦中招,整个机制会彻底失效。

在对消息进行哈希之前,必须先将负载序列化为字符串。问题就出在这里:Python 的 json.dumps 默认保留字典的插入顺序。

{"a": 1, "b": 2} 和 {"b": 2, "a": 1} 在语义上是同一个字典,但序列化后产生两个不同的字符串,进而产生两个不同的哈希值。在检测器看来,这是两条完全不同的消息。

结果是:循环检测一次都不会触发。而且从表面看,系统运行完全正常,你很难发现这个问题。

修复方法简单到只有一行:在序列化时加上 sort_keys=True。但如果不知道这个细节,你会把整套系统写完、发布、上线,而检测模块从头到尾什么都没做。

字典键序问题在 Python 3.7 之后变得尤为隐蔽,因为从那时起 dict 正式保证按插入顺序迭代,而在此之前顺序是实现细节。这意味着同一份逻辑数据如果由不同的代码路径构造(例如代理 A 先写 tool 字段再写 content,代理 B 反过来),序列化结果就会不同。类似的坑还出现在浮点数精度、时区表示、Unicode 规范化(NFC 与 NFD 形式)等地方。工程上的通用做法是:在哈希之前先做一次规范化序列化,即 sort_keys=True、separators=(',', ':') 去除空格、ensure_ascii=False 统一编码,然后再送入哈希函数。一些团队会直接把规范化后的 JSON 作为消息的规范形式存储,从源头杜绝分支。这类"规范化再比较"的思想在 Git 的对象模型、内容寻址存储(CAS)和区块链的默克尔树中都有体现。

指纹检测的边界在哪里

这位开发者列出了一份诚实的清单,说明这套方案无法覆盖的场景:

  • 换词不换意:语义相同但措辞不同的消息,哈希值无法识别,需要引入嵌入向量。
  • 每轮都是全新消息:这种情况属于步数限制该处理的问题,而非循环检测的范畴。
  • 格式错误的消息导致下游代理崩溃:这是 schema 层面的问题,与循环检测无关。

指纹检测精确捕获的只有一种情况:两个代理反复对彼此说同一件事,直到有人注意到。而这恰恰是成本最高的那种循环。

语义重复检测是循环识别的下一个层次,通常依赖嵌入向量(embedding)加余弦相似度。做法是把每条消息用一个句向量模型(如 sentence-transformers、OpenAI text-embedding-3-small)编码成稠密向量,再计算相邻消息之间的相似度,当相似度持续高于某个阈值(如 0.95)时判定为语义循环。相比精确哈希,嵌入方案能捕获"换词不换意"的情况,但代价是每一步都要调用一次嵌入模型,成本与延迟都显著上升,而且阈值调参本身也不稳定。更进阶的路线是让一个裁判模型(LLM-as-judge)周期性审视最近若干轮对话,判断是否在原地打转。实践中常见的折中是分层防御:第一层用哈希做零成本过滤,第二层用嵌入兜住漏网之鱼,第三层在成本失控时人工介入。没有任何单一手段能覆盖全部循环模式,这也是多代理可靠性至今仍是开放问题的原因之一。

生产环境的现实

如果你正在生产环境中运行多代理系统,却没有做负载指纹检测,那么你拥有的不是循环检测——你拥有的是一个步数限制,或者一张账单。

哈希你的负载消息,监控重复项,监控循环模式,尽早触发终止。这个建议简单,但背后是三天的代理拉锯战和大量被消耗的 token 换来的。

多代理协作的可靠性问题不会因为模型能力提升而自动解决,它需要在工程层面被正视。内容指纹检测是其中一个轻量但必要的防线。

#代理死循环#多代理系统#内容指纹检测#步数限制#LangGraph#CrewAI#AI代理可靠性#负载哈希
分享:

相关推荐