1. 项目概述:为什么我要写这份源码阅读笔记
先说结论:Pi Agent 是一个聚焦于构建智能体(AI Agent)应用的基础框架,它解决的核心问题是怎么把大模型能力、工具调用、记忆管理、上下文控制这些零零碎碎的东西,封装成一套可复用、可扩展的开发范式。这两年大模型应用层出不穷,但多数团队还是在自己堆代码:今天写个 prompt 模板,明天写个 function calling 的解析器,后天再补一个多轮对话的历史管理——每个项目都从零开始,每个项目都在重复造轮子。而 Agent 框架的使命,就是把这一层共性逻辑沉淀下来,让开发者把精力放在业务本身。
这篇笔记适合三类读者:一是刚接触 Agent 开发、想找一款框架快速上手的人;二是已经在用 LangChain 之类框架、想横向对比底层设计差异的人;三是纯粹想通过源码阅读提升架构能力的大模型开发工程师。我自己属于第二类和第三类之间——日常做 Agent 相关的工程化落地,读源码是我了解一个框架最快的方式。
选择 Pi Agent 作为阅读对象,有几个原因。一方面它的体量适中,不像某些巨型框架动辄几十万行代码,读起来负担大;另一方面它背后的 Agent 循环、Tool 协议、Memory 分组这几个核心模块设计得比较克制,没有太多炫技的成分,非常适合用来理解 Agent 运行的底层逻辑。尤其在热词里反复出现的“agent框架与编排”“agent记忆框架以及选型”这两个话题,Pi Agent 给出的答案很朴素但很实用,值得展开聊。
我读源码的习惯是:先跑通 Demo,再顺着一次完整的 Agent 调用去追踪代码路径,然后反向梳理模块边界,最后做横向对比。这篇是系列的第一篇,先讲清楚整体架构和 Agent 主循环这条主线,后面再逐个拆 Memory、Tool、Plugin 这些子系统。
2. 整体设计拆解:Pi Agent 的架构哲学和核心抽象
2.1 一次 Agent 调用到底发生了什么
理解一个框架最快的方式,是看它的最小闭环。Pi Agent 里最小的一次 Agent 运行,大致是这么一条链路:用户消息进来,经过 Message 预处理,被 Agent 循环接收;循环内部先组装 Prompt(把系统指令、上下文、历史记录、可用工具描述合成一个结构化请求),调大模型得到响应;如果响应里带 Tool Call,就去执行对应的工具函数,把结果追加回对话上下文,再继续下一轮推理;直到模型不再请求工具,循环才会退出并返回最终答案。
这一步流程本身没什么玄机,任何 Agent 框架本质上都是这套循环。但 Pi Agent 做得比较讲究的有两点。
第一点,它把“上下文”显式建模成一个受控对象,而不是简单往 prompt 里拼接字符串。循环里每次组装请求,都要经过一个 Context 管理器,它会按 Memory 分组的规则决定哪些历史进入当前请求、哪些需要裁剪、哪些必须保留。这个设计的直接好处是,你可以在不侵入业务代码的前提下,控制 token 消耗和上下文漂移问题。
第二点,Pi Agent 把工具(Tool)和模型响应里的 function calling 指令做了解耦。工具本身只声明“我能干什么、参数是什么”,真正决定调哪个工具的,是模型输出里的 tool_choice 和 call_id,框架负责把它们匹配起来。这个解耦很小,但很关键——这意味着你替换工具时,不需要改 Agent 循环的代码,只要保证新的工具描述能被模型理解即可。
说个我自己的感受。很多框架喜欢把 Agent 做得“重”:有复杂的 Workflow 引擎、有状态持久化中间层、有可视化编排面板。Pi Agent 没有这些,它把重心放在“单次 Agent 循环的执行质量”上。对于一个纯代码驱动的项目,这种风格反而更友好——你能清楚地看到每一次模型调用的输入输出,出了问题也容易定位。
2.2 分层结构:Core、Provider、Tool、Memory 四个核心层
从模块依赖看,Pi Agent 大致分成四层,我从依赖关系最底层的开始说。
第一层是Core 运行时层。这层包含 Agent 循环、Message 结构、上下文管理器、事件分发器等基础组件。它不依赖任何具体的模型厂商,也不关心你是用 OpenAI 还是本地模型。Core 层定义了一套接口契约,比如ChatModel接口是长什么样的、ToolCall应该包含哪些字段、AgentResponse如何组织,这些契约是框架稳定性的基石。
第二层是Provider 模型接入层。它解决的问题很朴素:让不同厂商的大模型 API 都长一个样子。OpenAI 的 ChatCompletion、Anthropic 的 Messages、以及各种兼容 OpenAI 协议的本地网关,在 Provider 层都会被适配成统一的请求/响应格式。源码里能明显看到一层薄薄的 Adapter 代码——它们不做业务判断,只做协议转换。这一层对上层是透明的,你在业务代码里换模型只需要改配置项,不动业务逻辑。
第三层是Tool 工具层。工具在 Pi Agent 里是一个独立的概念,有一套自己的注册、描述、校验机制。工具和 Agent 循环之间通过描述信息通信:循环把工具列表的 JSON Schema 发给模型,模型决定调用哪个,循环再分发执行。很多初学 Agent 的人会混淆工具注册和工具调用,觉得这两件事应该写在一起,实际上分开才是合理的——注册是静态声明,调用是动态行为,耦合在一起会导致工具越多、代码越混乱。
第四层是Memory 记忆层。这一层负责管理“哪些历史信息该放进下一次请求”。Pi Agent 用分组(Group)机制来处理记忆隔离,比如系统配置一组、业务上下文一组、通用闲聊一组,每组的优先级和保留策略可以独立配置。这一层我会在第四部分专门展开,它是 Agent 能不能维持长时间对话稳定性的核心。
当然,这个分层是源码里实际存在的模块边界,不是我看完硬拆出来的。阅读的时候你可以观察一个细节:每个顶层包里都有自己的 README 或者 package 描述文件,里面写清楚了该模块的职责和依赖方向,这一点对源码阅读者非常友好。
2.3 为什么用 Plugin 机制而不是硬编码集成
Pi Agent 另一个值得说的设计选择,是大量使用 Plugin/Extension 机制,而不是把功能直接写进主流程里。比如回调钩子、认证中间件、日志拦截器,这些都是以插件的形式挂载到 Agent 生命周期上的。典型场景是:你想在每次模型调用前追加一个用户输入敏感词过滤,不需要改 Agent 源码,注册一个 before_request 类型的插件就能实现。
这种设计带来的直接好处是扩展点特别明确。读源码的时候,你只要找到生命周期回调列表,就能知道一个请求经过哪些处理阶段、可以在哪个时机插入逻辑。我实际用过不少框架,插件机制做得好的和做得差的差距很大——做得差的插件只能挂在固定的两三个事件上,想做点稍微复杂的事就得拷一份源码改;做得好的插件机制提供了足够细致的生命周环节点,几乎任何横切逻辑都有合适的挂载点。
Pi Agent 属于后者。它定义的钩子贯穿了请求前、模型调用前、模型响应后、工具执行前、工具执行后、Agent 完成这几个节点,这意味着你可以在链路中任意位置做观测、拦截、改写,而不需要 fork 源码。对于生产环境来说,这一点意义重大——线上出问题时你能在必要的地方织入诊断逻辑,而不是被迫改业务代码。
3. 核心源码追踪:从入口到 Agent 循环
3.1 入口文件与初始化流程
我这里以实际的源码目录结构为参照做说明,建议你 clone 下来对着看。Pi Agent 的启动入口通常是一个 Agent 类的工厂方法,示例代码一般长这样:
from pi_agent import Agent, ChatModelFactory, ToolRegistry model = ChatModelFactory.create( provider="openai", model_name="gpt-4o-mini", api_key="...", base_url="...", ) tools = ToolRegistry() tools.register("get_weather", WeatherTool) agent = Agent(model=model, tools=tools, memory_policy="default") response = agent.run("今天北京天气怎么样?") print(response.text)初始化流程里值得关注的是ToolRegistry和memory_policy这两个参数。ToolRegistry不是简单的一个列表,它内部维护了工具名到工具对象的映射,并且在注册时会主动做一次“自检”——校验工具的 name 是否唯一、描述是否为空、参数的 JSON Schema 是否合法。这个自检动作在调试阶段很实用,很多问题可以在启动时报错而不是跑到一半才暴露。
memory_policy决定默认的上下文管理策略。源码里预置了几种策略,比如按 token 上限滑动窗口、按轮次保留最近 N 轮、按 message 重要性分级保留。初次接触时直接用 default 策略就够了,但如果你想复现长对话场景下的性能表现,建议手动指定一个更严格的裁剪策略。
Agent 对象创建完后,整个实例是线程安全的吗?我读代码发现主要的状态都是不可变的,工具注册表和模型实例在初始化后不会变化,每次 run 调用会创建独立的上下文副本。这意味着同一个 Agent 实例可以被多个请求复用,也就能说清楚为什么官方示例里敢于把它定义成模块级单例。
3.2 Agent.run 的结构化分解
run方法是整个框架的心脏。我把它拆成三个子阶段来看。
第一个阶段是请求准备好(prepare)。run 最开始会把输入消息归一化成内部 Message 结构,然后调用 Context 管理器查询当前记忆组的内容,生成 prompt。这里有一个容易忽略但很重要的细节:prepare 阶段会根据工具列表实时生成一份工具描述文档,这份文档也会被当作请求的一部分发给模型。工具描述文档的质量直接影响模型能不能正确调用工具,所以 Pi Agent 对工具描述的生成逻辑做了优化,比如自动从 docstring 里提取功能描述、自动格式化参数格式。实际使用里,我个人建议工具函数的 docstring 写详细一点,框架生成出去的效果会好很多。
第二个阶段是模型交互(interact)。这阶段执行一次完整的模型调用,拿到返回结果后判断是否包含 Tool Call。源码里特别处理了 Tool Call 的字段解析逻辑——不同 Provider 对 tool call 的返回格式有细微差异,比如有的在增量流里分片返回,有的把 arguments 作为字符串整体返回。Pi Agent 在解析层做了一次归一化,把差异都消化在 Provider 适配层,业务代码拿到的就是干净的字段。
判断逻辑不复杂,核心是看响应里有没有 tool_calls 数组,且数组不为空。如果为空,说明模型认为不需要调用工具,可以直接返回结果;如果不为空,则进入工具执行阶段。
第三个阶段是工具执行与循环(execute_tools_and_loop)。拿到 tool call 后,框架先把消息追加到上下文(注意是原始响应,不是处理后的结果),然后逐个执行被请求的工具。每个工具执行完成后,返回值会被转换成一个 ToolMessage 再追加回上下文。所有工具执行完毕,带着更新过的上下文回到模型交互阶段,重新发起调用,直到模型不再请求工具。
这套循环是 Agent 的通用范式。源码里最关键的是处理“多个工具同时被请求”的情况——循环会等待这一轮所有工具执行完成再进入下一轮推理,而不是一个工具一个推理来回。这样能保持上下文的一致性和规避重复推理的 token 浪费。
def run(self, user_input: str) -> AgentResponse: context = self.context_factory.create(user_input) while True: response = self.model.chat(context.build_prompt()) context.append_response(response) if not response.tool_calls: return AgentResponse(text=response.text, context=context) for call in response.tool_calls: result = self.tools.execute(call) context.append_tool_result(call, result)这段代码是我简化过的,但结构和源码主线是一样的。重点是理解两个地方:一是context.build_prompt()每次都基于最新的记忆状态生成请求,而不是直接拼历史;二是context.append_tool_result确保了工具结果以结构化方式进入上下文,而不是随手塞进文本。
3.3 关键数据结构:Message 与 ToolCall
如果只看两个数据结构就能理解这个框架,那就是 Message 类和 ToolCall 类。
Message 类在 Pi Agent 里不只是一个“文本 + 角色”的简单容器。它的字段至少包含:role(system/user/assistant/tool)、content(内容)、tool_call_id(关联的工具调用编号)、metadata(附加元数据)。其中tool_call_id字段是连接“模型请求工具”和“工具执行结果”的关键——没有这个编号,上下文无法把结果和之前的调用请求一一对应起来。
ToolCall 类则是工具调用的统一表达。它包含工具名称 name、调用参数 arguments(通常是 JSON 字符串)、调用唯一标识 id。特别说明一下,arguments 在大多数 Provider 返回时是字符串格式,需要二次解析才能拿到结构化参数,Pi Agent 在适配层做了统一解析,并且做了容错处理:如果参数解析失败,不会直接崩溃,而是给模型返回一个明确格式错误的 ToolMessage,让模型修正后重新调用。这种容错设计在生产环境里非常必要,因为大模型偶尔会生成不合法的 JSON 参数,直接抛异常会中断整个 Agent 运行,回传错误提示则给了模型一次自纠正的机会。
关于 Message 的设计,我读到源码注释里的一个细节:作者有意把 content 限制为字符串,而不是 OpenA 新支持的 content 数组(多模态内容格式)。这个取舍大概是为了兼容更多 Provider 和降低上层使用复杂度,代价是对多模态输入支持不够直接。使用的时候如果确实有图片输入需求,要么找扩展插件,要么得在 Provider 层做适配,这个需要注意。
4. 上下文与记忆管理实战:确保长对话不崩的三板斧
4.1 分组记忆:为什么单一大列表根本不够用
很多 Agent 框架的默认记忆模式,是把所有对话历史塞进一个列表里,每次请求全量发送。这个方案在测试环境里没什么问题,但上线后很快会遇到两个痛点:token 超限和上下文漂移。
token 超限好理解——对话长了,请求体积越来越大,最终超过模型窗口。上下文漂移则更隐蔽:模型收到的历史里堆满了无关紧要的内容,导致它在海量信息里找不到当前真正需要的线索,进而出现回答质量下降、遗漏关键约束。我在实践里经常遇到这种情况,前几轮聊得好好的,到第 20 轮突然开始忘记开始时用户指定的要求。很明显,不是模型“变笨了”,而是上下文里噪音太多,有效信号被稀释了。
Pi Agent 的分组记忆机制,核心思路是把不同性质的信息放在不同的组里。比如system_rules组专门放系统指令和固定约束,这个组优先级最高,永远不会被裁剪;business_context组放当前任务相关的上下文,它有独立的上限;chat_history组放日常对话记录,默认按滑动窗口裁剪。每次构建 prompt 时,框架按组的优先级和保留策略选择性取用,而不是一股脑全塞进去。
这个设计直接解决了我上面提到的两个痛点。系统约束被隔离在高优组里,无论对话怎么延续都不会丢失;普通闲聊则被严格限制在窗口内,token 消耗可控、噪音可控。
4.2 记忆裁剪的触发时机和策略对比
看了源码我发现,Pi Agent 的记忆裁剪发生在两个时机。第一个是写入时:每追加一条新消息,组内的 token 统计器就会更新,如果超过阈值,立刻触发裁剪。第二个是构建 prompt 时:从上下文取历史组装请求前,会做一次二次检查,防止并发写入导致的超限。双保险的时序设计不是冗余,是为了应对单线程世界里可能被忽略的边界情况。
关于裁剪策略,源码里预置了几种,我整理成了一张表:
| 策略 | 说明 | 适用场景 | 风险点 |
|---|---|---|---|
| 最近 N 轮 | 只保留最近 N 轮对话 | token 优先的轻量场景 | 早期信息全丢 |
| token 滑窗 | 按 token 总数限制保留 | 通用 | 长文本单轮可能挤占其他内容 |
| 重要性打分 | 按消息与系统目标的关联性打分保留 | 复杂任务 | 打分模型准确性难保证 |
| 混合策略 | 先按轮次淘汰、再按 token 裁剪 | 生产环境推荐 | 需要配置参数 |
默认情况下框架用的是 token 滑窗,因为它的行为最可预期。但我在实际项目里倾向于把重要性打分模式和滑窗结合起来——把用户最初描述的核心目标标记为重要消息,系统约束固定保留,其余普通对话走滑窗。这个混合策略在 Pi Agent 里通过自定义 MemoryPolicy 可以实现,之后有机会单开一篇细讲。
4.3 从源码里学习上下文管理的边界条件
读上下文管理这段代码时,有几次边界情况处理给我留下了深刻印象。
第一个是对空历史的处理。当对话历史的 token 统计为 0 时,所有裁剪策略都不能抛异常,而是要优雅地跳过,让请求得以用最小 prompt 正常发起。很多自研框架在这个环节容易粗心,一旦 history 为空就数组越界。
第二个是工具结果消息的裁剪策略。工具执行的返回值通常比较长,比如数据库查询结果可能有几千行。如果把它和普通聊天消息一起按 token 滑窗裁剪,会导致模型丢失前一轮工具输出的信息。Pi Agent 的处理是单独给 tool 相关消息一个阈值,比例上比普通消息更大,同时优先裁剪更早的历史工具消息而不是最近那条。这个设计思路非常实际。
第三个是上下文构建的幂等性。build_prompt()被调用多次不会改变内部状态,这让模型调用产生报错之后可以安全重试,而不用担心重复拼接。这点对工程可靠性很重要,重试是线上最常用的容错手段,如果重试本身就破坏状态,后果会比较难处理。
5. 工具协议解析:工具描述、注册与调用的完整链路
5.1 工具要怎么写才能被模型准确调用
Agent 框架里,工具不仅仅是一段 Python 函数,它需要向模型“介绍自己”。这个介绍的质量,决定了模型能不能在合适的时机调用合适的工具。我见过不少失败的 Agent 项目,根源就出在工具描述写得模糊,模型在多个工具间犹豫后选错了那个,或者该调用工具时觉得没必要而直接回答。
Pi Agent 沿用了 OpenAI function calling 的声明方式,一个工具需要提供:name(函数名,要语义清晰)、description(功能描述,要说明什么场景该用这个工具)、parameters(参数 JSON Schema,含每个参数的类型和含义)、required(必填参数列表)。框架在注册工具时会读取这些信息,转成模型链路里的 tool schema。不少框架封装会自动生成,省掉手写 JSON Schema 的重复劳动。
一个实用的技巧是:在参数的描述里补上“什么时候该填这个字段、从哪个上游数据取得”,效果比单纯写“用户ID”要好很多。模型对工具的理解,很大程度取决于这些说明文字的丰富程度。我翻看源码里的工具示例,发现官方示例的列子可以说非常细致,几乎每个参数都标注了业务约束,效果确实比默认的情况好很多。代码里这种细节的用心能被读到,是件挺愉快的阅读体验。
5.2 工具调用的执行流程与超时控制
来看源码里 AbstractTool 提供的默认执行机制,要点有这么几个:
同步与异步的统一。工具可以是普通函数,也可以是 async 函数。Pi Agent 内部把两种形态都包装成统一的 awaitable 调用。这带来一个便利:Web 框架里用同步编码、在异步入口里集成 Agent,工具切换不需要改 Agent 代码。
超时处理。每个工具调用默认有超时时间。源码里用了 asyncio.wait_for 做控制,超出时间后不会继续阻塞整个 Agent 循环,而是把超时错误转成一条 ToolMessage 回传给模型。回传内容会明确提示“这个工具超时了,请不要重复调用它”。这个细节对生产环境非常重要,否则一个慢工具存在导致整个链路卡死的可能,尤其是外部 HTTP 请求工具时间一长,如果没有超时保护,线上服务会很难处理。
异常捕获。工具内部抛出的异常不会直接冒泡到 Agent 循环外层,而是会被捕获后组装成 ToolError 消息。框架设计了工具执行失败的恢复路径——模型看到失败原因后,可以选择换一个工具、修改参数重试或直接基于失败信息回答。让模型拥有“决策空间”,是 Agent 鲁棒性的核心思想。
5.3 工具注册表的设计:按命名空间隔离
假如你的 Agent 集成了几十个工具,怎么管理它们?Pi Agent 的做法是命名空间隔离。工具全名由 namespace + name 组成,例如weather.get_forecast和math.add就属于两个互不干扰的空间。这个设计的好处,一是避免工具重名冲突,二是可以在注册表层面控制“只暴露某几个命名空间的工具给当前 Agent”。
这点在实际项目中很有用。举个例子,我做过一个客服 Agent,核心对话只用 5 个工具,但后台还注册了几十个管理工具。如果不开命名空间隔离,模型会看到上百个工具描述,不仅 token 消耗大,模型的选择准确率也明显下降。通过配置只暴露核心命名空间,Agent 的决策质量会明显提升。
另外,注册表里每个工具还有 enabled 标记,可以在不删除注册的情况下禁用某个工具。这个能力在灰度发布和故障降级时特别顺手——某个第三方接口不稳定,直接禁用对应工具,模型就会绕开这个调用路径,而不是反复尝试失败。
6. 常见问题与排查路径:读源码后我避开的那些坑
6.1 Agent 循环不退出怎么办
最常见的坑是 Agent 陷入了“工具调用→错误→再次调用→再次错误”的死循环。这类问题表象是请求长时间不返回,日志里看到同一工具被反复调用多次。
看透 Agent 循环代码后,排查思路就清晰了。首先检查工具异常分支是否被正确回传给了模型。如果框架没把错误信息作为 ToolMessage 追加进上下文,模型会对着同样的现状反复尝试,因为没有信息告诉它此路不通。其次给循环设置最大迭代次数限制,在 Agent 配置里加上 max_steps 参数,超限后强制终止并抛出“无法收敛”的提示。这个限制在自研代码里很容易漏掉,但几乎每个成熟框架都会内置。
还有一个比较容易忽略的原因:工具描述里的参数约束和真实输入缝隙。如果声明里说某参数枚举只有 A、B,但实际代码里又悄悄支持了 C,模型可能基于错误的预期发起调用,引发隐蔽的重试循环。写工具的时候参数校验要严格和声明对齐。
6.2 Prompt 里塞满了看不见的低质量消息
第二个常见问题是上下文膨胀。用户会话只有十几轮,但每次请求的 token 量却异常大。打开日志一看,原来是工具结果消息没有被裁剪机制管理到。尤其是指定了自定义 Memory 策略时,工具消息可能逃逸到“不裁剪”的分支里。
排查方法是打印每次请求的 prompt 组成——按角色统计 token 占比。如果 tool 角色的比例异常高,优先检查 Memory 策略是否对工具消息单独设置了上限。如果没有,就用上一段提到的混合策略,把最近的工具结果保留,更早的大结果果断裁剪。
6.3 不同 Provider 的工具调用表现不一致
这个坑比较隐蔽。同一个工具定义,在 OpenAI 上表现正常,换到国内某兼容模型上会偶尔漏掉工具调用或参数解析混乱。排查后发现,根本上还是各家对 function calling 的实现有差异:有的模型对工具描述的长度敏感,描述写太长会丢失部分内容;有的模型对参数 Schema 严格程度理解不同。
应对方式有两个思路。其一,工具描述尽量精简,长度控制在必要信息范围内,不要塞说明书;其二,对不同模型走不同适配配置,通过 Provider 层做小差别处理。遇到工具调用异常时,先看 Provider 适配代码里的解析逻辑,确认对参数格式的宽容度是否足够。在生产环境,我建议用几个主流模型分别跑一遍工具调用测试集,尽早发现模型差异,否则上线后再排查会很被动。
6.4 找到根因的排查方法论
读源码不只是为了读懂,更重要的是形成自己的排查方法论。我的习惯是:遇到问题先不看现象本身,而是顺着 Agent 一次调用的时间线重新走一遍全链路,在每个节点输出日志,观察输入输出是否符合预期。很多时候问题出在某一层的数据格式,而不是核心算法上。
具体到 Pi Agent,我在本地用一套自定义事件钩子打印了 prepare→interact→execute_tools→next_round 的完整流转,把每个阶段消耗的时间和 token 都记录了下来。这套观察手段帮助我在多个项目里快速定位问题。框架是否支持这种观测,其实也是我评估一个框架好不好的重要标准。Pi Agent 在核心生命周期节点的钩子覆盖是足够的,至少到目前为止,我还没有遇到想观测却找不到接入点的情况。
7. 读源码带来的几点个人体会
这篇是系列的开篇,翻完整体架构和核心循环之后,我最大的感受是:好的 Agent 框架不需要堆砌概念,它需要把基础循环做扎实、把扩展点留清楚,再把控制权还给开发者。Pi Agent 在这三个维度上都做得不错。
从源码阅读的收获角度看,最有价值的部分集中在上下文管理和工具协议这两块。这两个模块决定了 Agent 在真实场景里的可用性上限——上下文管不好,对话长了就变成“弱智”;工具协议设计得不清晰,模型决策就会出现混乱。如果你打算自己从零写一个 Agent 框架,这两块是值得花最多时间的部分。
按我的规划,下一篇笔记会展开 Memory 分组机制的实现细节,包括自定义策略如何编写、令牌计数的累积逻辑、以及和流式输出的配合方式。如果你正在选型 Agent 框架,或者单纯想提升框架阅读能力,从这套源码里入手不会让你失望。
最后说一个实操小技巧:读大型框架时,不要从 module 列表开始读,而是从“一个最小可运行的示例”出发,断点打在关键方法上,沿着调用栈一层一层看。示例代码跑通的那一刻,框架的地图也就基本印在脑子里了。这套方法,在读 Pi Agent 时特别适用——清楚主线之后,细节都可以按需展开,不用担心迷失。