Pi Agent 系列第三篇,这次不聊跑通了,聊点架构上的硬骨头。前两篇我们把环境搭起来、把第一个 Agent 跑通,但后台收到最多的私信是同一个问题:Pi 的代码仓库这么大,到底从哪儿看起?我的答案很直接——先看它的 monorepo 骨架。Pi 把命令行入口、核心编排、工具链、协议层全部收进同一个仓库,根目录的 workspace 配置、每个包之间的依赖关系、构建缓存策略,就是整个项目的骨架和血管。这篇我以 monorepo 为索引,把 Pi 的架构一层层剥开,讲清楚每个目录为什么存在、模块之间怎么协作,以及你二次开发时最容易踩的坑。
1. 先理解 monorepo:Pi 面对的是一道选择题
1.1 monorepo 与多仓库的本质差别
在看 Pi 的代码之前,得先搞清楚一个前提:像 Pi 这种体量的项目,代码组织方式基本只有两条路——多仓库(multirepo)和单仓库(monorepo)。多仓库的意思是每个模块独立建一个 git 仓库,比如pi-core、pi-cli、pi-tools各管各的;monorepo 则反过来,所有模块都放在同一个仓库里,通过 workspace 机制来区分边界。
很多人以为 monorepo 就是"把所有代码堆在一起",这是误解。monorepo 的核心不是物理上放一起,而是用统一的依赖图谱管理多个包的工程化关系。每个包依然有自己的package.json、自己的测试和构建,但它们的版本、依赖、发布节奏可以在同一个仓库内被统一编排。
我在看 Pi 仓库之前,专门对比过这两种模式的取舍:
| 维度 | 多仓库 | monorepo |
|---|---|---|
| 代码复用 | 依赖包版本发布,使用方自行升级 | 直接 workspace 引用,改完即生效 |
| 跨模块重构 | 要同时改多个仓库、多次提交 | 一次提交原子变更 |
| 新人上手成本 | 需要 clone 多个仓库、理解包间关系 | clone 一个仓库即可全局浏览 |
| 构建与测试 | 各仓独立 CI,联调成本高 | 统一流水线 + 增量缓存 |
| 版本一致性 | 容易漂移,需要手动对齐 | root 统一编排,天然一致 |
这个对比不是绝对的,但对于 Pi 这种核心逻辑、CLI、工具链高度耦合的 Agent 项目,monorepo 是明显更有优势的那条路。
1.2 Pi 选 monorepo 的三个核心理由
第一个理由是原子提交。Pi 的代码库里,@pi/core改一个决策逻辑,往往需要同步调整@pi/tools里的工具注册方式和@pi/cli里的参数透传。如果拆成多个仓库,一个功能改动可能要跨三四个仓库开 PR,等所有仓库都合并才能验证完整效果,极其痛苦。monorepo 里这就是一个提交的事,CI 跑完整个链路,改动完整可验证。
第二个理由是依赖调试成本。多仓库模式下,你想在本地调试@pi/core被 CLI 调用的效果,就得在pi-cli里npm link指向本地包。npm link在复杂依赖树下的坑我后面会专门讲,但简单说就是——它会把你的 node_modules 搞成一场灾难。Pi 用 pnpm workspace 后,@pi/core和@pi/cli之间是软链接直连,本地改动即时生效,不需要任何 link 操作。
第三个理由是基础设施复用。Pi 的 monorepo 根目录有一套统一的 ESLint、Prettier、TypeScript 配置和 CI 流水线。一个 PR 进来,全仓库的检查、测试、构建都走同一套规则,不会出现"CLI 用的 TS 版本和 Core 不一致"这种低级问题。模块多了以后,这种一致性省下的排查时间非常可观。
2. 仓库顶层:从根目录读起,骨架就藏在文件名里
2.1 根目录配置文件是阅读地图
拿到 Pi 的仓库,第一件事不是打开src,而是看根目录下到底有哪些文件。一个成熟的 monorepo 项目,根目录通常通过文件名的约定把自己的一切交代清楚。Pi 的仓库顶层结构大致长这样:
pi/ ├── apps/ │ ├── cli/ │ └── docs/ ├── packages/ │ ├── core/ │ ├── tools/ │ ├── mcp/ │ └── sdk/ ├── .npmrc ├── package.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── tsconfig.json ├── turbo.json └── .github/这里面每一个文件都不是摆设。pnpm-workspace.yaml声明了哪些目录是 workspace 包;turbo.json定义了构建任务之间的依赖关系和缓存策略;pnpm-lock.yaml锁住整个依赖树版本;根目录package.json则统一管理所有子包的脚本入口。
我最先看的一定是pnpm-workspace.yaml,因为它直接告诉你这个仓库的边界:
packages: - "apps/*" - "packages/*"这段配置的意思是:apps和packages下所有直接子目录都算作 workspace 里的独立包。它们之间可以互相引用,并且共享同一个锁文件。看到这个文件,你就知道 Pi 的仓库被划分成了两个大区:应用层和包层。
2.2 packages 与 apps 的边界:按"交付形态"划分
很多人第一次看 monorepo 会困惑:apps和packages到底什么区别?标准其实很简单——看这个包的最终交付形态是什么。
apps目录下放的是可运行的产品。apps/cli构建出来的东西是一个用户可以直接执行的命令行程序,它把packages里的逻辑组装成一个完整的交付物。apps/docs是文档站,本质也是一个独立部署的产品。它们的特点是:有自己的启动入口、有独立的环境变量、面向最终用户。
packages目录下放的是可复用的库。@pi/core是 Agent 编排的核心逻辑,别人可以在自己的程序里调用;@pi/tools是工具集注册表,可以被 core 消费,也可以被 sdk 暴露给外部;@pi/mcp是 Agent 和外界通信的协议层;@pi/sdk是给二次开发者的编程接口。这些包都不直接运行,它们是被组装进apps的零件。
判断一个包应该放哪里的标准我一直用这句话:如果你这个目录里有bin字段、有启动脚本、有生产环境配置文件,它大概率是 app;如果只有exports导出和 API 定义,它是 package。Pi 的划分非常干净,cli 里几乎没有核心逻辑,core 里没有一处直接读取用户配置的逻辑,这种边界感是 monorepo 架构最值钱的资产。
3. 核心包拆解:Pi 的三层骨架零件
3.1 编排层:Agent 的循环不是玄学
@pi/core是整个 Pi 的心脏,也是我建议你第二个打开看的包。它做的事情抽象出来只有一件:维护一个 Agent 循环。
这个循环可以拆成四个阶段:
- 感知(Perceive):接收用户输入、系统提示词、工具返回结果,组装成模型可以理解的上下文。
- 决策(Decide):把上下文交给大模型推理,得到下一个动作。这个动作可能是"调用某个工具""继续生成内容"或者"直接给出最终回答"。
- 行动(Act):如果模型决定调用工具,core 负责找到对应工具、传参、执行、拿到结果。
- 反思(Reflect):把工具结果拼回上下文,判断任务是否结束,没结束就带着新信息回到感知阶段。
@pi/core的核心抽象就是这个循环状态机。它不关心具体调用了什么模型,也不关心工具是代码执行器还是浏览器,它只负责把"感知-决策-行动-反思"这个循环稳定地转起来。这也是为什么 core 是所有包里最稳定的部分,模型可以换、工具可以加,循环不需要动。
3.2 工具层:能力不是写死的,是注册进来的
@pi/tools有意思的地方在于它的设计哲学:Pi 的能力是插件化的,不是内置的。每个工具模块暴露统一的接口,任何满足这个接口的代码都能被注册进 Agent 的工具列表。
一个工具通常声明这些信息:名字、描述、入参 schema、执行函数。名字和描述是给大模型看的,模型通过它们决定要不要调用这个工具、什么时候调用;入参 schema 是给参数校验用的,防止模型生成非法参数;执行函数才是真正干活的逻辑。
这种设计的直接后果是,每加一个新工具,不需要改动 core 的代码。tools 包本身维护一个注册表,core 启动时把注册表里的工具全部加载进上下文。所以看 Pi 的仓库时你会发现 core 的依赖列表非常短,真正"重"的依赖都沉淀在 tools 包里。
这里有个很关键的细节:工具的注册顺序会影响模型的调用倾向。Pi 在启动时会对工具列表做排序,把高频、轻量、安全的工具排在前面,因为模型在长上下文里更容易注意到排在前面的工具描述。这个细节如果你自己写 Agent,一定要记住。
3.3 协议层:Agent 与世界的对话契约
@pi/mcp解决的是 Agent 与外部系统通信的标准化问题。这里的 mcp 指的就是 Model Context Protocol,一种让 AI 应用和外部数据源、工具之间进行标准化交互的开放协议。
在设计上,Pi 没有让 core 直接去请求文件系统、数据库或 HTTP 服务,而是全部通过 mcp 这一层转发。这么做的收益有两个:
第一是隔离。core 不需要知道外部服务是 REST 接口还是本地命令行,只要拿到 mcp 标准的响应就能继续决策。协议成了挡在核心逻辑和外部世界之间的缓冲层。
第二是生态兼容。任何实现了 mcp 协议的外部服务,都可以无缝接入 Pi。这相当于给 Pi 开了一条对接全行业通用工具的高速公路,不用每个服务写一套私有 adapter。
在 monorepo 里看这个包,你会发现它的依赖最少——它存在的意义就是定义标准和实现协议,不掺杂业务逻辑。这也是分层清晰的标志。
4. 一次闭环任务在仓库里怎么跑完
4.1 入口触发:从敲下命令到上下文初始化
现在我们把三个核心包串起来,看一次真实任务在 Pi 仓库里是怎么流转的。这是理解 monorepo 包间协作关系最快的方式。
用户执行pi run "帮我写一个计算器的测试用例",入口在apps/cli。cli 做的事情非常少:解析参数、初始化日志、加载配置文件,然后调用@pi/sdk的start方法。sdk 负责组装核心执行环境——它会读取配置文件里配的模型信息,初始化@pi/core的循环实例,再从@pi/tools加载启用的工具列表,最后把这次会话的初始上下文构建出来。
这一步里最容易理解 monorepo 价值的就是依赖传递:cli 依赖 sdk,sdk 依赖 core 和 tools,它们的版本在 workspace 里直接联动,不存在"CLI 用的 core 是老版本"这种问题。
4.2 Agent 循环的决策路径:上下文是唯一事实来源
进入循环后,core 先把用户问题和工具描述组装成一个完整的 prompt,发给模型。模型返回的第一个决策可能是"调用 CodeTool 执行静态分析"。
此时 core 做三件事:校验参数格式、把这次决策记录进会话历史、调用 tools 包里注册的对应执行函数。执行结果不会直接粗暴地扔回给模型,而是先经过一层结果裁剪——太长的话截断、格式不对的清理、敏感信息脱敏——然后写回会话历史,带着新状态进入下一轮循环。
这个"上下文是唯一事实来源"的设计是 Pi 架构最核心的原则。所有中间状态都通过会话历史传递,不另搞一套内存状态。好处是断点续跑、日志回放、多轮一致性都变得非常简单——只要把历史恢复,Agent 就能从任意断点继续工作。
4.3 结果产出与状态回写:尾声也是起点
循环的退出条件有两个:模型输出了最终答案,或者超过最大轮数限制。最终答案会经 sdk 封装成结构化输出,交还给 cli 展示给用户。
但还有一个容易被忽略的设计:每次运行的完整轨迹都会被写回本地存储。这意味着你可以回顾上一次 Agent 执行了哪些工具调用、每步耗时多少、上下文最终消耗了多少 token。这些数据是优化提示词和工具描述的第一手素材,也是二次开发拓展功能时的重要参考。
从这条链路回头看 monorepo 的意义很清晰:cli、sdk、core、tools 各自只负责一段,通过 workspace 内部依赖无缝衔接。任何一段要替换,都只需要改动对应包,重新构建整体即可。
5. 工程化实践:依赖、构建和发布的骨架细节
5.1 pnpm workspace 与幽灵依赖问题
Pi 的依赖管理用的是 pnpm 的 workspace 模式。pnpm 和 npm/yarn 最大的区别是它不会把所有依赖平铺在 node_modules 顶层,而是采用内容寻址存储 + 符号链接的方式,每个包只能访问自己声明过的依赖。
这个特性在 monorepo 里尤其重要。npm 的依赖提升机制常常把"幽灵依赖"偷偷暴露给没有声明的包——你在 A 包里能importB 的依赖但不报错,因为它在顶层 node_modules 里。等发布上线后环境变了就崩。pnpm 的严格链式解析从依赖机制上杜绝了这个问题。
当然 pnpm 也有代价。Pi 的仓库里如果出现两个包依赖同一个库但版本要求不同的情况,会提示你可能要配置public-hoist-pattern或调整版本设计。这在 AI Agent 项目里很常见,比如 core 用 lodash 4,tools 里某个工具可能用 lodash 3。pi 的做法是尽量统一版本,把这种冲突扼杀在设计期。
实操提示:在 Pi 仓库里新增包时,先看清楚它依赖的库版本是否和 core 保持一致。不要图省事引入新版本,统一版本族能规避大量隐性问题。
5.2 Turborepo 增量构建的逻辑
Pi 的构建任务由 Turborepo 编排,配置文件是根目录的turbo.json。它的核心机制是缓存——根据输入文件内容、依赖关系、环境变量计算一个哈希值,如果哈希命中,就直接拿之前的构建产物,跳过整个构建过程。
这套机制在 monorepo 里的放大效应非常夸张。你改@pi/tools下某个工具,Turborepo 会只重构建 tools 包及其下游引用(sdk 和 cli ),core 如果没变就不会重跑。配合远程缓存,CI 上的构建速度可以提升一个数量级。
但缓存引擎带来的坑也很典型。我就遇到过 Pi 的构建缓存命中错误版本的场景——配置文件改了,但没有触发重新构建,因为 Turbo 没把那个配置文件纳入输入依赖。解决方法是显式地在任务的inputs里声明所有可能影响产物的文件路径。后面踩坑小结我会细说。
5.3 版本策略:同步发布还是独立发布
最后一个工程化问题是包的版本号怎么管理。Pi 用的是一种混合模式:核心包版本严格同步发布,core、sdk、mcp的版本号保持一致,因为它们内部的接口契约高度耦合;工具包则允许独立版本,因为工具迭代速度快、发布频率高。
这种策略在执行上有个小技巧:每次发版前,先跑一遍全量构建和测试,然后按依赖顺序从底层包往上发,最后更新顶层 cli。Pi 的 scripts 目录里有一个专门处理发布顺序的脚本,避免人为漏发、错发。monorepo 里最怕的无非是"上面改了、下面没发,一运行就崩"——有了自动化顺序控制,这个问题基本被消灭。
6. monorepo 实战中的坑,我替你们都踩过
6.1 幽灵依赖与依赖提升
最经典的问题是幽灵依赖。在早期用 npm workspace 跑 Pi 时,我曾经发现@pi/mcp里没有声明zod,但代码里直接import了zod——因为 npm 把 zod 提升到了顶层 node_modules,所以本地跑不报错。直到发布到干净环境,运行时才爆出Cannot find module 'zod'。
排查这类问题没有捷径,就是把node_modules彻底删掉,用 pnpm 重新安装再测试。pi 的仓库转向 pnpm 后,这种问题几乎绝迹。如果你在自己的项目里仍然用 npm/yarn,建议在 CI 里加一条检查:禁止未声明依赖被引用。
6.2 循环依赖:改一个包全盘崩溃
monorepo 发展到中期最常见的坑是循环依赖。七个包互相依赖,依赖图上形成了一个环。这种架构下每次构建都是碰运气,改一个包可能把另外几个全部拖垮。
Pi 的解法很朴素但有效:依赖方向必须单向流动。core可以依赖tools的接口定义,但tools永远不能反过来依赖core的实现。严格执行后,依赖图变成清晰的有向无环图,构建顺序、发布顺序全都自动确定。我判断一个 monorepo 是否健康的唯一标准,就是依赖图有没有环。
6.3 构建缓存失效与 CI 超时
还有一个让我挠头的坑是 Turborepo 远程缓存失效。你的代码没变,但远程缓存因为环境变量哈希变化导致每次都 miss,CI 上全量构建,每次跑二三十分钟。
解决姿势是把构建用到的所有环境变量显式放到turbo.json的env列表里,同时确认 CI 的环境变量集合保持稳定。一旦配置好了,注意观察本地构建日志里的cache hit命中率。Pi 仓库里日常命中率一般在 90% 以上,如果你发现这个数字大幅下降,先检查是不是有隐式输入文件漏掉了。
最后一个我在实战中体会到的小建议:把根目录的README.md里维护一张包依赖图(纯文字版就够),每个新来的同事第一件事就是读这张图。架构这种东西,代码敲多了人就容易陷到细节里看不清全貌,一张清晰的依赖关系图比任何文档都管用。看懂 Pi 的骨架,本质上就是看懂这张图的每一层为什么那样画——然后你就能顺着骨架长出新的肌肉。