文章目录
- 一、先看一个真实的死循环现场
- 二、死循环的四种典型模式
- 模式 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 系统,欢迎在评论区聊聊你踩过的死循环坑,以及你的防御方案。