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(北京) → 拿到结果 → 回答用户但线上跑起来你可能看到这样的 traceStep 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_STEPS20# 最多走 20 步MAX_ELAPSED_SEC120# 总耗时不超过 2 分钟defrun_agent(user_query):step0starttime.time()whilestepMAX_STEPSand(time.time()-start)MAX_ELAPSED_SEC:resultagent.step(user_query)ifresult.is_final:returnresult.output step1# 兜底强制返回当前已有的最好结果returnfallback_output(step)要点硬上限不是为了正常结束而是为了不正常时也能停下来触发硬上限后不要直接报错给用户而是把目前 Agent 已经拿到的中间结果打包返回至少用户不会看到一片空白Layer 2状态变更检测 —— 检测有没有在前进核心思想如果 Agent 连续 N 步对外输出的状态没有任何变化那它就是在原地踏步。什么叫状态可以是工具调用的参数参数一样 没进步中间思考的摘要思考内容一样 没进步检索到的文档集合文档集没变 没进步fromhashlibimportmd5classStateTracker:def__init__(self,stagnant_limit3):self.history[]self.stagnant_limitstagnant_limit# 连续 N 步无变化就触发defsnapshot(self,tool_name:str,tool_args:dict,thought:str)-str:把当前状态压成一个指纹rawf{tool_name}|{json.dumps(tool_args,sort_keysTrue)}|{thought[:200]}returnmd5(raw.encode()).hexdigest()defis_stuck(self,fingerprint:str)-bool:self.history.append(fingerprint)# 取最近 stagnant_limit 次如果指纹全一样 → 卡住了recentself.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,window6,pattern_ratio0.6):self.windowwindow# 看最近 6 步self.pattern_ratiopattern_ratio# 同工具占比超过 60% 就报警self.trace[]defrecord(self,tool_name:str):self.trace.append(tool_name)defis_cycling(self)-bool:recentself.trace[-self.window:]iflen(recent)self.window:returnFalsecounterCounter(recent)top_tool,top_countcounter.most_common(1)[0]returntop_count/self.windowself.pattern_ratio触发后强制切换工具集比如你已经用 search 查了 6 次了请改用 calculate 直接算或者直接回答。Layer 5语义去重 —— 最高级也是最准的前面四层都是规则匹配Layer 5 是真正理解语义的把最近 N 步的 LLM 输出做 embedding算余弦相似度。importnumpyasnpclassSemanticDetector:def__init__(self,embed_fn,window4,similarity_threshold0.92):self.embedembed_fn self.windowwindow self.thresholdsimilarity_threshold self.vectors[]defis_semantic_loop(self,text:str)-bool:vecself.embed(text)self.vectors.append(vec)iflen(self.vectors)self.window:returnFalserecentself.vectors[-self.window:]# 算两两余弦相似度全超过阈值就是语义重复foriinrange(len(recent)):forjinrange(i1,len(recent)):simcosine(recent[i],recent[j])ifsimself.threshold:returnFalsereturnTrue为什么放在最后一层因为 embedding 调用有成本时间 钱能被前面规则拦住的就不要浪费 embedding 调用。四、把五层组装起来一个完整的 Agent 循环classDefendedAgent:def__init__(self,llm,tools):self.llmllm self.toolstools self.state_trackerStateTracker(stagnant_limit3)self.fingerprintBehaviorFingerprint(window6,pattern_ratio0.6)self.semanticSemanticDetector(embed_fnllm.embed)defrun(self,query:str,task_type:strdefault):budgetget_budget(task_type)step0starttime.time()last_answerNonewhileTrue:# Layer 1: 硬上限 ifstepbudget[max_steps]:returnself._give_up(last_answer,reason达到步数上限)iftime.time()-startbudget[max_elapsed]:returnself._give_up(last_answer,reason达到时间上限)# 正常走一步 thought,action,action_inputself.llm.plan(query,history)# Layer 2: 状态变更检测 fpself.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# 执行工具 observationself.tools[action].run(**action_input)last_answerobservationifself.llm.is_final(observation):returnobservation step1def_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 系统欢迎在评论区聊聊你踩过的死循环坑以及你的防御方案。