尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

从零手写 ReAct 循环:吃透 AI Agent 工具调用

从零手写 ReAct 循环:吃透 AI Agent 工具调用 做 AI Agent 开发的人几乎都绕不开 ReAct 这个循环。但真正把 Agent 工具调用这件事讲透的人不多——很多人上来就用 LangChain 或者某个 Agent 框架create_react_agent一行代码跑通了 demo等到线上模型换了、工具返回格式变了、循环卡在第 7 步不动了就完全不知道从哪儿下手。我从去年开始做 Agent 相关的东西前后手写过三四版 ReAct 循环有纯文本解析的也有基于原生 Function Calling 的踩过的坑足够写一篇长文。这篇就干一件事把 ReAct 循环从零手写一遍一行框架代码都不用让工具调用这件事彻底变得透明。无论你是刚开始学 Agent 开发的新手还是已经在用框架但对底层机制一知半解的开发者跟着走一遍都会有收获。1. 先把概念掰开ReAct 到底循环了什么1.1 一个必须澄清的坑此 ReAct 非彼 React搜索 ReAct 的时候你会看到大量混淆结果一半是前端面试题一半是 AI Agent。这两个东西除了字母拼写一样没有任何关系。前端的 React 是 Meta 开源的 UI 库核心是组件化和虚拟 DOMAgent 领域的 ReAct 出自 2022 年的一篇论文全称是 Reasoning and Acting意思是让大模型把推理和行动交替进行。之所以会撞名纯粹是因为首字母缩写凑巧一样——Reason Act 拼出来正好是 React。我在团队里做内部分享的时候第一页 PPT 就是专门用来澄清这个的因为新同学搜资料经常搜到 React 生命周期、React Fiber 那一堆东西越看越懵。你只要记住一点聊 Agent 工具调用的时候ReAct 指的是一种提示词组织范式不是任何框架也不是某个模型的能力它本质上就是一套让模型在想和做之间来回切换的约定。1.2 循环的四个要素Thought、Action、Action Input、ObservationReAct 的骨架其实非常朴素。每一轮模型输出一段思考和一次行动外部程序执行这个行动把结果塞回去模型再基于新信息继续思考。用文字描述就是四个字段Thought模型的推理过程用自然语言写出我现在要干什么、为什么。Action要调用的工具名字必须和注册表里的名字严格对应。Action Input传给工具的参数字符串通常约定成 JSON。Observation工具执行的真实返回结果由程序生成模型不能自己编。循环终止的条件是模型不再输出 Action而是输出一个Final Answer。整条流程串起来大概是这个样子模型看到用户问题 → 思考需要一个计算器 → 输出Action: calculator→ 程序执行 → 把结果作为 Observation 回填 → 模型看到结果 → 输出最终答案。这里最关键的一条纪律是Observation 必须来自真实执行绝不能是模型想象的。我见过有人为了省事在提示词里写如果工具不可用你可以假设一个合理结果继续推理这种写法会直接摧毁整个 Agent 的可信度。工具调用之所以有价值就是因为它是模型接触真实世界的唯一通道一旦这条通道允许模型自己编那和纯聊天模型没有任何区别。1.3 为什么不直接上 Function Calling读到这儿你可能会问现在主流模型都有原生 Function Calling 了模型能直接按结构化格式返回工具调用请求为什么还要手写文本解析的 ReAct这个问题我认真想过答案有三层。第一层是兼容性并不是所有模型、所有部署环境都支持原生工具调用。你在本地跑一个量化过的开源小模型或者对接一个只提供纯文本补全接口的服务Function Calling 根本用不了这时候文本格式的 ReAct 是唯一选择。第二层是可观测性文本格式下模型的每一步思考都明明白白写在输出里出问题的时候你一眼就能看出它是在哪一步走偏的原生 Function Calling 把参数藏在结构化字段里反而不容易看出推理链路。第三层是理解成本先手写一遍文本版你才会真正明白为什么各大框架要设计 Tool 抽象、为什么要做输出解析器、为什么要有最大迭代次数这些看起来多余的机制。我的建议是两个版本都写一遍。文本版用来理解原理Function Calling 版用来上生产。下面第 3 节我会把两版代码都给出来你可以对照着看。2. 手写前的设计决策工具契约怎么定2.1 工具注册表一个字典就够了写代码之前先把设计想清楚比上来就敲键盘省事得多。工具注册表是整个 Agent 的心脏它的职责是维护工具名 → 可执行函数的映射并且向外提供一个给模型看的工具说明清单。最小实现就是一个字典加上几个约定TOOLS {} def register_tool(name, description, parameters): def decorator(func): TOOLS[name] { name: name, description: description, parameters: parameters, func: func, } return func return decorator这里有个设计上的取舍值得聊工具描述写多细合适。写太简略模型不知道该什么时候用它写太啰嗦又会占用宝贵的上下文而且容易让模型产生误判。我的经验是每条描述控制在两到三句话一句话说功能一句话说什么时候用如果参数有特殊要求再补一句。比如计算器工具就写执行数学表达式求值适用于任何需要精确计算的场景不要自己心算。输入必须是合法的四则运算表达式字符串支持括号和幂运算。这样模型基本不会用错。另一个容易忽略的点是工具的数量。我早期做过一个实验把 20 多个工具一股脑塞进提示词结果模型的工具选择准确率直接掉了一半——不是因为模型笨是因为选项太多注意力被分散了。后来改成按场景动态挑选工具子集比如用户问的是数据分析就只挂载计算器、表格查询、绘图三个工具准确率立刻回升。所以工具超过 8 到 10 个的时候一定要做检索或者分类筛选别全塞。2.2 提示词模板里必须写死的几条规则ReAct 的提示词是有固定套路的但细节决定成败。下面这份模板是我迭代了五六版之后基本稳定下来的版本你可以直接拿去改你是一个可以使用工具的智能助手。请严格按照以下格式回答 Question: 用户提出的问题 Thought: 我需要思考接下来做什么 Action: 要使用的工具名称必须是 [{tool_names}] 之一 Action Input: 传给工具的输入必须是合法的 JSON Observation: 工具返回的结果 ...Thought/Action/Action Input/Observation 可以重复多轮 Thought: 我现在知道最终答案了 Final Answer: 对用户问题的最终回答 硬性要求 1. 每次只能输出一个 Action不要一次输出多个。 2. Action Input 必须是单行合法 JSON不要加 markdown 代码块标记。 3. 绝对不要自己编造 ObservationObservation 只能由系统提供。 4. 拿到足够信息后立即输出 Final Answer不要做多余的调用。 5. 工具调用失败时根据错误信息调整参数重试最多重试一次。这里面每条规则都是有血泪的。第 1 条是因为模型很爱抢跑一次输出三个 Action解析器直接懵掉。第 2 条更常见模型习惯性地给 JSON 套上 json 围栏解析器不处理的话就报错。第 3 条是最重要的安全底线。第 4 条解决的是过度调用问题不加这条你会发现模型明明已经有答案了还要再调一次搜索。第 5 条是为了让失败可恢复否则一次工具异常整个任务就废了。还有两个参数级别的建议temperature 设成 0 或 0.1因为格式遵循度对随机性极其敏感温度一高模型立刻就忘了格式max_tokens 要给够至少 512我遇到过好几次因为思考太长被截断Action 行没输出完整解析器拿到的是一段残句。2.3 停止条件与最大轮次必须有个刹车Agent 最尴尬的失败模式不是答错而是永远停不下来。模型在搜索 → 觉得信息不够 → 再搜索 → 还是不满意之间无限循环每次调用的输入还略微不同你甚至没法靠动作去重拦住它。所以主循环里必须写死一个最大轮次我一般设 8 到 10 轮超过就强制中断把已经收集到的 Observation 拼起来让模型给一个信息不足的最终答案。除了轮数还可以加两个辅助的停止条件。一个是总 token 预算因为每一轮都要把历史消息全量重发token 消耗是随轮数快速上涨的跑飞一次账单会很难看。另一个是总耗时上限比如 60 秒超了就中断。这三个刹车任何一个生效都要在返回值里明确标记未正常完成方便上游做降级处理。我吃过一次亏线上一个 Agent 接口没设轮数上限某次遇到一个拧巴的问题它在里面转了四十多轮一个请求烧掉了小半天的额度。3. 从零写一个能跑的 ReAct 循环3.1 环境与依赖准备先说依赖其实只需要两个一个模型客户端一个用来发 HTTP 请求的库。我下面用最常见的 OpenAI 兼容协议来写base_url指向你自己选定的服务端点就行——无论是云厂商的 API还是本地起一个推理服务只要兼容 Chat Completions 协议都能直接跑。pip install openai环境变量里配两个值export LLM_BASE_URL你的服务端点 export LLM_API_KEY你的密钥 export LLM_MODEL你的模型名这里提一句模型选型。手写 ReAct 对模型的格式遵循能力要求相当高参数量太小的模型经常学不会Action Input 必须是合法 JSON这件事。如果你试的时候发现模型老是输出乱七八糟的格式先别怀疑代码换个指令遵循强一点的模型再试一遍大概率就好了。我用过好几个不同规模的模型做这个实验7B 级别的模型需要把提示词里的格式示例写得更死板一些才能稳定而更大的模型基本一次就能上手。3.2 工具定义与注册表实现先写三个典型工具计算器、查天气、查知识库。选这三个是因为它们覆盖了工具调用的三种典型形态——纯计算无副作用、调外部接口、查本地数据。import json import math import time from openai import OpenAI client OpenAI() TOOLS {} def register_tool(name, description, parameters): def decorator(func): TOOLS[name] { name: name, description: description, parameters: parameters, func: func, } return func return decorator register_tool( namecalculator, description执行数学表达式求值。适用于任何需要精确计算的场景不要自己心算。, parameters{expression: 合法的数学表达式字符串支持 - * / ** 和括号}, ) def calculator(expression: str) - str: allowed set(0123456789-*/.() ) if not set(expression) allowed: return f错误表达式包含非法字符{expression} try: # 生产环境请换成更安全的表达式解析器 result eval(expression, {__builtins__: {}}, {math: math}) return str(result) except Exception as e: return f计算失败{e} register_tool( nameget_weather, description查询指定城市的当前天气。用户问到天气、气温、是否下雨时使用。, parameters{city: 城市名称例如 杭州}, ) def get_weather(city: str) - str: # 实际项目里换成你自己的天气服务调用 fake_db {杭州: 晴24摄氏度湿度 55%, 成都: 多云19摄氏度湿度 70%} return fake_db.get(city, f没有查到 {city} 的天气数据) register_tool( namesearch_docs, description在内网知识库中检索文档片段适用于查询产品规则、内部流程等问题。, parameters{query: 检索关键词或自然语言问题}, ) def search_docs(query: str) - str: # 这里接你自己的检索服务先返回占位内容 return f关于「{query}」检索到 2 条片段……写法上有个细节工具函数永远不要往外抛异常一律把错误转成字符串返回。原因很简单异常会让主循环崩掉而错误信息作为 Observation 回传给模型模型是有机会自己纠正的。比如参数传错被工具拒绝它看到错误信息后往往会换个参数重试这个自愈能力在实战中相当有用。3.3 解析器实现最容易被低估的一环整个 ReAct 循环里代码量最少但最容易出问题的就是解析器。它的任务是把模型输出的自由文本解析成结构化的下一步动作。刚开始我写得很粗糙一个正则就完事结果线上每天都能收到解析失败的日志。后来逐步加固才有了现在这版。import re ACTION_RE re.compile(rAction\s*[:]\s*(.)) INPUT_RE re.compile(rAction\s*Input\s*[:]\s*(.), re.S) FINAL_RE re.compile(rFinal\s*Answer\s*[:]\s*(.), re.S) def clean(text: str) - str: # 去掉模型爱加的 markdown 加粗和代码块标记 text text.replace(**, ) text re.sub(r(?:json)?, , text) return text def parse_output(text: str) - dict: text clean(text) final FINAL_RE.search(text) action ACTION_RE.search(text) # 同时出现时谁在后面谁生效 if final and (not action or final.start() action.start()): return {type: final, answer: final.group(1).strip()} if action: name action.group(1).strip().strip(\) # 有些模型会把参数直接跟在同一行Action: calculator{expression: 11} inline_json None m re.match(r([A-Za-z_][A-Za-z0-9_]*)\s*(\{.*\}), name) if m: name, inline_json m.group(1), m.group(2) inp INPUT_RE.search(text) raw_input inline_json or (inp.group(1).strip() if inp else ) if not raw_input: return {type: invalid, reason: 缺少 Action Input, raw: text} try: args json.loads(raw_input) except json.JSONDecodeError: # 不是合法 JSON 时降级处理很多工具只有一个参数 args {_raw: raw_input} return {type: action, name: name, args: args} return {type: invalid, reason: 既没有 Action 也没有 Final Answer, raw: text}这段代码里有三个地方是我反复调试才补上的。第一是clean里的去 markdown 处理模型加粗Action这种事的概率高得离谱。第二是处理工具名和 JSON 挤在同一行的情况某些模型输出Action: calculator{expression:11}普通的正则直接就把整串当成工具名了。第三是 JSON 解析失败时的降级不硬性要求合法 JSON而是把原文塞进_raw字段让工具函数自己决定怎么处理——很多单参数工具直接取_raw就能用反而比强迫模型输出 JSON 更可靠。注意解析器的健壮性和模型强相关。换模型之后一定要重新跑一遍解析测试我换过一次模型原来好好的正则在新的输出习惯下直接失效了一半。3.4 主循环把 Thought-Action-Observation 串起来有了工具和解析器主循环就很简单了核心逻辑不到四十行MAX_STEPS 8 MAX_OBS_LEN 1500 SYSTEM_PROMPT 你是一个可以使用工具的智能助手。可用工具如下 {tool_desc} 请严格按照以下格式回答 Question: 用户问题 Thought: 你的思考 Action: 工具名必须是 [{tool_names}] 之一 Action Input: 传给工具的 JSON 参数 Observation: 工具返回结果由系统填写你不要自己生成 ...可重复多轮 Thought: 我现在知道最终答案了 Final Answer: 最终回答 硬性要求 1. 每次只输出一个 Action。 2. Action Input 必须是单行合法 JSON。 3. 绝不要自己编造 Observation。 4. 信息足够就立刻输出 Final Answer。 def build_tool_desc(): lines [] for t in TOOLS.values(): params , .join(f{k}: {v} for k, v in t[parameters].items()) lines.append(f- {t[name]}: {t[description]} 参数{params}) return \n.join(lines) def run_tool(name, args): if name not in TOOLS: return f错误工具 {name} 不存在可用工具{list(TOOLS.keys())} try: result TOOLS[name][func](**{k: v for k, v in args.items() if k ! _raw} or {}) except TypeError: # 参数签名不匹配时尝试把整个输入当单参数传 raw args.get(_raw) or json.dumps(args, ensure_asciiFalse) try: result list(TOOLS[name][func].__wrapped__.__call__ and [][0]) except Exception: try: result TOOLS[name][func](raw) except Exception as e: return f工具调用失败{e} except Exception as e: return f工具调用失败{e} result str(result) if len(result) MAX_OBS_LEN: result result[:MAX_OBS_LEN] ……结果已截断 return result def react_agent(question: str, max_steps: int MAX_STEPS) - str: tool_names list(TOOLS.keys()) system SYSTEM_PROMPT.format(tool_descbuild_tool_desc(), tool_names, .join(tool_names)) messages [ {role: system, content: system}, {role: user, content: fQuestion: {question}}, ] seen_actions {} for step in range(max_steps): resp client.chat.completions.create( model你的模型名, messagesmessages, temperature0.1, max_tokens1024, ) text resp.choices[0].message.content or messages.append({role: assistant, content: text}) parsed parse_output(text) if parsed[type] final: return parsed[answer] if parsed[type] invalid: obs f格式错误{parsed[reason]}。请严格按照模板输出 Action 或 Final Answer。 messages.append({role: user, content: fObservation: {obs}}) continue # 死循环检测 signature f{parsed[name]}::{json.dumps(parsed[args], sort_keysTrue, ensure_asciiFalse)} seen_actions[signature] seen_actions.get(signature, 0) 1 if seen_actions[signature] 3: obs 检测到你在重复调用同一个工具且参数相同请基于已有 Observation 直接给出 Final Answer。 messages.append({role: user, content: fObservation: {obs}}) continue obs run_tool(parsed[name], parsed[args]) messages.append({role: user, content: fObservation: {obs}}) return 任务未在限定步数内完成请补充信息后重试。有三处实现细节值得停下来解释一下。第一Observation 是用role: user回填的不是 assistant。这是我试出来的经验。如果把它拼进 assistant 消息里很多模型会把历史里的 Observation 当成自己以前说过的话进而开始续写新的 Observation也就是自己编工具结果。用 user 角色回填模型会把工具结果当成外部输入编造的概率大幅下降。第二格式错误不要直接抛异常而是当成一种特殊的 Observation 回传。模型看到格式错误缺少 Action Input之后下一轮有一半以上概率能自己纠正过来。这比直接返回错误给用户友好得多。第三重复动作要拦截。上面用了一个计数字典同一个动作签名出现三次就注入提醒。这招救过我好几次尤其是查知识库类的工具模型在检索结果不满意时会一遍遍用同样的词去搜。3.5 换成原生 Function Calling 的版本理解了文本版之后Function Calling 版就非常好懂了本质上是把解析文本这一步换成了读结构化字段。工具定义变成 JSON Schemadef to_openai_tools(): return [ { type: function, function: { name: t[name], description: t[description], parameters: { type: object, properties: { k: {type: string, description: v} for k, v in t[parameters].items() }, required: list(t[parameters].keys()), }, }, } for t in TOOLS.values() ] def react_agent_fc(question: str, max_steps: int 8) - str: messages [{role: user, content: question}] for step in range(max_steps): resp client.chat.completions.create( model你的模型名, messagesmessages, toolsto_openai_tools(), temperature0.1, ) msg resp.choices[0].message messages.append(msg) if not msg.tool_calls: return msg.content or for call in msg.tool_calls: name call.function.name try: args json.loads(call.function.arguments or {}) except json.JSONDecodeError: args {_raw: call.function.arguments} obs run_tool(name, args) messages.append({ role: tool, tool_call_id: call.id, content: obs, }) return 任务未在限定步数内完成。对比一下就能看出区别文本版靠提示词约束格式Function Calling 版靠模型训练时的结构化输出能力稳定性高一个档次代价是失去了一步一步可读的思考过程。两个版本我都在用简单任务和稳定模型上选 Function Calling需要排查问题或者模型能力不确定的时候选文本版。还有一种混合做法让模型先输出 Thought 文本再输出 Function Calling 请求兼顾可观测性和稳定性我目前在几个对可靠性要求高的场景里用的就是这种。4. 常见问题与排查技巧实录4.1 故障速查表下面这张表几乎涵盖了我遇到过的所有典型问题你可以当成排查手册用现象大概率原因处理方式一直输出自然语言从不调用工具提示词没写清工具适用场景在工具描述里补用户问 XX 时使用输出 JSON 带 json 围栏模型习惯解析器前置清理 markdown 标记工具名和参数挤在同一行输出格式漂移正则增加同行解析分支自己编造 ObservationObservation 用了 assistant 角色改用 user 角色回填并强化提示词同一工具反复调用没有循环检测动作签名计数超过阈值注入提醒卡在某一轮不动输出被 max_tokens 截断加大 max_tokens检查是否有超长思考拿到答案还要再调一次缺少及时终止的约束提示词里加信息足够立刻 Final Answer令牌消耗异常高历史消息全量重发截断历史轮数压缩 Observation换模型后解析全挂解析器与模型输出风格耦合换模型必跑解析回归测试工具报错后任务直接失败异常没被捕获工具函数内部 try/except错误转字符串4.2 三个不出现在文档里的实操心得第一给你的 Agent 加一个调试模式把所有中间消息打印出来。我在react_agent里加了个debug参数打开之后每一步的模型原始输出、解析结果、工具返回都往日志里写。这个功能看起来土但它是我排查问题最快的手段。很多时候你以为是解析器的问题打印出来一看是模型这一轮压根没输出 Action 行。第二Observation 一定要截断并且明确告诉模型被截断了。我设置的上限是 1500 字符超出部分砍掉并追加一句结果已截断。不加这句提示的话模型会以为检索到的就是全部内容然后基于不完整的信息得出错误结论。加了之后它往往会换个关键词再搜一次反而更接近正确答案。第三测试用例要覆盖工具失败这类分支。很多人做 Agent 测试只测正常路径结果上线之后工具一抖动就全线崩溃。我的做法是在测试环境里给工具加一个随机失败开关比如 20% 概率返回服务暂时不可用专门观察模型能不能自己重试或者换路。实测下来提示词里写了重试规则的话小模型也能做到七成左右的自愈率。5. 从玩具到可用几个实用的加固方向5.1 循环检测与上下文压缩第 3 节的代码里已经加了一个基础的去重检测但真实场景里的死循环花样更多。除了完全相同的动作还有一种情况是参数极其相近的动作比如模型把搜索词在杭州天气和杭州 天气之间来回切换签名去重拦不住。针对这种可以做一个模糊匹配把参数归一化之后再比较或者更简单粗暴一点统计最近三轮的工具调用如果都是同一个工具就注入提醒。上下文压缩是另一个必须做的工程项。因为 ReAct 每轮都要把完整历史重发token 消耗随轮数是二次增长的8 轮的对话很容易把上下文塞满。我的做法是保留最近 4 轮的完整消息更早的 Observation 只保留前 200 字符的摘要同时在摘要前明确标注以下是历史摘要。实测这个策略对准确率影响很小但 token 消耗能降四成左右。5.2 怎么判断你的 Agent 真的变好了没有评估的迭代就是瞎改。我的做法是维护一个 30 到 50 条的小测试集覆盖三类问题需要单次工具调用的、需要多次调用的、以及不需要调用工具直接回答的。跑一遍记录三个指标最终答案正确率人工标注或者用规则判定的通过率。平均调用轮数越少越好轮数多说明模型决策不够果断。工具调用准确率动作名和参数是否合理这个指标能帮你定位是提示词问题还是模型能力问题。有了这三个数字改动提示词或者换模型之后你才有底气说变好了。我早期改提示词纯靠感觉改完觉得更顺了一测正确率反而掉了三个点原因是我加的某条约束把模型带偏了。5.3 一个容易被忽视的边界工具是给模型的接口不是给人用的最后说一个设计观念上的事。很多人写工具函数是按给人用的思路写的参数很多、返回值很长、语义很丰富但模型其实处理不了太复杂的接口。我的经验是给 Agent 用的工具应该尽量做到参数少、语义窄、返回值短。一个工具有四五六个参数的时候模型填错的概率会明显上升返回值超过几百字的时候模型容易抓不住重点。比较实用的做法是拆细。与其做一个综合查询工具带六个参数不如拆成三个各带两个参数的专用工具。这样模型的选择空间更清晰参数填错的概率也更低。这个原则我在重构过一个 Agent 项目之后体会特别深——工具拆完之后整体调用准确率提升了接近二十个百分点代码反而更好维护了。还有一点做 Agent 工具调用的时候一定要在工具层面做权限和边界控制不能让模型决定它能碰什么数据。工具注册表本身就是一道很好的边界只有注册进去的能力模型才能触达未注册的一律拒绝。我把这条当成硬规则写在了代码里run_tool第一件事就是检查工具名是否在白名单内不在就直接返回错误。这个检查看起来多余但它保证了哪怕模型被提示词注入了乱七八糟的指令也调用不到没授权的功能。真正把这一圈走下来你会发现 ReAct 本身一点都不神秘难的是那些藏在细节里的工程判断——Observation 用什么角色回填、截断阈值定多少、动作去重怎么做、测试集怎么建。这些没有哪本书会告诉你只能自己踩一遍。我的建议是照着上面的代码先跑通一个能用的版本然后故意把模型换小、把工具搞失败、把问题弄复杂看它在压力下怎么表现这个过程比读十篇文章有用。
返回列表