ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Agent 死循环防御机制:从根因到实战的五层防护体系

Agent 死循环防御机制:从根因到实战的五层防护体系

文章目录

    • 一、先看一个真实的死循环现场
    • 二、死循环的四种典型模式
      • 模式 1:工具复读机(Tool Echo)
      • 模式 2:互相踢皮球(Agent Ping-Pong)
      • 模式 3:思考沼泽(Thinking Swamp)
      • 模式 4:状态机卡死(State Stuck)
    • 三、防御体系:五层纵深防护
      • Layer 1:硬上限(Hard Cap)—— 最后一道兜底
      • Layer 2:状态变更检测 —— 检测"有没有在前进"
      • Layer 3:步骤预算 + 时间预算 —— 给每类任务定配额
      • Layer 4:行为指纹检测 —— 识别"模式化重复"
      • Layer 5:语义去重 —— 最高级,也是最准的
    • 四、把五层组装起来:一个完整的 Agent 循环
    • 五、实战踩坑:三个我花过冤枉钱的地方
      • 坑 1:只检测"工具名",不检测"参数"
      • 坑 2:检测到循环就直接停
      • 坑 3:预算给得太宽松
    • 六、总结:一张图记住整个体系

一个 Agent 在生产环境跑了 3 小时,消耗了 12 万次 LLM 调用,最后输出的答案和第一分钟一模一样——这不是段子,是每个做过 Agent 的人都踩过的坑。本文从现象、根因、防御体系到代码实现,把"死循环"这件事讲透。

一、先看一个真实的死循环现场

假设你写了一个"天气查询 Agent",用户问:“北京今天天气怎么样?”

正常的执行轨迹应该是:

用户 → Agent 思考 → 调用 weather_tool("北京") → 拿到结果 → 回答用户

但线上跑起来,你可能看到这样的 trace:

Step 1: 我需要查询北京天气,调用 weather_tool Step 2: weather_tool 返回 "晴朗,25°C" Step 3: 我需要确认数据来源,再调用一次 weather_tool("北京") Step 4: weather_tool 返回 "晴朗,25°C" Step 5: 为了确保准确性,我应该再验证一下,调用 weather_tool("北京") Step 6: weather_tool 返回 "晴朗,25°C" ... Step 1000: 调用 weather_tool("北京")

症状一眼就能看出来:相同的工具被反复调用,输入参数几乎不变,LLM 却始终"觉得还没搞定"。

这就是 Agent 死循环。它的危害不只是烧 token——更严重的是:

  • 触发外部 API 的限流甚至封禁(你调了人家 1000 次天气接口)
  • 写操作类工具(发邮件、下单、转账)会造成真实世界的副作用
  • 整个任务栈挂住,后续请求排队超时


二、死循环的四种典型模式

要防住它,先得认出来它长什么样。我把见过的死循环归纳成四类:

模式 1:工具复读机(Tool Echo)

特征:同一个工具、相同参数、连续调用 N 次。

search_web("LangChain 教程") → 结果不满意 → search_web("LangChain 教程") → ...

根因:LLM 对返回结果"不满意",但它不知道怎么改进查询词,只能原样重试。

模式 2:互相踢皮球(Agent Ping-Pong)

特征:两个工具 / 两个子 Agent 之间来回传消息,谁都不输出最终答案。

Agent A → 调用 Agent B → Agent B → 调用 Agent A → ...

根因:多 Agent 协作时职责边界模糊,A 觉得 B 能搞定,B 觉得 A 更专业。

模式 3:思考沼泽(Thinking Swamp)

特征:不调用任何工具,纯文本"思考"输出一直在循环。

我需要分析这个问题... 让我再想想... 我应该从另一个角度考虑... 等等,之前的思路是不是有问题? ...

根因:任务本身超出了 LLM 的推理能力,但它没有"认输"机制,一直在原地打转。

模式 4:状态机卡死(State Stuck)

特征:Agent 的内部状态在两个节点之间反复横跳,永远到不了 END。

状态: RESEARCH → 状态: WRITE → 状态: RESEARCH → 状态: WRITE → ...

根因:状态转移条件设计有缺陷,或者 WRITE 节点每次都"觉得写得不够好"又跳回 RESEARCH。


三、防御体系:五层纵深防护

死循环不能靠一招解决,得像网络安全一样搞纵深防御。下面是我在生产环境打磨出来的五层防护模型,从便宜到贵、从粗到细:

┌─────────────────────────────────────────┐ │ Layer 5: 语义去重(最高级,最便宜的拦截在前面)│ ├─────────────────────────────────────────┤ │ Layer 4: 行为指纹检测 │ ├─────────────────────────────────────────┤ │ Layer 3: 步骤预算 + 时间预算 │ ├─────────────────────────────────────────┤ │ Layer 2: 状态变更检测 │ ├─────────────────────────────────────────┤ │ Layer 1: 硬上限(最后兜底) │ └─────────────────────────────────────────┘

设计原则:便宜的检查放在前面,贵的检查放在后面;能拦截在早期,就不要让它跑到后面。

Layer 1:硬上限(Hard Cap)—— 最后一道兜底

最简单粗暴,但必须有。

MAX_STEPS=20# 最多走 20 步MAX_ELAPSED_SEC=120# 总耗时不超过 2 分钟defrun_agent(user_query):step=0start=time.time()whilestep<MAX_STEPSand(time.time()-start)<MAX_ELAPSED_SEC:result=agent.step(user_query)ifresult.is_final:returnresult.output step+=1# 兜底:强制返回当前已有的最好结果returnfallback_output(step)

要点:

  • 硬上限不是为了"正常结束",而是为了"不正常时也能停下来"
  • 触发硬上限后,不要直接报错给用户,而是把目前 Agent 已经拿到的中间结果打包返回,至少用户不会看到一片空白

Layer 2:状态变更检测 —— 检测"有没有在前进"

核心思想:如果 Agent 连续 N 步,对外输出的状态没有任何变化,那它就是在原地踏步。

什么叫"状态"?可以是:

  • 工具调用的参数(参数一样 = 没进步)
  • 中间思考的摘要(思考内容一样 = 没进步)
  • 检索到的文档集合(文档集没变 = 没进步)
fromhashlibimportmd5classStateTracker:def__init__(self,stagnant_limit=3):self.history=[]self.stagnant_limit=stagnant_limit# 连续 N 步无变化就触发defsnapshot(self,tool_name:str,tool_args:dict,thought:str)->str:"""把当前状态压成一个指纹"""raw=f"{tool_name}|{json.dumps(tool_args,sort_keys=True)}|{thought[:200]}"returnmd5(raw.encode()).hexdigest()defis_stuck(self,fingerprint:str)->bool:self.history.append(fingerprint)# 取最近 stagnant_limit 次,如果指纹全一样 → 卡住了recent=self.history[-self.stagnant_limit:]returnlen(set(recent))==1andlen(recent)==self.stagnant_limit

触发后怎么办?不要直接停,而是注入一条"打断消息",强制 LLM 换思路:

iftracker.is_stuck(fingerprint):agent.inject_feedback("你已经连续 3 步在做同样的事情,但没有进展。""请停止重复当前操作,直接基于已有信息给出结论,或者换一个完全不同的策略。")tracker.reset()# 重置计数器,给它一次机会

这一层能挡住 70% 的"工具复读机"和"思考沼泽"。

Layer 3:步骤预算 + 时间预算 —— 给每类任务定配额

Layer 1 是全局硬上限,Layer 3 是按任务类型精细化分配预算。

# 不同任务类型,给不同的步数预算BUDGET_TABLE={"weather_query":{"max_steps":3,"max_elapsed":10},"code_generation":{"max_steps":15,"max_elapsed":90},"deep_research":{"max_steps":50,"max_elapsed":300},"customer_service":{"max_steps":8,"max_elapsed":30},}defget_budget(task_type:str)->dict:returnBUDGET_TABLE.get(task_type,BUDGET_TABLE["default"])

为什么要分类型?因为一个查天气的任务给 50 步预算,本身就是设计错误。预算要和任务的"合理复杂度"匹配,而不是一刀切。

Layer 4:行为指纹检测 —— 识别"模式化重复"

Layer 2 检测的是"完全没变",Layer 4 检测的是**“看起来在变,但其实是同一个循环模式”**。

比如:

Step 1: search("Python 教程") Step 2: search("Python 入门") Step 3: search("Python 基础") Step 4: search("Python 新手")

参数每次都不一样,但本质上是同一个工具 + 同一个语义意图在循环。

fromcollectionsimportCounterclassBehaviorFingerprint:def__init__(self,window=6,pattern_ratio=0.6):self.window=window# 看最近 6 步self.pattern_ratio=pattern_ratio# 同工具占比超过 60% 就报警self.trace=[]defrecord(self,tool_name:str):self.trace.append(tool_name)defis_cycling(self)->bool:recent=self.trace[-self.window:]iflen(recent)<self.window:returnFalsecounter=Counter(recent)top_tool,top_count=counter.most_common(1)[0]returntop_count/self.window>=self.pattern_ratio

触发后:强制切换工具集,比如"你已经用 search 查了 6 次了,请改用 calculate 直接算,或者直接回答。"

Layer 5:语义去重 —— 最高级,也是最准的

前面四层都是"规则匹配",Layer 5 是真正理解语义的:把最近 N 步的 LLM 输出做 embedding,算余弦相似度。

importnumpyasnpclassSemanticDetector:def__init__(self,embed_fn,window=4,similarity_threshold=0.92):self.embed=embed_fn self.window=window self.threshold=similarity_threshold self.vectors=[]defis_semantic_loop(self,text:str)->bool:vec=self.embed(text)self.vectors.append(vec)iflen(self.vectors)<self.window:returnFalserecent=self.vectors[-self.window:]# 算两两余弦相似度,全超过阈值就是语义重复foriinrange(len(recent)):forjinrange(i+1,len(recent)):sim=cosine(recent[i],recent[j])ifsim<self.threshold:returnFalsereturnTrue

为什么放在最后一层?因为 embedding 调用有成本(时间 + 钱),能被前面规则拦住的,就不要浪费 embedding 调用。


四、把五层组装起来:一个完整的 Agent 循环

classDefendedAgent:def__init__(self,llm,tools):self.llm=llm self.tools=tools self.state_tracker=StateTracker(stagnant_limit=3)self.fingerprint=BehaviorFingerprint(window=6,pattern_ratio=0.6)self.semantic=SemanticDetector(embed_fn=llm.embed)defrun(self,query:str,task_type:str="default"):budget=get_budget(task_type)step=0start=time.time()last_answer=NonewhileTrue:# ===== Layer 1: 硬上限 =====ifstep>=budget["max_steps"]:returnself._give_up(last_answer,reason="达到步数上限")iftime.time()-start>budget["max_elapsed"]:returnself._give_up(last_answer,reason="达到时间上限")# ===== 正常走一步 =====thought,action,action_input=self.llm.plan(query,history)# ===== Layer 2: 状态变更检测 =====fp=self.state_tracker.snapshot(action,action_input,thought)ifself.state_tracker.is_stuck(fp):self.llm.inject("检测到你连续 3 步没有进展。请直接基于已有信息给出最终答案。")self.state_tracker.reset()continue# ===== Layer 4: 行为指纹 =====self.fingerprint.record(action)ifself.fingerprint.is_cycling():self.llm.inject(f"你一直在重复调用{action},请换一个工具,或者直接回答用户。")continue# ===== Layer 5: 语义去重 =====ifself.semantic.is_semantic_loop(thought):self.llm.inject("你的思考内容高度重复,请跳出当前思路,直接输出结论。")continue# ===== 执行工具 =====observation=self.tools[action].run(**action_input)last_answer=observationifself.llm.is_final(observation):returnobservation step+=1def_give_up(self,last_answer,reason):"""优雅降级:不是报错,而是把已有结果包装一下返回"""return{"answer":last_answeror"抱歉,这个问题我暂时没能完全解决","degraded":True,"reason":reason,}

五、实战踩坑:三个我花过冤枉钱的地方

坑 1:只检测"工具名",不检测"参数"

一开始我只记录tool_name,结果 Agent 每次换不同参数调用同一个工具,我的检测器完全失灵。必须把参数一起算进指纹,甚至参数里的关键值也要 hash 进去。

坑 2:检测到循环就直接停

早期我一检测到循环就raise Exception,结果用户体验极差——本来 Agent 已经收集了 80% 的信息,就差最后一步,直接报错全没了。

正确做法:检测到循环 → 注入"打断消息" → 给 LLM 一次自我修正的机会 → 如果还不行,再优雅降级返回已有结果。

坑 3:预算给得太宽松

一开始怕"误伤",把max_steps设成 100,结果真出问题的时候,一个任务烧了 100 步才停。预算要宁紧勿松,正常任务用不到上限,真到了上限就是有问题。


六、总结:一张图记住整个体系

层级检测什么成本拦截率
L1 硬上限步数 / 时间几乎为 0兜底 100%
L2 状态变更指纹完全相同极低(hash)~70%
L3 预算分级按任务类型配额极低防止设计错误
L4 行为指纹同工具高频出现低(计数)~15%
L5 语义去重embedding 相似度中(embedding 调用)~10%

核心思路就一句话:让 Agent 跑得快不重要,让它"知道什么时候该停下来"才重要。

死循环不是 LLM 的 bug,而是 Agent 系统设计的必然产物——只要你给了模型"重试"的自由,它就会用满这份自由。防御机制的本质,是把这份自由关进一个有边界的笼子里。


如果你也在做 Agent 系统,欢迎在评论区聊聊你踩过的死循环坑,以及你的防御方案。

返回列表