【今日问题】
Agent 在 ReAct 循环中陷入"工具调用死循环"------反复调用同一个搜索工具 8 次,每次只是换个近义词,最终因为 token 耗尽而崩溃,却从未给出用户想要的答案。
【真实场景】
某 SaaS 公司为内部客服搭建了一个"合同条款查询 Agent"。产品经理给它配了一个 search_contract 工具,用来在企业知识库中检索合同模板。
上线第三天,一个销售同事问了句:
"帮我把去年签的那个 SaaS 订阅合同的自动续费条款找出来。"
Agent 开始工作了:
ini
第 1 轮:search_contract(query="SaaS 订阅合同 自动续费") → 返回 5 条结果
第 2 轮:search_contract(query="SaaS 订阅协议 自动续约") → 返回 3 条结果
第 3 轮:search_contract(query="软件即服务 合同 续费条款") → 返回 4 条结果
第 4 轮:search_contract(query="自动续费条款 SaaS 协议") → 返回 2 条结果
第 5 轮:search_contract(query="SaaS 合同 续约 自动") → 返回 3 条结果
第 6 轮:search_contract(query="续费 条款") → 返回 6 条结果
第 7 轮:search_contract(query="自动续费") → 返回 4 条结果
第 8 轮:search_contract(query="续费") → 返回 7 条结果
然后,上下文窗口爆了 。Agent 崩溃,返回 Token limit exceeded。用户等了 45 秒,什么都没得到。
产品经理怒了:"这智能体怕不是个智障吧?搜了 8 次同一个东西!"
【思考盲区】
在开始拆解之前,先看看你脑子里有没有这些"直觉陷阱":
| 盲区 | 你的直觉 | 实际情况 |
|---|---|---|
| "给它更多工具就好了" | 工具越多,能力越强 | 工具多了模型反而更"贪心",总想再试一个 |
| "把 max_iterations 调大" | 多跑几轮总能出结果 | 每一轮都在消耗 token,且模型不知道"何时该停" |
| "写更好的 prompt 就行" | 提示词是银弹 | Prompt 只能影响意图,不能阻止模型"上瘾" |
| "这是一个 bug" | 这是代码逻辑错了 | 这是涌现行为------模型在 ReAct 框架下自然产生的策略退化 |
| "换个更强的模型" | GPT-5 就不会这样 | 更强的模型可能搜得更"花哨",但停不下来的问题依然存在 |
核心误区:你以为 Agent 是"聪明但偶尔犯错",其实它在 ReAct 循环里更像一个"只知道锤子的人,看什么都是钉子"。
【逐步拆解】
1. 现象复现(最小示例)
用 LangChain 搭建一个极简复现场景:
ini
from langchain.agents import AgentExecutor, create_react_agent
from langchain_community.tools import DuckDuckGoSearchRun
from langchain_openai import ChatOpenAI
from langchain.prompts import PromptTemplate
# 只给一个搜索工具
search = DuckDuckGoSearchRun()
tools = [search]
llm = ChatOpenAI(model="gpt-4o", temperature=0)
# ReAct prompt(简化版)
prompt = PromptTemplate.from_template("""
You have access to: {tools}
Use this format:
Question: the input question
Thought: think about what to do
Action: the tool to use
Action Input: the input to the action
Observation: the result
... (repeat Thought/Action/Observation)
Thought: I now know the final answer
Final Answer: the final answer
Question: {input}
{agent_scratchpad}
""")
agent = create_react_agent(llm, tools, prompt)
executor = AgentExecutor(
agent=agent,
tools=tools,
max_iterations=10, # ← 危险!给足了"弹药"
verbose=True,
handle_parsing_errors=True
)
# 触发死循环的典型问题
result = executor.invoke({
"input": "2024年中国新能源汽车出口数据是多少?"
})
你会发现 Agent 的行为模式:
- 搜了 → 觉得不够精确 → 换个词再搜
- 又搜了 → 还是觉得信息不完整 → 再换个角度
- 无限循环,直到 token 打满
2. 根因分析
让我们用两个比喻来理解:
比喻 1:超市找酱油
你去超市买酱油。正常人是:走到调味料区 → 看到酱油 → 拿一瓶 → 结账走人。
但 Agent 的行为是:
- 走到调味料区,看到"生抽""老抽""味极鲜"......觉得信息还不够
- 走到隔壁货架,发现"蒸鱼豉油""煲仔饭酱油"......更困惑了
- 再走到进口区,看到"日式酱油""泰式鱼露"......
- 最后超市关门了(token 耗尽),你两手空空
Agent 的问题不是"找不到",而是不知道什么时候已经找到了。
比喻 2:侦探查案
ReAct 框架就像一个侦探:观察现场(Observation)→ 推理(Thought)→ 采取行动(Action)。
好侦探知道:证据够了,可以结案了。 烂侦探(我们的 Agent)会:反复勘察同一个现场,每次都认为"可能还有遗漏的线索"。
技术根因:
| 层面 | 问题 |
|---|---|
| Prompt 层 | ReAct 模板没有"收敛信号"------没有明确告诉模型什么情况下该输出 Final Answer |
| 工具设计层 | 每次搜索结果都是"新信息",模型无法判断"我已经搜够了" |
| 执行框架层 | max_iterations 是硬上限,不是智能停止条件 |
| 模型认知层 | LLM 天生有"信息贪"------给定更多轮次,它倾向于填满 |
3. 解决方案(三种方案对比)
方案 A:收敛式 Prompt(成本最低,推荐先试)
在 Prompt 中加入明确的"收敛信号":
ini
convergence_prompt = PromptTemplate.from_template("""
You are a precise research assistant. Follow these rules STRICTLY:
1. You may use tools to gather information.
2. AFTER each tool call, ask yourself: "Do I have enough to answer the user?"
3. If the answer is YES → IMMEDIATELY output Final Answer. Do NOT search again.
4. You may search AT MOST 3 times. After 3 searches, you MUST give the best answer
you can with whatever information you have.
5. If the first search result already contains the answer, DO NOT search again.
Just use it.
Available tools: {tools}
Format:
Question: {input}
Thought: ...
Action: ...
Observation: ...
... (max 3 rounds)
Thought: I have enough information
Final Answer: ...
{agent_scratchpad}
""")
优点 :零额外成本,立即可用 缺点:依赖模型遵循指令的能力,小模型可能无视
方案 B:信息增益门控(推荐生产环境)
在每次工具调用后,计算"这次搜索到底有没有带来新信息":
python
import hashlib
from difflib import SequenceMatcher
class GatedAgentExecutor:
"""
在工具调用之间插入"信息增益检查",如果新一轮搜索
没有显著新信息,直接终止并返回已有结果。
"""
def __init__(self, similarity_threshold=0.7, max_stagnant_rounds=2):
self.similarity_threshold = similarity_threshold
self.max_stagnant_rounds = max_stagnant_rounds
self.previous_observations = []
self.stagnant_count = 0
def has_new_information(self, new_observation: str) -> bool:
"""
判断新搜索结果是否带来了实质性新信息。
用最长公共子序列的倒数作为"新信息量"的度量。
"""
if not self.previous_observations:
self.previous_observations.append(new_observation)
return True
# 合并之前所有观察结果
combined_previous = " ".join(self.previous_observations)
similarity = SequenceMatcher(None, combined_previous, new_observation).ratio()
if similarity > self.similarity_threshold:
self.stagnant_count += 1
print(f"⚠️ 信息重复度 {similarity:.0%}, 停滞计数: {self.stagnant_count}")
return False
# 有新信息,重置停滞计数
self.stagnant_count = 0
self.previous_observations.append(new_observation)
return True
def should_terminate(self, current_observation: str, accumulated_results: list) -> bool:
"""综合判断是否应该终止搜索"""
# 条件1:信息停滞(连续 N 轮无新信息)
if self.stagnant_count >= self.max_stagnant_rounds:
print("🛑 信息停滞,终止搜索")
return True
# 条件2:已积累足够多的结果(搜索结果已经丰富)
if len(accumulated_results) >= 5:
print("🛑 已积累足够信息,终止搜索")
return True
# 条件3:单次搜索结果已经包含了明确的答案(简单启发式)
answer_indicators = ["是的", "不,", "根据", "第一条", "答案是", "具体来说"]
if any(indicator in current_observation for indicator in answer_indicators):
print("🛑 当前结果已包含明确答案,终止搜索")
return True
return False
# === 集成到 LangChain AgentExecutor ===
from langchain.agents import AgentExecutor
from langchain.callbacks.base import BaseCallbackHandler
class ConvergenceCallback(BaseCallbackHandler):
"""在 Agent 每轮迭代后进行检查"""
def __init__(self, gate: GatedAgentExecutor):
self.gate = gate
self.accumulated = []
self.force_stop = False
def on_agent_action(self, action, **kwargs):
"""工具调用前记录"""
pass
def on_tool_end(self, output, **kwargs):
"""工具返回后检查"""
self.accumulated.append(output)
if not self.gate.has_new_information(output):
# 标记需要干预
pass
if self.gate.should_terminate(output, self.accumulated):
self.force_stop = True
优点 :精确控制,不依赖模型自觉 缺点:实现复杂,相似度阈值需要针对具体场景调参
方案 C:结构化输出强制收敛(2025 新范式)
利用模型的 Structured Output / JSON Mode 能力,强制每轮输出包含"是否继续"的决策:
python
from langchain_core.pydantic_v1 import BaseModel, Field
from typing import Literal, Optional
class AgentDecision(BaseModel):
"""Agent 每轮的决策结构"""
reasoning: str = Field(description="当前轮次的推理过程")
decision: Literal["search", "answer"] = Field(
description="继续搜索还是给出最终答案"
)
search_query: Optional[str] = Field(
description="如果要搜索,具体搜什么"
)
final_answer: Optional[str] = Field(
description="如果决定回答,答案是什么"
)
confidence: int = Field(
description="对当前信息的信心程度,1-10",
ge=1, le=10
)
# 使用结构化输出
llm_with_structure = ChatOpenAI(model="gpt-4o").with_structured_output(AgentDecision)
class StructuredReActAgent:
"""
每轮都要求模型做出显式的"搜 vs 答"的抉择,
并且当 confidence >= 7 时自动终止。
"""
def run(self, query: str, max_rounds: int = 5):
context = f"用户问题: {query}\n当前已获取信息: 暂无"
for round_num in range(1, max_rounds + 1):
decision: AgentDecision = llm_with_structure.invoke(
f"{context}\n\n请决定: 继续搜索还是给出最终答案?"
)
print(f"第 {round_num} 轮: decision={decision.decision}, "
f"confidence={decision.confidence}")
if decision.decision == "answer" and decision.confidence >= 7:
return decision.final_answer
if decision.decision == "search":
# 执行搜索,更新 context
result = self.search(decision.search_query)
context += f"\n搜索结果 [{decision.search_query}]: {result}"
# 最后一轮强制输出
if round_num == max_rounds:
return llm_with_structure.invoke(
f"{context}\n\n这是最后一轮,你必须给出最终答案。"
).final_answer
优点 :最优雅,把"何时停止"变成了一个明确的分类决策 缺点:需要模型支持 structured output,老旧模型不适用
三种方案对比总结
| 维度 | A:收敛 Prompt | B:信息增益门控 | C:结构化输出 |
|---|---|---|---|
| 实现复杂度 | ⭐ | ⭐⭐⭐ | ⭐⭐ |
| 对小模型友好度 | ⭐⭐ | ⭐⭐⭐ | ⭐ |
| 不依赖模型自觉 | ❌ | ✅ | 部分 |
| 额外 token 消耗 | 几乎为零 | 中(相似度计算) | 低 |
| 可控性 | 低 | 高 | 最高 |
| 适用场景 | 原型验证 | 生产高可靠 | 前沿探索 |
推荐策略:先用 A 快速验证,上线前切到 B 或 C(取决于你的模型是否支持 structured output)。
【避坑指南】
max_iterations不是安全网,是死亡线。 把它设到 10,Agent 就会真的跑满 10 轮。建议设为 3~5,并在第 N-1 轮 Prompt 中注入"你必须现在回答"的指令。- 不要在工具描述里写"如果找不到就再搜一次"。 很多人在工具的
description里加类似 "If you don't find the answer, try a different query" 的提示------这等于在教模型"上瘾"。 - 观察结果(Observation)太长会淹没收敛信号。 如果每次搜索返回 5000 字,模型根本记不住"我该停了"。截断 Observation,只保留最相关的片段(比如用
max_tokens=500做摘要)。 - temperature=0 不代表行为确定。 很多人以为 temperature=0 就不会出死循环------错。死循环的原因是 ReAct 框架的结构性缺陷,不是温度的随机性。
- 小模型(7B-13B)更容易陷入死循环。 因为小模型的指令遵循能力弱,Prompt 里的收敛信号可能被完全忽视。这种情况下优先用方案 B(门控)。
【你来做一做】
现在给你一个动手练习:
场景 :把上面的方案 B(信息增益门控)改造一下,让它不是简单地比较"文本相似度",而是用一个轻量级本地模型(比如 Ollama 上的 qwen2.5:3b)来判断"这一轮搜索结果是否带来了新信息"。
提示:
ini
# 把 difflib.SequenceMatcher 替换成一个 LLM 调用
def has_new_information_with_llm(previous_results: list[str], new_result: str) -> bool:
prompt = f"""
你是一个信息评估助手。判断以下新搜索结果是否包含之前结果中没有的新信息。
【之前的结果汇总】
{' '.join(previous_results[-3:])} # 只看最近 3 轮
【新的搜索结果】
{new_result}
只回答 YES 或 NO。YES 表示有新信息,NO 表示信息重复。
"""
# 你的代码:调用本地 Ollama 模型
pass
思考题:
- 用 LLM 做信息增益判断 vs 用文本相似度,各自的 trade-off 是什么?(延迟、成本、准确性)
- 如果 LLM 本身也"犯傻"说 NO(但实际有新信息),会有什么后果?如何兜底?