从零手写一个最小可用Agent:核心架构与实现
1. 从零理解 Agent 开发为什么值得亲手造一个小 Agent很多人第一次接触 Agent 这个概念脑子里浮现的都是各种演示视频里那种能自己规划任务、调用工具、读写文件、还能自我纠错的“智能体”。看的时候觉得挺神奇但真要自己动手做一个往往就卡在第一步这东西到底由哪些部分组成它的“思考”是怎么变成“行动”的为什么有的 Agent 跑得稳有的跑两下就死循环我最初也有同样的困惑。后来参照了一个叫 pi-agent 的轻量实现自己动手撸了一个最小可用的 Agent才算把这条链路彻底打通。这篇文章就是把我从“看热闹”到“能跑通”的整个过程拆开来讲包括整体设计思路、核心模块的实现细节、实操中踩过的坑以及一套可以直接抄作业的最小实现方案。先明确一下这篇文章适合谁看。如果你已经会写基本的代码对 API 调用不陌生但没真正做过 Agent那这篇就是写给你的。如果你已经做过一些 Agent 项目但总觉得结构混乱、不好扩展也可以看看我这里的分层思路或许能给你一些重构的灵感。整篇内容围绕一个核心目标用最少的依赖实现一个能理解指令、能调用工具、能多轮循环直到完成任务的小 Agent。在开始之前先把这个 Agent 的最小能力边界说清楚。它不需要多模态不需要复杂的记忆系统也不需要接入几十个工具。它只需要具备三样东西一个能理解自然语言指令的“大脑”一套能执行具体动作的“工具”以及一个能把两者串起来的“循环控制”。这三样东西组合起来就已经是一个能干活的最小 Agent 了。后面所有的复杂度都是在这个骨架上长出来的。我参照 pi-agent 的思路把这个小 Agent 命名为 mini-agent方便后面描述。它的核心流程可以用一句话概括接收用户输入交给模型判断下一步该做什么如果需要调用工具就执行工具把结果再喂回模型循环往复直到模型认为任务完成。听起来简单但每一步都有不少细节值得推敲。2. 整体架构设计mini-agent 的骨架怎么搭2.1 核心模块划分与职责边界在动手写代码之前先把模块划分清楚这一步偷懒后面会加倍还回来。我见过不少人一上来就写一个大函数把模型调用、工具执行、循环控制全塞在一起结果调试的时候根本不知道问题出在哪一层。mini-agent 我分成了四个核心模块每个模块只干一件事。第一个是模型交互层负责和语言模型通信。它接收一段对话历史返回模型的回复。这一层不关心业务逻辑只负责把请求发出去、把响应拿回来。第二个是工具注册层负责管理所有可用的工具。每个工具都有自己的名称、描述、参数定义和执行函数。第三个是循环控制层这是 Agent 的心脏负责驱动整个“思考-行动-观察”的循环。第四个是对话历史管理负责维护消息列表确保模型每次都能看到完整的上下文。这样分的好处是每一层都可以独立替换。比如你想换一个模型提供商只需要改模型交互层想加一个新工具只需要在工具注册层注册想调整循环策略只需要改循环控制层。各层之间通过明确的接口通信不会互相污染。提示模块划分的粒度没有绝对标准但有一条原则值得参考——如果两个部分的修改原因不同就应该分开。模型调用会因为换供应商而改工具执行会因为加功能而改这两者的修改原因不同所以应该分开。2.2 为什么选择“循环式”而不是“流水线式”Agent 的架构模式大致有两种一种是流水线式把任务拆成固定的几个阶段依次执行另一种是循环式让模型在每一轮自己决定下一步做什么。mini-agent 选择了循环式原因很简单流水线式需要你提前知道任务的所有步骤但真实场景里用户的需求往往是模糊的需要 Agent 在执行过程中根据中间结果动态调整。举个例子用户说“帮我整理一下这个目录下的文件”。如果是流水线式你得提前定义好“扫描目录、分类文件、移动文件”这几个固定步骤。但用户可能只是想重命名也可能想按类型归档还可能想删除重复文件。这些分支在流水线式架构里很难优雅处理。而循环式架构下模型可以先扫描目录看到文件列表后再决定下一步灵活性高得多。当然循环式也有代价。最大的风险是死循环——模型可能反复调用同一个工具或者在一个问题上绕圈子。所以循环控制层必须设置最大轮次限制并且在检测到重复行为时主动中断。这个后面会详细讲。2.3 消息协议设计让模型“看得懂”上下文模型交互的核心是消息列表。每条消息都有角色和内容两个基本属性。角色通常有三种系统、用户、助手。系统消息用来设定 Agent 的行为准则用户消息是输入助手消息是模型的回复。但在 Agent 场景下还需要第四种角色——工具。工具消息用来承载工具执行的结果让模型知道刚才那个动作产生了什么输出。消息的顺序也很关键。一个典型的循环是这样的系统消息在最前面然后是用户消息接着是助手消息可能包含工具调用请求再是工具消息工具执行结果然后再是助手消息……如此往复。模型每次看到的都是完整的消息列表这样它才能基于全部历史做决策。这里有个容易踩的坑工具消息的格式必须和模型期望的格式严格一致。不同模型提供商对工具调用结果的格式要求不同有的要求用特定字段包裹有的要求用特定角色标识。如果格式不对模型可能会忽略工具结果或者直接报错。我在实操中就遇到过因为工具消息格式不对导致模型反复调用同一个工具的情况。3. 核心模块实现细节从模型调用到工具执行3.1 模型交互层如何稳定地拿到结构化输出模型交互层最关键的任务是让模型输出结构化的内容而不是一段自由文本。因为 Agent 需要知道模型是想“说话”还是想“调用工具”如果输出是自由文本解析起来会非常脆弱。目前主流的做法有两种一种是让模型输出特定格式的文本比如 JSON然后自己解析另一种是使用模型提供商提供的工具调用接口让模型直接返回结构化的工具调用请求。mini-agent 选择了第二种因为更稳定。工具调用接口会返回一个结构化的对象里面明确标识了工具名称和参数不需要自己做文本解析。但这里有个细节不是所有模型都支持工具调用接口如果不支持就得退回到文本解析方案。文本解析方案的核心是设计一个清晰的输出格式并且在系统提示里反复强调这个格式。我在实操中的经验是系统提示里对输出格式的描述要尽可能具体最好给出一个完整的示例。比如不要只说“请用 JSON 格式输出”而要说“请输出一个 JSON 对象包含 action 和 parameters 两个字段action 是字符串parameters 是对象。例如{action: read_file, parameters: {path: test.txt}}”。示例越具体模型出错的概率越低。注意即使使用了工具调用接口也要对返回结果做校验。我遇到过模型返回的工具名称不在注册列表里的情况如果不校验直接执行程序就会崩溃。校验的逻辑很简单检查工具名称是否存在检查参数是否完整检查参数类型是否正确。这三步做完能挡掉大部分异常。3.2 工具注册层设计一个易扩展的工具接口工具注册层的设计目标只有一个加一个新工具的时候不需要改其他任何代码。为了实现这一点每个工具需要自描述——它自己声明名称、描述、参数 schema 和执行函数。注册的时候只需要把这些信息塞进一个字典循环控制层根据名称查找并执行。工具的描述非常重要因为模型是根据描述来判断什么时候该用这个工具的。描述写得好模型用对的概率就高描述写得含糊模型就会乱用。我总结了一个写工具描述的模板第一句说明这个工具做什么第二句说明什么时候用第三句说明参数含义。比如一个读文件的工具描述可以写成“读取指定路径的文件内容。当需要查看文件内容时使用。参数 path 是文件的相对路径。”参数 schema 建议用 JSON Schema 格式因为大多数模型提供商都支持这种格式。JSON Schema 可以定义参数的类型、是否必填、默认值等信息模型会根据 schema 来生成参数。如果 schema 写得清楚模型生成的参数准确率会明显提升。# 工具注册的简化示例 tools {} def register_tool(name, description, parameters, func): tools[name] { name: name, description: description, parameters: parameters, func: func } # 注册一个读文件的工具 register_tool( nameread_file, description读取指定路径的文件内容。当需要查看文件内容时使用。, parameters{ type: object, properties: { path: {type: string, description: 文件的相对路径} }, required: [path] }, funclambda path: open(path).read() )3.3 循环控制层Agent 的“心脏”怎么跳循环控制层是整个 Agent 最核心的部分它决定了 Agent 什么时候继续、什么时候停止、什么时候报错。mini-agent 的循环逻辑是这样的先把系统消息和用户消息放进消息列表然后进入循环。每一轮循环把当前消息列表发给模型拿到回复。如果回复里包含工具调用请求就执行工具把结果作为工具消息追加到消息列表然后进入下一轮。如果回复里没有工具调用请求说明模型认为任务完成了循环结束返回最终回复。这个逻辑看起来简单但有几个关键决策点需要仔细处理。第一个是最大轮次限制。如果不限制模型可能永远不认为任务完成循环就会一直跑下去。我一般设置 10 到 15 轮具体取决于任务的复杂度。第二个是重复检测。如果模型连续两轮调用同一个工具、传同样的参数大概率是卡住了应该主动中断并提示模型换个思路。第三个是错误处理。工具执行可能失败失败信息也要作为工具消息返回给模型让模型知道刚才那个动作没成功需要调整策略。这里有个实操心得在系统提示里明确告诉模型“如果任务已经完成请直接回复结果不要再调用工具”。这句话能显著降低模型在任务完成后还继续调用工具的概率。另外在工具执行失败时返回的错误信息要尽量具体比如“文件不存在”比“执行失败”更有助于模型调整策略。3.4 对话历史管理上下文窗口的取舍对话历史管理看起来简单其实有不少讲究。最直接的做法是把所有消息都保留但上下文窗口是有限的消息太多会超出限制。所以需要一套裁剪策略。常见的做法是保留系统消息和最近 N 轮对话把更早的消息丢弃。但这样有个问题如果早期消息里有重要的工具执行结果丢弃后模型可能会重复执行。我的做法是分两级处理。第一级是保留系统消息和最近 N 轮完整对话。第二级是对更早的消息做摘要把关键信息压缩成一段简短文本作为一条系统消息插入。这样既控制了长度又保留了关键信息。摘要的生成可以再调用一次模型也可以用简单的规则提取。如果任务不复杂用规则提取就够了。提示上下文窗口的裁剪策略没有标准答案取决于你的任务特点。如果任务步骤多、依赖强就多保留一些历史如果任务独立性强就可以激进裁剪。建议先用保守策略观察模型表现后再调整。4. 实操全流程从零跑通一个 mini-agent4.1 环境准备与依赖安装先把环境搭起来。mini-agent 的依赖非常少核心只需要一个 HTTP 请求库和一个 JSON 处理库。如果你用的模型提供商有官方 SDK也可以直接用 SDK省去自己处理请求格式的麻烦。我这里的示例用通用的 HTTP 请求方式方便你替换成任何提供商。# 创建虚拟环境 python -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate # 安装依赖 pip install requests环境变量里配置好 API 密钥和接口地址。不要把密钥硬编码在代码里这是基本的安全习惯。可以用环境变量也可以用配置文件但配置文件要加入版本控制忽略列表。export AGENT_API_KEYyour-api-key export AGENT_API_BASEyour-api-base4.2 核心代码逐段拆解先写模型交互层。这一层的核心是构造请求体、发送请求、解析响应。请求体里需要包含模型名称、消息列表、工具定义等字段。响应里需要提取模型回复和工具调用请求。import os import json import requests def call_model(messages, tools): url f{os.environ[AGENT_API_BASE]}/chat/completions headers { Authorization: fBearer {os.environ[AGENT_API_KEY]}, Content-Type: application/json } payload { model: your-model-name, messages: messages, tools: tools, tool_choice: auto } response requests.post(url, headersheaders, jsonpayload) response.raise_for_status() return response.json()然后是工具注册层。前面已经给过示例这里补充一下工具执行的部分。执行工具的时候要捕获异常把异常信息作为结果返回而不是让程序崩溃。def execute_tool(name, arguments): if name not in tools: return f错误工具 {name} 不存在 try: result tools[name][func](**arguments) return str(result) except Exception as e: return f错误{str(e)}最后是循环控制层。这是把前面两层串起来的地方。def run_agent(user_input, max_turns10): messages [ {role: system, content: 你是一个助手可以使用工具完成任务。任务完成后直接回复结果。}, {role: user, content: user_input} ] tool_definitions [ {type: function, function: { name: t[name], description: t[description], parameters: t[parameters] }} for t in tools.values() ] for turn in range(max_turns): response call_model(messages, tool_definitions) message response[choices][0][message] messages.append(message) if not message.get(tool_calls): return message[content] for tool_call in message[tool_calls]: name tool_call[function][name] arguments json.loads(tool_call[function][arguments]) result execute_tool(name, arguments) messages.append({ role: tool, tool_call_id: tool_call[id], content: result }) return 达到最大轮次限制任务未完成4.3 参数选择与调优记录模型调用有几个参数值得调。第一个是温度Agent 场景下建议用较低的温度比如 0.1 到 0.3因为需要模型稳定地输出结构化内容而不是发挥创造力。第二个是最大输出长度根据任务复杂度设置一般 1000 到 2000 个 token 够用。第三个是工具选择策略设置为 auto 让模型自己决定是否调用工具如果希望强制调用可以设置为 required但一般不建议。最大轮次限制我一般设 10。实测下来大部分简单任务 3 到 5 轮就能完成复杂任务 8 到 10 轮也够了。如果超过 10 轮还没完成大概率是卡住了继续跑也没意义。重复检测的阈值我设的是连续两轮相同工具加相同参数触发后就中断并返回提示。注意不同模型对工具调用的支持程度不同。有的模型能很好地理解工具描述并正确调用有的模型则需要更详细的提示。如果发现模型经常调用错误的工具先检查工具描述是否清晰再考虑换模型。5. 常见问题与排查技巧实录5.1 模型不调用工具怎么办这是最常见的问题。模型收到用户输入后直接回复了一段文字而不是调用工具。原因通常有三个一是工具描述不够清晰模型没理解这个工具是干什么的二是系统提示没有强调可以使用工具三是模型本身对工具调用的支持不好。排查顺序是这样的先看工具描述把描述改得更具体加上使用场景说明再看系统提示明确写“你可以使用以下工具来完成任务”如果还不行就换一个对工具调用支持更好的模型。我在实操中遇到过一次工具描述写的是“处理文件”模型完全不知道什么时候该用。改成“读取指定路径的文件内容当需要查看文件内容时使用”之后模型就能正确调用了。5.2 工具调用参数错误怎么修模型生成的参数可能缺少必填字段或者类型不对。比如需要字符串的地方传了数字需要对象的地方传了字符串。解决方法是把参数 schema 写得更严格并且在系统提示里强调参数格式。另外在工具执行前做一次参数校验如果校验不通过把错误信息返回给模型让它重新生成。我一般会在工具执行函数里加一层校验检查必填参数是否存在、类型是否正确。校验不通过就返回具体的错误信息比如“参数 path 是必填的且必须是字符串”。模型收到这个信息后下一轮通常会修正。5.3 死循环的识别与中断死循环的表现是模型反复调用同一个工具或者在不同工具之间来回跳转但始终不结束。识别方法是记录每一轮的工具调用如果连续两轮的工具名称和参数完全相同就判定为死循环。中断后返回一个提示告诉用户任务可能无法完成并附上最后几轮的工具调用记录方便排查。还有一种隐蔽的死循环是模型不断调用工具但每次参数略有不同比如每次读取的文件路径不一样。这种情况比较难自动识别只能靠最大轮次限制兜底。如果发现任务经常跑到最大轮次可以考虑在系统提示里加入“如果连续多次尝试仍未成功请停止并报告问题”的指令。5.4 常见问题速查表问题现象可能原因排查方法解决思路模型不调用工具工具描述不清检查工具描述是否具体补充使用场景说明参数格式错误schema 不严格检查参数类型定义加校验并返回错误信息死循环模型卡住记录工具调用历史加重复检测和轮次限制工具执行报错参数不对或环境问题查看错误信息捕获异常并返回给模型上下文超限消息太多检查消息数量裁剪历史或做摘要5.5 几个让我少走弯路的实操心得第一个心得是系统提示要短而精。我一开始写了一大段系统提示结果模型反而抓不住重点。后来精简到几句话只保留最关键的指令效果反而更好。第二个心得是工具数量不要太多。工具越多模型选择错误的概率越大。如果确实需要很多工具可以考虑分组或者用路由的方式先让模型选择工具类别再选择具体工具。第三个心得是日志要打全。每一轮的模型输入、模型输出、工具调用、工具结果都要记录下来。出问题的时候翻日志比猜原因快得多。我一般会把日志写到文件里按时间戳命名方便回溯。第四个心得是先用简单任务验证链路再上复杂任务。链路没跑通之前不要急着加功能否则出了问题很难定位是链路问题还是功能问题。6. 扩展方向从 mini-agent 到更实用的形态6.1 加入记忆系统让 Agent 记住历史mini-agent 目前是无状态的每次运行都是全新的开始。如果要让它记住之前的交互就需要加入记忆系统。最简单的做法是把历史对话存到本地文件或数据库每次启动时加载。更复杂的做法是加入向量检索把历史对话做嵌入根据当前输入检索相关的历史片段。我试过用本地文件存历史实现简单但检索效率低。后来改成用轻量数据库按时间戳和关键词检索效果好一些。如果任务对记忆要求高可以考虑引入向量数据库但复杂度也会上升。建议先用简单方案确认需求后再升级。6.2 多工具协作与任务分解当工具数量增多时可以让 Agent 先做任务分解把大任务拆成小任务再逐个执行。实现方式是在系统提示里加入“先规划再执行”的指令让模型先输出一个任务列表然后按顺序执行。每完成一个子任务就把结果记录下来作为后续子任务的输入。这种模式的好处是可控性更强每个子任务都有明确的完成标准。坏处是灵活性降低如果子任务之间有依赖关系需要额外处理。我在实操中的做法是让模型自己决定是否需要分解如果任务简单就直接执行如果任务复杂就先分解。这样兼顾了灵活性和可控性。6.3 错误恢复与重试策略Agent 执行过程中难免遇到错误比如工具调用失败、模型输出格式不对等。一个好的 Agent 应该能从错误中恢复而不是直接崩溃。mini-agent 目前的错误处理比较简单只是把错误信息返回给模型。更完善的做法是加入重试策略比如工具调用失败后自动重试一次如果还失败再返回错误。重试策略需要设置最大重试次数避免无限重试。我一般设置 2 到 3 次超过就放弃并报告错误。另外重试的时候可以调整参数比如换一个工具或者换一种方式调用。这些策略可以在循环控制层实现根据错误类型决定是否重试以及如何重试。6.4 性能优化减少不必要的模型调用模型调用是 Agent 中最耗时的环节减少调用次数能显著提升性能。一个常见的优化是缓存。如果同一个工具用同样的参数被调用多次可以直接返回缓存结果不用重新执行。另一个优化是合并请求。如果模型在一轮里调用了多个工具可以并行执行减少等待时间。还有一个优化是提前终止。如果模型已经给出了最终答案就不用再进入下一轮。判断标准是模型回复里没有工具调用请求。这个逻辑 mini-agent 已经有了但可以进一步优化比如在系统提示里明确告诉模型“如果任务完成直接回复结果不要调用工具”减少模型在任务完成后还继续调用工具的情况。7. 我个人的一些体会做 mini-agent 这个项目的过程中最大的收获不是写出了多少代码而是对 Agent 的工作机制有了更直观的理解。以前看别人的 Agent 演示觉得神秘自己动手之后发现核心就是“模型判断加工具执行加循环控制”这三件事。把这三件事拆清楚剩下的都是工程细节。另一个体会是Agent 的稳定性比功能丰富度更重要。我一开始总想加更多工具、更多功能结果 Agent 经常出错。后来把工具精简到最必要的几个把循环控制做扎实稳定性反而上去了。对于刚上手的人来说建议先把最小链路跑通确认稳定后再逐步扩展。最后分享一个小技巧在调试 Agent 的时候把每一轮的模型输入和输出都打印出来用不同颜色区分。这样一眼就能看出模型在哪一步做了错误决策比看日志文件直观得多。这个习惯帮我省了很多排查时间。