ReAct:从工具调用到真正智能体的关键范式
2026/9/16 22:16:49 网站建设 项目流程

会调用工具只是自动化脚本,ReAct 才成就真正的智能体

先说说我最近被问到最多的一个事:很多同学拿个大模型接了几个API,能查天气、能算算术、能查数据库,就管自己叫“智能体开发工程师”了。每次看到这种我都要泼一盆冷水:会调用工具,本质上跟写了一段有if-else的脚本没太大区别。真正让程序从“自动执行”变成“智能体”的,是它知道自己什么时候该调工具、为什么要调、调完结果怎么影响下一步决策——这套循环,才是核心,而ReAct正是把这套循环标准化、框架化的关键范式。

用大白话说:脚本是“人把流程写死,机器照着跑”,Agent是“机器自己决定流程,人只给目标”。这中间的差距不是多接几个API能拉平的。ReAct这个词很多人听过,但真正理解它在智能体里扮演什么角色、又该怎么落地,其实是另一回事。

这篇文章我不打算给你堆理论,就围绕“为什么工具调用不等于智能体”和“怎么用ReAct把智能体做出来”两条线展开,重点讲我踩过的坑、调过的参、还有那个让无数人头疼的嵌套参数解析问题。适合正在做智能体开发、或者准备从脚本往Agent方向转型的同学参考。

1. 工具调用与ReAct到底差在哪

先说结论:工具调用只是“手”,ReAct给的是“脑”和“手”之间的配合协议。只有手,那叫远程遥控机器臂;配上脑和协议,才叫一个人在工作。

1.1 自动化脚本的核心局限

传统自动化脚本解决的是“确定性问题”。比如:请求这个接口、拿返回结果、做判断、再请求另一个接口。所有分支都是开发阶段就画好的,机器做的只是按照指令执行。这种方案最大的问题在于:它不具备对“意外情况”的处理能力。

举个例子,你写一个脚本自动处理订单退款。正常路径没问题:查订单→验证状态→调退款接口→记录结果。但如果用户已经申请过部分退款、或者订单状态是“已发货但物流异常”,脚本大概率就卡死或者直接报错。因为你不可能在写脚本时把所有可能的业务状态全枚举出来,即使枚举了,维护成本也高到离谱。

大模型加持下的工具调用解决了“理解”的问题,模型能看懂用户意图、能生成调用参数,但它本身不会自动决定“下一步该干什么”。你给它一个用户问题,它知道该调什么工具,但如果你不把完整的决策流程写清楚,它会陷入两种尴尬:要么每次都把所有工具试一遍,要么直接瞎编一个结果。

1.2 ReAct的核心机制:推理与行动的交织循环

ReAct这个词来自论文《ReAct: Synergizing Reasoning and Acting in Language Models》,翻译过来就是“在语言模型中协同推理与行动”。它想解决的,正是“能调用工具但不知道什么时候该调、调完怎么用”的问题。

ReAct定义了一个循环:

  • Thought(推理):模型基于当前信息,分析“我要解决什么问题、还缺什么信息、该走哪一步”。
  • Action(行动):根据推理结果,决定调用某个工具,传入结构化参数。
  • Observation(观察):接收工具返回的结果,把它当作新的已知信息。
  • 重复:把Observation带进下一轮Thought,继续循环,直到模型认为可以从已有信息中得出最终答案。

这个循环最大的价值在于:它把“决策”和“执行”解耦了。每一步行动背后都有推理支撑,每一次推理都会参考上一步行动的结果。模型不是一次性把整个计划列出来然后闷头执行,而是走一步看一步,像人一样边做边想。

注意:很多初学者混淆了ReAct和普通的“Function Calling”。Function Calling只是模型输出结构化工具调用参数的能力,它本身不包含决策循环。ReAct是建立在工具调用能力之上的一种完整决策框架。你可以没有Function Calling就用ReAct(让模型输出文本格式的动作指令再解析),但有Function Calling的ReAct实现起来会顺滑得多。

1.3 为什么说“会调用工具”只是自动化脚本

核心区别在于“状态管理”和“动态规划”。自动化脚本的状态机是开发者预先定义的,每一步都是静态的;而ReAct循环中的状态是模型根据上下文动态维护的,每一步该做什么,是模型结合实际返回结果推理出来的。

说白了,脚本是“有限状态机”,ReAct是“无限状态机”。后者的状态空间由自然语言表达,理论上可以覆盖任意复杂的业务场景,而不需要开发者在代码里穷举。这才是“智能”二字的来源——不是模型本身变聪明了,而是这个循环让模型的推理能力得以持续作用于外部环境,并通过工具结果校正自己的下一步判断。

所以下次再有人跟你说“我做个Agent就是给大模型接了几个API”,你可以回他一句:接了API是工具调用,能让模型自己在循环里决定下一步做什么,才叫智能体。

2. ReAct智能体的整体设计思路

搞清楚了原理,接下来聊聊实际落地时怎么设计一个能跑的ReAct Agent。我会从框架选型、循环设计、参数选择三个角度来拆解。

2.1 框架选型:从零实现还是用现成的

当前主流的选择有这么几条路:

方案优点缺点适合场景
完全从零实现灵活、可控、能深入理解原理开发量大,边界情况多学习、定制化极高的项目
LangChain的AgentExecutor生态成熟、组件齐全抽象层级多,出问题难排查快速原型、标准场景
Dify/Coze等平台可视化编排、上手快灵活性受限、难以深度定制非深度开发、业务验证
自研轻量循环 + Function Calling可控性好、代码量适中需要自己处理循环细节生产级定制项目(我推荐)

我个人的建议是:如果你刚开始学习ReAct,先走一遍从零实现的流程,别急着套框架。因为只有自己实现过一轮Thought→Action→Observation循环,你才能真正理解框架里那些抽象概念到底在干什么。等原理通了,再用LangChain或自研方案提速。

Dify和Coze这类平台适合验证业务逻辑,但如果你的Agent要对接内部系统、处理复杂鉴权、做精细的prompt控制,平台方案往往会碰壁。不是不能用,是深度定制时局限明显。我之前在智能体项目里接企业内部的订单系统,Dify的自定义工具得写HTTP服务包装一层,调试链路长了之后,定位问题特别痛苦,后来还是换成了自研方案。

2.2 核心循环设计:Thought-Action-Observation

一个最简的ReAct循环,伪代码是:

while True: # 1. 把历史轨迹发给模型 response = llm.chat(messages) # 2. 判断模型输出是"最终答案"还是"工具调用" if response.is_final_answer: return response.answer # 3. 解析出工具名和参数 tool_name = response.tool_name tool_args = response.tool_args # 4. 执行工具 observation = tools[tool_name](**tool_args) # 5. 把观察结果追加进对话历史,进入下一轮 messages.append(response) messages.append({ "role": "tool", "content": str(observation) })

这里面有四个关键点容易被忽略:

  • Step 2的判断必须有。模型不一定每次都想调工具,它可能在推理后直接得出结论。你要在prompt里明确告诉它:能得出答案就直接回答,不要硬调工具。
  • Step 3的参数解析是最大的坑。我后面专门用一节来讲,这里先提醒:嵌套结构、转义字符、多层引号都会让解析器翻车。
  • Step 5的“历史轨迹”必须完整保留。每一轮的Thought、Action、Observation都不能丢,这是模型判断下一步的依据。截断可以,但不能只保留Observation,否则模型的推理就失去了上下文。
  • 循环必须有终止条件。除了模型主动输出最终答案,你还要设置最大轮数(比如10轮),防止Agent在某个问题上无限循环绕圈。这个细节没做好,生产环境会出大乱子。

2.3 Temperature与模型选择的经验

Temperature这个参数在ReAct循环里比你想象得更重要。我见过不止一个人用默认参数跑Agent,结果模型在同一个工具上来回横跳,或者脑洞大开调了一堆不相关的工具。

我的经验是:

  • Reasoning(推理)环节temperature设在0到0.3之间,越低越稳定,因为推理需要的是确定性,不是创造性。
  • 如果你用的是支持reasoning模式的模型(比如带有思维链的推理模型),可以直接把temperature压到0,让模型走完整的思考流程。
  • 如果是创作类Agent(比如写文案、生成内容),那另说,核心循环的确定性依然优先,创造性靠应用层去补。

模型选择上,支持Function Calling的模型肯定优先。比如OpenAI的gpt-4o系列、Claude的tool use、以及国产的几款主流模型。如果你做中文场景,国产模型的Function Calling能力已经相当能打了,特别在涉及国内API对接时,反而更顺手。

3. 实操:从零实现一个带ReAct循环的智能体

理论扯完了,直接上代码。我拿一个最简单的例子来说:做一个能查询订单状态、计算退款金额的智能体。这个例子虽然小,但涵盖了ReAct循环的完整链路。

3.1 定义工具集

先定义两个工具:一个是查订单状态的,一个是计算退款金额的。这里为了演示简单,直接用Python函数模拟。

# tools.py from datetime import datetime, timedelta def get_order_status(order_id: str) -> str: """查询订单当前状态。""" # 模拟数据库查询 db = { "A1001": {"status": "已发货", "amount": 299.00, "created_at": "2024-11-20"}, "A1002": {"status": "已完成", "amount": 129.00, "created_at": "2024-11-18"}, "A1003": {"status": "待付款", "amount": 59.90, "created_at": "2024-11-25"}, } order = db.get(order_id) if order is None: return f"订单 {order_id} 不存在,请检查订单号" return f"订单 {order_id} 状态:{order['status']},金额:{order['amount']} 元,下单时间:{order['created_at']}" def calculate_refund_amount(order_id: str, refund_ratio: float = 1.0) -> str: """计算订单可退款金额。refund_ratio为退款比例,0到1之间。""" db = { "A1001": {"status": "已发货", "amount": 299.00}, "A1002": {"status": "已完成", "amount": 129.00}, } order = db.get(order_id) if order is None: return f"订单 {order_id} 不存在,无法计算退款金额" if order["status"] in ("待付款", "已取消"): return "该订单无需退款或已取消,不能计算退款" refund_amount = round(order["amount"] * refund_ratio, 2) return f"订单 {order_id} 可退款金额为:{refund_amount} 元(比例 {refund_ratio})" tools = { "get_order_status": get_order_status, "calculate_refund_amount": calculate_refund_amount, }

3.2 搭建主循环

接下来是主角——ReAct循环本体。我特意没用任何框架,让你能看清每一行在干什么。

# react_agent.py import json from openai import OpenAI client = OpenAI() # 这里假设你配好了API Key SYSTEM_PROMPT = """你是一个订单客服智能体。你可以使用以下工具来帮助用户: - get_order_status(order_id: str): 查询订单状态,入参为订单号 - calculate_refund_amount(order_id: str, refund_ratio: float): 计算退款金额,入参为订单号和退款比例 你需要一步步思考: 1. 先判断用户的问题需要哪些信息。 2. 如果缺少信息(比如没有订单号),直接向用户询问,不要调用工具。 3. 调用工具时,输出严格的JSON格式:{"name": "工具名", "arguments": {"参数名": "参数值"}} 4. 你每轮只能调用一个工具。 5. 拿到工具结果后,结合结果继续推理。如果已经能回答用户问题,直接输出最终答案。 """ def run_agent(user_query: str, max_steps: int = 5): messages = [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": user_query}, ] for step in range(max_steps): print(f"\n===== Step {step + 1} =====") response = client.chat.completions.create( model="gpt-4o", messages=messages, temperature=0, ) assistant_msg = response.choices[0].message content = assistant_msg.content # 打印模型的思考过程,方便调试 print(f"[Assistant]: {content}") # 尝试解析为工具调用 try: action = json.loads(content) # 如果解析出来是标准工具调用格式,就走工具执行分支 if "name" in action and "arguments" in action: tool_name = action["name"] tool_args = action["arguments"] print(f"[Tool Call]: {tool_name}({tool_args})") if tool_name not in tools: observation = f"错误:工具 {tool_name} 不存在" else: try: observation = tools[tool_name](**tool_args) except Exception as e: observation = f"工具执行出错: {str(e)}" print(f"[Observation]: {observation}") messages.append({"role": "assistant", "content": content}) messages.append({"role": "tool", "content": observation, "tool_call_id": f"call_{step}"}) continue except json.JSONDecodeError: pass # 如果不是JSON,说明模型认为已经可以直接回答 print("[Final Answer]") return content return "已达最大步数,未能得到最终结论。请尝试补充信息或简化问题。" if __name__ == "__main__": result = run_agent("我想查一下订单A1001的物流状态,如果状态是已发货,帮我算一下如果退款50%能退多少钱") print(f"\n========== 最终结果 ==========\n{result}")

3.3 关键参数与细节解释

上面这段代码有几个地方值得细讲:

  • 为什么要用纯文本JSON而不是Function Calling:我这里故意用纯文本JSON来让模型输出工具调用,是希望你能看清“模型输出→解析→执行→回填”的完整链路。真实项目中你可以直接用Function Calling机制,代码会更简洁,但原理完全一样——模型输出的是一段“结构化意图”,系统负责解析和执行。
  • tool_call_id是干嘛的:当你给模型回填Observation的时候,OpenAI的API要求提供tool_call_id,用来把工具结果跟对应的工具调用请求关联起来。如果你用纯文本方式(像我上面的示例),这一步可以简化,但用原生的Function Calling就必须带上。
  • 打印每一轮的状态:这个习惯强烈建议保留。调Agent的时候,打印出Thought/Action/Observation的完整轨迹,能让你在模型“胡说八道”的时候一眼看出是哪一步出了问题。我见过太多人黑盒调Agent,出了问题只能盲猜,效率极低。
  • 异常处理必须包住:工具执行时的异常不能让它直接炸掉。你要把异常转成Observation文本返回给模型,让模型自己判断“这个工具执行失败了,接下来怎么处理”。这才是Agent应有的行为——像一个真人遇到系统报错,会想办法换个思路而不是直接崩溃。

提示:如果你用的是OpenAI的Function Calling,模型的工具调用不会出现在content里,而是单独的tool_calls字段。解析逻辑要相应调整。我的建议是先用纯文本方式跑通循环,再迁到Function Calling,排障会轻松很多。

3.4 实测效果演示

拿上面那个例子跑一轮,实际输出大概长这样:

===== Step 1 ===== [Assistant]: {"name": "get_order_status", "arguments": {"order_id": "A1001"}} [Tool Call]: get_order_status({'order_id': 'A1001'}) [Observation]: 订单 A1001 状态:已发货,金额:299.0 元,下单时间:2024-11-20 ===== Step 2 ===== [Assistant]: 订单A1001状态为“已发货”,符合退款条件。用户要求退款比例为50%,现在计算退款金额。 {"name": "calculate_refund_amount", "arguments": {"order_id": "A1001", "refund_ratio": 0.5}} [Tool Call]: calculate_refund_amount({'order_id': 'A1001', 'refund_ratio': 0.5}) [Observation]: 订单 A1001 可退款金额为:149.5 元(比例 0.5) ===== Step 3 ===== [Assistant]: 订单A1001当前状态为“已发货”,原金额299元。按50%比例退款,可退款金额为149.5元。 [Final Answer]

注意观察Step 2里模型的行为:它没有直接回答,而是先复述了一下推理结果,再调用工具。这是思维链的自然表现,也是ReAct“推理与行动交织”的直接体现。模型不是盲目执行,而是先想“信息够了吗?不够,还差退款金额”,然后才调工具。

这个循环跑起来之后,你会发现Agent的容错能力远比脚本强。如果你故意给它一个不存在的订单号,模型在拿到“订单不存在”的Observation之后,会自动组织话术告诉你“查不到这个订单”,而不是像脚本一样抛一个KeyError出来。这就是ReAct最直观的价值。

4. 常见问题与排坑实录

实操过程中有几个问题我反复遇到,网上讨论度也特别高,这里集中整理一下,帮你提前避坑。

4.1 工具调用嵌套arguments解析失败

这是被问得最多的一个问题,尤其是那些把Function Calling和纯文本混用的项目。现象是:模型明明输出了工具调用,但解析arguments时老是出错,要么多一层嵌套取不到值,要么字符串被截断。

我举一个典型场景。假设工具签名是:

def send_email(to: str, subject: str, cc: list[str] = None): ...

模型输出可能是:

{ "name": "send_email", "arguments": { "to": "test@example.com", "subject": "Hi there", "cc": ["a@example.com", "b@example.com"] } }

看起来很规整对吧?但如果你用一些比较弱的模型,它可能会输出:

{ "name": "send_email", "arguments": "{\"to\": \"test@example.com\", \"subject\": \"Hi there\", \"cc\": [\"a@example.com\", \"b@example.com\"]}" }

注意这里arguments变成了字符串而不是对象。很多人解析时直接arguments["to"],结果拿到undefined,就开始怀疑人生。

这个问题的根源在于:有些模型在生成JSON时会“多想一步”,把内部结构再序列化了一层。这是训练数据导致的习惯性行为,不是bug,但你需要防御性解析。

解决方案是写一个递归解析器,尝试两种可能:先按对象解析,如果发现是字符串,再json.loads一次。我在生产项目里是这么处理的:

def safe_parse_arguments(arguments): """兼容嵌套JSON字符串的工具参数解析。""" # 如果本身就是字典,直接用 if isinstance(arguments, dict): # 但可能某个value还是个JSON字符串,这里做递归处理 for key, value in arguments.items(): if isinstance(value, str) and value.strip().startswith(("{"", "[")): try: arguments[key] = json.loads(value) except json.JSONDecodeError: pass return arguments # 如果整个arguments是个字符串,先尝试解析成JSON if isinstance(arguments, str): try: parsed = json.loads(arguments) return safe_parse_arguments(parsed) # 递归一层 except json.JSONDecodeError: raise ValueError(f"无法解析arguments字符串: {arguments[:100]}...") raise TypeError(f"不支持的arguments类型: {type(arguments)}")

这个函数我称为“无赖式解析”,因为它不假设模型输出格式,把常见的嵌套情况全部处理了一遍。实测下来,模型输出嵌套JSON字符串导致解析失败的情况,用这个函数能解决90%以上。

4.2 模型陷入死循环

症状是Agent在几步之间反复横跳,比如一直在调同一个工具、参数还不变,或者两个工具来回调,就是不给最终答案。

这个问题的直接原因是模型没能从Observation中提炼出“够了”的信号。它总是觉得还差一步,或者误判了当前状态。

我的排查思路:

  1. 第一件事检查prompt:你有没有在system prompt里明确写“如果你觉得信息已足够回答用户,就直接给出最终答案”?我见过很多循环问题,就是少了这一句话。模型不是不会停止,是没人告诉它可以停。
  2. 第二件事检查Observation质量:工具返回的结果是不是太冗余?返回一大堆无关字段,模型反而抓不住重点。我做过一个实验,把工具返回从“完整JSON”精简为“关键信息摘要”,循环轮数直接下降了30%。Observation不是越多越好,是要够用。
  3. 第三件事看Attention权重:如果模型在前几轮已经拿到答案,但后续Step又开始乱调工具,大概率是上下文被截断或者历史轨迹有重复信息干扰了判断。检查一下你的消息列表,有没有把重复内容塞进去。

设置max_steps上限是必须的兜底方案。但这里有个细节:别把max_steps设得太大,超过10步的Agent在响应速度和失败率上都会有显著劣化。如果你的Agent经常需要跑超过8轮才能完成一个任务,优先优化工具粒度或prompt,而不是提高上限。

4.3 工具参数幻觉与实际格式不匹配

模型生成的参数经常跟工具定义不完全一致,典型表现有:

  • 日期格式:工具要“2024-11-20”,模型输出“2024年11月20日”。
  • 枚举值:工具要“shipped”,模型输出“已发货”。
  • 列表参数:工具要["a", "b"],模型输出"a,b"

这属于ReAct循环里最常见的低质量问题,因为它不报错,但结果不对。脚本时代反而没这个问题,因为参数是开发写死的。Agent化之后,参数由模型生成,格式偏差就成了常态。

我的方案是在工具定义里给足约束。别只写“order_id: string”,要写“order_id: string,格式为字母A开头加四位数字,例如A1001”。模型给出的参数格式会准确得多。

另一个更稳妥的做法是加一个参数校验层。工具执行前,用pydantic或jsonschema做一次校验,不合格的参数尝试转换,转换不了再让模型重新生成。

from pydantic import BaseModel, Field class RefundParams(BaseModel): order_id: str = Field(pattern=r"^A\d{4}$") refund_ratio: float = Field(ge=0, le=1) # 用的时候直接 params = RefundParams(**tool_args)

这样至少保证参数进了工具函数之前是合法的。不合法就让模型重来,好过在函数内部爆一串看不懂的异常。

4.4 生产环境的稳定性保障

最后聊一下生产环境里跑ReAct循环的稳定性问题。实验室Demo能跑通跟线上稳定是两回事,有几个点特别重要:

  • 上下文的长度管理。每次循环都会往消息列表里塞内容,跑几轮之后token消耗飙升。你需要做截断策略:要么滑窗裁剪早期历史,要么把Observation做摘要后再存。
  • Prompt的版本管理。ReAct的prompt是一个需要反复迭代的东西。我在项目里会把SYSTEM_PROMPT做成可配置的模板,每次改动都用git记录版本,线上出问题时能快速回滚。别小看这个,Agent的prompt改动经常是“牵一发动全身”。
  • 审计日志。每一步的Thought、Action、Observation都打日志,并带上耗时和token数。这不仅是排查问题的依据,也是你后续优化成本结构的数据基础。我见过很多团队跑Agent,跑完了根本不知道token花在哪,优化无从谈起。
  • 并发与限流。Agent循环是串行的,但多个用户的Agent是并发的。你要考虑模型API的rate limit、工具调用的超时时间、以及整个循环的总超时。我用的是每次模型调用30秒超时,整个循环5分钟上限,超了直接返回失败重试。

提醒一句:ReAct在效果上确实强于纯脚本,但它也把“调试”这件事变难了——你不再是调试代码逻辑,而是在调试“模型的决策过程”。这个过程需要的是观察力和对提示词的感觉,这也是为什么我反复强调要把日志打全。看不到模型怎么想的,就永远调不好Agent。

5. 给想深耕Agent方向的同学一点真心话

聊了这么多技术细节,最后分享一点个人体会。Agent这行现在很热,各种平台、各种框架层出不穷,但说到底,比拼的还是你对“模型怎么思考”这件事的理解深度。Dify能做Agent,Coze能做Agent,LangChain也能做,但用平台的人扎堆,真正能把Agent调好的人依然稀缺。

我自己的感受是,先亲手实现一个ReAct循环,比用十个框架都管用。因为实现过一次,你就知道模型输出一个JSON的边界在哪里,知道Observation回传时上下文有多重要,知道“让模型自己决定下一步”这句话背后有多少细节。这些东西,看框架源码看不出来,非得自己踩一遍坑才记得住。

你如果现在正在做一个Agent项目,遇到模型不按套路出牌的情况,不用慌。回到ReAct循环本身,看看是哪一环出了问题:是模型推理不对,还是工具定义不清晰,还是Observation传递有缺失。80%的问题都出在这三个地方。

最后再分享一个小技巧:给Agent加工具的时候,别一上来就十几个工具往prompt里塞。模型在太多选项面前会“选择困难”,调用准确率会显著下降。我个人经验是,一个Agent的工具数量控制在5个以内,超过5个就考虑拆分职责、做子Agent或者按需动态加载工具列表。工具少而精,Agent的行为才会清晰稳定。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询