手写极简ReAct Agent框架:几百行代码实现可控透明工具调用

📅 发布时间:2026/10/21 22:46:35
手写极简ReAct Agent框架:几百行代码实现可控透明工具调用
前阵子我需要一个能把大模型接进外部工具的轻量方案。翻了一圈现成的 Agent 框架要么体积太大要么把底层逻辑包得严严实实任务失败时想追查一句“它当时为什么调用这个工具”都无处下手。于是我自己动手写了个极简的 Agent 运行框架取名叫 rea——就是 ReAct 模式前三个字母的组合。先说清楚 rea 是个什么东西它不依赖任何第三方 Agent 库主体代码只有几百行核心职责只有一个——让大模型在“思考”和“行动”之间循环直到任务完成。它能干什么接上任意一个大模型 API给它注册几个工具计算器、查时间、读文件、查数据库都行你就能像吩咐一个实习生一样交办任务它会自己拆步骤、调用工具、观察结果、继续推进最后把答案整理好交给你。哪类人适合看这篇文章我觉得两种人最合适。一种是在用各种 Agent 产品但总觉得背后像黑盒想一探究竟的人另一种是开发者想在项目里嵌一个轻量、可控、没有沉重依赖的 Agent 能力而不是引入一个几万行代码的庞然大物。如果你属于其一这篇内容应该能给你一套可以直接抄作业的实现思路。1. 项目缘起为什么放着现成框架不用非要自己写1.1 现有方案给我带来的三个痛点我原本的计划是直接用某款主流的 Agent 编排框架省得从轮子开始造。结果深入调研并跑了一个 Demo 之后反而坚定了自己写的决心。三个痛点非常具体。第一抽象层级太多。这类框架会把流程拆成链、代理、记忆、回调、工具规范、会话状态等一堆概念概念之间互相嵌套光看文档就要一两天。真到排错的时候框架把大模型的一长串原始输出直接甩给你中间哪一步判断错了、为什么走了这条路链路完全不可见。我一度为了排查一个问题把框架内部的调试日志全打开仍然要翻几百行晦涩的日志才能勉强定位。第二工具接入成本偏高。框架官方文档说“注册工具很方便”实际上要写一个工具类继承基类、实现回调方法、配置参数 schema、再注册一堆元信息一个五分钟能搞定的计算器函数硬要花半小时去适配框架的规矩。如果我只是想快速验证一个想法这种约束完全不能接受。第三功能覆盖和我的需求不匹配。这类框架倾向于把多种运行模式计划执行、反思、多智能体协作等都打包进来而我只想要一条足够清晰的 ReAct 主线。为了用一条主线去加载一大堆用不到的功能既浪费心智也让排错变得复杂。所以 rea 的设计目标从一开始就很明确做一个所有人能一眼看穿全部代码的 Agent 骨架。它不追求覆盖所有场景但把“推理—行动—观察”这条主线做成最扎实、最可控的一段代码。1.2 rea 的核心目标可控、透明、可扩展如果用三句话概括 rea 的定位是这样可控最大步数、重试策略、停止条件全部是显式参数谁都能改。透明每一步的 thought、action、observation 都打印出来任务结束后能够完整复盘。可扩展工具即函数注册即用不引入任何框架层面的约束。后面所有的内容都围绕这三句话展开。我先拆解 ReAct 这个模式本身再讲 rea 的代码实现然后给两个实操案例最后把我自己踩过的几个坑拿出来晒一晒。2. 核心机制拆解ReAct 循环到底在循环什么2.1 Thought / Action / Observation 的闭环ReAct 这个名字来自 Reasoning 与 Acting 的组合通俗讲就是让大模型“边想边做”而不是一次性憋出一个完整答案。循环中的每一步由三个角色构成Thought模型对当前状态的推理输出自然语言相当于模型在说“我觉得现在该做什么、为什么”。Action模型决定调用某个具体工具并携带参数。这一步会被解析成代码里的函数调用。Observation工具执行后返回的结果作为新的事实塞回上下文供模型做下一轮推理。你可以把这三者理解成“订外卖”的流程你先看一眼冰箱Observation得出结论家里没菜了Thought掏出手机下单Action等外卖送到再决定下一步怎么做新的 Observation 触发新的 Thought。这套流程对人类是本能但要在大模型身上稳定复现必须在代码层面搭建一个明确的循环结构。2.2 一个具体例子四则混合运算为了让概念落地拿一个最简单的任务来说明。用户问“(23.5 × 6 12) ÷ 3 等于多少”如果只靠大模型心算这类混合运算经常出错即便是主流大模型也偶尔会把优先级或小数处理错。但在 ReAct 模式下模型完全可以采用更稳妥的策略——借助计算器。第一轮循环模型输出的 thought 可能是“这是一个连续混合运算为了避免计算错误我应该调用计算器工具。”对应的 action 是 calculatoraction_input 是表达式字符串“(23.5*612)/3”。工具立刻返回约 51.6667这条结果被包装成 observation 进入下一轮上下文。第二轮循环模型看到运算结果输出 thought“结果已经拿到不需要再做其他操作。”随后 action 为 finish把 51.6667 作为最终答案返回。注意一个关键点整个过程里模型并没有真正参与运算它只负责两件事——判断何时调用工具以及解读工具返回的结果。这恰恰是 ReAct 最聪明的地方语言推理和精确计算各司其职让擅长的人干擅长的事。2.3 为什么这样设计能减少错误率在用 rea 跑了几十个测试任务之后我对这套机制为什么有效有了更实在的体会原因主要有三条。第一把计算负担外包给工具直接消除了大模型在算术上的幻觉。模型不再需要凭空推导数值它只需要能读懂结果而解读数字比计算数字可靠得多。第二推理链条完全可追溯。某一步错了你能明确看到是模型选错了工具还是参数写错还是结果被错误解释。这比一次性生成完整答案的黑盒模式容易排查得多。第三天然支持多步骤任务。因为每一步的 observation 都会成为下一步的输入模型能在执行过程中不断吸收新信息、修正计划而不是把整条路径都押在第一次输出上。像那种“先查日期、再算间隔”的问题只有循环式架构才能自然完成。3. 代码实现主循环、工具注册与解析容错3.1 整体架构一个人能看完的项目rea 刻意保持单体结构总共三个核心文件agent.pyAgent 主循环与提示词组装tools.py工具注册表、内置工具parse.py模型输出的解析与容错这么设计没有别的理由就是为了让任何人 clone 下来之后十分钟内能通读全部代码。工具注册集中在 tools.py主循环只在 agent.py 里想改行为、加功能边界都非常清晰。运行时依赖也只有两个一个你准备调用的大模型 API任何支持对话补全的服务都行以及一个能发 HTTP 请求的 Python 库。核心代码不依赖任何 Agent 抽象。3.2 工具注册机制装饰器即插即用工具注册是 rea 最重视的开发者体验我采用装饰器模式新增工具完全不碰主循环代码# tools.py TOOLS {} def register_tool(name): def decorator(func): TOOLS[name] func return func return decorator register_tool(calculator) def calculator(expression: str) - str: 计算四则运算表达式返回表达式结果。 # 具体实现见 3.4 的安全求值 ...装饰器的好处有两个。第一新工具的代码和它的注册信息放在一起看到函数就能看到工具名和职责。第二函数签名本身就是参数说明action_input 会被原样传给函数不用额外定义 schema。我还内置了两个常用工具一个 get_current_time用于获取当前日期时间一个 calculator用于安全计算表达式后面实操案例都会用到它们。3.3 Agent 主循环核心代码不超过四十行主循环是整个 rea 最核心的部分直接贴精简后的代码# agent.py def run_agent(instruction: str, max_steps: int 8) - str: messages [ {role: system, content: SYSTEM_PROMPT}, {role: user, content: instruction}, ] for step in range(max_steps): reply llm_chat(messages) messages.append({role: assistant, content: reply}) print(f--- Step {step 1} ---) print(reply) result parse_reply(reply) if result[action] finish: return result[output] tool TOOLS.get(result[action]) if tool is None: observation f错误未找到工具 {result[action]} else: try: observation tool(result.get(action_input, )) except Exception as exc: observation f工具执行出错{exc} messages.append({ role: user, content: fObservation: {observation}, }) return 达到最大步数任务未完成。删掉注释和空行这个函数不足四十行。但几个关键设计点值得展开讲。第一observation 为什么要放在 user 消息里而不是 system 消息绝大多数对话模型遵循 system/user/assistant 三角色交替的规则在 assistant 输出之后跟一条 user 消息是最稳妥、不会触发格式报错的做法。我最初尝试用 system 消息承载 observation结果部分模型在长对话中开始忽略它改成 user 消息之后稳定了许多。第二工具不存在或抛异常时我并没有中断循环而是把错误信息转成 observation 归还给模型。这相当于给了模型一次自我修正的机会——它看到错误提示后通常会换一个工具或修正参数再试。这个“错误回灌”设计是任务失败率下降的关键推手。第三max_steps 默认 8 是一个调出来的经验值。步骤太少复杂任务跑不完步骤太多累计的 token 开销和时间成本会剧增。我试过 3 步简单任务偶尔能过一遇到多步依赖就卡死也试过 20 步大部分任务在第 6 到第 8 步已经得出结论后面全是无效调用。8 步在当前场景下是一个不错的平衡点。3.4 输出解析让格式漂移不再致命模型输出 JSON 时经常前后夹带多余内容比如先来一句“好的我来调用工具”再跟一段 JSON甚至直接用自然语言回答。解析函数需要做到三点从回复中稳健抽取 JSON、解析失败时优雅降级、不把异常抛出循环外。# parse.py import json import re def parse_reply(reply: str) - dict: match re.search(r\{.*\}, reply, re.S) if not match: return {action: finish, output: reply} try: return json.loads(match.group()) except json.JSONDecodeError: return {action: finish, output: reply}解析成功就返回结构化的 action 与参数解析失败就把整段回复当作最终答案返回。我称之为“降级优先”策略——宁可让用户直接看到模型的原始回答也好过循环卡死或后台报错。这个策略后来在实战中救了我好几次因为总有模型的输出格式会偶尔抽风必须要有一个不会炸的兜底路径。4. 实操过程搭一个能用的多工具 Agent4.1 前置条件与最小依赖要复现这套代码只需三件事装好 Python 与 HTTP 客户端拿到一个大模型 API 的访问权限和密钥把密钥配置到环境变量里。整个项目依赖极轻不需要安装任何 Agent 框架。系统提示词SYSTEM_PROMPT是决定 ReAct 循环能否正常工作的关键这里给出一份经过多次调整的模板你是任务执行助手。当需要外部信息或精确计算时你必须按以下 JSON 格式输出 {thought: 你对当前情况的判断, action: 工具名或 finish, action_input: 传给工具的参数或最终答案} 如果结果已经拿到输出 action 为 finish。 可用工具calculator(expression) 计算四则运算get_current_time() 获取当前日期时间。这份提示词里有一个容易被忽略的细节我把可用工具清单直接写死在系统提示词里。这样做的好处是模型在每个循环步骤都能看到工具列表不必翻找上下文。如果以后工具变多可以用程序动态拼接这段内容不必手工维护。4.2 场景一带计算器的连续运算第一个演示任务是前面提到的那道混合运算题。跑起来后控制台会打印出非常清晰的链路--- Step 1 --- {thought: 这是一个连续混合运算直接心算容易出错我应该使用计算器工具。, action: calculator, action_input: (23.5*612)/3} --- Step 2 --- {thought: 计算器返回了结果可以直接给出答案。, action: finish, action_input: 51.666666666666664}整个过程非常快大模型没有做一丁点算术结果却精确到了浮点数的极限精度。这个例子也顺便验证了 rea 的核心卖点模型负责指挥工具负责干活两者边界干净明确。4.3 场景二多步工具依赖的信息查询第二个演示案例更有意思任务是这样一句话“今天是几号距离下个月的第一天还有多少天”第一步模型的 thought 是“我先要拿到今天的日期才能计算差值”于是调用 get_current_time。工具返回今天的日期字符串。第二步模型拿到日期后thought 变成“今天是已知日期需要通过计算器算出到下个月一号的天数差”。于是调用 calculator把两个日期转换成可计算的表达式。工具返回天数。第三步模型输出 finish汇总出完整答案。这个场景能跑通靠的正是前面提到的两个设计错误回灌以及 observation 放进 user 消息。如果哪一步工具报错模型会自动换一种表达方式再试用户几乎感知不到内部发生了纠错。这种容错能力是生产环境中 Agent 能不能用起来的决定性因素之一。4.4 和重量级框架的取舍对比写到这里把 rea 和那些重量级框架放一起做个对比方便判断什么场景该用谁维度rea 这类极简框架重量级 Agent 框架代码量几百行可通读数万行起步工具接入一个装饰器搞定继承、回调、配置全套排错体验每步打印链路透明依赖框架日志解析功能覆盖聚焦 ReAct 主线多模式、记忆、多智能体学习成本一个下午数天到数周适用场景深度定制、轻量嵌入、学习研究复杂企业级系统开发结论很简单要深度定制、要搞懂机制、要轻量嵌入选 rea 这种自研骨架最合适要快速铺开多智能体协作、要利用庞大生态那还是用重量级框架更划算。没有谁绝对更好只有匹配不匹配。rea 的价值不在于功能有多全而在于你能真正看懂它——对于想进阶的人能把底层读透比单纯调包重要得多。5. 踩坑实录与排查思路速查5.1 模型输出格式漂移这是我在使用中遇到频率最高的问题。系统提示词里明明要求 JSON模型偶尔还是输出“好的我来调用工具……”这类自然语言导致解析函数直接走降级路径任务失败。排查思路其实不复杂先看 parse_reply 拿到的原始输入确认模型到底输出了什么再检查系统提示词里是否给了足够强的约束样例。我在提示词里固定带了一个 JSON 示例之后格式漂移发生率明显下降。另一个技巧是解析失败时把整段回复打印出来很多时候能帮你定位到是哪个环节没写清楚让模型产生了误解。5.2 无限循环同一个工具反复被调用最典型的现场是模型连续四五轮输出相同的 action 和相同的参数observation 也一直没有提供新信息整个循环在原地打转。本质原因是模型没有从 observation 中获取到有效反馈。对策分两层。第一层是硬止损max_steps 参数兜底绝不让它无限跑下去。第二层是软干预我在主循环里加了一个轻量去重逻辑记录最近三轮的“action action_input”组合如果完全重复就强制终止并在结果里提示疑似陷入循环。这个简单去重实测能拦截掉一大半死循环场景。5.3 上下文窗口膨胀ReAct 模式下每一轮循环都会把 assistant 回复和 observation 追加进 messages任务稍长prompt 体积就蹭蹭上涨。token 一多接口延迟变高、费用上升模型还容易在长上下文里注意力涣散、忽略早期指令。我的处理方式是双重截断observation 超过 500 字符就截断后再放入消息同时只保留最初的 system 消息、用户原始指令和最近四轮对话历史。远古轮次的细节对当前决策基本没有帮助没必要一直背着。5.4 工具报错后模型不自愈反而重复犯错最让我头疼的是某些工具抛出了明确错误比如“参数不是一个合法的数学表达式”模型却仍然用一模一样的参数重试七八遍。后来我发现问题出在错误消息的措辞上。解决办法是让工具在异常分支返回“可行动的提示”而不是冷冰冰的异常信息。拿计算器举例表达式解析失败时我不返回“SyntaxError: invalid syntax”而是返回“表达式格式应为字符串例如 (12)*3。请检查括号、空格和运算符后再试”。当模型从观察结果中看到一条具体的修正建议时下一轮输出明显聪明了很多。这类“错误翻译”是 Agent 工程里很值得投入成本的一个环节。我把常见问题整理成一张速查表方便直接对号入座现象可能原因处理方式输出无法解析为 JSON提示词约束不足在系统提示词里给 JSON 示例解析失败走降级路径循环不终止模型没有有效反馈设置 max_steps对重复调用强制终止上下文过长每轮追加消息截断 observation只保留最近四轮历史工具错误反复出现错误消息没有指导性把异常翻译成带修正建议的提示再回灌6. 我后来想明白的几个设计取舍与迭代思路6.1 后续迭代的三个方向rea 做到现在这个程度已经能稳定完成不少任务但我很清楚它的边界在哪里如果继续迭代我会优先考虑三个方向。第一个方向是给循环加上规划层。现在它是纯 ReAct每一步都靠模型即兴判断对步骤特别多的任务可以先让模型生成一个粗略计划再在计划引导下逐步执行。这相当于给模型配了一张地图比每一步都看路标更高效。第二个方向是记忆管理。目前上下文窗口靠截断硬扛后续可以引入一个轻量摘要机制当对话步数超过阈值时用大模型把前面的关键信息压缩成一段摘要替换掉原始消息。这是从“丢数据”到“精炼数据”的升级。第三个方向是工具调用方式的扩展。现在的 action_input 只支持字符串后续可以支持结构化参数和复杂对象传递让工具能访问更大范围的数据。6.2 亲手实现一遍机制比读十篇文档都管用最后分享一点个人经验。在折腾 rea 的过程中我最大的收获不是代码本身而是亲手实现一遍之后获得的底层理解。读文档和写代码对知识的吸收程度完全不同。如果你也想深入了解 Agent 这类系统我强烈建议找一个热门模式用最小代码实现一遍再把实现途中撞见的各种错误当成最好的学习素材。这些坑永远不会出现在官方文档里。