ReAct:从工具调用到真正智能体的关键范式

ReAct:从工具调用到真正智能体的关键范式 会调用工具只是自动化脚本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( modelgpt-4o, messagesmessages, temperature0, ) 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: fcall_{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: testexample.com, subject: Hi there, cc: [aexample.com, bexample.com] } }看起来很规整对吧但如果你用一些比较弱的模型它可能会输出{ name: send_email, arguments: {\to\: \testexample.com\, \subject\: \Hi there\, \cc\: [\aexample.com\, \bexample.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中提炼出“够了”的信号。它总是觉得还差一步或者误判了当前状态。我的排查思路第一件事检查prompt你有没有在system prompt里明确写“如果你觉得信息已足够回答用户就直接给出最终答案”我见过很多循环问题就是少了这一句话。模型不是不会停止是没人告诉它可以停。第二件事检查Observation质量工具返回的结果是不是太冗余返回一大堆无关字段模型反而抓不住重点。我做过一个实验把工具返回从“完整JSON”精简为“关键信息摘要”循环轮数直接下降了30%。Observation不是越多越好是要够用。第三件事看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(patternr^A\d{4}$) refund_ratio: float Field(ge0, le1) # 用的时候直接 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能做AgentCoze能做AgentLangChain也能做但用平台的人扎堆真正能把Agent调好的人依然稀缺。我自己的感受是先亲手实现一个ReAct循环比用十个框架都管用。因为实现过一次你就知道模型输出一个JSON的边界在哪里知道Observation回传时上下文有多重要知道“让模型自己决定下一步”这句话背后有多少细节。这些东西看框架源码看不出来非得自己踩一遍坑才记得住。你如果现在正在做一个Agent项目遇到模型不按套路出牌的情况不用慌。回到ReAct循环本身看看是哪一环出了问题是模型推理不对还是工具定义不清晰还是Observation传递有缺失。80%的问题都出在这三个地方。最后再分享一个小技巧给Agent加工具的时候别一上来就十几个工具往prompt里塞。模型在太多选项面前会“选择困难”调用准确率会显著下降。我个人经验是一个Agent的工具数量控制在5个以内超过5个就考虑拆分职责、做子Agent或者按需动态加载工具列表。工具少而精Agent的行为才会清晰稳定。