你有没有遇到过这种场景:让AI帮你查一个需要核实的事实,它一本正经地给出结论,还附上了引用来源,结果你点开一看,链接根本不存在。再或者,你明明给它配了好几个工具,它宁可硬答也不肯调用一下,仿佛那些工具不存在。这不是某一个模型抽风,而是绝大多数大模型Agent的通病——它们天生就是个“嘴强王者”,却缺一套“先想清楚、再动手、边做边改”的底层机制。这个问题困扰了我很久,直到我重新研究了ReAct框架,把提示词和循环逻辑彻底迭代一版,AI才从“什么都敢答的莽夫”慢慢变成了“谋定而后动的谋士”。这篇文章我把ReAct的核心原理、一份能跑通的最小实现、以及实测中踩过的坑全部摊开来讲,希望对正在折腾AI Agent的开发者和深度用户有点参考价值。
1. 为什么AI会变成“莽夫”:没有推理与行动的闭环
1.1 你以为的“AI思考”,其实只是概率接龙
先聊一个容易被忽略的事实:大模型本身不会“停下来反思”。你问它一个问题,它做的事情是逐字预测下一个最可能出现的token,顺着概率一路生成下去,直到输出结束。这个过程很快,看起来很聪明,但它并没有一个“我需要先验证一下”的机制——它脑子里根本没有“核实”这个动作。你可以把它理解成一个人被要求即兴演讲,嘴不能停,想到哪儿说到哪儿,说错了也没人打断他。普通人演讲前还会打个草稿,但大多数AI应用连草稿都没有。
这也是为什么很多Agent项目看着部署得挺像样,一问就露馅。你给它再多的外部工具,如果它从模型层面就没有“先思考、再行动、观察结果后再思考”的结构,那工具也就是个摆设。它照样可以直接根据训练数据里的模糊印象生成答案,而这些答案往往一本正经地错。
1.2 “莽夫”AI的三个典型症状
症状一:编造事实。这是最典型的幻觉问题。模型不是故意撒谎,它只是不知道“不知道”这个词的边界。你问它某公司成立的年份,它训练数据里有一点模糊信息,模型就会自信地补全细节,甚至把多个公司信息拼接起来。更麻烦的是,它会煞有介事地给出一个“看起来合理”的出处。
症状二:一条路走到黑。当模型在一个多步推理任务中走错第一步时,后续推理往往全部跟着错,而且没有任何纠偏机制。因为整个推理发生在模型内部,是一种隐式的过程,你看到的是它输出一大段分析,但这个分析中的错误没有一个外部信号去触发修正。
症状三:有工具但不用。很多人给Agent接入了搜索引擎或计算器,以为万事大吉,实测却发现模型很少触发工具调用。原因在于:单轮提示词里反复强调“你可以使用工具”,和模型真正理解“我应该在什么时候停下生成、主动调用工具”是两回事。工具对模型来说只是一个文本描述,它并没有把这个描述和自己的生成策略对接起来。
1.3 根因:推理和工具调用被彻底割裂了
我见过不少团队做Agent的方式很直接:要么搞一个“思维链提示词”,让模型先思考后回答;要么搞一个“函数调用”,把工具挂上。这两种方案本身没错,但它们是割裂的。
纯思维链模式,模型可能思考得很漂亮,但它没办法把思考落成一次真实的工具调用——那个思考结果最终只是模型自己编的。函数调用模式,模型确实能调工具,但缺少一个“为什么调、调完怎么用”的推理框架。于是模型会乱调,或者调用完一次就草草收场。割裂的根因在于:没有把推理和对真实世界的观察放进同一个循环里。而ReAct框架做的事情,恰恰就是把这两者缝在一起。
2. ReAct的核心循环:Thought、Action、Observation如何让AI从莽夫变谋士
2.1 把“想”和“做”织进同一条轨迹
ReAct这个名字是Reasoning(推理)和Acting(行动)的合成词。它的思路很朴素:不要让模型一口气憋出最终答案,而是让它在每走一步之前都先生成一段思考(Thought),根据思考执行一个具体动作(Action),拿到动作结果(Observation)之后,再进入下一轮思考。这个循环一直持续到模型认为信息足够了,才输出Final Answer。
你可以把它类比成一个人在陌生城市里找路。莽夫的做法是出发前在地图上随便指一个方向,然后一口气走到底,走错了也不回头。谋士的做法是每到一个路口,停下看路牌,判断方向,走一段,再根据沿途地标修正路线。ReAct就是后者——它强迫模型“每次只走一步,并且每步都看一眼现实”。
2.2 三要素怎么配合
我们拆开看这三个要素:
- Thought:模型用自然语言记录“我目前知道什么、还缺什么、下一步打算怎么做”。这一步相当于把模型内部隐式的推理过程外显出来,变成一段可以检查和分析的文本。
- Action:模型做出的具体决策,通常是调用一个工具,比如“搜索某个关键词”“执行某个计算表达式”。每个Action都对应一个明确的操作接口。
- Observation:动作执行后返回的真实结果,它被拼接回对话消息序列,变成模型下一轮思考的输入。
这三者构成一个闭环:Thought驱动Action,Action产生Observation,Observation反过来修正Thought。模型不是“一次性推理完再行动”,而是“推理一步、行动一步、观察一步、再推理一步”。
用一个实际例子来走一遍这个循环。假设题目是:某城市2024年相比2023年人口增长了多少?
- Thought:我不知道2024年人口数据,需要先查询。
- Action:搜索“某城市2024年常住人口”
- Observation:约1250万人
- Thought:还需要2023年的数据才能计算增长。
- Action:搜索“某城市2023年常住人口”
- Observation:约1200万人
- Thought:两个年份数据都有了,现在计算差值。
- Action:计算器执行“1250-1200”
- Observation:50
- Thought:信息足够了,可以回答。
- Final Answer:增长了约50万人。
注意第7步和第8步:模型没有直接写1250-1200的结果,而是把计算动作交给了外部工具,获得一个可验证的返回值。这个细节就是“谋士”和“莽夫”的区别——不靠嘴算,靠工具算。
2.3 为什么这种设计能大幅减少幻觉
幻觉的本质是模型在信息不足时强行生成补全。ReAct之所以有效,是因为它在“信息不足”和“输出答案”之间硬生生插入了多轮工具请求和真实观察。模型被训练成:只有在看到足够多Observation、确认数据齐了之后,才允许生成Final Answer。一旦某一步Thought错了,下一个Observation往往是和预期不一致的真实返回,模型就有机会发现“哎,实际情况和我想的不一样”,从而自发修正。
这个纠正机制是纯Prompt和纯Function Calling都不具备的。纯Prompt方案中,模型错了就是错了,没有外部反馈进来;函数调用方案中,模型虽然能调工具,但缺少“调完之后怎么影响下一步”的思考框架。ReAct把反馈回路补上了。
2.4 和常见方案的对比
| 方案 | 有推理 | 有行动 | 行动后有修正 | 典型问题 |
|---|---|---|---|---|
| 普通QA | 无 | 无 | 无 | 直接编造 |
| CoT(思维链) | 有 | 无 | 无 | 思考错了就全错 |
| 函数调用 | 无 | 有 | 弱 | 不知道什么时候该调 |
| Plan-and-Execute | 有 | 有 | 弱 | 计划错了不回头 |
| ReAct | 有 | 有 | 强 | 多轮延迟、成本更高 |
Plan-and-Execute值得单独说一下。它让模型先做一个整体计划再执行,看起来比ReAct更高瞻远瞩,但问题是:如果计划本身在第一步就错了,后面的执行全部跟着错,而且没有机制在执行中途根据观察结果调整计划。ReAct更“笨”也更稳——它不追求一次想清楚,而是走一步看一步,用事实不断修正路线。对于开放性问题,这种笨办法反而更可靠。
3. 代码级拆解:从零实现一个最小ReAct Agent
3.1 环境准备与模型选择
动手之前先准备好环境。我用的是Python,主流的OpenAI兼容接口都能直接透传,你需要提前申请好API Key并配置环境变量。模型选型上,我建议用一个支持指令遵循的通用模型即可——不强求必须是顶级大模型,但一定要在System Prompt里强调“严格按格式输出”。如果你手头只有弱模型,反而是个更好的测试场景:你能更直观地看到ReAct在格式维护上到底给模型帮了多大忙。
核心依赖就两个:一个是大模型SDK,另一个是Python自带的re库用来做输出解析。不需要任何Agent框架,我们先从零手搓,看清每一行代码在干什么。
3.2 定义工具:先给AI一双看得见的手
工具定义是ReAct Agent的地基。工具不在多,关键是描述清楚“什么时候用、参数填什么、返回什么”。下面定义两个演示工具:一个模拟搜索,一个计算器。
import re from openai import OpenAI client = OpenAI() def tool_search(query: str) -> str: fake_db = { "某城市2024年常住人口": "约1250万人", "某城市2023年常住人口": "约1200万人", } return fake_db.get(query, f"未找到与'{query}'相关的信息") def tool_calculator(expression: str) -> str: try: result = eval(expression) return str(result) except Exception as e: return f"计算错误: {e}" TOOLS = { "搜索": { "desc": "查询本地知识库中的事实信息,适合查找人口、年份、公开数据", "func": tool_search, }, "计算器": { "desc": "执行四则运算,输入是数学表达式,比如 1250-1200", "func": tool_calculator, }, }这里有个细节:eval在真实生产环境绝对不能直接对着模型输入执行,有代码注入风险。这里只是演示用,等你看懂流程之后,务必换成白名单解析或ast库做安全计算。搜索工具我用了一个fake_db模拟真实搜索引擎,实际项目中可以换成联网搜索API。
3.3 构造ReAct提示词:用格式和示例约束模型
提示词是ReAct的灵魂。系统提示词里必须明确规定:模型在信息不足时只能输出Thought、Action、Action Input,在信息充足时才允许输出Final Answer。最关键的是必须提供一组few-shot示例。没有示例,很多模型根本理解不了什么是Action Input,给出的Action五花八门。
SYSTEM_PROMPT = """你是一个擅长使用工具的智能助手。 你必须严格按照下面的格式交替输出: Thought: 你当前的想法 Action: 工具名称(必须是 搜索 或 计算器) Action Input: 工具入参 当你已经获得足够信息并可以回答问题时,必须用下面的格式输出: Thought: 你当前的想法 Final Answer: 最终答案 下面是一个完整示例: Question: 某城市2024年相比2023年人口增长了多少? Thought: 我需要先查询2024年的人口数据。 Action: 搜索 Action Input: 某城市2024年常住人口 Observation: 约1250万人 Thought: 还需要2023年的人口数据来做对比。 Action: 搜索 Action Input: 某城市2023年常住人口 Observation: 约1200万人 Thought: 已经拿到两年数据,现在计算差值。 Action: 计算器 Action Input: 1250-1200 Observation: 50 Thought: 数据已经足够,可以给出最终答案。 Final Answer: 2024年相比2023年人口增长了约50万人。 请开始。"""仔细看这个示例的前两轮“Thought → Action → Observation”,它其实在教模型两件事:第一,未知信息必须查;第二,比较类任务需要把两个数据都拿到手再动手算。示例本身就在帮助模型规避幻觉,比你在规则里写十句“不要瞎编”都管用。
3.4 主循环:解析输出、执行工具、拼接观察结果
接下来是主循环。它的逻辑不复杂,一共四步:
- 把当前会话消息发给模型,获取回复。
- 解析模型输出,判断它是“Final Answer”还是“Action”。
- 如果是Action,查工具表并执行,拿到Observation。
- 将模型回复和Observation追加进消息列表,进入下一轮。
MAX_STEPS = 6 def parse_response(text: str): final_match = re.search(r"Final Answer:\s*(.+)", text, re.S) if final_match: return {"type": "final", "content": final_match.group(1).strip()} action_match = re.search(r"Action:\s*(.+)\nAction Input:\s*(.+)", text, re.S) if action_match: return { "type": "action", "action": action_match.group(1).strip(), "input": action_match.group(2).strip(), } return None def run_agent(question: str): messages = [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": f"Question: {question}"}, ] for step in range(MAX_STEPS): response = client.chat.completions.create( model="your-model-name", messages=messages, temperature=0, ) text = response.choices[0].message.content parsed = parse_response(text) if parsed is None: messages.append({"role": "assistant", "content": text}) messages.append({ "role": "user", "content": "输出格式不正确,请严格按照 Thought / Action / Action Input 或 Final Answer 格式输出。", }) continue if parsed["type"] == "final": return parsed["content"] tool = TOOLS.get(parsed["action"]) if tool is None: observation = f"未知工具: {parsed['action']}" else: print(f"> Step {step+1}: {parsed['action']}({parsed['input']})") observation = tool["func"](parsed["input"]) messages.append({"role": "assistant", "content": text + f"\nObservation: {observation}"}) messages.append({"role": "user", "content": "请根据Observation继续思考并输出下一步。"}) return "达到最大迭代次数,未能得到最终答案。" if __name__ == "__main__": print(run_agent("某城市2024年相比2023年人口增长了多少?"))注意两个地方。第一,temperature=0很关键,ReAct循环对随机性非常敏感,温度高了模型很容易在第三步开始输出格式漂移。第二,每条Observation都要以文本形式拼进assistant回复后面,再补一条user消息要求继续——这是为了让模型“看得到”自己的上一个动作结果。很多初学者把Observation放在system里,导致模型感知不到反馈,循环就废了。
3.5 跑一遍试试
如果你把上面的代码完整执行,会看到类似这样的输出:
> Step 1: 搜索(某城市2024年常住人口) > Step 2: 搜索(某城市2023年常住人口) > Step 3: 计算器(1250-1200) 2024年相比2023年人口增长了约50万人。模型先查了两年数据,再调用计算器,最后给出了答案。整个过程里,模型在第一步并没有急着算,而是老老实实先找数据——这个行为模式,就是ReAct从机制上带给它的。
4. 实测中踩过的坑:格式失控、死循环与上下文爆炸
4.1 格式不稳定:模型突然不按套路出牌了
我在多个模型上跑过这套代码,遇到最多的就是格式漂移。模型该输出Action的时候突然开始长篇大论解释自己的计划,或者直接跳过Action输出一句“我想我找到了答案”然后给一个不完整的结论。原因在于,通用模型本来就被训练成“直接回答用户问题”,你今天让它“想一步、做一步、停一步”,它的行为模式被打破了。
解法分两层。第一层是提示词加固:在System Prompt中明确说“你每一步只能输出固定格式,不要输出其他内容”,并且多给一个负面示例,比如“错误输出:让我先看看……”。第二层是解析器容错:我的parse_response里用了正则,如果解析失败就回退给模型一条“格式不正确,请重新输出”,让模型自我纠偏。实测下来,加了负面示例后,格式错误率能下降一半以上。
4.2 循环卡死:模型反复调用同一个工具
还有一类问题更让人头大:模型陷入死循环,反复调用同一个工具、输入几乎相同的内容。比如连续三轮都搜同一个词,明明Observation已经返回“未找到相关信息”,它还是下决心再搜一次。原因大概率是模型不知道还有别的工具可用,或者它想从同一个动作里挖出更多细节,但工具返回的信息并没有变化。
我用的方案是检测重复:记录每一轮的Action和Input,如果连续两轮完全相同,就往对话里注入一条提示,比如“同样的输入不会产生新结果,请更换工具或换一个搜索关键词”。同时设置MAX_STEPS兜底,到了6步还不收敛就直接终止,避免在真实场景里耗光你的token预算。不要指望模型自觉,止损条件一定要提前写好。
4.3 上下文爆炸:Observation把模型“喂撑”了
Observation越多,模型看到的上下文越长,这个坑在高频工具调用场景下尤其明显。一次搜索结果可能几百上千字符,跑五六轮就是好几千token累积进去,不仅成本上升,模型也会在长上下文里“迷失”,开始忘记最初的问题或重复历史信息。
处理办法有几个:一是截断Observation,后端只取前200个字符返回给模型;二是让模型自己“压缩”信息,比如在Tool内部先做一层摘要;三是在多轮后显式要求模型“忽略无关历史,基于最近三条Observation继续”。我一般用前两种,简单直接,效果立竿见影。顺带提一句,如果你的Agent需要处理的信息量特别大,最好用支持长上下文的模型,否则一万token的对话很容易把你锁死。
4.4 工具描述不清晰:模型不知道该怎么填参数
最后一个坑出现在Action Input上。模型的输入千奇百怪:搜索工具它可能只填“某城市”,而不是完整的“某城市2024年常住人口”;计算器它可能填“1250万减去1200万”,把“万”字也带进去,eval直接报错。说白了,模型对工具的理解完全来自你写的描述,描述写得模糊,它就会用模糊的方式来调用。
我的经验是:工具描述里必须包含参数格式和至少一个调用示例。比如计算器的描述写成“输入是纯数学表达式,不能包含中文单位,例如 1250-1200”,模型就会照着示例填。另一个技巧是给工具起一个强语义名字,比如把“计算器”改成“执行四则运算”,把“搜索”改成“查询知识库事实”,命名本身就在给模型提示。
5. 从“能跑”到“稳定可用”:我的几条实战心得
5.1 提示词里的示例,才是ReAct真正的调参旋钮
很多人以为调ReAct Agent就是换模型、调温度,其实不是。我在几次实践中发现,影响ReAct表现最大的变量,是你System Prompt里那几个few-shot示例选得好不好。示例里覆盖了几种典型工具调用模式,模型就更容易学会举一反三。如果你的Agent需要在不同业务场景下工作,不要用一套全局prompt,而是针对每个场景准备几组高相关性的示例动态拼装。
5.2 工具的设计决定Agent的上限
ReAct的思维框架再完美,能调用的工具太弱也没用。工具粒度要适中:太小,每一步都调用,上下文膨胀很快;太大,模型描述不准,Action Input极易填错。我常用的标准是“一个工具只做一件原子的事,但这件事必须是模型扼要描述就能说清楚的”。另外,工具的返回格式尽量统一成纯文本,不要丢一堆结构化JSON给模型,模型读起来费劲,下一轮Action的准确性也会下降。
5.3 止损兜底必须提前设计
永远不要在线上环境中让Agent无限循环。除了MAX_STEPS,最好再加一层全局超时时间,以及一个“最终兜底”:如果步数用尽仍然没有得到Final Answer,就回退成普通问答模式的回答,或者明确告诉用户“查不到这个信息”。这样做既保护了token预算,也保证了产品体验。Agent不是神,它总会遇到查不到、算不了的情况,怎么体面地结束一次对话,才是产品层面的关键。
5.4 什么时候不该用ReAct
在文章最后我想说句实话:不是所有任务都适合套ReAct。如果只是一个常识性问题,直接问模型就行;如果是一个纯数学逻辑推导,CoT可能比ReAct更快也没那么多格式开销;如果你的业务链路已经固定,比如“输入→查订单→填表单”,Plan-and-Execute甚至硬编码流程更稳。ReAct真正的主场是那些开放式的、需要多步查证、并且过程中可能走错路的问题。判断标准很简单:如果这个问题允许AI中途改变策略,并且错了代价不高,那就值得上ReAct。
我在几个模拟项目里跑下来,最直观的感受是:ReAct把原来“不可见”的幻觉变成了一段可以审视的思考轨迹。哪怕模型最后答错了,你也能回头检查到底是哪一步想岔了、哪一次工具调用跑偏了。就冲这一点,我觉得每个玩AI Agent的人都值得自己手搓一次ReAct。等你亲手跑通一遍再回头看那些Agent框架,很多封装好的“黑魔法”就都不神秘了。