从零手写一个极简Agent:深入理解循环控制与工具调用原理
1. 为什么我要自己动手写一个 Agent市面上Agent框架已经多到挑花眼LangChain、AutoGPT、MetaGPT、CrewAI随便拎一个出来功能都比我自己写的强十倍。但我还是花了两个周末参照一个极简Agent实现我管它叫pi-agent从零撸了一个能跑的小Agent。原因很简单用框架和懂原理是两码事。你可能已经能用现成框架搭出一个能查天气、能搜网页、能调API的助手但当它答非所问、工具调用死循环、上下文爆炸的时候你根本不知道从哪下手排查。这就像会开车但不会修车平时没事一旦抛锚就只能叫拖车。自己写一遍Agent哪怕功能简陋你对循环控制、工具注册、消息管理、终止条件这四个核心环节的理解会完全不一样。这篇文章适合两类人一是用过Agent框架但想搞清楚底层到底怎么转的开发者二是完全没接触过Agent想找一个最小可运行示例入门的同学。我会把pi-agent的设计思路拆开讲清楚每个模块为什么这么设计然后给出可以直接跑的代码最后分享我在调试过程中踩过的坑。代码用Python写不依赖任何Agent框架只用标准库加一个HTTP请求库总共不到300行。先说清楚我这个小Agent能干什么接收用户输入判断是否需要调用工具如果需要就调用对应工具并把结果喂回给模型循环直到模型给出最终回答。工具目前注册了三个——计算器、当前时间查询、模拟搜索。麻雀虽小但Agent的核心骨架全在里面。2. pi-agent 的整体设计思路拆解2.1 核心循环Agent的心跳Agent和普通Chatbot最本质的区别在于循环。普通Chatbot是“输入→模型→输出”一条直线Agent是“输入→模型→判断→调工具→再判断→再调工具→...→输出”。这个循环就是Agent的心跳pi-agent的核心逻辑可以用一段伪代码概括messages [system_prompt, user_input] while True: response call_llm(messages) if response 包含工具调用: 执行工具把结果作为新消息追加到 messages continue else: return response 的文本内容看起来简单但魔鬼在细节里。循环的终止条件是什么如果模型一直要求调工具怎么办工具调用失败了怎么处理消息列表越来越长超出上下文窗口怎么办这些问题在写第一版的时候全都会冒出来。pi-agent的设计哲学是“最小可用”不追求支持所有模型、不追求并发、不追求流式输出只把最核心的循环跑通。我参照这个思路在实现时做了几个关键决策最大循环次数设为10超过就强制终止并返回当前状态工具执行异常时把错误信息作为工具结果返回给模型让模型自己决定下一步消息列表不做自动截断但会在system prompt里提醒模型注意上下文长度。提示最大循环次数这个参数非常关键。设太小复杂任务跑不完设太大一旦模型陷入死循环就是烧钱。我实测下来10到15次是比较合理的区间具体取决于你的工具复杂度和任务类型。2.2 工具注册机制让模型知道有什么能用Agent的能力边界由工具决定。pi-agent的工具注册设计得很巧妙——用装饰器字典的方式把工具的名称、描述、参数schema和实际执行函数绑定在一起。为什么这么设计因为大模型调用工具靠的是函数签名描述它需要知道这个工具叫什么、干什么用的、需要传什么参数、参数是什么类型。我一开始想偷懒直接把Python函数的docstring丢给模型结果模型经常传错参数类型。后来改成显式定义JSON Schema问题就解决了。具体来说每个工具注册时提供四样东西字段作用示例name工具唯一标识calculatordescription告诉模型什么时候用执行数学计算支持加减乘除和幂运算parameters参数schema{expression: {type: string, description: 数学表达式}}func实际执行的Python函数def calculate(expression): ...这个设计的好处是解耦新增工具只需要写一个函数加一个注册装饰器不用改核心循环的任何代码。我在原有三个工具基础上加第四个工具时只花了五分钟。2.3 消息管理Agent的记忆系统消息列表是Agent的“工作记忆”。pi-agent里消息分四种角色system、user、assistant、tool。这里有个容易踩坑的地方——不同模型对tool角色的消息格式要求不一样。有的模型要求工具结果用roletool加tool_call_id有的模型要求用roleuser把结果包一层。pi-agent的做法是在适配层做转换核心循环只认统一的内部格式。我实现的时候简化了这一点直接按照OpenAI兼容格式来组织消息。每条工具调用结果都带上对应的tool_call_id这样模型能准确知道哪个结果对应哪次调用。当模型一次返回多个工具调用时并行调用这个id就特别重要否则结果会串位。消息管理的另一个重点是system prompt的设计。pi-agent的system prompt不长但把几件关键事情说清楚了你是一个助手你可以使用工具使用工具时严格按照格式返回如果不需要工具就直接回答。我在此基础上加了一条“如果工具返回错误尝试分析原因不要重复同样的调用。”这条规则显著减少了无效循环。3. 核心模块的详细实现与关键细节3.1 工具定义与注册的完整代码先看工具注册部分。我用一个全局字典TOOLS来存所有工具注册装饰器负责把函数信息写进去import json import math from datetime import datetime TOOLS {} def register_tool(name, description, parameters): def decorator(func): TOOLS[name] { name: name, description: description, parameters: parameters, func: func } return func return decorator register_tool( namecalculator, description执行数学计算支持加减乘除、幂运算和常见数学函数, parameters{ type: object, properties: { expression: { type: string, description: 数学表达式例如 23*4 或 sqrt(16) } }, required: [expression] } ) def calculate(expression): try: allowed {k: v for k, v in math.__dict__.items() if not k.startswith(_)} allowed[abs] abs allowed[round] round result eval(expression, {__builtins__: {}}, allowed) return str(result) except Exception as e: return f计算错误: {str(e)}这里有个安全细节必须说eval不能直接裸用。我把__builtins__设为空字典只放行math模块里的函数和几个安全内置函数。即便如此eval仍然有风险生产环境建议用ast解析或者专门的表达式求值库。我这个小Agent是本地跑着玩的所以用了这个简化方案。再注册两个工具register_tool( nameget_current_time, description获取当前日期和时间, parameters{type: object, properties: {}, required: []} ) def get_current_time(): return datetime.now().strftime(%Y-%m-%d %H:%M:%S) register_tool( namesearch, description模拟搜索根据关键词返回一条模拟结果, parameters{ type: object, properties: { query: {type: string, description: 搜索关键词} }, required: [query] } ) def search(query): mock_db { python: Python是一种广泛使用的高级编程语言由Guido van Rossum创建。, agent: Agent是一种能感知环境并采取行动以实现目标的系统。, 天气: 今日晴气温18-26摄氏度。 } for key in mock_db: if key in query.lower(): return mock_db[key] return f未找到关于{query}的结果。3.2 消息组装与模型调用消息组装的核心是把工具定义转换成模型能理解的格式。OpenAI兼容接口要求工具以tools参数传入格式是[{type: function, function: {...}}]。我写了一个转换函数def build_tools_payload(): return [ { type: function, function: { name: t[name], description: t[description], parameters: t[parameters] } } for t in TOOLS.values() ]模型调用部分我用requests直接发HTTP请求。这里的关键是解析模型返回的tool_calls字段。当模型决定调用工具时返回的message里会有一个tool_calls数组每个元素包含id、function.name和function.argumentsJSON字符串。我需要把这些解析出来依次执行工具然后把结果按格式追加到消息列表。def call_llm(messages): payload { model: your-model-name, messages: messages, tools: build_tools_payload(), tool_choice: auto } resp requests.post(API_URL, jsonpayload, headersHEADERS) return resp.json()[choices][0][message]tool_choice设为auto让模型自己决定是否调用工具。你也可以设为required强制每次必须调工具或者指定具体工具名。实测auto最灵活但偶尔会出现该调工具却不调的情况这时候可以在system prompt里加强引导。3.3 主循环的完整实现把上面几块拼起来就是主循环def run_agent(user_input, max_turns10): messages [ {role: system, content: SYSTEM_PROMPT}, {role: user, content: user_input} ] for turn in range(max_turns): message call_llm(messages) messages.append(message) tool_calls message.get(tool_calls) if not tool_calls: return message.get(content, ) for tc in tool_calls: func_name tc[function][name] try: args json.loads(tc[function][arguments]) except json.JSONDecodeError: args {} if func_name in TOOLS: try: result TOOLS[func_name][func](**args) except Exception as e: result f工具执行异常: {str(e)} else: result f未知工具: {func_name} messages.append({ role: tool, tool_call_id: tc[id], content: str(result) }) return 达到最大循环次数任务未完成。这段代码有几个值得注意的点。第一每次循环都把模型的完整message追加到messages包括tool_calls字段这样下一轮模型能看到自己之前请求了什么。第二工具执行结果用roletool加tool_call_id返回这是OpenAI格式的要求。第三异常处理分两层——JSON解析异常和工具执行异常都转成文本返回给模型让模型有机会自我修正。注意有些模型在收到工具错误后会反复尝试同样的调用。我在system prompt里加了“如果工具返回错误分析原因后再决定是否重试不要原样重复”之后这种情况少了很多。如果还是出现可以在代码层面加一个检测连续两次相同工具相同参数就强制中断。4. 实操过程中的问题排查与经验总结4.1 常见问题速查表我在调试过程中遇到的问题基本集中在下面这几类整理成表格方便对照排查问题现象可能原因排查方法解决方案模型不调用工具直接回答system prompt不够明确 / tool_choice设置问题打印完整请求payload检查强化prompt或临时设tool_choice为required工具调用参数解析失败模型返回的arguments不是合法JSON打印原始arguments字符串加try-except兜底把解析错误返回给模型循环不终止模型反复调同一工具 / 终止条件没写对打印每轮的工具调用记录设max_turns加重复调用检测工具结果模型“看不见”tool_call_id不匹配 / 消息格式错误检查追加的消息结构确保tool_call_id与请求中的id一致上下文超长报错消息列表累积过多统计token数截断早期消息或做摘要压缩4.2 三个我踩过的坑第一个坑工具描述写得太模糊。我最初给计算器工具的描述是“做计算”结果模型经常在不需要计算的时候也调它比如用户问“今天天气怎么样”它居然调计算器去算日期差。后来把描述改成“执行数学表达式计算仅在需要进行数值运算时使用”误调用率大幅下降。工具描述是给模型看的prompt写得越精确模型用得越准。第二个坑忽略并行工具调用。有一次用户问“现在几点顺便算一下23乘以47”模型一次性返回了两个tool_calls。我第一版代码只处理了第一个导致第二个结果丢失模型下一轮又请求了一次。修复方法就是遍历tool_calls数组每个都执行并追加结果。这个细节在文档里往往一笔带过但实际很容易漏。第三个坑system prompt里没限制输出格式。模型有时候会在最终回答里夹杂类似“我将调用calculator工具”这样的元描述用户体验很差。我在prompt里加了一句“最终回答直接给出结果不要描述你的思考过程或工具调用计划”输出就干净了。4.3 性能与成本优化心得Agent的token消耗比普通对话高得多因为每轮循环都要把完整消息列表发给模型。我实测一个需要调两次工具的查询token消耗大约是普通对话的4到6倍。几个优化手段精简system prompt每轮都发省下来的token乘以循环次数很可观。工具结果截断如果工具返回很长的文本只保留关键部分比如搜索结果只取前200字。合理设置max_turns大部分任务3到5轮就结束了设10是安全上限不要设50。缓存不变的system prompt部分API支持prompt caching能省不少钱。另外工具执行本身也要加超时。我有个模拟搜索工具一开始没加超时某次网络抖动导致整个Agent卡住。后来给所有工具执行包了一层超时控制超过5秒就返回“工具执行超时”。5. 从最小Agent到实用Agent的扩展方向5.1 增加记忆与持久化目前这个小Agent是无状态的每次对话结束消息列表就丢了。要变成实用助手第一步就是加持久化。最简单的做法是把messages列表存到本地JSON文件或SQLite下次对话时加载回来。但要注意上下文窗口限制——不能无限往列表里塞历史消息。我的做法是保留最近N轮对话更早的做摘要压缩把摘要作为一条system消息放在前面。5.2 工具生态的扩展思路三个工具只是演示。实际项目中工具可以分成几类信息获取类搜索、读文件、查数据库、操作执行类写文件、发请求、控制设备、计算分析类代码执行、数据统计。每加一类工具都要考虑权限控制和错误恢复。比如写文件工具要不要限制只能写某个目录执行失败后是重试还是放弃这些策略最好在工具注册时就定义好而不是散落在主循环里。5.3 多Agent协作的雏形当单个Agent的工具多到一定程度模型选择工具的准确率会下降。这时候可以考虑拆成多个专职Agent——一个负责理解意图做路由几个负责具体领域。pi-agent的架构很容易扩展成这样把主循环里的工具调用换成一个子Agent调用子Agent有自己的工具集和prompt。我试过拆成“计算Agent”和“查询Agent”两个路由准确率比单Agent带六个工具时高不少。代码写到这儿这个小Agent已经能稳定处理“帮我算一下(1527)*3然后告诉我现在几点”这类复合请求了。整个过程最深的体会是Agent的复杂度不在代码量而在边界情况的处理。模型什么时候调工具、调错了怎么办、循环怎么退出、上下文怎么管理这些才是真正花时间的地方。把最小版本跑通一遍再回头看那些框架的文档很多设计决策就一目了然了。