摘要:ReAct(Reasoning + Acting)是 AI Agent 的核心循环范式,但在实际项目中频繁翻车——死循环调用同一个 Tool、Observation 给了但模型不认、Agent 越聊越偏。面试官问「ReAct」的时候,真正想知道的是你知不知道这些坑。

📖 目录

  1. 开篇:ReAct 的「三行真理」和「三个深渊」
  2. 陷阱一:Thought 循环——模型陷入了「内耗」
  3. 陷阱二:Observation 盲区——模型收到结果但不认
  4. 陷阱三:主题漂移——Agent 忘记了自己在干什么
  5. 实战:在 ReAct 循环中加「保险丝」
  6. 面试追问
  7. 总结

开篇: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?评论区说说。

更多推荐