资讯动态

从零手写ReAct Agent:深入解析Tool Calling与循环机制

发布时间:2026/10/20 19:55:54 来源:尧图企业网站定制
1. 从一次工具调用失败说起为什么要手写ReAct Agent去年冬天我在调试一个自动化任务系统时遇到了一个很典型的问题模型明明知道该调用哪个工具参数也传对了但整个流程就是跑不通。日志里显示工具被调用了返回结果也拿到了可模型下一轮却像失忆一样完全忽略了这个结果继续重复调用同一个工具。当时我第一反应是框架的bug翻了两天源码才发现问题出在我对Tool Calling的理解上——我一直以为它是模型的原生能力实际上它只是ReAct循环里的一环而循环的终止条件、观察结果的注入方式、消息历史的组织格式每一个细节都会影响最终行为。这件事之后我决定抛开所有现成的Agent框架从零手写一个ReAct Agent。不是为了造轮子而是想搞清楚三件事Tool Calling到底是怎么被触发的观察结果是怎么回到模型上下文里的循环什么时候该停这三个问题不搞清楚用再高级的框架也只是在调参碰运气。这篇文章适合两类人一类是正在用Agent框架但总觉得“不太可控”的开发者另一类是好奇ReAct和Tool Calling底层机制的技术爱好者。我会从最核心的循环结构讲起然后拆解工具调用的触发逻辑接着讲观察结果的注入方式最后分享我在手写过程中踩过的几个坑。全程不依赖任何Agent框架只用最基础的模型调用接口代码逻辑用伪代码和Python片段说明你可以直接照着复现。提示本文假设你已经了解基本的对话模型调用方式知道什么是system prompt和user message。如果这些概念还不太清楚建议先补一下基础再往下看。2. ReAct循环的骨架Thought、Action、Observation三段式到底怎么跑2.1 为什么是这三段而不是别的结构ReAct这个名字拆开就是Reasoning和Acting核心思想是让模型在行动之前先“想一下”。这个“想”不是随便想而是有明确格式约束的推理过程。我最初以为Thought只是一个可选的装饰后来发现它承担了一个非常实际的功能给模型一个显式的推理空间让它在决定调用哪个工具之前先把当前状态和下一步目标对齐。如果没有Thought模型直接输出Action会发生什么我实测过在工具数量超过5个的场景下模型选错工具的概率会明显上升。原因很简单模型需要在一次前向传播中同时完成“理解当前状态”和“选择工具”两个任务而Thought相当于把第一个任务单独拎出来让模型先输出一段自然语言推理再基于这段推理做选择。这就像你让人直接报答案他可能蒙但让他先写个解题思路再报答案准确率就上来了。Observation是工具执行后的返回结果。这里有个容易忽略的点Observation不是随便塞回去就行它的格式和位置会直接影响模型下一轮的推理。我试过把Observation放在assistant消息里也试过单独作为一条user消息效果完全不同。后面会详细讲这个坑。三段式的循环逻辑用伪代码表示大概是这样while not done: thought model.generate(context Thought:) action parse_action(thought) if action.type final_answer: done True break observation execute_tool(action) context fThought: {thought}\nAction: {action}\nObservation: {observation}\n看起来很简单但每一行都有讲究。parse_action怎么解析execute_tool的异常怎么处理context怎么拼接才不会让模型困惑这些细节才是真正决定Agent能不能跑通的关键。2.2 循环终止条件模型说“我完成了”就真的完成了吗终止条件是我踩的第一个大坑。最初我的设计是模型输出一个特殊的Action类型叫finish我就认为任务结束。听起来很合理对吧但实际跑起来发现模型有时候会在信息还不完整的时候就输出finish因为它觉得“差不多够了”。更麻烦的是有时候模型会陷入死循环反复调用同一个工具每次都输出finish但又不给出最终答案。后来我改成了双重判断模型必须输出finish并且附带一个非空的最终答案同时循环次数不能超过预设上限。上限这个事很关键我一般设8到10轮超过就强制终止并返回当前最好的结果。为什么是8到10因为实测下来大多数任务在5轮以内就能完成超过8轮的基本都是模型卡住了再跑下去只是浪费token。还有一个细节终止时要把完整的消息历史返回给调用方而不是只返回最终答案。因为调试的时候你需要看到中间每一步的Thought和Observation不然出了问题根本不知道是哪一步歪了。2.3 消息历史的组织方式为什么不能简单拼接字符串我一开始图省事直接把Thought、Action、Observation拼成一个长字符串塞进context。跑简单任务没问题但任务一复杂就出问题模型会分不清哪些是它自己说的话哪些是工具返回的结果。有一次它把Observation里的内容当成了自己的Thought然后在下一轮里说“根据我之前的推理……”实际上那段推理是工具返回的JSON。正确的做法是用结构化的消息列表每条消息有明确的role。我的做法是system message放工具定义和格式要求user message放原始任务描述assistant message放Thought和Actionuser message放Observation注意这里用user角色不是assistant为什么Observation要用user角色因为从模型的角度看Observation是“外部世界给它的反馈”而不是它自己生成的内容。用assistant角色会让模型误以为这是它自己的输出从而在后续推理中产生混淆。这个细节我在三四个不同的模型上都验证过用user角色明显更稳定。3. Tool Calling的本质模型到底是怎么“选择”工具的3.1 工具定义不是越多越好关键是描述的质量很多人以为工具定义就是把函数签名翻译成JSON Schema参数名、类型、描述填上就行。我一开始也这么想直到发现模型经常选错工具。后来我把工具描述改了一版选对率从大概六成提升到了九成以上。改了什么主要是三点第一描述里要写清楚“什么时候用这个工具”而不只是“这个工具是什么”。比如一个搜索工具不要只写“搜索互联网”要写“当需要获取实时信息、最新数据或你不确定的事实性内容时使用此工具”。这样模型在犹豫要不要搜索的时候就有了明确的判断依据。第二参数描述要给出示例值。比如query参数不要只写“搜索关键词”要写“搜索关键词例如‘2024年新能源汽车销量’”。模型看到具体示例后生成参数时会更准确尤其是涉及日期格式、单位这种容易出错的参数。第三工具数量控制在7个以内。我试过给模型15个工具结果它经常在相近的工具之间反复横跳。后来精简到5个核心工具准确率明显提升。如果确实需要很多工具可以考虑分组或者用两级选择先让模型选工具类别再选具体工具。3.2 Action的解析为什么正则比JSON解析更可靠模型输出的Action格式我试过两种方案一种是要求模型输出JSON然后用json.loads解析另一种是要求模型输出特定格式的文本然后用正则提取。实测下来正则方案在稳定性上完胜JSON方案。原因在于模型生成JSON时经常出现一些微妙的问题多一个逗号、少一个引号、字符串里有没有转义的反斜杠。这些问题在人类看来很小但json.loads会直接抛异常。而正则方案对格式的容忍度更高只要关键字段能提取出来就行。我的Action格式是这样的Action: tool_name Action Input: {param1: value1, param2: value2}解析时先用正则匹配Action:\s*(\w)提取工具名再用正则匹配Action Input:\s*(\{.*?\})提取参数JSON。如果JSON解析失败就尝试用更宽松的方式提取键值对。这样即使模型输出格式有点小瑕疵也能正常执行。注意正则里的.*?要用非贪婪模式否则遇到多个Action时会匹配到最后一个导致解析错误。这个坑我踩过调试了半天才发现是正则贪婪模式的问题。3.3 工具执行的安全边界不要让模型直接操作危险接口手写Agent最大的好处是你完全控制工具执行层。我在工具执行外面包了一层安全检查主要做三件事参数校验检查必填参数是否存在、类型是否正确、值是否在允许范围内。比如一个删除文件的工具路径参数必须限定在特定目录下。超时控制每个工具调用设置最大执行时间超过就强制中断并返回超时错误。没有这个的话一个卡住的工具会让整个Agent挂起。异常捕获工具执行抛出的任何异常都要被捕获转换成模型能理解的错误信息返回。不要让异常直接冒泡到循环外面否则Agent会直接崩溃。错误信息的格式也很重要。不要只返回“执行失败”要返回具体的失败原因比如“参数date格式错误期望YYYY-MM-DD实际收到‘2024/01/01’”。这样模型下一轮就能根据错误信息修正参数而不是盲目重试。4. 观察结果的注入为什么你的Agent总是“忘记”上一步做了什么4.1 Observation的格式设计结构化比自然语言更有效我最初把工具返回的原始结果直接塞回context比如一个搜索工具返回一大段网页文本。结果模型经常抓不住重点要么忽略关键信息要么被无关内容带偏。后来我改成工具返回结构化数据再由一个格式化层转成简洁的Observation。举个例子搜索工具返回的是JSON数组每条包含标题、摘要、链接。我不会把整个JSON塞回去而是格式化成Observation: 找到3条相关结果 1. 标题A - 摘要A 2. 标题B - 摘要B 3. 标题C - 摘要C这样模型一眼就能看到关键信息不会被JSON的括号和引号干扰。如果结果太长我会截断到前500个字符并在末尾加“...结果已截断”。截断这个事很关键不截断的话context会迅速膨胀不仅浪费token还会让模型注意力分散。4.2 多轮观察的累积什么时候该丢弃历史ReAct循环跑多了之后context里会堆积大量的Thought和Observation。我实测发现超过6轮之后模型对早期Observation的回忆准确率会明显下降。这不是模型的问题而是注意力机制本身的限制——context越长每个token分到的注意力越少。我的处理策略是保留最近3轮的完整交互更早的Observation只保留摘要。摘要怎么生成简单粗暴的方式是只保留每条Observation的第一句话复杂一点的方式是让模型自己总结。我一般用第一种因为够用且不额外消耗token。还有一种情况需要特殊处理如果某一轮的工具调用失败了那条Observation要保留完整的错误信息不能摘要。因为错误信息是模型修正行为的依据摘要后可能丢失关键细节。4.3 观察结果与推理的衔接如何让模型“接得上”上一轮这是我最开始没注意到的问题模型有时候会忽略上一轮的Observation直接开始新的推理。比如上一轮搜索返回了结果它下一轮却说“我需要搜索一下”。后来我在system prompt里加了一句话“在每一轮Thought开始时先总结上一轮Observation的关键信息再决定下一步行动。”这句话加进去之后模型“接不上”的情况减少了大概七成。另一个技巧是在Observation末尾加一个引导词比如“基于以上结果下一步应该”。这相当于给模型一个软性的格式提示让它自然地从观察过渡到推理。不要小看这种引导词在模型行为控制上格式提示往往比内容提示更有效。5. 手写过程中踩过的五个坑每一个都让我重新理解了Agent5.1 坑一system prompt里工具定义和格式要求打架我一开始把工具定义和输出格式要求都放在system prompt里结果模型经常混淆要么把工具定义当成输出格式的一部分要么在Action里输出工具定义的原文。后来我把它们分开工具定义放在system prompt的开头用明确的标记隔开比如tools和/tools格式要求放在system prompt的末尾用format和/format包裹。这样模型能清楚地区分两部分内容。5.2 坑二模型输出带前缀的Action正则匹配不到有些模型喜欢在Action前面加“好的我将调用”之类的自然语言。我的正则一开始只匹配行首的Action:结果这些带前缀的输出全部解析失败。后来我把正则改成Action:\s*(\w)不加行首锚定同时在解析前先做一次清洗把常见的自然语言前缀去掉。这个坑让我意识到永远不要假设模型会严格按照你要求的格式输出解析层必须有一定的容错能力。5.3 坑三工具返回空结果时模型陷入死循环有一次搜索工具返回了空数组模型收到Observation: 未找到相关结果之后下一轮又调用了同一个搜索工具参数一模一样。然后又是空结果又调用循环了五六次才因为达到上限而终止。后来我在Observation里加了引导“未找到相关结果建议尝试其他关键词或使用其他工具。”这样模型就知道该换策略了而不是傻傻重试。5.4 坑四多工具并行调用时的顺序依赖有些任务需要先调用工具A拿到结果后再调用工具B。但模型有时候会同时输出两个Action期望并行执行。如果B依赖A的结果并行就会出错。我的处理方式是在system prompt里明确说明“每次只能调用一个工具等待Observation后再决定下一步”。虽然牺牲了并行效率但换来了稳定性。如果确实需要并行可以在工具层做依赖检查发现依赖未满足就返回错误让模型重新规划。5.5 坑五最终答案的格式与中间步骤混淆模型有时候会把最终答案也包装成Action格式比如Action: final_answer但内容却是空的。或者反过来在应该输出Action的时候直接给出了最终答案。我的解决方案是在system prompt里用非常明确的格式说明如果你已经可以回答用户的问题输出 Final Answer: [你的答案] 如果你需要调用工具输出 Action: [工具名] Action Input: [参数JSON]同时解析层要能区分这两种情况。我一般先检查是否包含Final Answer:如果有就直接提取答案并终止循环否则再尝试解析Action。6. 从手写Agent到理解框架为什么自己实现一遍这么重要手写这个ReAct Agent花了我大概三个周末的时间代码量不到500行但收获远超之前用框架调参的几个月。最大的体会是框架帮你屏蔽了细节但也屏蔽了你对问题的理解。当你自己实现一遍之后再回头看那些框架的文档和源码会有一种“原来如此”的感觉。比如LangChain的AgentExecutor之前我一直搞不懂它的max_iterations和early_stopping_method到底怎么配合。自己写完循环之后才明白max_iterations就是循环上限early_stopping_method决定达到上限后是返回错误还是返回当前最好的结果。这些概念在手写过程中变得非常具体不再是抽象的配置项。另一个收获是对模型行为的理解。手写过程中你会被迫思考模型为什么会输出这个格式为什么它会忽略Observation为什么它会在工具选择上犹豫这些问题的答案在框架的使用文档里是找不到的只有自己一步步调试才能体会。如果你也在用Agent框架我强烈建议你抽时间手写一个最小实现。不需要支持很多工具不需要复杂的错误处理只要能跑通“推理-行动-观察”的完整循环就行。写完之后你对Tool Calling和ReAct的理解会上一个台阶再用框架的时候也会更有掌控感。最后分享一个我在调试时常用的小技巧在每一轮循环结束时把完整的context打印到日志里包括system prompt、每一条消息的role和content。这样当Agent行为异常时你可以直接看到模型实际收到的输入是什么而不是靠猜。这个习惯帮我省了很多调试时间也让我发现了不少消息格式上的问题。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑