资讯动态

用Python从零搭建会调用工具的AI Agent:从原理到类封装

发布时间:2026/10/20 18:30:02 来源:尧图企业网站定制
如果你用Python写过一个自动回复机器人多半碰到过这种尴尬它只能听懂固定指令换个问法就崩。我自己的第一个版本在帮我查一下某城市今天的天气再整理成一条短信发出来这种需求面前直接哑火。原因不是模型不够聪明而是我把它当普通聊天接口在用没有让它成为真正会自己规划、自己动手的AI智能体Agent。这篇实操笔记写给完全0基础的朋友目标是搭一个可复用、看得懂、以后还能随便扩展的Agent。全程用Python不整花活。我会从最基础的概念讲起手写每一步代码最后把整个Agent封装成一个类方便你换场景复用。看完你至少能做出一个会调用工具的智能助手而不是一个只会聊天的壳。1. 先别急着写代码Agent的大脑、手脚和便签本模型很多人一说Agent就觉得是魔法其实把它拆开看就三样东西一个会推理的模型当大脑一组能被调用的工具当手脚一段能记录上下文的消息列表当便签本。理解了这三件事后面所有代码都是在围绕它们转圈。1.1 Agent和普通脚本的本质区别普通脚本的逻辑是硬编排的。比如你想写一个自动提醒脚本代码流程是读配置、查日历、发消息每一步都是事先定死的一旦需求变成顺便帮我查一下天气再决定提不提醒就得改代码。Agent的思路完全反过来。你只给它一个目标和一些工具它自己决定流程。比如告诉它今天有雨的话提醒我带伞它会自己判断先查天气数据发现下雨然后以提醒的方式输出。这个自己判断的过程不是写死的而是模型根据上下文临时生成的。你可以把Agent理解成一个刚入职的实习生。实习生不需要你告诉每一步怎么做你只要给他资源工具、告诉他目标prompt他能自己拆解任务、执行、遇到问题再调整。普通脚本是流水线Agent是能自己排流水线的调度员。1.2 一个Agent最小闭环的五个环节不管多复杂的Agent跑起来都离不开下面这个循环。环节职责代码里对应的东西接收任务理解用户输入把用户消息放入 messages补充上下文让模型知道我是谁、有什么工具system prompt 工具描述调用模型思考模型决定下一步该干嘛requests 请求模型接口执行工具调用真实函数拿到结果你自己写的Python函数反馈结果把工具结果交回模型把结果追加到 messages看到没有整个过程非常像人在干活接到任务看看手头有什么资料想想怎么办动手做看结果不行再调整。你不需要一开始就理解每一步的细节先记住这个闭环后面代码就是把这个环一遍一遍跑起来。2. 0基础技术选型真正要决定的只有三件事2.1 为什么语言选Python而不是其他因为Python处理字符串和JSON太方便了。Agent从模型那里拿到的、传给工具的全是字符串和JSON字典。Python不用声明类型写几行就能跑而且调用模型接口只需要熟悉requests一个库就够。你不需要先学一堆语法才能动手。复制代码、改配置、跑起来遇到报错再看是什么问题。只要会用print打印结果、能看懂字典和列表你就可以开始写了。那些把Python学完一大半再来做Agent的建议纯属浪费时间。2.2 模型接口怎么选本地开源模型 vs 云服务接口这块是刚开始最容易纠结的其实只需要问自己三个问题有没有现成的接口地址数据能不能出本地愿不愿意为调用付费对比维度本地开源模型云服务接口网络要求只需本地安装推理工具需要能访问模型服务硬件要求需要一定显存或内存无要求隐私控制数据不出本机数据交给服务方上手难度需要装推理工具和模型简单拿钥匙就能用效果小模型可能不够稳大模型更聪明工具调用更稳我的建议是入门阶段先把代码写好模型接口用一个配置文件占着随时能换。你可以先用本地推理工具加载一个小模型跑通逻辑也可以直接申请一个云服务的接口密钥。代码完全不变只改配置文件里的模型名和地址就行。2.3 框架选不选入门期我建议裸写现在Agent框架非常多有做流程编排的、有做多Agent协作的、有做记忆管理的。框架确实能少写代码但问题在于你还没理解底层逻辑就被框架牵着走。一旦出问题根本不知道是模型问题、工具问题还是框架封装导致的问题。我做工具调用和Agent循环都会先用基础的requests直接写一遍。你自己把每一步打出来看模型返回了什么、工具执行了什么、哪里断了一目了然。等你能手写一个Agent循环之后再去看框架会发现原来框架干的就是你手写那点事只是封装得更好。顺序不能反。3. 第一个能跑的Agent三版代码一步一步递进3.1 最简版本让模型学会当助理先装依赖只需要requests。然后我们写一个最简的对话函数。pip install requests接着写核心调用函数。为了兼容不同模型服务我用标准的接口格式把对话历史以messages列表传进去模型返回新的回复。import requests import json API_BASE_URL https://api.llm.example.com/v1/chat/completions API_KEY 你的密钥 MODEL_NAME 你的模型名 def call_llm(messages, temperature0.2): headers { Content-Type: application/json, Authorization: fBearer {API_KEY} } payload { model: MODEL_NAME, messages: messages, temperature: temperature } resp requests.post(API_BASE_URL, headersheaders, jsonpayload, timeout60) resp.raise_for_status() return resp.json()[choices][0][message][content]这个函数的原理很容易理解把所有对话消息发给模型服务模型根据最后一条消息和上面的历史生成并返回下一句话。temperature是控制随机程度的Agent任务我习惯设在0.2左右让输出稳定一些聊天可以高一点。有了这个函数第一个Agent就是一行循环messages [ {role: system, content: 你是一个乐于助人的小助理回答要简洁、准确。} ] print(Agent已上线输入 exit 退出) while True: user_input input(你: ) if user_input.lower() exit: break messages.append({role: user, content: user_input}) reply call_llm(messages) print(Agent:, reply) messages.append({role: assistant, content: reply})到这里你已经有一个最简单的Agent了。它有个缺陷没有任何行动能力不管你问它现在几点、今天天气如何它都只能凭训练数据里的记忆回答或承认不知道。下一步我们让它能用上自己手里的工具。如果你用的是本地推理工具加载开源模型通常它们也提供完全一样的接口格式。你只需要把API_BASE_URL改成类似http://localhost:11434/v1/chat/completions并关掉API密钥认证其它代码一行都不用改。这也是我建议接口统一用这一套格式的原因切换成本极低。3.2 第二版把记忆变成维护消息列表Agent的第二版升级就是学会管理messages。很多人把记忆想得太玄其实在对话式Agent里记忆就是消息列表本身。系统要维护三件套system消息人设和规则永远在第一条user消息每次的用户输入assistant消息模型上次的输出要注意的是消息列表不能无限膨胀。模型一次能处理的长度是有限的塞太多历史容易把重要指令挤掉还会拖慢响应速度。我习惯在每次对话前后检查长度超过阈值就删掉最早的非system消息。MAX_MESSAGES 10 def trim_messages(messages): system_msgs [m for m in messages if m[role] system] others [m for m in messages if m[role] ! system] if len(others) MAX_MESSAGES - 1: others others[-(MAX_MESSAGES - 1):] return system_msgs others这个trim_messages函数会在每次追加消息后调用。删掉最老的对话保留最近的对话这样既保留了上下文又不会把模型撑爆。你如果做的Agent是更正式的任务还可以把重要的结论单独抽出来放进一个长期记忆文件但那是后话新手先把短时记忆做好就够了。3.3 第三版从聊天到会干活的转折点前两版本质上还是个聊天机器人。真正让它变成Agent的是一个多步循环先让模型思考返回一个行动指令Agent解析指令执行工具把结果反馈回去模型再决定是继续调用工具还是直接回答。这个循环用大白话描述是这样第1步把用户问题可用工具说明书发给模型 第2步模型返回我想调用工具A参数是... 第3步Agent去执行工具A 第4步把工具A的结果追加到消息里 第5步回到第1步 第6步模型说已经有结果了我直接回答 - 退出循环这个循环最关键的地方在于模型不直接执行代码它只是输出意图。比如用户说帮我算一下23乘以17模型不会自己算得多准它输出的是我要调用calculator工具参数是23和17。真正执行计算的是你写的Python函数。模型负责动脑函数负责动手。这种模型输出指令代码执行指令的设计彻底把聪明和可靠分开了。模型再聪明也可能算错但你写的calculator函数是精确的。这也是Agent工程化的核心思想让不稳定的部分尽量少干活让稳定的部分做关键计算。4. 长出手的完整实现工具调用的原理与落地代码4.1 给模型一份工具说明书模型怎么知道有哪些工具可以用答案是我们在请求里附带一份工具说明书。这份说明书不是代码而是模型能读懂的JSON结构描述每个工具叫什么、干什么用、参数是什么格式。打个比方你雇了个助手他不可能见过你所有工具所以你在桌上放了张卡片写了计算器可以算加减乘除需要两个数字。模型看到卡片才会在合适的场景下选择使用。工具说明书的最小结构就是两个字段name和description再加上一个描述参数的parameters。多个工具放在一个列表里发给模型。TOOLS [ { type: function, function: { name: calculate, description: 当你需要做数学运算时调用支持加、减、乘、除, parameters: { type: object, properties: { a: {type: number, description: 第一个数字}, b: {type: number, description: 第二个数字}, op: {type: string, description: 运算符号 - * /} }, required: [a, b, op] } } } ]这段JSON告诉模型三件事工具存在、工具能干什么、要传什么参数。模型看到以后如果在消息里说帮我算一下36乘以24它可能返回这样一段内容我想调用 calculate 工具参数是 a36, b24, op*我们的代码要做的事情就是解析模型返回的这段调用意图找到对应的Python函数把参数字典传过去拿到结果。4.2 工具注册器让新增工具像填表格一样简单我写工具时最烦的是每次都要在函数本体和JSON说明书之间来回复制。后面我改成了一种更优雅的做法用一个装饰器让工具函数自己带上说明书。TOOL_REGISTRY {} def register_tool(name, description, parameters): def decorator(func): TOOL_REGISTRY[name] { function: func, description: description, parameters: parameters } return func return decorator然后定义工具的时候只需要在函数上面加一行装饰器register_tool( namecalculate, description做数学四则运算, parameters{ type: object, properties: { a: {type: number}, b: {type: number}, op: {type: string, enum: [, -, *, /]} }, required: [a, b, op] } ) def calculate(a, b, op): if op : return a b if op -: return a - b if op *: return a * b if op /: return a / b if b ! 0 else 除数不能为0 return 不支持的运算符函数本体和说明书放在一起维护起来特别爽。加新工具的时候你只需要多写一个函数和一行装饰器主循环一行都不用改。这也是后面可复用的基础。再写一个查询本地备忘录的工具这样两个工具配合就能看到Agent的工作模式NOTES {购物清单: 牛奶、鸡蛋、面包} register_tool( namequery_note, description查询本地备忘录里的内容, parameters{ type: object, properties: { key: {type: string, description: 备忘录名称} }, required: [key] } ) def query_note(key): return NOTES.get(key, 没有找到这个备忘录)4.3 决策-执行-反馈三明治Agent主循环代码工具准备好了接着写最关键的Agent主循环。我把它做得非常直白就是为了让你看清楚每一步。import json import requests AGENT_SYSTEM_PROMPT 你是一个会调用工具来处理问题的智能助手。 遇到需要计算、查询等任务时优先调用工具不要自己凭空猜测结果。 工具调用的结果会在后续消息中给你请根据结果组织最终回答。 def build_tool_descriptions(): return [ { type: function, function: { name: name, description: meta[description], parameters: meta[parameters] } } for name, meta in TOOL_REGISTRY.items() ] def execute_tool(name, args): meta TOOL_REGISTRY.get(name) if not meta: return json.dumps({error: f未知工具: {name}}, ensure_asciiFalse) try: result meta[function](**args) return json.dumps(result, ensure_asciiFalse) except Exception as e: return json.dumps({error: str(e)}, ensure_asciiFalse)call_llm要支持带工具和不带工具两种模式。带工具时模型可能会返回tool_calls字段里面装着它想调用的函数名和参数。我们先把判断逻辑写出来def call_llm_with_tools(messages, tools): headers { Content-Type: application/json, Authorization: fBearer {API_KEY} } payload { model: MODEL_NAME, messages: messages, tools: tools, temperature: 0.2 } resp requests.post(API_BASE_URL, headersheaders, jsonpayload, timeout120) resp.raise_for_status() return resp.json()[choices][0][message]然后就是那个决策-执行-反馈三明治MAX_TURNS 5 def run_agent(user_input, max_turnsMAX_TURNS): messages [ {role: system, content: AGENT_SYSTEM_PROMPT}, {role: user, content: user_input} ] tool_descs build_tool_descriptions() for turn in range(max_turns): message call_llm_with_tools(messages, tool_descs) tool_calls message.get(tool_calls) # 情况1模型想调用工具 if tool_calls: messages.append({ role: assistant, content: message.get(content) or , tool_calls: tool_calls }) for call in tool_calls: fn_name call[function][name] fn_args json.loads(call[function][arguments]) print(f[工具调用] 第{turn 1}轮 - {fn_name}({fn_args})) tool_result execute_tool(fn_name, fn_args) messages.append({ role: tool, tool_call_id: call[id], content: tool_result }) continue # 情况2模型直接回答不再调用工具 final_answer message.get(content, ) messages.append({role: assistant, content: final_answer}) return final_answer return 我已经尝试了好几轮但还没有得到最终答案。你可能需要简化一下问题。这个循环每次执行完工具都会把结果追加到messages里再带着新消息回去问模型。模型看到工具结果后有两种选择要么觉得够了直接回答要么发现结果不对需要再调一次工具。这个模型决策、代码执行、结果反馈的循环就是Agent一切复杂行为的来源。你可以试着运行一下if __name__ __main__: while True: query input(你: ) if query exit: break answer run_agent(query) print(Agent:, answer)问一句帮我算一下18乘以7再算一下结果除以3。你会看到控制台打印出工具调用记录然后模型拿着两次结果组织出最终回答。看到这个输出说明你的Agent已经长出手了。4.4 工具设计的三条原则工具写多了以后我总结出三条铁律新手照着做能少踩坑。第一工具入参一定要做校验。模型不是人它传参可能传错类型、传错数量。你需要在函数内部对参数做防御不要假设一定会传对。第二工具返回值必须是字符串或可JSON序列化的结构。因为返回值要拼进消息里再发给模型如果是复杂对象记得json.dumps一下不然模型看到一堆Python表达式会发懵。第三工具出错时也要返回一句人能看懂的话。比如工具里返回key error: 参数缺失模型看到这种错误通常不知道怎么办。你返回查询备忘录时缺少备忘录名称请补充名称模型马上就能顺着这句话调整。5. 从能跑到可复用配置化、工具注册、类封装三板斧5.1 一切变量进配置文件你在3.1里写的API_BASE_URL、API_KEY、MODEL_NAME如果硬编码在代码里换一个场景就得改代码。更好的做法是都放进配置文件。# config.py API_BASE_URL https://api.llm.example.com/v1/chat/completions API_KEY 你的密钥 MODEL_NAME 你的模型名 MAX_TURNS 5 SYSTEM_PROMPT 你是一个智能助手回答问题前先调用可用工具。以后换模型、换密钥、调整最大轮数都只改这个文件。代码主体完全不动。这一步看着小事但正是可复用和一次性脚本的分水岭。5.2 用注册器管理工具库4.2里的register_tool装饰器就是把工具变成即插即用的插件。只要在某个文件里定义好带装饰器的函数程序启动时注册器会自动收集主循环通过build_tool_descriptions()拿到全部工具执行时通过execute_tool()按名字找到函数。新增工具时你不需要动主循环、不需要改Agent类只需要新增一个函数文件。如果你的Agent跑在一台服务器上甚至可以做成扫描目录自动加载工具文件那基本上就是一个轻度插件系统了。5.3 封装成Agent类一次初始化多次调用工具化和配置化做完我把整段逻辑封装成类方便同一个程序里起多个不同身份的Agent。class Agent: def __init__(self, system_prompt, toolsNone, max_turns5): self.system_prompt system_prompt self.max_turns max_turns self.tools tools or build_tool_descriptions() self.messages [{role: system, content: system_prompt}] def reset(self): self.messages [{role: system, content: self.system_prompt}] def run(self, user_input): self.messages.append({role: user, content: user_input}) for turn in range(self.max_turns): message call_llm_with_tools(self.messages, self.tools) tool_calls message.get(tool_calls) if tool_calls: self.messages.append({ role: assistant, content: message.get(content) or , tool_calls: tool_calls }) for call in tool_calls: fn_name call[function][name] fn_args json.loads(call[function][arguments]) result execute_tool(fn_name, fn_args) self.messages.append({ role: tool, tool_call_id: call[id], content: result }) else: content message.get(content, ) self.messages.append({role: assistant, content: content}) return content return 达到最大轮数返回失败。封装成类之后复用的方式就非常舒服。做一个客服助手客服 Agent( system_prompt你是客服小助手。查订单要调query_order工具催发货要先调status工具。, max_turns4 ) result 客服.run(我的订单怎么还没发货)做一个学习答疑助手只需要换system_prompt和工具集答疑助手 Agent( system_prompt你是数学答疑助手所有计算必须调用计算器工具禁止心算。, toolsbuild_tool_descriptions() ) answer 答疑助手.run(鸡兔同笼头35个脚94只各有多少只)同一个代码内核换一套工具和提示词就是完全不同的应用。这就是可复用的含义——不是复制粘贴一份新代码而是换配置。5.4 三个可复用场景指南场景需要新增的工具特殊提示词要点客服助手查订单、查物流、查退款规则态度友好不知道就查工具学习答疑计算器、知识库检索计算必须用工具别口算数据整理读取CSV、聚合统计、格式化表格先看数据结构再决定分析步骤每个场景的Agent代码完全一样变的只有tools和system_prompt。你可以把这个类单独存成一个agent.py文件几个月后再拿起来用还是一样顺手。6. 实测中绕不开的坑我的排错记录6.1 工具返回格式不规范导致循环卡死第一次跑通Agent时我遇到过一个特别经典的问题模型反复调用同一个工具停不下来。打印日志发现工具返回的是Python的浮点数比如42.0我忘了转成字符串就直接塞进消息里。模型看到的消息结构完全是乱的它看不懂工具返回了什么只能再调一次同款工具陷入死循环。排查链路是这样的先打印每一轮message的内容发现工具调用一直没停再打印工具执行结果发现返回值类型是一个浮点数没有变成规范的JSON字符串。修复方法很简单execute_tool里统一用json.dumps(result, ensure_asciiFalse)。经验是工具返回给模型的内容格式必须极为规范。哪怕返回的是一个数字也要明确标注它是什么比如{result: 42.0}。模型不是人它会严格按照字面意思理解任何模糊都能让它犯傻。6.2 上下文膨胀与关键指令被挤掉第二个坑发生在连续聊了很多轮之后。系统发现之前定义好的工具模型开始忘记主动调用了。原因很简单消息太长早期的系统指令和工具说明被大量对话历史挤出了模型的注意力窗口。解决办法有两个。一个是每次调用工具时把工具说明重新放在最靠近当前问题的地方很多模型对末尾消息更敏感你可以把工具描述以系统消息形式追加在用户输入前面。另一个是控制消息轮数超过6轮对话就折叠历史把前面的对话概要交给模型而不是原封不动地堆着。6.3 模型不按说明书出牌有的模型对工具的JSON格式支持不稳定偶尔会返回一段JSON代码而不是规范的tool_calls字段。这种情况我用的兜底方案是当模型没有返回工具调用字段时用正则检查它是否输出了形如{name: ..., arguments: {...}}的内容如果匹配就手动转化成工具调用。这样一顿操作兼容性会好很多。如果实在频繁出现格式问题先做三件事把temperature降到0把工具数量控制在5个以内把工具参数类型写得极其简单。复杂参数是模型格式错误的温床。6.4 切换模型之后行为完全变了同样一段代码换一个模型就变笨不是错觉。有的模型适合直接表达工具调用意图有的模型对temperature特别敏感有的模型对中文格式要求高。我试过同一套提示词一个模型能在3轮内解决另一个要6轮还答不对。解决思路是加一层兼容适配层把模型的输出先统一格式化成我们内部的结构再做后续处理。也就是说在call_llm_with_tools外面包一层函数把各家模型返回结果转换成统一的指令字典。这样主循环永远只看自己内部的格式不被不同模型的返回样式干扰。把上面这些坑汇总成一张速查表下次卡住直接对照现象可能原因解法工具被反复调用工具返回格式不规范统一返回JSON字符串模型不使用工具消息太长/工具说明被稀释精简上下文重新注入工具说明输出格式乱temperature太高/工具参数复杂降温到0.2以下简化参数换模型后变笨模型间能力差异加兼容适配层针对模型调提示词最后的一点个人体会整个Agent搭下来我最强烈的感受是真正难的不是代码而是让模型稳定地输出意图这件事。代码部分就那么几十行一个合格的Python新手一小时内能写完。但要让工具调用每一步都准确、每个坑都能排查、每次换模型都不用改主逻辑这些才是实战里花时间最多的地方。我自己的建议是先别追求一步到位做一个大而全的Agent。你先复现这篇文章里的最小循环再往里面加一个你自己真实会用到的工具。比如我就给Agent加了一个读取本地记录文件并汇总的工具它立刻从玩具变成了能帮我干活的东西。加完第一个工具你会对Agent的理解有一个质的提升。后续你想再玩复杂可以研究怎么把Agent的短期记忆变成向量数据库或者让多个Agent分工协作但基础永远是今天这个循环。

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

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

免费获取报价 →
↑