
1. 从能跑就行到跑得明白为什么要手写一个 Agent市面上关于 Agent 的文章十篇里有八篇在讲概念——什么自主规划、工具调用、记忆机制配一张花里胡哨的架构图看完之后你依然不知道一个 Agent 从零到一到底该怎么落地。我自己也经历过这个阶段看了大量资料脑子里全是名词真到动手的时候连一个最小的可运行循环都写不出来。后来我换了个思路不再去追那些大而全的框架而是找一个结构足够清晰、代码量足够小的参考实现把它拆开、读懂、再自己重写一遍。pi-agent 就是这样一个适合剥开看的对象——它没有堆砌太多抽象层核心逻辑用几百行就能说清楚。我照着它的思路用 Python 从零实现了一个能跑的小 Agent整个过程踩了不少坑也积累了一些文档里不会写的经验。这篇文章要做的就是把这段经历完整地摊开。我会讲清楚一个 Agent 的最小闭环由哪几块组成、每一块为什么这样设计、pi-agent 在哪些地方做了取舍、我自己实现时遇到了什么问题。目标读者是那些已经了解大模型基本调用、想进一步搞明白 Agent 内部运转机制的开发者。你不需要有 Agent 开发经验但至少要能看懂 Python 代码和 API 调用的基本写法。需要提前说明的是我这里的小 Agent指的是一个具备任务理解、工具调用、结果观察、循环推进这四件事的最小系统。它不追求生产级的健壮性但每一个环节都是真实可用的你可以基于它继续往上加东西。下面进入正题。2. 一个 Agent 的最小闭环到底由哪几块拼成2.1 把 Agent 拆成大脑 手脚 记忆三个部件很多人一上来就想搞一个全能 Agent结果代码写到一半就乱了。我的建议是先做减法把 Agent 拆成三个最基础的部件每个部件只干一件事。大脑负责决策也就是根据当前的任务状态决定下一步该做什么。在实现上大脑就是一次大模型调用输入是当前的上下文输出是一个结构化的动作指令。这个动作可能是调用某个工具也可能是直接给出最终答案。手脚负责执行也就是真正去干活的部分。工具可以是读文件、发请求、算数学、查数据库任何你能用代码封装成函数的东西都可以。手脚本身没有智能它只负责忠实地执行大脑下达的指令然后把结果返回。记忆负责记录也就是维护整个任务的上下文。它要记住用户最初说了什么、大脑之前做过哪些决策、每个工具返回了什么结果。记忆的质量直接决定了 Agent 能不能在多轮循环中保持连贯。这三块拼起来就形成了一个闭环大脑读记忆 → 做出决策 → 手脚执行 → 结果写回记忆 → 大脑再读记忆。循环往复直到大脑决定任务完成。pi-agent 的结构基本就是这个思路只不过它在记忆管理上做了一些工程化的处理比如对历史消息做截断、对工具结果做摘要。这些后面会细讲。2.2 为什么是循环而不是一次性调用这里有个容易被忽略的关键点Agent 和普通的一问一答最大的区别就在于循环。普通的大模型调用是单轮的——你问一句它答一句结束。但 Agent 面对的任务往往是多步的比如帮我查一下这个目录下所有日志文件里出现次数最多的错误码这就至少需要三步列目录、读文件、统计。你没法用一次调用完成因为中间结果需要反馈给模型让它决定下一步。所以 Agent 的核心是一个while循环每一轮都做四件事把当前记忆喂给大脑、解析大脑的输出、执行对应的动作、把结果追加到记忆里。循环的终止条件通常有两个大脑明确表示任务完成或者达到了预设的最大轮数上限。注意最大轮数上限这个保护机制一定要加。我早期版本没加结果有一次模型陷入死循环反复调用同一个工具白白烧了一堆 token。后来我固定设成 10 轮超过就强制中断并返回当前状态。2.3 pi-agent 在结构上的取舍少一层抽象多一分可控我对比过好几个 Agent 实现pi-agent 最让我舒服的地方是它没有过度抽象。有些框架会引入 Planner、Executor、Memory Manager、Tool Registry 一大堆类每个类之间还有复杂的依赖关系读起来像在解谜。pi-agent 则相对扁平核心逻辑集中在一个循环函数里工具用字典注册记忆就是一个列表。这种设计的代价是扩展性稍弱——比如你想加一个复杂的多 Agent 协作机制就得自己动手改不少地方。但好处是可控每一轮发生了什么、模型看到了什么、工具返回了什么你都能一眼看穿。对于学习和中小型项目来说这种透明性比扩展性更重要。我自己的实现基本沿用了这个思路只在两个地方做了调整一是把工具注册改成了装饰器写法加工具更方便二是给记忆加了一个简单的滑动窗口防止上下文无限增长。这两点后面会展开。3. 大脑部分怎么让模型稳定输出可执行的动作3.1 用结构化输出约束模型而不是靠求它让模型输出可解析的动作指令是 Agent 开发里第一个真正的难点。早期我试过在提示词里写请以 JSON 格式输出你的决策结果模型十次里有三次会加一句好的以下是我的决策然后才给 JSON解析直接崩。后来我总结出两条经验。第一条是用 API 原生的结构化输出能力。现在主流的大模型接口基本都支持指定返回格式比如传入一个 JSON Schema模型会保证输出符合这个 schema。这比在提示词里求它靠谱得多。第二条是即使有结构化输出也要做防御性解析。因为网络抖动、模型版本差异等原因偶尔还是会有意外。我的做法是包一层 try-except解析失败就把原始输出记下来然后给模型发一条你的上一条输出格式不对请重新输出的纠正消息让它重试一次。实测下来加上这层兜底之后解析成功率基本能到 99% 以上。下面是我用的动作 schema简化版长这样{ type: object, properties: { thought: {type: string}, action: {type: string, enum: [call_tool, finish]}, tool_name: {type: string}, tool_input: {type: object}, final_answer: {type: string} }, required: [thought, action] }thought字段是让模型先想一想再行动这个字段不参与执行但对提升决策质量有明显帮助。action只有两个取值要么调工具要么结束。这种二选一的设计比让模型自由发挥要稳定得多。3.2 提示词里必须写清楚的几件事结构化输出解决了格式问题但内容问题还得靠提示词。我踩过的坑是模型不知道该用哪个工具或者明明有工具却自己瞎编答案。提示词里必须写清楚的东西有这么几样。第一是工具清单每个工具的名字、用途、参数格式都要列出来最好给一个调用示例。第二是决策原则比如如果任务需要外部信息优先调用工具而不是凭记忆回答。第三是终止条件明确告诉它什么情况下应该输出finish。我用的系统提示词大致是这样的结构你是一个任务执行助手。你可以使用以下工具 - read_file(path): 读取指定路径的文件内容 - list_dir(path): 列出目录下的文件 - run_python(code): 执行一段 Python 代码并返回结果 决策原则 1. 需要外部信息时必须调用工具获取不要凭猜测回答 2. 每次只做一个动作 3. 任务完成后用 finish 动作给出最终答案 请以指定的 JSON 格式输出你的决策。这段提示词看起来简单但每一条都是踩坑之后加上的。特别是每次只做一个动作这条早期没写的时候模型会一次性输出好几个工具调用导致执行逻辑变得很复杂。3.3 温度参数和模型选择对稳定性的影响还有一个容易被忽略的细节是温度参数。Agent 的大脑需要的是稳定和可预测不是创意。我一开始用了默认温度结果同样的输入模型有时候调工具、有时候直接回答行为很不一致。后来把温度调到 0 或者接近 0稳定性明显提升。模型选择上我的经验是决策环节用能力强的模型执行环节用便宜快的模型。因为决策需要理解复杂任务、选择合适工具对模型能力要求高而执行往往只是把参数填进函数简单模型就够。这种搭配能在保证效果的同时控制成本。不过这里有个权衡如果决策和执行用的是同一个模型上下文可以完全共享实现简单如果分开就要考虑两边上下文怎么同步。我自己的小 Agent 为了简单用的是同一个模型但在提示词里区分了决策模式和总结模式。4. 手脚部分工具注册与执行的安全边界4.1 工具不是越多越好而是越正交越好新手容易犯的一个错误是拼命加工具觉得工具越多 Agent 越强。实际上工具太多会带来两个问题一是提示词变长模型选择困难二是工具之间功能重叠模型不知道该用哪个。我的原则是工具要正交也就是每个工具负责一个独立的、不重叠的能力。比如read_file和list_dir是两个正交的工具一个读内容一个列目录但如果你再加一个read_text_file就和read_file重叠了只会让模型困惑。pi-agent 的工具集也很克制核心就几个。我自己的实现里工具数量控制在 5 个以内每个都有明确的边界。如果发现某个任务需要新能力我会先想清楚它能不能用现有工具组合出来实在不行才加新工具。4.2 用装饰器注册工具让加工具变成一件顺手的事工具注册的写法直接影响开发体验。我见过用一个大字典手动维护的加一个工具要改好几个地方容易漏。后来我改成了装饰器写法TOOLS {} def tool(name, description, parameters): def decorator(func): TOOLS[name] { func: func, description: description, parameters: parameters } return func return decorator tool( nameread_file, description读取指定路径的文件内容, parameters{path: string, 文件路径} ) def read_file(path): with open(path, r, encodingutf-8) as f: return f.read()这样加工具只需要在函数上加一个装饰器工具的描述和参数定义就在函数旁边维护起来一目了然。而且TOOLS字典可以直接序列化成提示词里的工具清单不用手动同步。4.3 执行工具时必须做的三件事校验、限时、兜底工具执行是 Agent 里最容易出问题的环节因为它是真正和外部世界交互的地方。我总结了三条必须做的防护。第一是参数校验。模型生成的参数不一定合法比如路径不存在、参数类型不对。执行前一定要校验校验失败就返回一个清晰的错误信息给模型让它自己纠正。我见过直接open(path)结果抛异常把整个循环搞崩的写法这是大忌。第二是超时控制。有些工具可能卡住比如网络请求、大文件读取。如果不加超时整个 Agent 就挂在那里了。我的做法是给每个工具执行包一层超时超过阈值就中断并返回超时错误。第三是异常兜底。任何工具执行都要包在 try-except 里把异常转成字符串返回给模型而不是让异常往上抛。因为模型看到错误信息后往往能自己调整策略比如换个路径重试。def execute_tool(name, params): if name not in TOOLS: return f错误工具 {name} 不存在 try: result TOOLS[name][func](**params) return str(result)[:2000] # 截断防止结果过长 except Exception as e: return f工具执行出错{str(e)}注意那个[:2000]截断这也是踩坑得来的。有一次工具返回了一个几万字的文件内容直接塞进上下文下一轮调用就超了 token 限制。截断虽然会丢信息但比整个流程崩掉强。5. 记忆部分上下文管理才是 Agent 的隐形战场5.1 记忆不是全都塞进去而是该记的记该丢的丢很多人以为记忆就是把所有历史消息都拼起来发给模型简单粗暴。但实际跑起来你会发现上下文增长得非常快几轮之后就可能超出模型的窗口限制而且 token 成本也扛不住。记忆管理的核心是取舍。哪些信息必须保留我的经验是三类用户最初的任务描述、最近几轮的决策和结果、以及任何被标记为关键的中间结论。哪些可以丢重复的工具调用结果、冗长的原始数据、已经消化过的中间过程。pi-agent 在这一点上的处理比较务实它会对历史做一定程度的压缩。我自己的实现用了一个简单的滑动窗口保留最前面的系统提示和用户任务保留最近 N 轮的消息中间的老消息如果太多就做摘要。5.2 滑动窗口 摘要一个够用又不复杂的方案滑动窗口的逻辑很直白维护一个消息列表当长度超过阈值时把中间部分的消息合并成一条摘要消息。摘要可以用模型生成也可以用简单的规则拼接。def manage_memory(messages, max_len20): if len(messages) max_len: return messages # 保留头部系统提示 用户任务 head messages[:2] # 保留尾部最近的消息 tail messages[-(max_len - 3):] # 中间部分做摘要 middle messages[2:-(max_len - 3)] summary summarize(middle) return head [{role: system, content: f历史摘要{summary}}] tail这个方案不完美比如摘要可能丢信息但胜在简单可控。我实测下来对于大多数中小型任务这个策略足够用了。如果你的任务特别复杂可以考虑更精细的记忆方案比如向量检索但那是另一个话题了。5.3 一个容易被忽略的细节工具结果的格式统一记忆里存的东西格式要统一否则模型读起来会混乱。我早期犯的错是工具结果有时候是字符串、有时候是字典、有时候是列表模型看到之后经常理解错。后来我定了个规矩所有工具结果在写入记忆前统一转成带前缀的字符串。比如工具 read_file 返回内容。这样模型一眼就能看出这是哪个工具的结果不会和用户消息混淆。这个小小的格式统一对提升多轮连贯性帮助很大。6. 把循环跑起来主流程的组装与调试6.1 主循环的骨架其实就二十行前面几块都准备好之后主循环本身反而很简单。核心逻辑就是不断重复决策-执行-记录这个过程def run_agent(task, max_turns10): messages [ {role: system, content: SYSTEM_PROMPT}, {role: user, content: task} ] for turn in range(max_turns): messages manage_memory(messages) decision call_llm(messages) if decision[action] finish: return decision[final_answer] result execute_tool(decision[tool_name], decision[tool_input]) messages.append({role: assistant, content: str(decision)}) messages.append({role: user, content: f工具结果{result}}) return 达到最大轮数任务未完成这二十行就是 Agent 的心脏。看起来简单但每一行背后都有前面讲的那些设计考量。我建议你先把这二十行跑通再往上加东西。6.2 调试 Agent 的正确姿势把每一轮都打出来Agent 调试和普通程序调试不一样因为它的行为有随机性出错的时候你很难一眼看出是哪一步的问题。我的做法是把每一轮的完整信息都打印出来模型看到了什么、输出了什么决策、工具返回了什么。def call_llm(messages): print( * 50) print(当前上下文) for m in messages: print(f[{m[role]}] {m[content][:200]}) response client.chat(...) print(模型决策, response) return parse(response)这个日志看起来啰嗦但排查问题时非常有用。我遇到过好几次Agent 行为诡异的情况都是靠这个日志定位到根因的——有时候是提示词有歧义有时候是工具返回格式不对有时候是记忆里混进了脏数据。6.3 常见故障与对应排查方向跑起来之后你会遇到各种问题。我把最常见的几类整理成一张表方便对照排查现象可能原因排查方向模型不调工具直接编答案提示词没强调必须用工具检查系统提示词的决策原则反复调用同一个工具工具结果没让模型满意或陷入循环看工具返回是否清晰加轮数上限解析 JSON 失败模型输出格式不稳定用结构化输出 重试兜底上下文超限记忆没做压缩检查滑动窗口和截断逻辑工具执行报错参数不合法或环境问题看参数校验和异常兜底这张表里的每一条我都是真金白银踩出来的。特别是反复调用同一个工具这条早期没加轮数上限的时候模型能在一个工具上死磕十几轮token 哗哗地烧。7. 从能跑到好用几个提升 Agent 质量的经验7.1 给模型思考的空间但别让它想太多前面提到 schema 里有个thought字段这个设计很关键。让模型在行动前先输出一段思考能显著提升决策质量。但要注意思考不能太长否则会挤占上下文也会拖慢速度。我的做法是在提示词里限制思考控制在两句话以内。这个thought还有个额外好处调试的时候你能直接看到模型为什么做这个决策比只看动作要清楚得多。很多时候 Agent 行为不对看它的 thought 就能明白问题出在哪。7.2 工具描述要写得像给新人的说明书工具描述的质量直接影响模型能不能正确使用工具。我见过写得很敷衍的比如read_file: 读文件模型看了根本不知道参数该怎么填。好的工具描述应该像给新人的说明书说清楚这个工具干什么、参数是什么格式、有没有注意事项。比如read_file的描述我会写成读取指定路径的文本文件内容。参数 path 是文件的完整路径必须是字符串。如果文件不存在会返回错误信息。这样模型一看就知道怎么用出错率大大降低。7.3 别追求一步到位先让最小闭环跑通最后一条也是最重要的一条经验不要一上来就追求完美。我见过太多人一开始就想搞一个支持多 Agent 协作、带向量记忆、能自我反思的复杂系统结果卡在第一步就放弃了。正确的做法是先让最小闭环跑通——哪怕它只能读文件、只能跑十轮、只能处理简单任务。跑通之后你自然会发现哪里不够用然后有针对性地加东西。我自己的 Agent 就是从只能读文件开始的后来才慢慢加了执行代码、目录列举这些能力。每一步都是被真实需求推着走的而不是凭空设计出来的。这个思路其实也是 pi-agent 给我的启发它的结构之所以清晰就是因为每一块都只解决一个具体问题没有为了看起来强大而堆砌功能。剥开它、理解它、再自己实现一遍这个过程本身就是最好的学习。如果你也照着这个思路走一遍我保证你对 Agent 的理解会比看十篇概念文章都深。跑通之后你可以试着加一个新工具或者改一改记忆策略观察 Agent 行为的变化——这种改一点、看效果的循环才是真正把知识变成自己的过程。