news 2026/10/22 11:34:21

100行Python复刻AI Agent:大模型+工具调用的核心循环

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
100行Python复刻AI Agent:大模型+工具调用的核心循环

最近社区里聊AI Agent的人越来越多,很多人入门的第一站是那个叫OpenClaw的开源Demo——一个看起来不大、但五脏俱全的个人助理原型。市面上把Agent讲得神乎其神的课不少,真正扒开看,核心只是一条循环:大模型决定做什么,代码负责真做,做完再让模型看结果。我这里把这类Agent Demo统称为OpenClaw,用它当例子说说底层逻辑。这篇我会用100行Python把这个循环复刻出来,不引任何重型框架,全程只有一个HTTP调用、一个工具注册表和一个循环。适合第一次接触AI Agent、想从零跑通最小示例的同学;也适合那些已经看了很多概念文章、却一直没动手写过代码的读者。读完之后,你能亲手看到Agent是怎么决定调工具、怎么消化结果,以及为什么一个不到一百行的循环能撑起一个Agent的骨架。

1. 先把OpenClaw拆开看:Agent的三层结构

在写代码之前,我建议你先忘掉Agent这个词。它既不神秘,也不是某个模型的新能力,而是把一堆成熟组件串起来的一套流程。OpenClaw这类Demo看起来复杂,剥到最里面只有三层:大脑、手、控制循环。

1.1 大脑层:大模型只做决策,不做执行

大模型是大脑,任务是读对话历史、看工具清单、决定下一步该做什么。它不真的去执行函数。现代大模型大多支持“工具调用”:你给它一份JSON格式的函数说明,它回答的不是执行结果,而是“我想调用哪个函数,参数填什么”。

这里可以打一个比方:大模型是顾客,工具说明是菜单,你的代码是厨房。顾客看了菜单点了“鱼香肉丝”,厨房才知道具体要炒什么。如果菜单上没写清楚这道菜是什么,顾客要么不点,要么乱点一道完全不对的菜。所以在Agent系统里,工具定义的description就是菜单上的菜品描述,描述写得越准,模型点菜就越准。

这也意味着,Agent要输出的不总是最终答案,而是“下一步决策”。当用户问“现在几点了”,如果模型不调用时间工具,它只能根据训练数据编一个时间;如果它正确调用了get_time工具,就能拿到真实结果再组织语言回答。前者的答案不可信,后者的答案才叫智能。

1.2 工具层:给Agent装上一双“爪子”

OpenClaw名字里的Claw(爪子)挺形象:工具层就是Agent伸出去抓取信息的爪子。每个工具就是一个普通Python函数,比如获取时间、做加法、查股票。为了让模型理解并使用这些函数,需要把它们转换成JSON Schema格式,也就是工具说明书。

说明书包含三块:name函数名、description什么时候用、parameters参数结构。参数用JSON Schema描述,标明每个参数的类型、是否必填。模型不是靠读源码来理解你的工具,而是靠这份说明书。说明书写得好,Agent才聪明;说明书写得模糊,模型就靠猜,一猜就出错。

工具层还需要一个函数映射表,比如TOOL_MAP:把函数名字符串映射到实际函数。运行时,模型返回{"name": "add"},代码就拿这个名字去TOOL_MAP里找对应函数调用。这样新增工具时,只需要加一个函数、加一份schema、加一条映射,主循环完全不用改动。这也是OpenClaw这类项目能不断长出插件生态的原因。

1.3 控制层:循环、轮次与终态

把大脑和手串起来的是控制层。标准流程是:把用户问题放进messages,请求大模型,模型返回两条路之一。要么是最终回答content,要么是工具调用tool_calls。如果是工具调用,就执行对应函数,把结果追加到messages,再次请求模型。

这个过程会反复,直到模型不再请求工具。所以控制层的核心是循环。循环里必须设置最大迭代次数,比如5。为什么?想想一个模型卡在“查错—重试—再查错”的循环里,如果没有上限,你的API账单会一直涨,用户也只能干等。设上限是Agent的保险丝,也是一条非常务实的工程经验。

还要处理多工具调用的场景。模型一次可能返回多个tool_calls,OpenClaw的核心循环允许并行或串行执行它们。做最小复刻时,用for循环依次执行即可;执行完所有工具后,把每个工具结果都追加进去,再进入下一轮。这个顺序别搞错,否则模型无法把结果和调用一一对应起来。

2. 动手前准备:环境、API与单文件结构

2.1 环境与依赖:为什么不用框架

这个复刻只需要Python 3.9以上和requests库。用Python包管理器安装requests即可。如果你习惯管理环境变量,可以再装一个小工具用来读取.env文件,但这不是必须的。很多人问:为什么不直接上一个Agent开发框架?因为框架会帮你隐藏大量细节,你学到的就成了“会调框架”,而不是“理解Agent”。用requests直连大模型API,每个环节都透明,出了问题你能精准定位到具体是在解析、调用还是循环上的毛病。

你还需要一个大模型API接口,只要支持工具调用就行。现在很多模型服务商都提供兼容风格的大模型HTTP接口,差不多都是发送messages和tools,接收content和tool_calls。拿到API密钥后,放到环境变量里读取。复制代码时要注意:千万不要把密钥写死在代码里,一旦你把代码公开分享,密钥就可能泄露,轻则被盗刷,重则整个服务被滥用。

2.2 配置三个关键变量

运行时只需要三个环境变量:LLM_API_KEY、LLM_API_BASE、LLM_MODEL。下面是一个.env文件示例:

LLM_API_KEY=换成你的密钥 LLM_API_BASE=https://your-endpoint/v1/chat/completions LLM_MODEL=my-model

LLM_API_BASE指向大模型兼容接口的地址,不同服务商路径可能略有差异;LLM_MODEL填你开通的模型名。把环境变量独立出来的好处是,以后换模型供应商时,不用改代码,只改.env文件就够了。代码里统一用os.getenv读取,没设置时使用默认值,能少很多“环境变量没配对”的报错。

2.3 文件结构和工具选型

为了让代码控制在100行左右,我会把所有逻辑放在一个main.py里。目录结构很简单:

agent-demo/ .env main.py

如果你打算继续扩展,以后可以拆成tools.py、llm.py、agent.py,但入门阶段先不拆。100行的单文件,让你一眼能看完整条链路,这是最优的学习路径。

工具选型上我故意做了区分:get_time是零参数工具,add是两个必填整数参数的工具,get_stock_price是一个字符串参数的工具。这三种基本覆盖了工具调用的常见参数形态。get_stock_price返回的是模拟价格数据,保证示例不依赖任何外部真实数据服务也能跑通。你完全可以把它换成天气查询、本地文件读取之类的函数,结构一模一样。

3. 核心代码逐段拆解:100行复刻OpenClaw

3.1 先看完整代码

下面这段代码是核心骨架,可以直接拷贝运行。替换好环境变量后,启动main.py试试。

import json import datetime import os import requests # ---------- 工具实现 ---------- def get_time(): return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") def add(a, b): return {"result": int(a) + int(b)} def get_stock_price(symbol): return {"symbol": symbol, "price": 100.0, "currency": "CNY"} TOOL_MAP = { "get_time": get_time, "add": add, "get_stock_price": get_stock_price, } # ---------- 工具注册表:给模型看的“菜单” ---------- TOOLS = [ { "type": "function", "function": { "name": "get_time", "description": "获取当前的日期和时间。当用户询问今天日期、现在几点时使用。", "parameters": {"type": "object", "properties": {}, "required": []}, }, }, { "type": "function", "function": { "name": "add", "description": "计算两个整数的和。当用户要求做加法时使用。", "parameters": { "type": "object", "properties": { "a": {"type": "integer", "description": "第一个加数"}, "b": {"type": "integer", "description": "第二个加数"}, }, "required": ["a", "b"], }, }, }, { "type": "function", "function": { "name": "get_stock_price", "description": "查询某只股票的演示价格。输入股票代码,例如DEMO。", "parameters": { "type": "object", "properties": { "symbol": {"type": "string", "description": "股票代码"}, }, "required": ["symbol"], }, }, }, ] SYSTEM_PROMPT = "你是一个有用的AI助手。你可以调用工具来获取信息。请根据用户问题判断是否调用工具;如果工具结果足以回答,请直接给出答案。" # ---------- 请求大模型 ---------- def call_llm(messages): url = os.getenv("LLM_API_BASE", "https://your-endpoint/v1/chat/completions") headers = { "Authorization": "Bearer " + os.getenv("LLM_API_KEY", ""), "Content-Type": "application/json", } payload = { "model": os.getenv("LLM_MODEL", "my-model"), "messages": messages, "tools": TOOLS, "tool_choice": "auto", } resp = requests.post(url, json=payload, headers=headers, timeout=30) resp.raise_for_status() return resp.json()["choices"][0]["message"] # ---------- 主循环 ---------- def run_agent(user_query, max_iterations=5): messages = [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": user_query}, ] for _ in range(max_iterations): message = call_llm(messages) if not message.get("tool_calls"): return message.get("content", "") messages.append(message) for tool_call in message["tool_calls"]: fn_name = tool_call["function"]["name"] arguments = json.loads(tool_call["function"]["arguments"] or "{}") if fn_name not in TOOL_MAP: result = {"error": f"Function {fn_name} not found"} else: try: result = TOOL_MAP[fn_name](**arguments) except Exception as e: result = {"error": str(e)} messages.append({ "role": "tool", "tool_call_id": tool_call["id"], "content": json.dumps(result, ensure_ascii=False), }) return "达到最大迭代次数,任务结束。" if __name__ == "__main__": query = input("请输入问题:") print(run_agent(query))

3.2 工具注册表解析:模型眼中的“菜单”

TOOLS列表是给模型看的菜单。每个工具都有type和function两部分,function里最关键的是name、description、parameters。description的作用是让模型在“用户问题”和“工具能力”之间做匹配。比如get_time的描述里明确写了“当用户询问今天日期、现在几点时使用”,模型就知道聊天气时不需要叫它。

parameters使用JSON Schema描述。properties定义每个参数的名称、类型和说明;required数组告诉模型哪些参数必须传。add工具要求a和b都是integer,模型就必须生成两个整数参数。如果description里再解释“a是第一个加数”,模型生成参数的准确率会更高。这一层就是Agent的接口契约。

那为什么代码里还要一份TOOL_MAP?因为TOOLS是给模型看的契约,TOOL_MAP是给代码引擎用的路由表。模型不直接执行函数,代码引擎也不理解自然语言,两者通过函数名字符串对接。每新增一个工具,两个地方都要同步更新:一个让模型“看见”,一个让代码“找到”。漏了TOOLS里的定义,模型就不会调用;漏了TOOL_MAP里的映射,代码就会报函数不存在。

3.3 call_llm函数解析:一次透明的模型请求

call_llm只做一件事:把messages和tools打包成HTTP请求,发给大模型服务。url从环境变量读取,headers带鉴权token,payload里的tools就是前面的注册表,tool_choice设为auto表示让模型自己决定是否调用工具。响应拿到后,取出choices[0].message返回。

如果模型决定调用工具,message里会多出tool_calls字段。它是一个数组,每个元素包含id和function对象;function.name是函数名,function.arguments是一段JSON字符串。我们后续就是解析这个字段来执行工具。如果模型决定直接回答,message里通常只有content字段,主循环发现没有tool_calls就退出。

这里有个非常容易踩的坑:不要把整个HTTP响应塞进messages。需要塞回下一条请求的是message对象,也就是content和tool_calls都在的那个对象,不是包着状态码和choices的完整响应体。一旦把完整响应塞回去,模型看到一堆无关字段,会完全混乱。只取choices[0].message,这是复刻过程中最常见的错误之一。

3.4 主循环解析:终态判定与异常兜底

run_agent里,messages先放一个system提示词和用户问题。循环开始后调用call_llm。如果message里没有tool_calls,就把content返回给用户,过程结束。这是最基础也最重要的终态判定,代表Agent认为“我已经拿到足够信息,可以回答了”。

如果有tool_calls,先把这条assistant消息追加到messages。为什么要追加?模型必须看到自己上一轮说“我要调用get_time”,这是对话上下文的一部分。接着for循环逐个执行tool_call。用json.loads解析arguments,然后从TOOL_MAP中取出函数并调用。这里我特意用了try...except,把函数执行异常变成结构化结果。就算工具本身报错,Agent也不会崩,而是会把错误信息返回给模型,让模型自己调整参数或者换一个方案。这条经验在真实Agent开发中非常有用。

执行完工具,每个结果都要以role为tool的消息追加到messages。content是JSON字符串,tool_call_id和对应调用id一致。下一轮模型才知道哪个调用对应用哪个结果。如果漏掉tool_call_id或顺序错乱,模型会无法理解工具结果,轻则重复调用,重则胡言乱语。循环结束后如果还没有返回内容,就返回“达到最大迭代次数,任务结束”,避免无限循环。

3.5 运行效果与调试方法

运行python main.py,终端会等待输入问题。输入“现在几点了?”后,正常情况下Agent会调用get_time,拿到当前时间,再组织语言回答。输入“100加23等于多少?”,Agent会调用add,返回123。输入“查询股票代码DEMO的价格”,它会调用get_stock_price。即使拿不到真实市场数据,你也能完整观察到工具调用链路。

如果模型一直不调用工具,先确认你用的模型是否支持工具调用功能,以及服务商的API路径是否正确。你可以在call_llm函数里加一行print(json.dumps(message, ensure_ascii=False)),这样每次模型返回的内容都会打印出来,调试非常直观。加完调试代码运行一遍,看到原始输出后,基本就能定位是字段名问题、参数解析问题还是模型不支持的问题。这一行调试代码在正式环境里记得删掉。

这个最小Demo不带记忆,每次启动都是新会话。OpenClaw真实产品还会有对话存储、身份管理、定时任务等,但核心复刻到这里,你已经把最难的部分拿下了:理解Agent的决策循环。后续所有扩展,都是在循环不变的前提下添加组件。

4. 复刻过程中最常见的坑与排查方法

4.1 tool_calls字段结构对不上

不同大模型服务商对“工具调用”的返回虽然大体类似,但细节可能不同。有的返回choices[0].message.tool_calls,有的放在别的位置;有的tool_call没有id字段,有的把arguments提前解析成了对象而不是字符串。代码里写死字段名就会踩坑。

解决办法是先用原生态打印看结构。遇到报错时,在call_llm里加一个调试分支,把data原样输出,然后根据实际结构微调代码。我当时就是因为两个模型返回结构有差异,干脆写了一个normalize_tool_calls的小函数,统一转成标准结构。后续换模型都不用慌,解析层稳定,Agent主循环就稳定。

4.2 模型不调用工具,直接编答案

这个问题在小模型上特别常见。模型明明看到了tools,却还是凭训练记忆回答。原因一般有三个:一是模型本身不支持工具调用;二是工具描述不清晰,模型觉得不需要;三是system提示词没有引导。解决办法:换支持工具调用的模型;把“当用户问实时信息时,必须调用工具”写进SYSTEM_PROMPT;把工具描述写得更直白。

还可以用“示例对话”引导。在messages里预先放一段演示了工具调用过程的对话,模型会模仿这个模式。但示例会占用token,也会让代码超出100行,所以这个技巧更适合生产阶段使用。最小复刻阶段,我建议把精力放在提示词和工具描述上,这两个改好了,模型不调用工具的情况会大幅减少。

4.3 arguments解析失败

偶尔模型会返回一段不合法JSON,比如少了一个引号,或者外面包了markdown代码块标记。json.loads会直接抛异常。更稳的做法是先清洗。

可以写一个极短的清洗函数:去掉首尾空格,如果以开头,截取第一次和最后一次之间的内容;再尝试json.loads。如果还是失败,就把原始arguments字符串放进错误结果给模型,让它自己修正后重新调用。这属于容错设计,真实产品里非常必要,因为大模型偶尔会输出不规范内容,你不能指望它永远守规矩。

4.4 循环死循环和费用失控

我在调试时遇到过模型不断调用同一个工具、参数完全一致的情况。原因是模型对工具结果不满意,或者上下文里同一个错误反复出现。没有max_iterations的话,请求会一直发出去,API账单会以肉眼可见速度增长,用户也只能干等。

除了max_iterations,还可以做一个简单检测:记录最近几次的函数名和参数,如果完全一致,就主动终止并提示“任务陷入重复”。同时要注意工具结果的体积。如果一次查询返回几百条记录,模型下一轮要继续处理,token消耗会很大。工具结果最好做截断或摘要,只把关键信息放进上下文。

4.5 工具执行结果格式不规范

工具返回值最好统一为可JSON序列化的对象。如果你返回的是日期对象、集合这类结构,json.dumps会直接报错。解决办法是让所有工具都返回字典或字符串,先统一成规范格式,再交给主循环处理。

我习惯让所有工具返回字典,比如{"result": 123}或{"error": "..."},然后在主循环里统一json.dumps。这样工具内部想怎么实现都行,只要出口格式一致,主循环就不用关心细节。时间长了你会发现,给工具制定一套弱约束的输出规范,Agent的稳定性会提升一大截。

常见问题症状排查方法解决方案
tool_calls结构对不上代码取字段时报KeyError打印原始响应写解析函数统一结构
模型不调用工具直接输出编造的答案检查模型能力和提示词换模型、优化工具描述
arguments解析失败json.loads异常打印arguments原文清洗后再解析
循环死循环相同工具被反复调用观察日志设上限、检测重复调用
工具返回格式不规范json.dumps报错查看工具返回值类型统一返回字典或字符串

5. 从100行骨架到完整产品:几个值得继续深挖的方向

5.1 给Agent增加长期记忆

现在的Demo是一次性会话,用户退出后一切归零。真实产品里,Agent需要记住用户偏好、历史任务。最简单的做法是把messages序列化存进本地文件或数据库,每次对话前加载最近几轮;更聪明一点,用向量数据库存语义记忆,需要时检索相关片段放回上下文。

这个扩展不会改变核心循环,只是把“上下文从哪里来”变得丰富。你可以先把历史消息拼接进messages,再让模型继续。等跑通了,再考虑向量检索和摘要压缩。记忆不是必须的,但有没有记忆,Agent用起来的感觉完全不同。

5.2 工具权限与安全边界

Agent的能力来自工具,风险也来自工具。你给Agent一个删除文件函数,它就可能在你没确认时把文件删掉。所以在工具层要做分级:只读工具直接允许;写操作工具需要用户二次确认;高风险操作要引入白名单和审计日志。

最小复刻里没有权限概念,因为所有工具都是演示性质。但把它变成产品前,一定要补上这层。我给自己的模拟项目X加过一个包装器,统一检查工具名和参数,不符合策略的调用直接拒绝并返回错误给模型。模型会理解“这个操作被禁止”,然后调整方案。权限控制越早加越好,等到出事故再加就晚了。

5.3 并行工具调用和任务规划

真实Agent面对复杂任务时,不会只调用一次工具就结束。更常见的是模型先规划:把“查天气、定机票、订酒店”拆成多个子任务,再逐个或并行执行。最小循环已经支持多tool_calls,你可以在for循环里改成并发执行,前提是工具之间没有依赖关系。

任务规划可以让Agent更稳定。一种简单做法是:先让模型生成一个JSON数组,包含步骤和工具参数,然后代码按步骤执行,而不是每步都重新问模型。这样虽然牺牲了一些灵活性,但成本和时延更可控。后续可以单独写一篇讲规划器的实现,这也是从复刻走向自研的关键一步。

5.4 复刻OpenClaw后,下一步该怎么练

别急着上重型框架。先用这个100行Demo做三个小实验:给Agent加一个“获取当前城市天气”的工具,观察它怎么选;把工具描述写得不完整,看模型会犯什么错;把max_iterations改成1,看任务会怎么失败。这三个实验能让你快速理解工具说明、上下文和循环各自的作用。

然后可以试着加一个会话记忆功能,让Agent记住你叫A同学,下次自动称呼你。这些完成后,你会发现再去看任何Agent框架的源码,都能迅速定位核心循环在哪。这个复刻项目的价值不在“我写了多少行代码”,而在于你亲手搭起了最底层的地基。

我自己复刻这个Demo的时候,最意外的一点是:真正难的不是写循环,而是把工具描述写清楚。模型就像一个新同事,你给它的工具说明书如果不明确,它就会用自己的想象补全,然后给你一个看似合理但完全不对的结果。所以在Agent开发里,描述能力比代码能力更值得练。现在你有了这个最小骨架,后续不管是接入更多工具、加记忆,还是上框架,都不会再觉得Agent是个黑盒了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/22 11:33:54

Python爬虫实战:抓取豆瓣短评生成个性化词云

爬虫这个词,在不少人眼里可能还带点“黑科技”滤镜,但拆开了其实就是三步:发一个请求、拿回一段页面、把我们要的信息抠出来。今天这篇教程,我从头写了一套完整的 Python 爬虫小项目:抓取豆瓣最新电影短评,…

作者头像 李华
网站建设 2026/10/22 11:33:40

Python爬虫实战:抓取豆瓣最新短评并生成中文词云

我最早写爬虫练手,就是拿豆瓣影评做词云,当时没想太多,纯粹想看看大家的评论里到底在聊什么。结果一上手才发现,从“能拿到数据”到“生成一张像样的词云图”,中间隔着一大堆细节:反爬怎么处理、网页结构怎…

作者头像 李华
网站建设 2026/10/22 11:32:06

fastbin_reverse_into_tcache:绕过 tcache 双重释放检查的堆利用深入解析

how2heap 的练习我前后刷过三遍,每次都会发现一些之前忽略的细节。今天想聊的是第二篇里一个非常经典的 2.31 考点: fastbin_reverse_into_tcache 。这个技巧本质上是把 fastbin 里的伪造节点反向塞进 tcache,从而绕过 tcache 的 double fr…

作者头像 李华
网站建设 2026/10/22 11:32:06

嵌入式C语言指针核心应用:内存模型、寄存器操作与野指针排查

指针这东西,C语言里绕不过去的坎。尤其是嵌入式开发,每天跟寄存器、缓冲区、硬件地址打交道,不懂指针可以说是寸步难行。我见过太多人刚开始学嵌入式,被指针搞得心态爆炸,其实指针没那么玄乎,你把它想明白了…

作者头像 李华
网站建设 2026/10/22 11:32:06

基于中心化Q-Learning的双无人机NOMA通信路径与功率联合优化

1. 项目概述与核心思路拆解 1.1 双无人机加NOMA:这个组合到底在解决什么问题 先说个大白话版本的背景。如果你现在打开手机基站地图,会发现大量基站是固定的,覆盖区域也是规划死的。但在某些场景下,这种固定基础设施根本来不及部…

作者头像 李华
网站建设 2026/10/22 11:29:48

主成分分析(PCA)详解:原理、手写实现与常见坑

提到主成分分析(PCA),很多朋友的第一反应是“又来了一个降维算法”。确实,几乎每一个涉及数据处理的项目里,PCA都像一个默认选项:数据太乱?跑一下PCA。特征太多?跑一下PCA。可视化做…

作者头像 李华