拆解最小Agent参考实现:从零手写AI Agent的完整指南
做 Agent 这几年最常被问的一句话是Agent 到底怎么从零搭起来。市面上框架一大堆装完依赖、配好 key、跑通 demo感觉会了但换一个场景又抓瞎。我自己的经验是与其啃大框架不如找一个足够小的参考实现把它从头到尾剥一遍。这里说的 pi-agent就是一个典型的最小 Agent 参考实现功能不多但骨架很完整。参照它我用一个周末的零碎时间手写了一个小 Agent整个过程从设计到落地踩了不少坑也把很多框架替你藏起来的细节看清楚了。这篇文章就把这个拆解和复现过程完整写下来包括架构怎么拆、代码怎么写、问题怎么排给想动手实现自己 Agent 的人做个参考。这个内容适合两类人一是已经跑过主流 Agent 框架的 API 示例、但对内部机制还是感觉玄学的人二是想在业务里做一个真正贴合自己需求的小 Agent、不想被框架绑架的开发者。我尽量把思路和代码都讲细能直接照着抄。1. 先拆一个最小 Agent 的骨架到底是什么1.1 pi-agent 这类极简实现教给我们的核心思路pi-agent 这类实现最大的价值不是代码本身而是它揭示了一个事实Agent 的本质不是模型而是模型 工具 循环这三者的组合。很多初学者以为 Agent 就是把 prompt 写长一点再调大模型结果发现模型回答得再漂亮也没法替你执行操作、查询数据、操作文件。pi-agent 的架构让我意识到Agent 和普通对话 API 调用的分界线在于模型输出是否被当作指令去执行而不是当作答案去展示。再往细了看pi-agent 的代码量很小核心文件大概只有几百行没有复杂的抽象层没有插件机制没有可视化界面。但就是这么一个简陋的东西把 Agent 的完整生命周期跑通了接收用户任务、让模型做推理、解析模型是否想调用工具、执行工具、把结果返回给模型、模型继续推理直到给出最终答复。这个循环一旦通了Agent 就有了。我从里面提取到的核心法则就一条先把循环跑通再谈优化。很多人一上来就想要多智能体、记忆系统、人设管理结果连最基础的模型 工具 循环都没走通后面全是空中楼阁。1.2 四个模块一个循环缺一不可拆开来看任何最小可用的 Agent 都逃不出下面这几个组成部分pi-agent 也不例外模型接口层封装对底层大模型 API 的调用把发消息、收回复这个动作统一。关键点是支持流式还是非流式、支持多轮消息、后面还要支持工具调用参数。工具注册层Agent 能执行什么动作全在这层定义。每个工具就是一个名字 描述 参数 schema 实际执行函数的元组。模型看到的是描述系统执行的是函数。上下文管理层负责维护系统人设 历史消息 工具返回结果的对话记录。这一层决定了模型有没有足够的上下文做判断。主循环层Agent 的大脑所在。循环里反复做把上下文发给模型 → 看模型回复什么 → 如果请求调用工具就执行并回填结果 → 再次发消息直到模型输出最终答案或达到轮次上限。很多框架把这四层包装得很好接口调用十分简单但这恰恰是问题所在。我见过不少开发者框架换了一个又一个对上下文该截断还是该压缩工具调用了但失败了怎么告诉模型循环如何防止停不下来这些问题完全没有概念。这四个模块每一个展开都有不少坑后面我会按模块逐个讲。2. 设计动手前先把方案取舍想清楚2.1 模型层别把模型调用写死在业务代码里参照 pi-agent我的第一原则是模型层要做隔离。不是说模型调用不能直接写在循环里而是说至少要有明确的函数边界否则后面想换模型、想加缓存、想统一处理限流都得满文件找代码。我最初的实现里模型调用就是一路裸写 requests后来想给所有请求统一加一个超时与重试才发现改动量比想象中大得多。我建议的最小抽象是一层ChatClient。它对外的接口就一个输入一个消息列表messages输出一个回复可以是纯文本回复也可以是包含工具调用请求的结构化回复。内部处理什么内部翻译我常说一句话底层厂商怎么调上层业务不关心。业务逻辑只知道我发了消息得到了回复至于回复是哪个模型生成的、中间经历了几次重试都不需要关心。这种单函数抽象已经足够支撑一个小 Agent 的全部需求。另一个考虑点是工具调用的表达方式。pi-agent 用的是主流的结构化工具调用协议即模型回复里包含一个 tools 字段里面是参数 JSON。我自己实现的时候最初想偷懒用让模型输出一段固定格式文本然后正则解析的方式结果发现解析成功率惨不忍睹——模型总会多输几个字、少个括号、格式飘一点。后来还是老老实实走结构化工具调用让模型以 schema 约束的形式输出解析稳定性立刻上来了。这个选择我非常推荐能用协议解决的事不要用 prompt 去赌。2.2 工具层注册制是 Agent 的能力清单工具层的设计我直接采用 pi-agent 的注册制思路用一个字典把工具名称映射到函数。每个工具定义四样东西名字、描述、参数说明、执行函数。模型在看到系统提示时会看到所有工具的名字 描述 参数 schema然后据此决定要不要调用、传什么参数。这就像给模型发了一张菜单让它照着菜单点菜而不是让它自由发挥乱写菜名。这里有个细节值得展开工具的描述是给模型看的不是给人看的。描述写得越清楚模型调用越准确。我第一次写工具时描述写的是获取天气结果模型经常在不需要的时候也调用。后来参考 pi-agent 的写法把描述改成根据地理位置获取当前天气信息适用于用户询问天气、出行准备等场景模型的使用准确率明显提升。描述里包含适用场景、参数含义、边界条件效果完全不一样。参数 schema 也尽量不要偷懒用宽松的定义。如果一个参数是枚举值就在 schema 里写明如果是必填就标 required。模型是概率输出约束越明确它的行为越可控。我甚至见过有些极简实现直接让工具函数接收一个字典schema 乱写最终模型传参各种各样函数内部全靠猜——这种代码跑几轮就崩。2.3 循环层终止条件比循环逻辑本身更重要主循环逻辑本身不复杂伪代码就几行while True: reply chat_client.chat(messages) if reply.has_tool_call: result execute_tool(reply.tool_call) messages.append(tool_result_message(result)) else: return reply.content但真正难的是终止条件的设计。我见过最常见的 bug 是 Agent 陷入工具调用死循环模型反复调用某个工具然后把结果又发给模型模型又调用永远停不下来。pi-agent 的做法是加了两个约束最大循环轮次以及最终回复必须是纯文本而非工具调用。这两个约束缺一不可。最大轮次防御的是模型失控最终回复校验防御的是模型永远不打算结束。我后来还加了一条更实用的规则如果模型连续多次调用同一个工具且参数几乎一致就强制终止并提示用户检查工具是否真的需要被反复调用。这个灵感来自我自己踩过的一个坑——某次 Agent 不断用同一个搜索工具查同一条 query不仅白白消耗 token还给了用户一个完全错误的答案。Agent 的循环机制不应该无条件信任模型的每一步决策必要的护栏是有价值的。3. 实操参照 pi-agent 手写的最小实现3.1 工程结构与准备工作动手之前先定工程结构。我的做法很简单不引入任何框架依赖只用 Python 标准库加一个 HTTP 请求库。文件划分如下mini_agent/ ├── chat_client.py # 模型接口层统一处理对话与工具调用 ├── tools.py # 工具定义与注册 ├── agent.py # 主循环逻辑 ├── main.py # 命令行入口 └── config.py # 模型配置与系统提示词这种结构的好处是每个文件职责单一出了问题能快速定位。我见过不少人的 Agent demo 一个文件写了上千行改起来牵一发动全身。对于学习性质的项目保持文件小而清晰远比看起来工程化重要。准备工作方面需要有一个可用的模型 API key。选模型时我建议先用支持工具调用协议的模型不确定是否支持的话可以先直接在 API 文档里确认。我自己的经验是即便同一个厂商的模型不同版本的协议支持程度也不同最好先在调试脚本里验证一次模型能否正确返回结构化工具调用再开始写 Agent 逻辑。这一步验证能省掉后期大量排查时间。3.2 模型接口层实现模型接口层我把它封装成一个类核心方法是 chat。它接收消息列表和工具定义返回一个统一结构要么是纯文本回复要么是工具调用请求。用一个 dataclass 来表示回复更清晰from dataclasses import dataclass, field from typing import Any, Optional dataclass class AgentReply: content: Optional[str] None # 最终文本回复 tool_name: Optional[str] None # 需要调用的工具名 tool_args: Optional[dict] field(default_factorydict) # 工具参数 is_tool_call: bool False # 是否是工具调用请求然后封装请求逻辑。请求体的构造针对工具调用进行了处理把工具描述列表传给模型让它可以在需要时按 schema 输出调用请求。解析响应时先检查响应的结构化字段里有没有工具调用信息有就组装成 AgentReply 的工具调用形态没有就按文本回复处理。import json import requests from typing import List, Dict, Any class ChatClient: def __init__(self, api_key: str, base_url: str, model: str): self.api_key api_key self.base_url base_url self.model model def chat(self, messages: List[Dict[str, Any]], tools: List[Dict[str, Any]]) - AgentReply: body { model: self.model, messages: messages, tools: tools if tools else None, } headers { Authorization: fBearer {self.api_key}, Content-Type: application/json, } resp requests.post( f{self.base_url}/chat/completions, headersheaders, jsonbody, timeout60, ) resp.raise_for_status() data resp.json() choice data[choices][0] msg choice[message] # 优先处理结构化工具调用 tool_calls msg.get(tool_calls) if tool_calls: tc tool_calls[0] return AgentReply( is_tool_callTrue, tool_nametc[function][name], tool_argsjson.loads(tc[function][arguments] or {}), ) return AgentReply(contentmsg.get(content))这里有一个实操要点解析工具参数时必须做异常保护。模型返回的 arguments 字段虽然是 JSON 字符串但偶尔会多出多余内容或者格式稍有偏差。json.loads 失败时不要直接让程序崩溃而是返回一个特殊错误结果给主循环由主循环把工具参数解析失败这个信息回传给模型让它重新生成。这个兜底逻辑看起来细枝末节但实际运行中能救你很多次。3.3 工具注册层实现工具层我用一个简单的注册器。每个工具是一个 dataclass 实例包含名称、描述、参数 schema 和执行函数。注册器维护一个字典同时提供生成模型可读的工具定义列表和按名调用工具两个能力。以天气查询和本地文件读取两个示例工具为例from dataclasses import dataclass from typing import Callable, Dict, List, Any import json dataclass class Tool: name: str description: str parameters: Dict[str, Any] func: Callable[..., str] def to_schema(self) - Dict[str, Any]: return { type: function, function: { name: self.name, description: self.description, parameters: self.parameters, }, } class ToolRegistry: def __init__(self): self._tools: Dict[str, Tool] {} def register(self, tool: Tool): self._tools[tool.name] tool def schema_list(self) - List[Dict[str, Any]]: return [t.to_schema() for t in self._tools.values()] def call(self, name: str, args: Dict[str, Any]) - str: if name not in self._tools: return f错误未找到工具 {name} try: return self._tools[name].func(**args) except Exception as e: return f工具执行失败{str(e)}注册两个演示工具时参数 schema 必须按 JSON Schema 的标准来写。名称和描述我都会反复斟酌描述里包含何时使用的信息参数里给出类型与限制条件。天气工具的 code 参数我限定为字符串并要求提供城市名读文件工具则限定 path 参数。这些约束写清楚模型的表现会稳定得多。def register_demo_tools(registry: ToolRegistry): registry.register(Tool( nameget_weather, description根据城市名查询当前天气适用于用户询问某地天气、是否适宜出行等场景, parameters{ type: object, properties: { city: {type: string, description: 城市中文名如 北京} }, required: [city], }, funclambda city: f{city} 今日多云气温 18~26 摄氏度风力三级, )) registry.register(Tool( nameread_file, description读取本地文本文件的完整内容适用于用户想查看文件内容或分析文档的场景, parameters{ type: object, properties: { path: {type: string, description: 文件绝对路径} }, required: [path], }, funclambda path: open(path, encodingutf-8).read(), ))工具函数返回值统一约定为字符串。这是 pi-agent 给我的一个重要启发无论工具执行结果是什么类型最后都以字符串形式回填给模型。这样模型侧不需要理解复杂的结构化结果只需要读取文本。对于需要结构化数据的场景可以用 JSON 字符串返回模型依然能理解。3.4 主循环实现主循环是 Agent 的核心。我参照 pi-agent 的结构实现了一个带最大轮次控制的循环。整个循环的逻辑是先拼好消息列表发送给模型判断回复类型如果是工具调用就执行并回填结果否则返回最终答复。循环外面包一层轮次控制防止无限循环。class Agent: def __init__(self, client: ChatClient, registry: ToolRegistry, system_prompt: str, max_rounds: int 10): self.client client self.registry registry self.system_prompt system_prompt self.max_rounds max_rounds def run(self, user_message: str) - str: messages [ {role: system, content: self.system_prompt}, {role: user, content: user_message}, ] for _ in range(self.max_rounds): reply self.client.chat(messages, self.registry.schema_list()) if not reply.is_tool_call: return reply.content or 空回复 # 记录模型发出的工具调用意图 messages.append({ role: assistant, content: , tool_calls: [{ id: call_demo, type: function, function: { name: reply.tool_name, arguments: json.dumps(reply.tool_args), }, }], }) # 执行工具并回填结果 tool_result self.registry.call(reply.tool_name, reply.tool_args) messages.append({ role: tool, tool_call_id: call_demo, content: tool_result, }) return 已达最大轮次任务尚未完成请尝试简化问题或补充信息这里有一个特别关键的细节tool_calls 的消息格式。很多模型接口要求如果 assistant 消息里包含了工具调用意图必须携带 idtool 角色的消息里要把同一个 id 带回来。一旦 id 对不上或者 assistant 消息格式不规范模型接口可能报错。我在实现时就是在这里栽过跟头第一次运行时模型返回工具调用之后我直接塞了一条 roleassistant 的普通文本消息进去结果下一轮请求直接被拒。后来仔细看了协议才明白工具调用的回填必须严格按照assistant 工具意图 tool 执行结果成对出现来构造消息序列。3.5 入口与运行入口文件很简单读取配置初始化各组件然后进入命令行交互模式from config import API_KEY, BASE_URL, MODEL, SYSTEM_PROMPT def main(): client ChatClient(api_keyAPI_KEY, base_urlBASE_URL, modelMODEL) registry ToolRegistry() register_demo_tools(registry) agent Agent(client, registry, SYSTEM_PROMPT) print(Mini Agent 已启动输入 exit 退出) while True: user_input input( ) if user_input.strip().lower() in (exit, quit): break answer agent.run(user_input) print(f[Agent] {answer})系统提示词方面我参考 pi-agent 的写法把 Agent 的身份、能力边界、工具使用规则都写在里面。一份好的系统提示词可以显著减少模型在循环中的无效行为。我用的版本大致如下你是一个高效可靠的小型智能助手。你可以使用提供的工具完成用户的请求。 使用工具的规则 1. 如果用户请求需要工具才能完成请先调用工具不要凭空编造结果。 2. 在调用工具前先在心里确认工具参数避免传入空值。 3. 如果工具执行结果出现错误或缺失说明原因并请求用户补充信息。 4. 当你已经获得足够信息能够直接回答用户时直接给出最终答复不要再调用工具。规则 4 尤其重要。如果没有这条模型会更倾向于为了调用而调用不停让工具转圈。4. 打磨从能跑到能用的几个关键升级4.1 上下文管理别让对话无限膨胀最小实现跑通之后第一个会撞上的问题就是上下文长度。每多一轮工具调用消息序列里就多两条消息一次任务可能产生十几条甚至更多。用户再多聊几轮token 直接逼近模型上限。pi-agent 这种学习项目不会处理这个问题但我们自己的实现必须在早期就考虑。我的做法是分两级短期对话保持完整多轮消息但超过一定条数后做裁剪单次任务内的工具调用结果在回填时做截断。比如工具返回一个超大文本文件内容直接全文塞进消息里模型会被大量无关 token 淹没既浪费上下文又影响判断。我会给工具结果设一个长度上限超出部分用内容过长已截断共 N 字以下为前 2000 字代替。实测下来这个策略对成本和效果都有明显改善。4.2 错误恢复把失败信息喂回给模型同一个工具第一次调用失败不代表这件事就该放弃。很多 Agent 实现的做法是失败即终止model 得到的是一个错误字符串然后直接向用户说抱歉我无法完成。这样太浪费了。更好的做法是我在 3.2 里提到过的防御逻辑的升级版当工具执行异常时把异常信息作为 tool role 消息回填让模型基于错误信息自我纠正。举个例子有次我让 Agent 读取一个不存在的文件工具返回文件不存在。模型看到这条结果后没有直接放弃而是意识到可能用户把文件名写错了于是向用户确认抱歉未找到该路径请确认文件名是否正确或者告诉我文件放在哪个目录。这种表现看起来像智能其实背后就是一条消息回填的差异。错误信息给到模型它就能基于信息做推理不给它就只能瞎猜。4.3 多轮任务的状态管理最后一个升级点是为 Agent 增加简单的状态跟踪。最朴素的做法是在系统提示词里加一段当前任务进度字段每次工具调用后更新。比如处理一个多步骤任务——先查天气再根据天气推荐穿衣——Agent 需要记住第一步的结果。如果上下文里天然有这些信息靠模型自身就能完成不需要额外设计。但更复杂的任务比如遍历目录找到所有大于 100MB 的文件并列出大小排序单纯靠上下文传递中间状态容易出现信息遗漏。这时我会在工具执行结果里附带结构化摘要或者干脆维护一个任务状态 dict在每次回填时把关键中间结果固化进消息。核心原则是把关键状态显式写入上下文不要指望模型自己记住。5. 常见问题与排查技巧实录5.1 模型不返回工具调用这是新手最容易遇到的问题工具定义都传了模型就是不调用直接输出文本答案。我排查的顺序是这样的先确认模型接口是否真的支持工具调用在单独脚本里发一条最简单的工具请求测试再看工具 schema 是否规范特别注意 type 字段必须写成 objectproperties 里每个参数都要有完整类型最后看系统提示词有没有明确提示可以使用工具完成当需要时可以调用工具。很多时候问题出在描述太模糊模型没有意识到当前场景适合用工具。把工具的适用场景写进 description命中率立刻不同。5.2 循环停不下来Agent 反复调用工具是我遇到过的最头疼的问题。排查时我一般抓三条线第一条是看是否每个工具调用后都回填了正确的 tool 结果如果某一次回填格式错了接口报错但循环还在继续第二条是看模型是否在猜参数——如果参数的生成完全靠猜工具结果永远不满足需求模型就永远在重试第三条是增加轮次上限和重复调用检测这属于防御机制我在 2.3 提到过。实战中给我最大帮助的是日志工具把每次循环里的模型回复全文工具调用参数工具执行结果都打出来循环一多就能看出模型的行为模式是参数飘了还是上下文不够。5.3 工具参数解析失败模型返回的参数偶尔不合法 JSON这是概率性问题。处理方式分两层外观上解析失败时不要向模型回传解析失败这种无意义信息而是回传参数解析失败请仅返回合法的 JSON 参数并确保参数值与 schema 匹配内在上我建议在工具执行前做一层参数校验对必填字段进行检查缺失时回传明确错误。这两层下来解析失败导致的运行中断基本可以消除。5.4 关于调试与测试的小建议给 Agent 调试和给普通程序调试完全不同。普通程序有确定的输入输出Agent 每次输出都有随机性。我后来形成的习惯是为每个工具准备断言测试直接调用工具函数验证其正确性为每个典型场景准备固定的测试用例比如查北京天气读取某路径文件观察 Agent 的完整行为链路关键回归用例跑 5 遍看成功率而不是单次结果。Agent 类程序的健康标准不是100% 一次成功而是失败时能不能体面地自我纠正。这个思路移植到任何 Agent 项目里都适用。6. 最后分享一点我的体会把 pi-agent 剥开再自己重写一遍之后最大的感受是Agent 开发的门槛从来不在调大模型接口这一步而在怎么设计工具、怎么管上下文、怎么让模型的行为可控这些脏活累活上。框架能帮你省略的是代码量不能帮你省略的是对机制的理解。我强烈建议每个做 Agent 的开发者都亲手从零写一个最小版本不一定要用于生产但这个过程会让你在遇到问题时脑子里有清晰的定位地图而不是只会搜报错信息。另一个想提醒的是不要在一开始就追求强智能。我见过很多 Agent 项目死在了过度设计上多智能体协作、复杂记忆系统、自动规划器应有尽有结果跑起来处处报错没人改得动。而 pi-agent 这种极简风格提醒我Agent 的价值在于稳定地完成一项确定的工作而不是花里胡哨地演示各种能力。先把一个循环做到跑不崩、纠错能力强再慢慢往上加东西这条路走的每一步都是扎实的。