Agent框架源码精读:主循环、工具调用与记忆管理

发布时间:2026/11/4 3:34:22
Agent框架源码精读:主循环、工具调用与记忆管理

最近把 Pi Agent 框架的源码从头到尾过了一遍。起因很朴素:手头有个多轮问答 Agent,一到第 7 轮就开始忘记前面聊了什么。我一开始以为是模型上下文不够,后来怀疑是消息组装出了问题,最后干脆把项目源码拉下来,按调用链逐段读。这个框架表面上是给大模型应用提供 Agent 能力的 SDK,实际读下来,它的核心价值不在“调通一次大模型接口”,而在于把复杂任务拆成可编排、可观测、可插拔的工作流。这篇源码阅读笔记我不打算逐行贴代码,而是把主干拆开:Agent 主循环、上下文构建、工具调用、记忆管理。这四条线走通,你就基本掌握了框架的设计取舍和扩展边界。适合正在做智能体应用、纠结 Agent 编排方式、以及想在一周内精读一个开源框架的人参考。

1. 为什么值得花时间细读这套 Agent 框架源码

1.1 框架定位:它不是简单的模型调用 SDK

很多人提到 Agent 框架,第一反应是“封装好了模型调用,我只要传 prompt 就行”。真去读源码会发现,Pi Agent 把大量复杂度放在“任务怎么拆、步骤怎么编排、结果怎么回流”上,而不是模型 API 本身。框架里负责调用模型的部分可能只有几百行,真正撑起框架的是 Planner、Executor、Context、ToolRegistry 之间的协作关系。

我读下来的判断是,Pi Agent 的代码规模适中,模块边界清楚,适合作为学习 Agent 工程化的样本来精读。它没有把每个能力都做成重量级组件,而是保留了“你可以替换掉某个模块但不影响其他模块”的接口风格。这一点很像 Spring Boot 和若依这类业务框架的模块化思路,但 Agent 框架更强调运行时决策,而不是静态的 CRUD 脚手架。

读源码能带来的最大价值,是让你在团队里不再当一个“只会调 API 的同学”。比如产品提了一个“让 Agent 自主决定要不要用工具”的需求,如果你只懂调用层面,会觉得很悬;如果你读懂了 ToolRegistry 的注册、校验、调用、结果回填机制,就能把需求拆成几个明确的开关和策略配置。

1.2 哪些人真正需要这份阅读笔记

我梳理了一下,最需要读 Agent 框架源码的人是下面三类。

第一类是正在做智能体应用的大模型开发工程师。这类人通常已经跑通过 demo,但一进入多轮、多工具、长记忆场景就出问题,需要理解框架的边界。

第二类是要做技术选型或自研 Agent 平台的工程负责人。团队里用哪个框架、框架做不到哪一步,都需要从源码层面判断,而不是只看 README 里的功能列表。

第三类是做 RAG 或传统后端系统、想引入“规划 + 执行”能力的开发者。这类人读源码不是为了照搬,而是想把 Planner 和执行器拆出来,嵌入自己的服务里。

如果只是随便跑一下 demo,确实不需要读源码。但只要你开始调行为、改策略、加自定义工具,源码就是你唯一的真相来源。

1.3 读源码的收益边界:别指望读完全懂

读一套框架源码不等于读完就能造出同样的框架。我的真实体会是,读源码最大的收益是建立“边界感”:知道哪些能力是框架给的,哪些能力要自己写,哪些坑是框架设计时就注定的。

举个例子,Pi Agent 的主循环里如果做了失败重试,那重试多少次、重试间隔多久、重试之后是回到原步骤还是重新规划,这些策略代码可能只有几十行。但你只读文档,永远不知道这个策略藏在哪里。只有读源码,你才理解为什么有些任务会“卡住”而不是“报错退出”。

所以这份笔记不会罗列每个类的注释,而是围绕“一条请求进来之后,框架内部到底经历了什么”来展开。

2. 整体架构拆解:一条请求在框架里怎么走

2.1 目录结构与模块边界

读一个框架,第一步不要急着看某个算法,先看包结构。我读的主线版本里,目录划分大致如下:

pi_agent/ ├── core/ # AgentContext、消息、状态模型、事件定义 ├── planner/ # 任务规划器,负责生成 Plan ├── executor/ # 执行器,负责运行 Plan 里的步骤 ├── tools/ # 工具注册、参数解析、结果标准化 ├── memory/ # 短期记忆、长期记忆、存储适配 ├── llm/ # 模型接入、流式输出、token 计数 └── io/ # 输入输出格式化与校验

把每个模块的职责和推荐阅读顺序整理成了表格,方便对照:

模块核心职责建议阅读顺序
coreAgentContext、消息结构、状态生命周期1
planner生成多步计划、处理规划失败2
executor执行步骤、调度工具调用、处理结果3
tools工具注册、参数校验、输出标准化4
memory短期/长期记忆、存储适配5
llm模型接入、流式输出、token 计算6
io输入输出校验、错误消息包装7

我建议从 core 和 planner 开始读。原因是这两个模块定义了“数据长什么样”和“任务怎么拆”,后面 tools、memory、executor 都是围绕它们跑的。如果你一上来就钻到 tools 里研究某个 JSON Schema 生成逻辑,很容易迷失在细节里。

2.2 核心对象:AgentContext、Plan、StepResult

整个框架中有三个对象值得先记住:AgentContext、Plan、StepResult。

AgentContext 不是简单的 dict,而是一个有生命周期的状态载体。它贯穿一次完整任务的始终,里面通常包含当前问题、已执行步骤、中间变量、工具结果、消息历史。框架里各模块不直接互相引用,而是通过读写 AgentContext 来协作。这个设计对我启发很大,因为很多自研项目里,不同模块喜欢直接调用彼此的方法,最后变成一团乱麻。

Plan 是 Planner 输出的有序步骤列表。它不是普通数组,每个步骤会自带前置条件、依赖关系和成功标准。比如一个“查天气再决定穿什么”的任务,Plan 里可能有两个步骤,第二步依赖第一步的结果;如果没有第一步成功,第二步就不该执行。

StepResult 是每一步执行后的结果包,包含成功标志、输出内容、错误信息、耗时和 token 消耗。这个对象是排查问题的关键。框架里几乎所有观测指标都来自 StepResult,如果你看完源码后只能记住一个对象,我建议记这个。

2.3 数据流:从用户输入到结果返回

一条请求在框架里的流转,可以用一段有序列表讲清楚:

  1. 用户请求进入 Agent.run 入口。
  2. AgentContext 被初始化,当前用户输入、历史消息、可用工具列表都会写进去。
  3. Planner 读取 AgentContext,生成 Plan。
  4. 主循环进入迭代:Executor 取出当前需要执行的步骤。
  5. 如果步骤需要调用工具,Executor 从 ToolRegistry 找到对应工具,校验参数并执行。
  6. 工具执行结果写回 AgentContext,作为后续步骤的输入。
  7. 循环检查终止条件:计划是否全部完成、是否达到最大步数、是否收到取消信号。
  8. 最终结果从 Context 中汇总,经过 io 模块格式化后返回给调用方。

这套流程里最重要的一句话是:所有东西都在 Context 里流转,模块之间不要直接强依赖。你读源码时会发现,新增一种工具不太需要改动 Planner,新增一种记忆策略也不用动 Executor,原因就在这个总线设计上。

3. 核心链路解析:Agent 主循环里最值得记住的三件事

3.1 主循环不是 while True

很多 Agent 实现就是把“调用模型、拿到回复、再调用模型”写成一个死循环。看似简单,实际上隐患很大。Pi Agent 的主循环虽然看起来也是重复迭代,但有几个关键约束:最大步数限制、失败退出条件、取消事件检查。

最大步数限制很好理解,就是防止 Agent 在一件做不到的事情上反复消耗 token。源码里通常对应一个 max_steps 参数。我实测下来,如果任务需要超过 5 步,大概率是规划拆得有问题,而不是模型能力不够。

失败退出条件更值得留意。有些框架一旦某一步失败,会默认重试同一个步骤,但 Pi Agent 这样的成熟框架会把选择权交给策略:是重试原步骤,还是重新生成计划,还是直接结束并返回部分结果。这个分支逻辑藏在 executor 或策略回调里。如果你遇到 Agent 死循环,基本就是这里没有配好。

取消事件检查往往被忽略。长任务跑起来之后,用户可能已经不想等了。如果主循环不检查取消标志,就算外部把任务标记成取消,内部还是会把整个 Plan 跑完。我在源码里看到类似 cancelled 的状态检查时,才意识到一个可中断的 Agent 比一个“跑得快的 Agent”更重要。

3.2 上下文组装:动态裁剪比塞满更策略

读上下文构建这段代码时,我最大的感受是:Agent 框架并不粗暴地把所有历史塞给模型。每条消息进入模型之前,都要经过组装、裁剪和格式化的过程。

典型组成顺序是系统提示词、工具定义、历史消息、当前问题。但工具定义的 token 开销非常大,一个完整的 function calling schema 可能占几千 token。如果把所有工具每次都全量发过去,上下文很快就爆了。

源码里通常会有 token 预算机制。比如给历史消息分配 60% 的预算,给工具定义分配 20%,给当前请求留 20%。裁剪历史消息时不是简单按字符切,而是按“完整消息边界”切,避免把一条 JSON 从中间截断。

我在笔记里专门记了一条:不要为了保留更多历史而不发工具定义。因为模型在不知道有哪些工具的情况下,很容易选择纯文本回答,根本不调用工具。这一点如果不读源码,很难从文档里领会。

3.3 Planner 和 Executor 为什么要分离

最初我觉得 Planner 和 Executor 分开是过度设计,自己写一个编排函数也能完成。后来读到失败重试场景才明白,分开是为了“重新规划”这个动作。

如果规划和执行耦合在一起,某一步失败之后,系统不知道是该调整后续步骤还是重新开始。分离之后,Executor 可以抛出 StepExecutionError,然后由上层决定重新调用 Planner。这种设计让失败恢复变得可控,而不是在代码里到处写 if 分支。

分离还带来一个好处:Plan 可以被记录、回放、评估。你可以把某次任务的 Plan 存下来,之后做回归测试,看看同一个问题在不同模型版本下的规划是否稳定。这对做 Agent 评测非常重要,后面我会单独讲。

4. 工具调用与记忆管理:框架的两条隐蔽主线

4.1 工具注册:把“万物”收敛成统一协议

Agent 框架里最活跃的部分不是模型调用,而是工具调用。Pi Agent 的核心思路是把所有工具都收敛成同一种协议:一个名字、一段描述、一个参数 Schema、一个执行函数。

我按源码里的调用链整理了一个最小注册示例:

registry = ToolRegistry() @registry.register def get_weather(city: str) -> dict: """查询指定城市的天气""" return { "city": city, "weather": "晴", "temperature": 22, }

这个设计的关键在于,模型不需要知道工具内部怎么实现,它只要看到 JSON Schema 就知道什么时候该调用、需要传什么参数。框架内部会把函数签名转成 function calling schema,并在调用后把结果标准化成一个 dict。

我在实际使用中最看重的是“统一输入输出协议”。只要每个工具都返回 dict,后续的上下文组装和步骤依赖就都能统一处理。一旦有人返回字符串、有人返回对象、有人直接打印日志,整个数据流会立刻变得不可控。

4.2 参数绑定与结果回填:工具链路上最容易翻车的一环

模型返回的内容不总是干净的 JSON。参数名可能差一点点,类型可能不对,甚至会把参数和描述混在一起。框架里的参数绑定机制承担了大量“容错”工作。

源码中通常会用类似 Pydantic 的模型做参数校验。如果模型生成的参数无法通过校验,框架会返回一个错误给模型,让它重新生成。这地方要注意,重试次数一旦设置过大,就会变成反复调用模型,token 消耗成倍增加。合理的做法是重试两次以内,再不通过就直接走人工兜底。

结果回填也不只是把 dict 放进上下文。框架会记录这个结果来自哪个工具、对应的 request id、耗时等元信息。这样后续步骤才能知道某个字段的数据是否可信、是否需要过时处理。

4.3 记忆模块:短期、长期、工作记忆的三层划分

读记忆模块时我发现,很多开发者把“历史消息”直接当成“记忆”,这是理解上的偏差。Pi Agent 这类框架通常会把记忆拆成三层:

短期记忆负责会话内的消息窗口,通常是一个有上限的 Buffer,比如保留最近 20 轮。实现简单,但问题是超出的历史会被丢弃。如果你的业务要求“从第 30 轮还记得第 1 轮的信息”,纯 Buffer 是不够的。

长期记忆负责跨会话持久化。它需要存储介质,可能是内存、Redis、SQLite,也可能是向量数据库。源码里通常会把它封装成统一的 MemoryStore 接口,里面有 save、load、search 三个核心方法。切换存储后端时,上层代码基本不用动。

工作记忆则是当前任务过程中产生的临时状态,比如已经查到的中间结果、当前正在处理的变量、待验证的假设。这个记忆不需要持久化,任务结束就可以清空。

我自己的项目踩过一个坑:一上来就引入向量库做长期记忆,结果发现真正的问题是短期记忆窗口太短。先定义清楚每一层记忆的职责,再去选型,比迷信某一种组件更重要。

5. 源码阅读方法:我是怎么做笔记和定位关键代码的

5.1 先找入口文件,而不是按目录顺序读

读源码时最容易犯的错误是按目录顺序从 top 读到 bottom。读了一个小时还在看工具类,完全不知道主流程长什么样。

我的做法是直接找入口文件。Python 项目通常会有一个main.py 或 cli.py,服务型框架会有一个 server.py 或 factory.py。找到 Agent.run 方法之后,用 IDE 的“查看调用层次”功能,把第一轮调用链路拉出来。第一遍只读链路,不读实现。等你知道大致的路径,再回头精读每个环节。

这个方法对新手特别有效。因为第一遍你可能不知道为什么要有 Planner,但你看到 run 方法里 Planner.plan 被调用之后,下一步必然走到 Executor.execute,这时你就建立起了最基础的调用关系。

5.2 让我眼前一亮的三个细节

这套框架里有三个细节让我印象很深。

第一个是“冷热提示词分离”。框架会把策略性提示词和业务提示词放在不同的模块里。调整某个任务的业务提示词时,不需要碰通用策略,降低了改动风险。

第二个是工具调用超时和重试被做成了显式配置。框架在 Executor 里会检查每个工具调用的耗时,超过阈值就触发重试或取消。这样 Agent 不会因为一个外部接口卡住而无限等待。

第三个是事件追踪结构。框架里几乎所有关键节点都会发出结构化事件,比如 plan_created、step_started、tool_result 等。排查问题时不需要靠日志字符串匹配,直接订阅事件流就能还原一次任务的完整轨迹。这个设计我会直接用回自己的业务里。

5.3 问题清单法:带问题进入源码

读源码之前,我会先列一个“问题清单”,每一条都是一个具体疑问,然后在读的过程中逐个回答。这样读起来不容易迷失,笔记也有明确的产出。

我通常会先用表格记录问题:

问题涉及模块读完后结论
多轮会话历史在哪个阶段被写入?memory/context在每轮模型调用结束后写入 Buffer
工具返回结果如何被截断?tools/executor按固定长度截断,保留元信息
并行调度由谁负责?executor由异步任务池统一调度
失败重试会重新规划吗?planner/executor由策略决定,默认重试当前步骤

这个方法让源码阅读从“看书”变成了“破案”,效率高很多。

6. 从源码到项目落地:最小 Demo 与调参逻辑

6.1 最小可跑 Demo:半小时跑通主流程

读完源码之后,我建议第一时间写一个最小 Demo 验证理解。这里给出一个符合 Pi Agent 调用风格的示例,具体类名以你拉到的版本为准:

from pi_agent import Agent from pi_agent.tools import ToolRegistry from pi_agent.memory import BufferMemory def get_weather(city: str) -> dict: return {"city": city, "weather": "晴", "temperature": 22} registry = ToolRegistry() registry.register(get_weather) agent = Agent( model="your-model-endpoint", planner="react", memory=BufferMemory(max_messages=20), max_steps=5, ) result = agent.run("北京明天适合跑步吗?", tools=registry) print(result.output)

这个 Demo 虽然简单,但已经把核心链路串起来了:Agent 被创建后接收一个任务,Planner 生成步骤,Executor 调用天气工具,结果回填到 Context,最终输出答案。

我在第一次跑通时特意把 max_steps 设成 3,让一个原本需要 4 步的任务提前触发终止条件。这样能直观感受到主循环的边界约束,比读十遍 max_steps 的注释都有效。

6.2 参数调节的源码依据

看了源码之后,很多参数不再是无脑设置的,而是有明确的依据。

BufferMemory(max_messages=20) 控制的是短期记忆窗口。如果业务场景需要依赖很早之前的信息,这个值要调大,但要注意 token 消耗。max_steps=5 控制的是主循环最大迭代次数。规划合理时 5 步通常够用,但涉及多工具串行加验证的任务,可能要到 8 步。

工具调用相关的超时和重试参数也值得显式设置。框架默认值往往适合 demo,生产环境最好把 timeout 设成 10 秒,把重试次数限制在 2 次以内。否则单个工具接口抖动时,Agent 会长时间卡住。

6.3 把源码知识用回自己的业务

读源码最舒服的状态是:不需要照搬整套框架,只把解决自己问题的部分摘出来。我自己的项目借鉴了三样东西。

第一样是 AgentContext 总线状态管理。现在我的服务里不再散落各种临时变量,而是统一放在一个带生命周期的上下文对象里,极大减少了传参混乱。

第二样是 Planner 与 Executor 分离。业务上遇到复杂的异步流程,我会先把计划拆成可记录的数据结构,再写执行器去跑。这样用户能看到当前走到哪一步,运维也能快速定位失败节点。

第三样是事件追踪。我在关键业务节点都会发布结构化事件,而不是只打日志。排查问题的时间至少省了一半。

7. 实战排错:我遇到的四个典型问题

7.1 工具失败重试导致“死循环”

现象是 Agent 反复调用同一个工具,每次都报错,但仍然反复重试。排查日志后发现每条 step 的请求参数完全一致,说明框架在按原参数原样重发,而不是重新规划。

根因是主循环里没有对失败步骤做“语义区分”。解决方式是在工具执行失败时,把错误信息当作一次合法结果回填给模型,让模型决定下一步;同时设置重试上限。只让模型看到“工具失败”是不够的,必须给它两个选择:换参数重试或者停止。

7.2 历史截断把消息切碎

现象是模型回答突然开始输出残缺 JSON,或者引用不存在的工具结果。检查 Core 层后发现,裁剪历史是按字符长度截断的,不是按完整消息边界截断。一条被切了一半的工具调用 JSON 成了上下文里的毒药。

解决方式是把记忆裁剪改成“整消息丢弃”,优先丢最旧且不依赖当前步骤的消息。同时给工具结果单独设置保留条数,避免一个长 JSON 把整轮历史挤掉。

7.3 长期记忆拖慢响应

现象是每轮对话都要等 2 到 3 秒,日志显示记忆模块在每一次模型调用前都做了向量检索。明明上一轮已经查过相同内容,数据库层面已经有了,但还是会重新检索。

排查后确认是长期记忆模块没有做会话内缓存。我在业务上做了一个简单改造:把本次会话已检索到的实体和结论放到工作记忆里,新的模型调用优先复用工作记忆,只有发现信息不够时才触发长期记忆检索。响应耗时直接降了一半。

7.4 并行工具结果乱序

现象是任务里同时调用了两个查询工具,返回顺序不一致导致下一条提示词里的数据互相覆盖。深层原因是框架在异步执行工具时,并没有为各个结果附加“步骤标识”,回填时按完成顺序覆盖同一个占位字段。

解决方式是在 Context 里为每个工具调用分配独立的 slot,结果回来之后先存到自身 slot,再由执行器决定如何合并展示。这个坑提醒我,并行不只是“同时跑”,更要把结果边界设计清楚。

8. 后续阅读路线与扩展方向

8.1 三遍阅读法:从跑通到改造

读这套框架,我不建议只读一遍。我的安排是三遍。

第一遍跑主流程:不深究细节,只看一个请求怎么进来、怎么出去,目标是画出一条完整调用链。

第二遍精读关键链路:重点读上下文组装、工具调用、失败分支,把笔记里所有“为什么”补上。

第三遍做改造实验:比如替换记忆后端,或新增一种 Planner 策略。只有动手改过代码,才算真正读懂了它。

第一遍大概一小时,第二遍需要半天,第三遍可以结合自己的业务做。三遍下来,你对 Agent 工程化的理解会比看任何教程都深。

8.2 和传统框架对照时,关注点完全不同

很多从 Spring Boot、若依这类后端框架过来的开发者,第一次读 Agent 框架会不适应。传统框架的核心是数据模型、事务、权限,而 Agent 框架的核心是决策循环、状态流转、工具编排。

对照阅读时,我更关注生命周期管理、可观测性、扩展点、社区维护情况这几个维度。不要去抠某个数据库操作和某个 API 封装,因为 Agent 框架里根本没有传统意义的数据库 CRUD,它的“存储”大多落在记忆模块里。

8.3 后续阅读计划:记忆模块与评测落地

这篇笔记只覆盖了主干。下一步我会专门拆记忆模块的源码,把短期、长期、工作记忆的接口设计和存储选型讲细,再结合 Agent 评测落地经验写一份可用指标清单。这套框架里有些设计很适合用 pytest 这类测试工具做回归验证,也会一并整理出来。

我自己读完一遍的最大体会是:源码阅读不是为了背接口,而是为了看清框架作者在约束条件下做的取舍。Pi Agent 的代码结构和复杂度都还在适合精读的范围内,难的是把规划、执行、记忆、工具这条链路的限制条件想透。下一篇我会把记忆模块的源码拆得更细,也会补一组可控的评测实验。如果你也在读这套框架,欢迎带着你的问题来聊。