ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

智能体死循环1.5亿次调用:根因排查与工程熔断方案

智能体死循环1.5亿次调用:根因排查与工程熔断方案

1. 一个“死循环”是怎么被非科班选手揪出来的

先把事情说清楚:某头部厂商高调发布了一款号称“AI 时代拳头产品”的智能体平台,宣传语里全是“自主规划”“多步推理”“工具调用”这类让人肾上腺素飙升的词。结果一个三本文科背景的独立开发者,用最笨的办法——反复跑、反复看日志、反复数调用次数——发现这个智能体在特定任务下会陷入死循环,单次会话内工具调用次数能飙到 1.5 亿次量级,直到触发平台的硬性截断才停下来。

这个数字不是精确统计出来的,而是通过日志里循环调用的特征推算出来的:同一个工具、同样的参数、几乎相同的返回,被反复请求,间隔时间稳定在毫秒级,持续到超时上限。换句话说,智能体卡在了一个“思考—调用—再思考—再调用”的闭环里,自己走不出来。

这件事值得写,不是因为“大厂翻车”这种猎奇角度,而是因为它暴露了一个所有做智能体的人都迟早会撞上的问题:当模型获得自主调用工具的能力后,谁来保证它不会把自己绕死?这跟模型聪不聪明没关系,跟工程约束做没做够有关系。

我写这篇东西,是想把“死循环”这件事从现象到原理到排查到修复,完整地拆一遍。适合三类人看:正在做智能体产品的开发者、被智能体不稳定问题折磨的测试同学、以及想理解“AI 自主性”边界的产品经理。不需要你有很深的算法背景,但需要你愿意跟着日志一行行看。

2. 智能体为什么会自己把自己绕进死循环

2.1 从“单次问答”到“自主循环”的本质变化

传统的大模型调用是一个开环:你给一个 prompt,模型返回一个结果,结束。哪怕结果不好,也不会自己再跑一遍。但智能体(Agent)引入了一个关键机制——循环。它的基本流程是:模型输出一个动作(比如调用某个工具)→ 执行工具 → 把结果塞回上下文 → 模型再输出下一个动作 → 直到模型认为任务完成或触发终止条件。

这个循环是智能体能力的来源,也是所有稳定性问题的根源。开环系统最多是“答得不好”,闭环系统可能“永远答不完”。你可以把它理解成一个自动导航:单次问答是给你一张地图让你自己看,智能体是你设了目的地后它自己开车。车会不会绕圈,取决于导航逻辑和路况判断,而不取决于发动机多强。

2.2 死循环的三种典型触发路径

根据我自己的复现和社区里同行的反馈,智能体死循环基本逃不出这三类:

第一类是工具返回信息不足导致的重复尝试。模型调用了一个搜索工具,返回结果为空或者格式不对,模型判断“没拿到信息”,于是用几乎相同的 query 再调一次。如果工具本身对这类 query 就是稳定返回空,那这个循环可以无限持续。模型没有“换个思路”的元认知,它只会重复它认为最可能有效的动作。

第二类是目标判定模糊导致的“差一点”循环。任务要求是“找到满足条件 A、B、C 的结果”,模型找到一个满足 A、B 但 C 差一点的,于是它去调整参数再找,找到的又是差一点,来回震荡。问题出在终止条件没有被明确定义成可判定的布尔表达式,而是留给了模型“自己感觉完成了没有”。

第三类是多工具协作时的状态不一致。工具 A 写入的状态,工具 B 读不到,或者读到的格式对不上,模型在 A 和 B 之间反复横跳试图“对齐”,但底层数据模型本身就不自洽,怎么调都对齐不了。

2.3 为什么“1.5 亿次”这个量级是可能的

很多人第一反应是:1.5 亿次调用,平台不早就该限流了吗?这里有个关键点——循环的速度极快。如果每次循环只是模型生成一个短决策加上一次轻量工具调用,单次往返可能只有几十到几百毫秒。按 100 毫秒算,一小时就是 3.6 万次,一天就是 86 万次。如果并发或者重试机制叠加上去,量级会迅速膨胀。

更关键的是,很多平台的终止条件设计得过于宽松。常见的终止条件只有“模型主动输出 finish”和“达到最大步数上限”。如果最大步数上限设得很大(比如为了支持复杂任务设成几千步),而每一步内部又可能触发工具层面的重试,那实际调用次数会是步数的若干倍。1.5 亿这个数字,大概率是“步数上限 × 每步工具重试次数 × 某种并发放大”的结果,而不是单条链路老老实实跑了一亿多次。

这里有个容易被忽略的细节:日志里看到的“调用次数”和计费口径的“调用次数”往往不是一回事。排查时一定要先确认你数的是哪一层。

3. 复现与定位:一个文科生是怎么数出来的

3.1 最小复现环境的搭建思路

要复现死循环,不需要多复杂的架构。核心就三样东西:一个能跑智能体的框架、一个会“稳定返回不理想结果”的假工具、一份能记录每次调用的日志。

假工具是关键。我建议直接写一个最简单的函数,输入什么就返回一个固定的、信息量不足的结果,比如永远返回“未找到相关记录”。这样就能把变量控制住——不是工具时好时坏,而是工具稳定地“不给力”,看模型会不会自己走出来。

# 一个故意“不给力”的假工具,用于复现死循环 call_count = 0 def fake_search(query: str) -> str: global call_count call_count += 1 # 记录每次调用的 query,方便后续分析是否重复 with open("tool_calls.log", "a") as f: f.write(f"{call_count}\t{query}\n") return "未找到相关记录"

日志格式要包含三个字段:调用序号、时间戳、入参。时间戳用来算循环速度,入参用来判断是不是在重复同一个动作。这三个字段一摆出来,死循环的特征就非常明显了——入参高度相似,时间间隔稳定,序号一路涨。

3.2 从日志里识别死循环的三个特征

拿到日志后,不用写复杂的分析脚本,用最朴素的方法就能看出来:

  • 入参重复度高:把 query 去重后看数量,如果去重后只有个位数,但总调用量是几万几十万,那基本可以确定是循环。
  • 时间间隔稳定:相邻两次调用的时间差如果集中在某个很窄的区间(比如都是 80-120 毫秒),说明是机器在稳定地循环,不是人在操作。
  • 没有收敛趋势:正常的多步推理,调用次数应该随着任务推进逐渐减少或趋于稳定。死循环的调用次数是线性甚至指数增长的,看不到头。

我当时就是靠这三条,在一个下午的时间里把问题从“感觉有点慢”定位到了“确定是死循环”。不需要什么高级工具,sort | uniq -c | sort -rn这种命令行组合就够用了。

3.3 定位到具体是哪一步开始跑偏

找到循环还不够,得知道是从哪一步开始进入循环的。方法是在日志里找“第一次出现重复入参”的位置。往前翻几步,看模型在那之前收到了什么工具返回、做了什么决策。

我的经验是,跑偏往往发生在第一次工具返回不符合模型预期的那一步。模型原本预期工具会返回一个结构化结果,结果返回了一段自然语言说“没找到”,模型没有正确处理这个“没找到”的信号,而是把它当成了“再试一次可能就有了”,于是进入循环。

排查心得:不要一上来就看模型输出,先看工具返回。大部分死循环的根因在工具返回的语义和模型预期的语义对不上。

4. 根因拆解:不是模型笨,是约束没做够

4.1 终止条件设计的常见误区

大部分智能体框架的终止条件只有两个:模型说“我完成了”,或者步数超限。这两个都不够。

“模型说我完成了”不可靠,因为模型可能永远觉得自己“还差一点”。步数超限也不够,因为超限之前可能已经跑了几百万次工具调用,损失已经造成了。

正确的做法是多层终止条件叠加:

终止条件类型具体规则作用
硬性步数上限单次会话最多 N 步兜底,防止无限跑
重复动作检测连续 K 步入参相似度超过阈值提前掐断循环
无进展检测连续 M 步没有产生新的有效信息识别“原地打转”
时间上限单次会话总时长超过 T防止慢速循环
成本上限累计 token 或调用次数超过预算控制经济损失

这几层里,重复动作检测是最有效的。实现起来也不复杂:把最近几步的工具入参做哈希或者算相似度,如果连续几步高度相似,直接判定为循环并终止。

4.2 工具返回语义与模型预期的错位

这是最隐蔽也最常见的问题。工具返回“未找到”,在人类看来是明确的“此路不通”,但在模型看来,这可能只是一个“暂时性失败”,它会倾向于重试。

解决办法是让工具返回携带明确的“可重试性”信号。比如返回结构里加一个字段:

{ "status": "not_found", "retryable": false, "suggestion": "该查询条件在当前数据源下无结果,建议更换关键词或数据源" }

retryable: false这个信号会显著降低模型重试的倾向。suggestion字段则给模型一个“下一步该干嘛”的明确指引,避免它自己瞎猜。

4.3 上下文膨胀反过来加剧循环

还有一个恶性循环:每次工具调用和返回都会塞进上下文,上下文越来越长,模型处理越来越慢,但模型看到的“历史”里全是失败的尝试,这反而强化了它“还没成功、需要继续”的判断。

我实测过一个案例:上下文超过一定长度后,模型开始“忘记”自己已经试过某些参数,于是重复调用之前已经失败过的工具。这就是上下文管理没做好导致的循环加剧。

对策是对历史做摘要压缩,尤其是失败的尝试,不要原样保留,而是压缩成一句“已尝试 X、Y、Z 方案,均未成功”。这样既保留了信息,又不会让上下文被无效尝试撑爆。

5. 修复方案与工程加固实操

5.1 给循环装上“熔断器”

最直接的修复是在智能体执行循环里加一个熔断器。逻辑很简单:维护一个最近 N 次调用的指纹列表,每次新调用前先算指纹,如果和列表里的高度重复,直接中断并返回一个明确的错误。

import hashlib from collections import deque class LoopBreaker: def __init__(self, window=5, threshold=3): self.window = deque(maxlen=window) self.threshold = threshold def _fingerprint(self, tool_name, params): raw = f"{tool_name}:{sorted(params.items())}" return hashlib.md5(raw.encode()).hexdigest() def check(self, tool_name, params): fp = self._fingerprint(tool_name, params) self.window.append(fp) # 如果窗口内重复指纹超过阈值,判定为循环 if self.window.count(fp) >= self.threshold: return False # 触发熔断 return True

这个熔断器的参数需要根据任务特点调。窗口太小容易误杀正常重试,窗口太大反应迟钝。我的经验是窗口 5、阈值 3 是个不错的起点,然后根据实际误杀率微调。

5.2 重写工具返回协议

前面提到工具返回要携带可重试性信号,这里给一个更完整的返回协议模板:

{ "status": "success | not_found | error | partial", "retryable": true, "data": {}, "message": "人类可读的说明", "next_action_hint": "建议的下一步动作,可选" }

关键点是status和retryable要分开。status描述事实,retryable描述建议。有些错误是暂时性的(比如超时),retryable为 true;有些是确定性的(比如参数格式错误),retryable为 false。模型看到retryable: false就不会傻傻重试了。

5.3 上下文压缩与失败尝试的归档

对于已经失败过的尝试,不要原样留在上下文里。我的做法是维护一个“已尝试方案”的摘要列表,每次失败后追加一条精简记录,同时把详细的失败上下文移出主上下文。

具体操作上,可以在每轮循环结束后做一次检查:如果本轮没有产生新的有效信息,就把本轮的详细内容压缩成一行摘要,替换掉原来的详细记录。这样上下文长度能控制在合理范围内,模型也不会被大量重复的失败信息干扰。

5.4 监控与告警:让死循环在造成损失前被发现

修复是一方面,监控是另一方面。你不可能预判所有循环路径,所以必须有一套监控能在循环刚冒头时就报警。

我建议监控这几个指标:

  • 单会话工具调用次数:超过某个阈值(比如 50 次)就告警,不用等到几百万次。
  • 重复调用率:最近 10 次调用里重复指纹的占比,超过 50% 告警。
  • 单会话时长:超过预期时长的 3 倍告警。
  • 无新信息步数:连续多步没有产生新的有效信息,告警。

这些指标不需要很精确,关键是早发现。死循环的可怕之处在于它的增长速度,早一分钟发现,可能就少几万次无效调用。

6. 常见问题与排查速查

6.1 死循环排查速查表

现象可能原因排查动作修复方向
调用次数线性增长不收敛终止条件缺失或过松检查最大步数配置加多层终止条件
入参高度重复工具返回语义不明确看工具返回是否含 retryable 信号重写返回协议
上下文越来越长失败尝试未压缩统计上下文长度增长曲线加摘要压缩
多工具间反复横跳状态不一致检查各工具读写的数据格式统一状态模型
循环速度极快单步耗时短且无节流看时间戳间隔加节流或熔断

6.2 几个容易踩的坑

第一个坑是只设了步数上限就以为万事大吉。步数上限是兜底,不是主要防线。等步数上限触发时,可能已经跑了几十万次工具调用了。

第二个坑是把重试逻辑放在工具层而不是智能体层。工具层的自动重试会放大循环——智能体调一次工具,工具内部重试 10 次,那循环速度直接乘 10。重试决策应该交给智能体,工具层只负责如实返回。

第三个坑是日志记录不完整。很多人只记了调用次数,没记入参和时间戳,导致发现问题后无法定位根因。日志三要素——序号、时间戳、入参——一个都不能少。

第四个坑是用“模型应该能自己走出来”来安慰自己。模型没有这种元认知能力,它只会沿着概率最高的路径走。约束必须由工程层提供,不能指望模型自觉。

6.3 一个反直觉的经验

我踩过最深的坑是:加了熔断器之后,误杀率比预期高。原因是有些正常任务确实需要连续调用同一个工具多次,只是参数略有不同。如果指纹算法太粗糙(比如只哈希工具名不哈希参数),就会把正常调用误判为循环。

解决办法是把指纹做得更细:工具名 + 关键参数 + 参数值的归一化。同时阈值不要设得太激进,宁可漏杀也不要误杀。漏杀还有步数上限兜底,误杀会直接破坏正常功能。

7. 这件事对整个智能体行业的启示

7.1 自主性的边界必须由工程定义

“AI 时代拳头产品”这种宣传语背后,隐含了一个危险的假设:模型越自主越好。但工程实践反复证明,自主性必须有边界,而边界的定义权在工程层,不在模型层。

模型负责在边界内做决策,工程负责画边界。边界画得好,模型的能力才能安全释放;边界画不好,再强的模型也会把自己绕死。这跟给小孩划定活动范围是一个道理——不是不信任小孩,而是有些风险必须提前隔离。

7.2 评测体系里必须包含“稳定性”维度

现在大部分智能体评测都在测“任务完成率”“推理准确率”,很少有人测“在异常情况下会不会失控”。1.5 亿次死循环这种事,在常规评测里是测不出来的,因为常规评测的任务都是“正常路径”。

我建议在做智能体评测时,专门加一组“异常注入”测试:工具返回空、工具返回格式错误、工具超时、工具返回矛盾信息。看智能体在这些情况下会不会进入循环。这组测试的价值,比多测几个正常任务高得多。

7.3 给独立开发者的机会

大厂产品出这种问题,对独立开发者来说反而是机会。因为大厂的架构往往很重,改一个终止条件可能要走很长的流程。而独立开发者可以快速迭代,把稳定性做得比大厂还好。

具体来说,独立开发者可以在自己的智能体产品里把“循环防护”做成一个卖点:明确告诉用户“我们的智能体不会跑飞,有熔断、有预算控制、有实时监控”。这在企业级场景里是很有吸引力的。

7.4 我个人的几条实操建议

最后分享几条我自己在做的习惯,不一定对,但确实帮我避免了不少坑:

第一,任何智能体上线前,先用假工具跑一遍异常路径。假工具稳定返回空、返回错误、返回超长文本,看智能体怎么反应。这一步花不了多少时间,但能提前暴露大部分循环问题。

第二,把工具调用次数做成一个显眼的面板。不要藏在日志里,要放在监控首页。数字涨得快不快,一眼就能看到。

第三,给每个智能体会话设一个“预算”,不管是 token 预算还是调用次数预算。预算用完就停,不管任务完没完成。这比任何复杂的终止条件都可靠。

第四,定期回看历史会话的调用分布。正常会话的调用次数应该是一个有长尾的分布,如果出现几个异常高的离群点,那就是循环的痕迹,值得深挖。

这件事说到底,不是某个产品的问题,而是整个行业在从“单次问答”走向“自主智能体”过程中必须跨过的一道坎。跨过去了,智能体才真正可用;跨不过去,再炫酷的 demo 也只是 demo。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进