资讯详情

从零手写AI Agent:深入ReAct模式与工具系统原理

📅 2026/9/20 5:45:16 | 华诺云谱 👁 阅读
从零手写AI Agent:深入ReAct模式与工具系统原理
市面上讲 AI Agent 的文章我翻过不少绝大多数一上来就甩 LangChain 的代码或者直接调某个框架的封装结果读者跑完 demo 还是不知道 Agent 到底是怎么想的。这篇不一样我们从零开始用最朴素的方式搭一个能跑、能扩展、能真正理解原理的 Agent。整个过程只依赖 Python 和一个 LLM 接口不引入任何重型框架。读完你手里会有一个完整的、每一行代码都能解释清楚的 Agent而不是一个黑盒。先说清楚我们要做什么。一个 AI Agent 的核心就三件事接收任务、决定下一步动作、执行动作并根据结果继续决策。这跟人做事没本质区别——你让助理订机票他会先查航班发现没票就换日期再查再确认最后下单。Agent 就是这个循环的自动化。关键词里的 ReAct、工具系统、LLM本质上都是在服务这个循环。适合谁看如果你写过一点 Python知道函数和字典是什么但对 Agent 只有模糊概念这篇就是给你写的。如果你已经用过某些框架但觉得云里雾里这篇也能帮你把底层逻辑捋顺。全程不需要 GPU不需要本地部署模型一个 API Key 就够。1. 动手之前先把概念理清楚1.1 Agent、LLM、AI 模型到底谁是谁这三个词被混用得最厉害先把它们摆正位置。AI 模型是最大的概念任何能从数据里学到规律并做出预测的程序都算图像识别模型、语音合成模型、推荐模型都是。LLM大语言模型是 AI 模型里专门处理文本的那一类它的特点是参数量大、用海量文本训练、能理解和生成自然语言。DeepSeek、GPT 系列、Claude 系列都属于 LLM。所以如果有人问DeepSeek 属于哪个答案很明确它是一个 LLM而 LLM 是 AI 模型的一个子集。那Agent呢Agent 不是模型它是一个系统。你可以把 LLM 理解成一个很聪明但只会动嘴的大脑你问它什么它答什么但它不能主动去查数据库、不能发邮件、不能读文件。Agent 就是给这个大脑装上手脚——它让 LLM 能够调用工具、观察结果、再决定下一步。所以三者关系是AI 模型 ⊃ LLM而 Agent LLM 工具 循环控制逻辑。这个区分很重要因为它决定了你搭 Agent 时的思路。你不是在训练一个模型你是在编排一个模型让它在一个循环里反复工作。1.2 ReAct 模式Agent 的思考骨架ReAct 是 Reasoning Acting 的缩写是目前最主流的 Agent 工作范式。它的核心思想特别朴素让 LLM 在每一步都先想一下Reasoning再做一个动作Acting然后观察动作的结果Observation基于结果继续想下一步。用文字描述这个循环是这样的思考用户想知道今天北京的天气我需要调用天气查询工具。 动作调用 get_weather参数是 city北京 观察返回结果晴25 摄氏度 思考我已经拿到天气信息可以回答用户了。 最终回答今天北京晴天气温 25 度。看到没LLM 在这里扮演的是决策者它不直接回答问题而是决定我现在该干什么。工具负责真正干活循环控制负责把这一来一回串起来。ReAct 之所以好用是因为它把 LLM 的推理能力和外部工具的执行能力结合了起来弥补了 LLM 不能访问实时信息、不能执行操作的短板。我一开始也不理解为什么要让模型想这一步直接让它输出动作不行吗实测下来加上思考步骤后模型选错工具的概率明显下降。因为强制它先输出推理过程相当于让它打草稿逻辑链条更完整。这跟人做数学题要先写步骤是一个道理。1.3 工具系统Agent 的手和脚工具系统说白了就是一堆函数每个函数干一件具体的事。但关键在于这些函数必须用结构化的方式描述给 LLM 看让 LLM 知道有哪些工具可用、每个工具需要什么参数。一个工具通常包含三部分信息名称name、描述description、参数定义parameters。名称是 LLM 调用时用的标识描述告诉 LLM 这个工具是干嘛的、什么时候该用参数定义告诉 LLM 要传什么。描述写得越清楚LLM 选对工具的概率越高。我踩过的坑就是描述写得太简略比如只写查询数据结果模型根本不知道查的是什么数据经常乱调。工具的设计有个原则一个工具只做一件事且这件事要足够原子化。别搞一个处理用户请求的万能工具而要拆成查询订单取消订单修改地址这种细粒度工具。这样 LLM 的决策空间清晰出错也好定位。2. 环境准备别在第一步就卡住2.1 Python 安装与虚拟环境如果你机器上还没有 Python去官网下 3.10 以上的版本。为什么强调 3.10因为后面我们会用到一些类型注解的新语法低版本会报错。安装时记得勾选Add Python to PATH这一步漏了后面命令行里敲 python 会提示找不到命令是新手最常见的坑。装完验证一下python --version能打印出版本号就对了。接下来强烈建议用虚拟环境别把依赖装到全局。虚拟环境的好处是每个项目独立不会互相污染。创建和激活python -m venv agent-env # Windows agent-env\Scripts\activate # macOS / Linux source agent-env/bin/activate激活后命令行前面会出现(agent-env)字样说明你已经在虚拟环境里了。这时候装的包都只影响这个环境。2.2 依赖安装与 API Key 的安全处理我们只需要两个包一个 HTTP 请求库一个环境变量管理库。pip install requests python-dotenvrequests用来调 LLM 的接口python-dotenv用来管理密钥。这里要重点讲一下密钥泄露的问题这是新手最容易犯的致命错误。很多人图省事直接把 API Key 硬编码在代码里# 千万别这么干 API_KEY sk-xxxxxxxxxxxx一旦你把代码传到 GitHub密钥就暴露了别人可以拿去刷你的额度账单能吓死人。正确做法是用.env文件存密钥代码里读环境变量。在项目根目录建一个.env文件LLM_API_KEY你的密钥 LLM_BASE_URL你的接口地址然后建一个.gitignore文件把.env写进去这样 git 就不会追踪它.env agent-env/ __pycache__/代码里这样读import os from dotenv import load_dotenv load_dotenv() API_KEY os.getenv(LLM_API_KEY) BASE_URL os.getenv(LLM_BASE_URL)注意.env文件永远不要提交到版本库.gitignore一定要在第一次 commit 之前就配好。如果已经提交了光删文件没用历史记录里还在得用工具清理历史或者直接换密钥。2.3 验证接口连通性在写 Agent 之前先单独测一下能不能调通 LLM别等 Agent 写完发现是接口问题排查起来费劲。import os import requests from dotenv import load_dotenv load_dotenv() def test_connection(): url f{os.getenv(LLM_BASE_URL)}/chat/completions headers { Authorization: fBearer {os.getenv(LLM_API_KEY)}, Content-Type: application/json } payload { model: your-model-name, messages: [{role: user, content: 说一句你好}] } resp requests.post(url, headersheaders, jsonpayload, timeout30) print(resp.status_code) print(resp.json()) test_connection()能返回正常内容就说明环境没问题。如果返回 401检查密钥返回 404检查接口地址超时检查网络。这一步跑通后面就顺了。3. 从零实现一个 ReAct Agent3.1 定义工具让 LLM 知道它能干什么我们先定义两个简单的工具来演示一个计算器一个查当前时间。工具本身是普通 Python 函数但我们要额外提供一份给 LLM 看的说明书。import datetime def calculator(expression: str) - str: 计算数学表达式 try: result eval(expression) return str(result) except Exception as e: return f计算错误: {e} def get_current_time() - str: 获取当前时间 return datetime.datetime.now().strftime(%Y-%m-%d %H:%M:%S)注意calculator里用了eval这在生产环境是危险的因为用户可以注入任意代码。这里只是为了演示真实项目一定要用安全的表达式解析库比如ast.literal_eval或者专门的数学解析库。这个坑我必须提醒别直接抄到线上。接下来把工具注册成 LLM 能理解的格式TOOLS { calculator: { function: calculator, description: 计算数学表达式输入应该是一个合法的数学表达式字符串例如 2 3 * 4, parameters: { expression: 要计算的数学表达式 } }, get_current_time: { function: get_current_time, description: 获取当前的日期和时间不需要任何参数, parameters: {} } }这个字典结构就是我们的工具系统核心。function是真正执行的函数description和parameters是给 LLM 看的。描述里我特意写了示例因为实测发现带示例的描述能让模型更准确地构造参数。3.2 构造提示词把工具信息喂给模型LLM 本身不知道我们有哪些工具得通过提示词告诉它。这个提示词是整个 Agent 的灵魂写得好不好直接决定成败。def build_system_prompt(): tool_descriptions [] for name, info in TOOLS.items(): params , .join(info[parameters].keys()) if info[parameters] else 无 tool_descriptions.append( f- {name}: {info[description]} (参数: {params}) ) tools_text \n.join(tool_descriptions) return f你是一个智能助手可以通过调用工具来完成任务。 可用工具 {tools_text} 你必须严格按照以下格式回复 思考: 你的推理过程 动作: 工具名称 动作输入: 工具的参数JSON格式 当你已经可以回答用户问题时使用以下格式 思考: 你的推理过程 最终回答: 你的回答 规则 1. 每次只能调用一个工具 2. 动作输入必须是合法的JSON 3. 如果不需要工具就能回答直接用最终回答格式 这个提示词的关键在于格式约束。我们要求模型输出思考/动作/动作输入这样的固定结构后面才能用代码解析。如果不约束格式模型会自由发挥解析起来就是灾难。我试过不约束格式模型有时候输出一大段话有时候用 markdown 代码块包起来正则都写不过来。3.3 解析模型输出把文本变成结构化动作模型返回的是纯文本我们要从中提取出它想调用哪个工具、传什么参数。这是整个流程里最容易出 bug 的地方。import json import re def parse_response(text: str) - dict: text text.strip() # 检查是否是最终回答 if 最终回答: in text: answer text.split(最终回答:)[-1].strip() return {type: final, content: answer} # 提取动作 action_match re.search(r动作:\s*(.), text) input_match re.search(r动作输入:\s*(.), text, re.DOTALL) if not action_match: return {type: error, content: 无法解析动作} action action_match.group(1).strip() action_input input_match.group(1).strip() if input_match else {} # 清理可能的 markdown 代码块标记 action_input action_input.replace(json, ).replace(, ).strip() try: params json.loads(action_input) except json.JSONDecodeError: params {} return {type: action, action: action, params: params}这里有个实战经验模型经常在 JSON 外面套 markdown 代码块所以要先清理掉json和。另外模型有时候会输出单引号的 JSONjson.loads会失败可以加一层容错用ast.literal_eval兜底。这些都是在真实调用中反复遇到才总结出来的。3.4 主循环让 Agent 转起来现在把前面所有零件组装起来。主循环的逻辑是把对话历史发给 LLM解析它的输出如果是动作就执行工具并把结果加回历史如果是最终回答就结束。def run_agent(user_input: str, max_steps: int 10): messages [ {role: system, content: build_system_prompt()}, {role: user, content: user_input} ] for step in range(max_steps): response call_llm(messages) print(f\n--- 第 {step 1} 步 ---) print(response) parsed parse_response(response) if parsed[type] final: return parsed[content] if parsed[type] error: messages.append({role: assistant, content: response}) messages.append({role: user, content: 格式错误请严格按照要求格式回复。}) continue # 执行工具 action parsed[action] params parsed[params] if action not in TOOLS: observation f错误不存在名为 {action} 的工具 else: try: func TOOLS[action][function] observation func(**params) except Exception as e: observation f工具执行出错: {e} print(f观察结果: {observation}) messages.append({role: assistant, content: response}) messages.append({role: user, content: f观察结果: {observation}}) return 达到最大步数限制任务未完成call_llm就是封装好的接口调用函数把 messages 发出去拿回文本。max_steps是安全阀防止模型陷入死循环一直调工具。我见过模型因为工具一直返回错误反复重试同一个动作没有步数限制的话能跑到天荒地老烧钱又没结果。跑一下试试result run_agent(帮我算一下 (15 27) * 3 等于多少然后告诉我现在几点) print(\n最终结果:, result)正常的话你会看到模型先调 calculator 算出 126再调 get_current_time 拿到时间最后整合成回答。整个过程你能看到每一步的思考和动作这就是 ReAct 的魅力——透明、可调试。4. 让它更靠谱错误处理与稳定性优化4.1 模型不按格式输出怎么办这是最高频的问题。模型有时候会忘记格式输出一段自然语言或者把动作写成Action。应对策略有三层。第一层是提示词里反复强调格式并且给出正例。第二层是解析失败时把错误信息喂回去让模型自己纠正就像上面代码里parse_response返回 error 时那样。第三层是解析时做模糊匹配比如同时匹配动作和Action用正则的|就能搞定action_match re.search(r(?:动作|Action):\s*(.), text, re.IGNORECASE)我实测下来加了错误回喂之后模型自我纠正的成功率挺高一般一两次就能回到正轨。但如果连续三次都格式错误建议直接中断说明这个模型对指令的遵循能力太差换个模型比死磕更划算。4.2 工具执行失败的兜底工具报错是常态网络超时、参数类型不对、外部服务挂了都可能。关键原则是工具的错误不要抛出去中断整个 Agent而要作为观察结果返回给模型。try: observation func(**params) except TypeError as e: observation f参数错误{e}请检查参数格式 except Exception as e: observation f执行失败{e}把错误信息返回给模型后它往往能自己调整。比如参数传错了它看到参数错误就会重新构造。这比直接崩溃友好太多。但要注意错误信息里别泄露敏感内容比如数据库连接串返回给模型的东西要过滤。4.3 控制成本与死循环Agent 每一步都要调一次 LLM步数多了成本直线上升。几个实用的控制手段控制手段作用建议值max_steps限制最大循环次数8-15工具超时防止单个工具卡死10-30 秒重复动作检测发现原地打转就中断连续 2 次相同动作历史裁剪控制上下文长度保留最近 10 轮重复动作检测特别有用。实现很简单记录上一步的动作和参数如果这一步完全一样就注入一条提示你已经执行过这个动作请换一个思路或者直接中断。我遇到过模型因为工具返回的结果它看不懂就一直重复调用加上这个检测后问题基本消失。5. 从玩具到实用进阶方向5.1 记忆系统让 Agent 记住上下文我们现在的 Agent 只有短期记忆就是那个 messages 列表对话一结束就没了。真实场景往往需要长期记忆——记住用户偏好、记住之前做过的事。最简单的做法是把历史对话存到文件或数据库每次启动时加载。进阶一点的做法是引入向量检索把历史对话转成 embedding 存起来需要时按相似度召回。关键词里提到的 embedding 就是这个用途。不过对于入门阶段先用文件存 JSON 就够了别一上来就上向量库复杂度陡增。5.2 多工具与工具编排真实项目里工具可能有几十个这时候工具描述的组织就很重要。可以给工具分类提示词里按类别列出减少模型的决策负担。另外可以引入工具选择的预处理步骤先用一次 LLM 调用筛选出可能相关的几个工具再让主循环在缩小后的范围里选这样准确率和成本都能优化。5.3 接入现成框架的时机什么时候该从手写转向框架我的判断标准是当你需要多 Agent 协作、复杂的工具依赖管理、或者要接入大量现成集成时框架能省很多事。但如果只是单 Agent 加几个工具手写的可控性反而更好出问题也好排查。别为了用框架而用框架先把原理搞懂框架只是加速器。6. 几个我踩过的坑和对应解法第一个坑是提示词里的工具描述和实际函数签名不一致。有次我改了函数参数名忘了同步改描述结果模型一直按旧参数名传工具一直报错。后来我养成了习惯工具描述直接从函数签名自动生成避免手动维护出错。第二个坑是JSON 解析的边界情况。模型有时候会在 JSON 后面加一句解释比如{expression: 11} 这是计算结果直接json.loads就崩了。解法是用正则先提取出最外层的{...}再解析。这个正则要写得贪婪一点匹配到最后一个}。第三个坑是中文标点。模型偶尔会用中文引号或者全角括号导致 JSON 非法。可以在解析前做一次字符替换把中文标点转成英文。虽然不优雅但很实用。第四个坑是上下文越来越长导致变慢变贵。对话轮次多了之后每次请求都带着全部历史token 消耗飞快。解法是定期裁剪只保留系统提示词、原始任务和最近几轮交互。如果任务需要早期信息就把它摘要成一句话塞进系统提示词里。第五个坑是工具返回结果太长。比如查数据库返回几千行全塞进上下文直接把 token 撑爆。解法是在工具内部做截断或摘要只返回关键信息。工具的设计要考虑给模型看这个场景不是返回得越全越好。这套手写 Agent 的代码我用了挺久虽然简陋但每一行都清楚在干什么。后来我把它当模板换不同的工具就能快速搭出各种小助手。真正理解了 ReAct 循环和工具系统之后再去看那些框架的源码会发现它们做的无非是把这套逻辑工程化、加了更多容错和集成而已。底层的东西通了上层用什么工具都是顺手的事。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。