资讯动态

手写100行Python Agent:核心循环与工具调用全解析

发布时间:2026/10/22 11:33:56 来源:尧图企业网站定制
如果你被各种 AI Agent 概念折腾得够呛——什么 Multi-Agent、Plan-and-Execute、记忆机制、工具调用、ReAct 循环——建议你先别急着去啃那些架构图和论文。我教新手最快的路径是先亲手写一个 100 行的最小 Agent让它能跑、能干活、能出 bug然后所有概念一下子就都有了落脚点。这篇就干这个事用 Python 写一个脚本复刻一把标题里说的 OpenClaw 这类开源通用 Agent 的核心链路。这类 Agent 的共同点是给大模型接上终端、文件读写等工具让它能自己规划、执行、纠错直到完成你布置的任务。我们不去复制它的工程外壳只复刻最核心的“思考-行动-观察”循环。跑通之后你会发现那些看起来很唬人的框架本质就是今天这 100 行代码加上各种外围功能。这篇适合谁完全没写过 Agent 的新手看完能独立写出第一个能干活的 Agent写过但总觉得概念飘着的朋友看完能把零散知识点串成一条线想快速评估某个 Agent 框架靠不靠谱的人也能用这篇文章里的最小内核做参照系。1. Agent 不是 API 调用先搞清楚它到底在循环什么1.1 模型只是“大脑”Agent 是“大脑 手脚 循环”很多人第一次写 AI 程序就是调一次大模型接口输入 Prompt拿到回答。那是纯聊天不是 Agent。Agent 和聊天的本质区别在于三件事模型能做决策、决策能触发真实动作、动作结果能再次被模型看到。想象一下你让一个实习生去整理办公室。纯聊天模型像是你在电话里问他“东西怎么摆”他只能给你建议但没法动手。Agent 则是你给他配了一双手他看了一眼柜子观察决定把文件放进抽屉行动拉开抽屉发现里面有旧文件新的观察于是先清理再放新的下一步行动。这个“看一眼→动一下→再看一眼”的闭环就是 Agent 的全部秘密。OpenClaw 这类项目之所以让人觉得“哇塞”不是因为模型变聪明了而是因为它把上面这个闭环做成了流畅的产品模型可以调用终端命令、读写文件、抓取网页每次调用的结果会被塞回对话历史让模型继续推理。你说“帮我整理一下这个目录”它就先列出目录内容自己规划先删临时文件再看大文件一步步完成。1.2 最小复刻的三个组成部件我们要复刻的最小内核只有三个部件一个支持工具调用的大模型接口它负责理解任务、决定调用哪个工具、分析工具返回的结果。不用关心具体是哪家的只要是那种“会返回结构化工具调用指令”的模型就行。一组真实可执行的工具比如执行 shell 命令、读写文件。这些是 Agent 的“手脚”没有它们模型只能纸上谈兵。一个循环控制中枢把模型的决策翻译成真实调用把执行结果转回模型能读的消息并控制最大步数防止它跑飞。我给新人讲这个结构时常打一个比方模型是快递员的大脑工具是快递员的腿和手循环中枢是调度系统。大脑说“去 3 号柜取件”调度系统就派腿过去取完回来告诉大脑“3 号柜是空的”大脑再决定“那去 4 号柜看看”。没有调度系统大脑和腿是断开的什么也干不成。1.3 为什么要“复刻”直接看 OpenClaw 源码不行吗这是一个很实在的问题。OpenClaw 这种项目的源码动不动几千上万行里面有大量工程化细节多模型适配、流式输出、并发控制、配置解析、插件机制、前端界面、打包部署。这些东西对一个想理解 Agent 原理的新手来说不是财富是噪音。你会被一堆类名和配置文件淹没完全看不见主干。复刻的好处是我们只保留主干所有工程细节都丢开。一旦你亲手写出这个最小循环再回去看任何 Agent 框架的源码你会自动在脑子里做减法哦这部分只是在外面套壳那段代码干的事和我那 100 行里第 67 行是一样的。这也是为什么我一直推荐用“最小可运行系统”来入门而不是从研究大型项目起步。2. 100 行代码的骨架把思考-行动-观察写成一个不会卡死的循环2.1 先看整体流程再写代码我写最小 Agent 时通常直接把这个流程画在脑子里不画那些花哨的架构图把一个系统提示词和用户任务放入消息列表。把整个消息列表发给模型同时附上工具描述。模型返回两种结果之一要么是一段普通文本意思是我干完了要么是一个工具调用指令意思是我要用某个工具参数是这些。如果是工具调用指令我们就在真实环境里执行它把结果包装成一条“工具消息”追加到消息列表然后回到第 2 步。如果模型直接给了最终答案循环结束把它返回给用户。这个流程有一个学名叫 ReAct即 Reasoning Acting 交替进行的模式。别被高深的名字吓到它本质上就是模型说一句“我要查一下当前目录”推理我们执行“ls”行动把输出给它看观察它再说下一步怎么做再推理。一句话总结让模型像一个会用电脑的真人那样边想边做。2.2 核心循环代码逐段拆解下面这段代码是整个复刻版 Agent 的心脏。我省略了具体的模型请求实现因为各家接口细节不同但消息结构和调用逻辑是通用的。你在自己的机器上跑的时候把你用的模型服务和密钥代入call_llm即可。import json SYSTEM_PROMPT 你是一个运行在电脑上的通用型AI Agent。 你可以调用工具来完成用户任务可选工具包括 - execute_shell(command): 在命令行执行一条命令返回标准输出和标准错误 - read_file(path): 读取指定文本文件的内容 - write_file(path, content): 把文本内容写入指定文件 规则 1. 先用工具收集必要的真实信息不要凭空编造结果。 2. 如果工具执行出错分析错误原因并换一种方式重试。 3. 任务完成时用 final_answer 总结你做了什么、结果是什么。 def call_llm(messages, tools): # 这里对接你使用的模型服务要求支持 function calling 机制。 # 把 messages 和 tools 传给模型返回解析后的 assistant 消息字典 # 结构至少包含 role / content可能包含 tool_calls。 # # tool_calls 的通用格式 # [ # { # id: call_xxx, # function: { # name: execute_shell, # arguments: {\command\: \ls\} # } # } # ] raise NotImplementedError(请接入你的模型服务) def run_agent(task: str, max_steps: int 10): messages [ {role: system, content: SYSTEM_PROMPT}, {role: user, content: task}, ] for step in range(max_steps): print(f[debug] step {step 1}/{max_steps}) # 让模型基于当前完整对话历史做一次决策 assistant_msg call_llm(messages, toolsTOOL_SCHEMAS) messages.append(assistant_msg) # 情况一模型没有要求调用工具说明它认为任务完成了 if not assistant_msg.get(tool_calls): final_answer assistant_msg.get(content) or print([debug] 收到最终回复) return final_answer # 情况二模型要求调用一个或多个工具 for tool_call in assistant_msg[tool_calls]: fn_name tool_call[function][name] fn_args json.loads(tool_call[function][arguments]) print(f[debug] 调用工具 {fn_name}参数{fn_args}) # 在真实环境里执行工具 tool_result execute_tool(fn_name, fn_args) # 把工具的执行结果作为一条 tool 消息回传给模型 messages.append({ role: tool, tool_call_id: tool_call[id], content: tool_result, }) # 超出最大步数强制结束 print([debug] 达到最大步数任务被强制终止) return messages[-1].get(content) or 没有拿到最终结果2.3 为什么非要把“步数”卡死我见过不少第一次写 Agent 的朋友很喜欢把循环写成while True理由很单纯“AI 需要一直尝试到成功为止啊。”这个想法很危险。真实世界里模型可能陷入死循环反复调用同一个命令、在同一个错误上打转、或者面对一个不可能完成的任务时疯狂重试。不限制步数你的终端就会像一个失控的实习生一晚上反复“整理”同一个文件柜。max_steps这个参数看起来不起眼实际上是最重要的安全阀。另外步数限制还能控制成本。每一次循环都是一次模型请求都会产生真实消耗。设置 10 步、15 步等于给预算上了个天花板。出问题时先结束再人工看 debug 日志比让程序无限烧钱理智得多。2.4 为什么工具结果要“塞回对话”而不是存到变量里新手经常问我工具结果拿到了为什么不输出到一个变量下次调用时直接传个参数原因在于模型是“无状态”的它每次决策都只能基于你给它的上下文。只有把工具结果作为一个新消息放回对话历史模型下一次调用时才能“看到”这个结果才知道自己上一步做了什么、接下来该怎么调整。所以 Agent 的对话历史本质上就是它的“工作记忆”。你把观察结果写进去模型就知道了你不写它就两眼一抹黑。很多 Agent 表现“很蠢”调用了工具之后好像忘了结果十有八九是消息列表没有正确维护工具返回没有回写。这是整个 Agent 编码中最容易出错、也最关键的一环。3. 工具调用这层“三次握手”从会聊天到会干活3.1 工具描述你是在教模型用你的 API要让模型知道能调用什么工具你得先把工具“介绍”给它。这不是随便在 Prompt 里写一句“你可以执行命令”就行而是要遵循一套结构化的描述格式告诉模型工具叫什么、是干什么的、参数有哪些。模型会在这套描述的基础上输出一个符合格式的调用请求。我把这个过程叫作“三次握手”第一次我们把工具说明书交给模型第二次模型说“我要调用 execute_shell参数是 commandls”第三次真实环境执行后我们把执行结果作为回应交给模型完成这次调用。任何一次握手断了Agent 都会开始胡说八道。一个标准的工具描述大概长这样TOOL_SCHEMAS [ { type: function, function: { name: execute_shell, description: 在电脑上执行一条 shell 命令并返回标准输出和标准错误。适合查看目录、运行脚本、启动程序、检查网络状态等操作。, parameters: { type: object, properties: { command: { type: string, description: 要执行的完整命令例如 ls -la } }, required: [command] } } }, { type: function, function: { name: read_file, description: 读取指定路径的文本文件内容返回文件前 2000 个字符。适合查看代码和配置文件。, parameters: { type: object, properties: { path: { type: string, description: 文件绝对路径或相对路径 } }, required: [path] } } }, { type: function, function: { name: write_file, description: 把指定的文本内容写入文件如果文件所在目录不存在会自动创建。, parameters: { type: object, properties: { path: { type: string, description: 目标文件路径 }, content: { type: string, description: 要写入的文本内容 } }, required: [path, content] } } } ]有一点要注意工具名称必须和代码里实现的函数名严格对应参数名也不能改比如 schema 里写的是command实现里就不能用cmd。模型很死板它只会按你给的描述输出参数名参数对不上程序解析时会直接报错。3.2 三个最常用的工具实现让 Agent 拥有读写和执行能力光有描述还不够我们得让 Agent 真的能动手。我选三个最通用的工具作为复刻版的标准配置它们覆盖了绝大多数简单任务execute_shell执行终端命令这是 Agent 的“万能手”。read_file读取文件内容这是 Agent 的“眼睛”。write_file写入文件内容这是 Agent 的“手”。import os import subprocess def execute_shell(command: str, timeout: int 15) - str: 执行 shell 命令返回输出。timeout 防止命令卡死。 try: proc subprocess.run( command, shellTrue, capture_outputTrue, textTrue, timeouttimeout, ) output (proc.stdout proc.stderr).strip() if not output: return (命令执行成功但没有任何输出) # 截断过长输出防止撑爆上下文 if len(output) 2000: return output[-2000:] \n...(输出过长已截断) return output except subprocess.TimeoutExpired: return f(命令执行超时超过 {timeout} 秒) except Exception as e: return f(命令执行出错: {e}) def read_file(path: str) - str: 读取文本文件限制返回长度。 try: with open(path, r, encodingutf-8, errorsignore) as f: content f.read().strip() if len(content) 2000: return content[-2000:] \n...(文件过长已截断) return content or (文件为空) except Exception as e: return f(读取文件失败: {e}) def write_file(path: str, content: str) - str: 写入文本文件自动创建目录。 try: abs_path os.path.abspath(path) os.makedirs(os.path.dirname(abs_path), exist_okTrue) with open(abs_path, w, encodingutf-8) as f: f.write(content) return f成功写入 {len(content)} 字符到 {abs_path} except Exception as e: return f(写入文件失败: {e}) def execute_tool(name: str, args: dict) - str: 工具分发器根据工具名决定调用哪个函数。 if name execute_shell: return execute_shell(args.get(command, )) if name read_file: return read_file(args.get(path, )) if name write_file: return write_file(args.get(path, ), args.get(content, )) return f(未知工具: {name})这里每个工具函数都返回字符串是整个设计里最容易被忽略但很关键的点。为什么统一字符串因为工具结果是往消息列表里塞的消息内容必须是文本。无论命令输出、文件内容还是错误信息全部转成字符串模型才能继续处理。你要是直接返回一个subprocess.CompletedProcess对象消息序列化就得炸。另外给execute_shell加timeout参数是我的习惯。真实环境里一条ls可能很快但一条curl可能卡几分钟。加上 15 秒的超时限制至少能保证 Agent 不会因为一条命令被永久卡死这是最小系统里最划算的保险。3.3 为什么工具结果也必须“截断”工具结果太长是整个 Agent 工程化里最隐蔽的性能杀手。模型一次能处理的上下文是有限的如果cat一个 10 万行的日志文件直接把全部内容塞回对话轻则模型开始忽略关键信息重则直接超过上下文长度报错。所以我在每个工具函数里都做了同样一件事只保留最近 2000 个字符并明确标注“已截断”。别小看这个动作它是在告诉模型完整的信息我给不了但你需要的答案大概率在这段里如果不够你可以继续用指令去提取比如用grep关键词。截断不是让模型“凑合”而是引导它学会用更精准的查询代替大而全地读取。当然2000 不是硬性标准。如果任务比较简单500 也够用如果模型要对长文档做摘要可能需要 5000 甚至更多。关键是你要有这个意识不是所有内容都值得进上下文Agent 的上下文是昂贵的“办公桌”桌面上只能放当前最重要的材料。4. 完整跑通让复刻版 Agent 自己统计代码行数4.1 一个真实的思考-行动-观察轨迹纸上谈兵没意思我们直接跑一个任务“统计当前目录下所有 Python 文件的总行数并把结果写入 result.txt。”这个任务对新手非常合适它需要多个工具配合也能展现 Agent 逐步推理的过程。启动run_agent(统计当前目录下所有 python 文件的总行数并写入 result.txt)后一份典型的执行轨迹是这样的[debug] step 1/10 [debug] 调用工具 execute_shell参数{command: ls} (工具结果): agent.py result.txt tools_demo.py [debug] step 2/10 [debug] 调用工具 execute_shell参数{command: find . -name *.py | xargs wc -l} (工具结果): 12 agent.py 89 tools_demo.py 101 总 [debug] step 3/10 [debug] 调用工具 write_file参数{path: result.txt, content: Python 文件总行数: 101} (工具结果): 成功写入 19 字符到 /home/user/project/result.txt [debug] step 4/10 [debug] 收到最终回复最终模型给出的回复大概是我已经统计了当前目录下所有 Python 文件的总行数结果为 101 行并写入到了 result.txt 中。其中 agent.py 为 12 行tools_demo.py 为 89 行。整个过程看起来很自然像一个人在干活。但在程序底层每一次工具调用背后都隐藏着我前面说的“三次握手”模型生成一个结构化的调用请求程序解析并执行结果作为一个新消息回写。你看到的那 4 步其实就是循环中转了好几个来回。4.2 每次“观察”如何影响下一步决策注意看第二步模型直接用了find和xargs wc -l的组合命令而不是老老实实地一个文件一个文件地去读。为什么它能做出这么“聪明”的选择因为在第一步模型通过ls观察到了当前目录的文件名它判断出“文件数量不多可以用一行命令完成统计”。如果第一步的观察结果是“目录非常大”模型可能会改用find过滤后逐个读取。这说明观察结果不是简单的信息堆积而是下一步决策的依据。这也是为什么我强调工具结果必须回写消息列表模型每一次看到的新信息都会直接改变它的下一步行动。如果你的程序把工具结果存在变量里却没有回写你看到的 Agent 就会像一个失忆的人反复问同一个问题。第三步模型选择write_file而不是直接用 shell 的重定向echo result.txt这个选择挺有意思。对于模型来说工具列表里有write_file这个专为写入而生的函数使用它是更符合直觉的选择。这不是“最优解”但它是合理且可用的方案。Agent 的“智能”不在于每一步都最优而在于它能基于当前观察做出可执行的合理决策并在出错时自我纠正。4.3 “100 行”是怎么算出来的你可能会好奇代码真的是 100 行吗我拆一下模块作用估计行数SYSTEM_PROMPT run_agent 循环Agent 核心驱动约 45 行工具描述 TOOL_SCHEMAS模型与工具的接口说明约 30 行execute_shell / read_file / write_file / execute_tool真实工具实现约 35 行call_llm 占位函数模型请求接入点约 5 行加起来确实在 100 行左右。当然“100 行”不是一个严格的极限而是一种信号一个能干活的最小 Agent真的不需要那么多代码。如果你看到某个框架动不动几万行那是因为它要在生产环境里解决海量并发、多租户、权限隔离、企业集成这些问题。那些问题都很重要但不是 Agent 的本体。5. 稳定复现清单环境、模型和容易翻车的细节5.1 环境准备与模型接入写完了代码怎么把它跑起来我列一个亲测有效的最小准备流程# 1. 创建虚拟环境Python 3.10 以上比较稳 python3 -m venv agent-demo source agent-demo/bin/activate # 2. 安装模型服务的基础 SDK pip install your-llm-sdk # 3. 配置密钥注意不要写进代码里 export LLM_API_KEY你的密钥然后你需要把第 2 节代码里的call_llm占位函数替换成真实调用。需要注意的接口字段几乎都是通用的messages直接传我们维护的那个消息列表。tools传TOOL_SCHEMAS。response_format如果服务支持可以设成自动选择工具。返回结果通常取choices[0].message再把它转成字典格式。我第一次跑通时最深的体会是模型服务的“工具调用”接口各家叫法不一样有的叫 function calling有的叫 tool use但消息结构大同小异。关键是先拿一个极小的例子确认格式再接入主循环不要一上来就调试完整版那样分不清问题出在模型还是出在代码。5.2 小白最容易踩的五个坑我把带新人时反复出现的翻车点整理成一个表你写的时候对照着查现象根本原因解决办法模型从来不调用工具只会说“我建议你先执行 ls”模型本身不具备工具调用能力或服务没开启该功能换成支持 function calling 的模型确认请求里传了 tools程序报 JSON 解析错误模型生成的 arguments 不是合法 JSON打印原始内容排查加上容错解析比如清掉多余缩进必要时换更强的模型工具结果太长模型开始胡说八道上下文被无关信息填满关键信息被淹没工具输出统一截断到 2000 字符内并加“已截断”提示Agent 反复调用同一个命令原地打转模型看不到自己的历史调用记录或看不到错误原因检查 tool 消息是否真实回写把错误信息写得清楚具体命令行卡住不返回某个命令执行时间过长给 execute_shell 加上 timeout 参数默认 15 秒这里面最值得展开说的是 JSON 解析。模型偶尔会把参数写成带注释的 JSON或者多一个逗号直接json.loads会崩。一个简单且稳妥的兜底方案是在解析失败时把原始字符串清理一下比如去除首尾的花括号外噪音再尝试json.loads。更省事的做法是选用返回质量更稳定的新模型。记住工具调用试错成本最低的方式是把原始的tool_calls字段打印出来看而不是靠猜。5.3 安全边界能执行 shell 的 Agent 绝不能裸奔这节必须重点强调。一个能调用execute_shell的 Agent等于把一台电脑的“遥控器”交到了模型手里。模型幻觉导致的错误命令可能会删文件、装软件、改配置。本地演示还好一旦部署到服务器或接入真实业务系统风险是实打实的。我的建议按安全层级从低到高本地演示务必在虚拟环境或容器里跑不要用 root 用户目录也限制在一个专用文件夹内。工具白名单只允许少数几条白名单命令比如ls、cat、grep、find禁止rm、mkfs、curl | bash这类高风险操作。人工审批在工具分发器里加一步确认逻辑高危命令执行前需要人工输入y确认。审计日志把每一次工具调用记录下来包括时间、参数、结果出问题能回溯。很多开源 Agent 项目在工程上做了大量安全设计就是为了解决这类风险。复刻版虽然简单但安全意识不能跟着一起“简化”。你可以先想清楚如果你的 Agent 突然执行了一条可怕的命令会发生什么想清楚这一点再决定要不要加防线。6. OpenClaw 在内核之外还做了什么从最小可用到好用可信6.1 工程外壳让 Agent 从“能跑”到“好用”复刻完这个最小内核你其实已经理解了 OpenClaw 这类项目的一半。剩下那一半就是在这个循环外面不断加工程外壳。第一层是模型接入的抽象化。最小版代码里call_llm是写死的生产级项目会做成配置驱动允许你换不同的模型服务商甚至加一层容错主力模型超时了自动切换备用模型。这一层不用改循环逻辑纯粹是增强健壮性。第二层是流的体验优化。当前实现里模型思考期间用户只能干等。生产项目会做流式输出让用户看到“正在调用工具”“命令执行中”“已获取结果”这些中间状态。我自己的习惯是先跑通同步版本再改流式否则并发和消息队列的问题会把你淹没。第三层是记忆的持久化。最小版的记忆只存在于消息列表里程序一重启就全丢了。给 Agent 加一个长期记忆模块让它能把之前的任务结论、常用配置、用户偏好存下来下次启动时加载。这一步看似简单但对 Agent 的“智能感”提升非常明显。6.2 从“能用”到“可信”人机协同与审计我接触过的 Agent 项目里真正走向生产环境的几乎没有一个是完全自动、完全无人值守的。比较务实的路线是“人在回路”模式Agent 自主执行低风险步骤遇到高风险操作或不确定性大的分支时暂停并请求用户确认。这比追求全自动靠谱得多。审计也是生产环境的硬需求。每次 Agent 调用了什么工具、喂了什么参数、产出了什么结果、耗时多少、花费多少都该有结构化日志。出了问题能查花费超标能发现模型行为异常能及时封禁。最小内核里加一个简单的日志打印很容易生产级项目则会专门做一套事件流系统。还有一种常见增强是“规划器”。最小版 Agent 是走一步看一步面对复杂任务容易迷失。给它加一个规划模块让模型先把任务拆成子目标再逐个执行这就是“Plan-and-Execute”模式的核心思路。很多人觉得这是高深技术但本质上就是让模型在开工前写一个 to-do list然后根据进展勾选和调整。6.3 下篇预告这个内核还能往哪长写到这里你会发现 Agent 的想象力其实是无限的但一切都建立在今天这个循环上。你学会了控制循环就等于拿到了所有 Agent 框架的“操作系统的内核源码”。我在实际带新人的过程中体会最深的一件事是动手复刻一遍比读十篇架构分析都管用。这 100 行代码不是终点只是一扇门。打开这扇门后你会慢慢理解工具应该怎么设计、上下文应该怎么管理、模型应该怎么引导。如果这篇你跑通了下篇我们继续往里加东西先给 Agent 装上“长期记忆”再装一个“规划器”让它能接住那种多步骤、跨多个文件的复杂任务而不是像现在这样只适合一步到两步的简单活儿。到时候你会发现从“最小可用”到“真正能干复杂的活”差的不是更多的代码而是你对这个循环的理解深度和扩展技巧。

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

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

免费获取报价 →
↑