AI Agent 面试:ReAct 循环的 3 个致命陷阱
摘要:ReAct(Reasoning + Acting)是 AI Agent 的核心循环范式,但在实际项目中频繁翻车——死循环调用同一个 Tool、Observation 给了但模型不认、Agent 越聊越偏。面试官问「ReAct」的时候,真正想知道的是你知不知道这些坑。
📖 目录
- 开篇:ReAct 的「三行真理」和「三个深渊」
- 陷阱一:Thought 循环——模型陷入了「内耗」
- 陷阱二:Observation 盲区——模型收到结果但不认
- 陷阱三:主题漂移——Agent 忘记了自己在干什么
- 实战:在 ReAct 循环中加「保险丝」
- 面试追问
- 总结
开篇:ReAct 的「三行真理」和「三个深渊」
ReAct 是 AI Agent 最经典的思考-行动循环。面试官考你的标准答案:
Observation(观察到什么) ↓ Thought(思考需要做什么) ↓ Action(调用工具、搜索、计算) ↓ Observation(获取结果) ↓ ...循环直到得出最终答案...
这段话只要看过 Agent 面试题的人都会背。但面试官接下来会问的才是分水岭:
「你在实际项目中遇到过循环停不下来吗?」「模型调用了工具但无视了返回结果,怎么处理?」「Agent 聊着聊着就开始编造不存在的对话历史,你排查过吗?」
这篇文章不讲 ReAct 的基本原理——那东西谁都会背。我直接讲三个你在任何面经和文档里都找不到的「致命陷阱」,以及我在实战中怎么堵住这些漏洞。
陷阱一:Thought 循环——模型陷入了「内耗」
核心结论
Agent 进入了一个死循环:Thought → Action → Observation → 又回到一模一样的 Thought → 同一个 Action → 重复的 Observation。没有 Token 浪费在「思考别的可能性」上,所有 Token 都在原地踏步。
真实场景
Agent 日志(10 轮循环后截取)
Round 1:
Thought: 用户询问天气,我需要调用天气查询工具。
Action: get_weather(city="北京")
Observation: {"temperature": 25, "condition": "晴"}
Round 2:
Thought: 用户询问天气,我需要提供详细的天气信息。
Action: get_weather(city="北京", detail=true)
Observation: {"temperature": 25, "condition": "晴", "humidity": "60%"}
Round 3:
Thought: 用户询问天气,我需要更精确的数据。
Action: get_weather(city="北京", detail=true, unit="celsius")
Observation: {"temperature": 25, "condition": "晴"}
Round 4:
Thought: 用户询问天气,我可能还需要提供风速信息。
Action: get_weather(city="北京", field="wind")
Observation: {"wind": "3级"}
Round 5:
Thought: 我再检查一下温度...
Action: get_weather(city="北京")
Observation: {"temperature": 25, "condition": "晴"}
...(直到 max_turns 耗尽)
你看到了什么?Agent 永远在 Thought 阶段写「XXX,我需要XXX」,永远在 Action 阶段调用 get_weather,永远没有走到 Final Answer 阶段。它陷入了 「自我怀疑」——觉得信息还不够,需要再查一次。
根因分析
从 Token 生成机制来看,Thought 阶段模型在生成「推理过程」时,Attention 同时看向 System Prompt 中的指令(「你是一个有用的助手」)、User Message(「帮我查天气」)、以及上一轮的 Observation。当 Observation 内容太丰富或者太冗长时,模型倾向于再查一次来验证——因为「验证」在训练数据中是一种低风险行为,而「直接回答并可能出错」是高风险的。
这就是 Thought 循环的本质:模型宁愿多调用一次 Tool 来确认,也不愿意冒险给出一个可能不完整的回答。这是 RLHF 对齐训练的副作用——模型被惩罚过「给出错误回答」,但从未被惩罚过「调用太多 Tool」。
解决方案
方案一:Hard Stop——固定 max_turns + 强制输出
Python 伪代码
MAX_TURNS = 5
for turn in range(MAX_TURNS):
response = llm.invoke(messages, tools=tools)
if response.type == "function_call":
# 如果是最后一轮,不再调用 Tool,直接让模型用现有信息回答
if turn == MAX_TURNS - 1:
final_prompt = "你已经调用了足够多的工具。基于已有的信息,直接回答用户的问题。"
response = llm.invoke(messages + [{"role": "user", "content": final_prompt}])
return response.content
tool_result = execute_tool(response.function_call)
messages.append(tool_result)
else:
return response.content
方案二:Soft Limit——在 System Prompt 中定义「充分信息原则」
System Prompt 片段
调取信息的原则:
1. 每次只能调用一个工具获取一条信息
2. 获取到信息后,优先基于现有信息回答
3. 如果你发现你已经获取了足够回答用户问题的信息,立即给出最终答案
4. 工具调用次数限制:最多 3 次
金句:ReAct 循环不是越深越好。不会停的 Agent 比不会答的 Agent 更可怕——它在消耗你的 Token 和耐心。
陷阱二:Observation 盲区——模型收到结果但不认
核心结论
Agent 调用了 Tool,Tool 返回了正确结果,但模型在下一轮 Thought 中写的是「我没有找到相关信息」或「我需要更多数据」。
这不是模型在撒谎——它是真的「没看到」Tool 返回的内容。
根因分析
模型的 Attention 窗口有限。当 Conversation History 累积到一定长度,Tool 的返回结果被模型「遗忘」了——不是被物理删除,而是在 Softmax Attention 的计算中,模型分配给那段历史 token 的注意力权重趋近于零。对于模型来说,那部分内容在数学意义上「等于不存在」。
还有一个更隐蔽的原因:Tool 返回结果的格式——如果返回的是纯 JSON,且 JSON 的 Value 部分很长,模型在解析 Token 序列时会把 JSON 的 Key 当作「无用信息」过滤掉。你返回了 2000 个 token 的 JSON,其中只有 50 个 token 是实际内容,模型可能把那 50 个 token 当作噪音直接忽略了。
解决方案
让 Tool 返回结果「可读」——不要直接返回原始 JSON,先做一步格式化。
✅ 格式化 Tool 输出
# ❌ 直接返回原始 JSON
{
"status": "success",
"data": {
"temperature": 25,
"condition": "晴",
"humidity": 60,
"wind": "3级"
},
"metadata": {
"source": "weather_api",
"cached": true,
"latency_ms": 120,
"api_version": "v2.1"
}
}
# ✅ 格式化后返回给模型
天气查询结果:
- 温度:25°C
- 天气:晴
- 湿度:60%
- 风速:3级
在 Tool 返回结果开头加摘要
摘要在前,详细内容在后
【查询完成】已获取北京当前天气信息:晴,25°C。
详细数据:
- 温度:25°C
- 天气:晴
- 湿度:60%
- 风速:3级
原始信息在 Tool 定义中就「不可能」——消除模型的侥幸心理
Tool 返回中加入置信度标记
当你的 Tool 返回为空或不确定时,在结果中显式标记:
{
"found": true,
"summary": "已查询到相关信息",
"content": "..."
}
// 如果没找到:
{
"found": false,
"summary": "未找到匹配信息",
"suggestion": "建议换用更短的关键词重试"
}
金句:模型「看不见」Observation,问题的根源不是模型能力,是 Attention 窗口的物理极限。
陷阱三:主题漂移——Agent 忘记了自己在干什么
核心结论
Agent 开始时在回答用户的问题,几轮 Tool 调用后,开始问用户「还需要什么帮助吗」,再后来开始自我发挥——各种编造不存在的信息。
这就是主题漂移。不是走了弯路。是彻底偏了航向。
真实场景
Agent 日志——主题漂移全过程
User: "帮我查一下这个季度的销售数据"
→ Thought: 用户需要销售数据
→ Action: query_sales_data(quarter="Q3")
→ Observation: 返回销售数据...
→ Thought: 用户可能需要知道和上一季度的对比
→ Action: query_sales_data(quarter="Q2")
→ Observation: 返回 Q2 数据...
→ Thought: 用户可能还想知道按区域划分的数据
→ Action: query_sales_data_by_region(quarter="Q3")
→ Observation: 返回区域数据...
→ Thought: 用户的问题已经回答完毕,我可以给出分析建议了
→ Action: 开始写分析报告...
→ Thought: 作为 AI 助手,我还可以主动推荐相关产品
→ Final: "根据你的销售数据,我推荐你购买我们的高级分析套餐..."
← 漂移完成:用户问「查数据」,Agent 在「推销产品」
你看到模型做错了吗?从单条记录看,每一步都有合理的 Thought。但累计起来,模型彻底偏离了最初的用户意图。这就是 Chain-of-Thought 的累加误差。
解决方案
在 User Message 中注入「不变的锚点」
每一轮都回注原始用户问题
messages = [
{"role": "system", "content": "你是 AI 助手。"},
{"role": "user", "content": original_user_query}, # 原始问题
]
for turn in range(MAX_TURNS):
response = llm.invoke(messages, tools=tools)
if response.type == "function_call":
# 回注原始问题 + 已获取的信息摘要
anchor = f"【初始问题】{original_user_query}\n【已获取信息】{summarize(messages)}"
messages.append({"role": "user", "content": anchor})
tool_result = execute_tool(response.function_call)
messages.append(tool_result)
else:
break
在 Tool 返回结果中「偷偷」提醒模型
Tool 返回中嵌入原始问题上下文
system_prompt = """
当前上下文:
原始用户问题:{original_query}
已完成的步骤:{steps}
剩余信息:{remaining}
继续执行以下步骤时,请始终围绕原始问题:
1. 不再调用新的无关工具
2. 如果已有足够信息,直接回答用户问题
3. 最终答案必须回答原始用户问题
"""
金句:模型不是故意跑偏,是它的 Attention 机制天然存在「遗忘曲线」。作为开发者,你有责任帮它锚定方向。
实战:在 ReAct 循环中加「保险丝」
一个带「保险丝」的 ReAct 循环完整代码
Python:带保险丝的 Agent 循环
import json
def agent_loop(original_query, tools, max_turns=5):
"""
带保险丝的 ReAct Agent 循环
"""
messages = [
{"role": "system", "content": f"""你是 AI 助手,负责回答用户问题。
规则:
1. 原始用户问题永远不会改变:{original_query}
2. 最多调用 {max_turns} 次工具
3. 每次调用工具前,先思考:这次调用是否直接有助于回答原始问题?
4. 如果工具返回了足够的信息,立即给出最终答案
5. 第 {max_turns} 次 Tool 调用后强制输出最终答案"""},
{"role": "user", "content": original_query}
]
for turn in range(max_turns):
response = llm.invoke(messages, tools=tools)
if not response.is_function_call:
return response.content
if turn == max_turns - 1:
# 最后一轮:告诉模型直接回答
messages.append({
"role": "user",
"content": "这是最后一次调用。必须基于已有信息回答用户问题。"
})
response = llm.invoke(messages)
return response.content
# 执行 Tool
tool_result = execute_function(response.function_call)
# 【保险丝 1】格式化 Tool 返回
formatted = format_for_llm(tool_result, max_tokens=500)
# 【保险丝 2】回注原始问题
messages.append({
"role": "user",
"content": f"【当前步骤】正在回答:{original_query}"
})
messages.append({
"role": "function",
"name": response.function_call.name,
"content": formatted
})
# 【保险丝 3】语义去重:检测是否重复调用
if has_duplicate_call(messages):
messages.append({
"role": "user",
"content": "你刚刚已经调用了相同的工具并获得了结果。直接使用已有结果回答。"
})
return "错误:已达到最大调用次数。"
def has_duplicate_call(messages):
"""检测是否在重复调用同一个工具"""
calls = [m for m in messages if m["role"] == "assistant" and "function_call" in m]
if len(calls) >= 2:
last_two = calls[-2:]
if (last_two[0]["function_call"]["name"] ==
last_two[1]["function_call"]["name"]):
return True
return False
面试追问
Q1:ReAct 和 Plan-and-Execute 有什么区别?什么时候用哪个?
ReAct 是「边想边做」——每一步都重新观察和思考。Plan-and-Execute 是「先计划后执行」——模型先输出完整计划,再逐步执行。当任务的步骤之间依赖关系明确、执行过程可预期时,用 Plan-and-Execute(比如数据 ETL 流程)。当任务需要动态探索、不确定下一步是什么时,用 ReAct(比如开放域问答)。
Q2:怎么判断 Agent 进入了 Thought 循环?
三个信号:同一个 Tool 被连续调用 3 次以上、Thought 的语义距离小于阈值(可以在 embedding 层面做余弦相似度检测)、Action 的参数几乎完全不变。工程上最简单的做法:记录所有 Tool 调用的 name+参数的哈希值,如果哈希值连续重复 3 次以上,直接中断循环。
Q3:Agent 面对「一无所知」的问题应该怎么办?
不要硬答。这属于 Agent 的「拒绝策略」设计。标准做法:Tool 返回空结果时,模型应该输出「我搜索了相关信息,但没有找到答案」而不是「根据现有信息,我推断……」——后一种回答在依赖关系复杂的系统中会酿成大祸。
Q4:多工具场景下,ReAct 循环的效率怎么优化?
核心瓶颈不是推理速度,是 Tool 调用的等待时间。批量调用并行工具能够将多轮回合减少到 1-2 轮。比如当 Agent 需要「查天气 + 查日历 + 查交通」时,在一次 Action 中并行触发三个工具,而不是串行三步。OpenAI 的 Parallel Function Calling 就是为此设计的。
总结
| 陷阱 | 症状 | 保险丝 |
|---|---|---|
| Thought 循环 | 重复调用同一个 Tool,永不抵达 Final Answer | Hard Stop(max_turns) + Soft Limit(充分信息原则) |
| Observation 盲区 | 模型收到了结果但「假装没看见」 | 格式化 Tool 输出 + 摘要前置 + 置信度标记 |
| 主题漂移 | Agent 越聊越偏,最终偏离原始问题 | 回注原始问题 + 每轮锚定 + 语义去重 |
核心一句话:面试官问 ReAct,不是为了听你复述「Thought-Action-Observation」——他想知道你知道这些循环会在哪断掉、以及你怎么不让它断掉。
参考资料
- 《ReAct: Synergizing Reasoning and Acting in Language Models》—— Shunyu Yao 等,ICLR 2023
- OpenAI Function Calling 最佳实践:platform.openai.com
- LangChain Agent 实现:python.langchain.com
💬 你在 Agent 开发中遇到过什么奇葩的 Bug?评论区说说。
更多推荐



所有评论(0)