Appearance
第18章 地图的比例尺——pi-mono
小a读完概念篇,信心满满地打开了 pi-mono。三分钟后,他盯着屏幕发呆——packages/ 下面躺着几十个目录,ai、agent、tui、coding-agent、protocol……他随手点开一个,又是几百个文件。
“老z,”他喊道,“这地图也太大了。我该从哪个目录开始?”
老z走过来,看了一眼屏幕,没有指任何目录,反而问:“概念篇留下的问题是什么?”
“模型、循环、会话和终端各自的边界,在一套真实工程里究竟落在哪里。”小a背得很熟。
“对。你不是来‘读完 Pi’的,是来验证这几个边界的。”老z说,“所以先别按目录猜——先固定地图的比例尺,再画主路径。”
本篇的已确认事实全部以 pi-mono 的提交 583f153d502aa8e958eefdb9af0fbd3344e68f95 为准;提交日期为 2026-08-01。本书选取的下列九个核心/产品范围包的 package.json 版本均为 0.83.0,而根 package.json 的 monorepo 版本是 0.0.3,不能混用。
这个“九个”是本书的阅读范围,不是根 workspace 的总数:根 workspace 还包含扩展示例;packages/evals 是私有工作区包,不是发布包;packages/coding-agent/install-lock 是私有的安装锁根,也不在这九包范围内。
先固定地图的比例尺
小a:“九个包……是不是九个独立程序?”
“不是。”老z说,“工作区名和发布包边界能证明可独立导入的单元,不能证明运行时一定独立进程。先读包元数据和导出,再顺着一次输入的控制流找证据。”
比喻:地图的比例尺
先定“一厘米代表多少公里”,地图才不误导。九包是本书的阅读比例尺——不是仓库的全貌,更不是九个独立进程。
本书所说“九个包”是下表选取的九个产品/基础范围包。包的职责来自其 package.json 的名称、描述、依赖和导出;描述是维护者声明,不是对全部代码行为的证明。
| 包 | npm 包名 | 可确认的边界证据 | 本书沿它追什么 | 暂不在本章断言 |
|---|---|---|---|---|
packages/ai | @earendil-works/pi-ai | 有根、api/*、providers/* 等导出 | 模型、认证、流适配 | 各厂商服务质量 |
packages/agent | @earendil-works/pi-agent-core | 依赖 pi-ai,根和 ./node 导出 | 循环、Harness、会话 | 具体 CLI 策略 |
packages/tui | @earendil-works/pi-tui | 独立终端 UI 包 | 组件与渲染 | Agent 决策 |
packages/coding-agent | @earendil-works/pi-coding-agent | pi 二进制入口,依赖前述核心包 | 命令行装配 | 所有扩展行为 |
packages/protocol | @earendil-works/pi-protocol | 传输中立的 CBOR 协议包 | 帧、schema、版本 | 网络可靠性 |
packages/client | @earendil-works/pi-client | 依赖 pi-protocol | 远程会话客户端 | 服务端策略 |
packages/server | @earendil-works/pi-server | 实验性服务包,依赖 AI、CLI、协议 | 服务端会话 | 生产部署保证 |
packages/storage/sqlite-node | @earendil-works/pi-storage-sqlite-node | Node SQLite 会话后端 | 持久化替代后端 | 浏览器可用性 |
packages/evals | @earendil-works/pi-evals | 私有评测工作区包 | 用例与 judge | 产品质量结论 |
小a盯着 coding-agent 那行,忽然发现一件事:“它依赖 AI、Agent、TUI、Protocol 和 Client?那 agent 呢?”
“agent 只直接依赖 AI。”老z说,“这张表已经暴露了一个容易跳过的事实:通用循环被设计成不必直接知道终端或远程协议。由此可以推断,通用循环是独立于产品外壳的;但不能由依赖图推出任何一个产品都必须使用 Harness。”
九包分三层:谁依赖谁
小a看着表格还是觉得散。老z拿过笔,在纸上画了一张三层图:“按依赖方向分层,九包的关系一目了然。箭头表示"依赖",上层调下层,下层不知道上层存在。”
text
┌─────────────────────────────────────────────────────────┐
│ 产品层 │
│ ┌──────────────────┐ ┌──────────────┐ │
│ │ pi-coding-agent │ │ pi-server │ │
│ │ (命令行产品入口) │ │ (实验性服务端) │ │
│ └────────┬─────────┘ └──────┬───────┘ │
└───────────┼─────────────────────┼────────────────────────┘
│ │
┌───────────┼─────────────────────┼────────────────────────┐
│ 核心层 │ │ │
│ ┌────────▼─────────┐ ┌───────▼────────┐ │
│ │ pi-agent-core │ │ pi-client │ │
│ │ (循环+会话+Harness)│ │ (远程会话客户端) │ │
│ └────────┬─────────┘ └───────┬────────┘ │
│ │ │ │
│ ┌────────▼────────────────────▼────────┐ │
│ │ pi-storage-sqlite-node │ │
│ │ (Node SQLite 会话后端) │ │
│ └──────────────────────────────────────┘ │
└───────────┼─────────────────────────────────────────────┘
│
┌───────────┼─────────────────────────────────────────────┐
│ 基础层 ▼ │
│ ┌────────┐ ┌─────────┐ ┌──────────┐ ┌────────┐ │
│ │ pi-ai │ │ pi-tui │ │pi-protocol│ │pi-evals│ │
│ │(模型流) │ │(终端UI) │ │(协议编解码)│ │(评测) │ │
│ └────────┘ └─────────┘ └──────────┘ └────────┘ │
└─────────────────────────────────────────────────────────┘“读这张图有三个要点。”老z说:
- 基础层四个包互不依赖——pi-ai 只管模型流,pi-tui 只管终端,pi-protocol 只管字节编解码,pi-evals 只管评测。它们各自独立,谁也不知道谁。
- 核心层搭在基础层上——pi-agent-core 依赖 pi-ai(它要调模型),pi-client 依赖 pi-protocol(它要走远程),pi-storage-sqlite-node 同时依赖 ai 和 agent-core(它要存会话)。
- 产品层把核心层和基础层装配起来——pi-coding-agent 依赖五个包,是最终的产品入口;pi-server 依赖三个包,提供远程服务。产品层最厚,但它只是装配,核心逻辑在下面两层。
小a:“那读源码该从哪层开始?”
“从核心层开始读——pi-ai 和 pi-agent-core 是整条主路径的脊梁。基础层按需穿插(读到协议再翻 protocol,读到终端再翻 tui)。产品层最后看,因为它只是把下面的零件拼起来。”老z说,“这就是本书源码篇 12 章的阅读顺序:先 17-20 章打通核心层,再 21-23 章看产品和终端,最后 24-26 章看协议、远程和评测。”
从公开导出辨认可替换面
小a:“那入口在哪儿?从 index.ts 开始看吗?”
“对,但先分清根入口和子路径。”老z打开 packages/ai/src/index.ts:
ts
// packages/ai/src/index.ts(模块入口;583f153d,节选)
// Provider factories live under "@earendil-works/pi-ai/providers/*",
// API implementations under "@earendil-works/pi-ai/api/*".
export * from "./api/lazy.ts";
export * from "./models.ts";
export * from "./types.ts";“注意开头的注释——Provider 工厂在 providers/*,API 实现在 api/*。这不是目录约定,而是 package.json 的 exports 也列出的子路径边界。”老z说,“上面的代码只说明根入口可得到哪些 API,不能说明一次请求的顺序。”
他又打开 packages/agent/src/index.ts:“这边把 agent.ts、agent-loop.ts、harness/agent-harness.ts、JSONL store、repository、compaction 和 skills 都导出了。它说明宿主可以组合这些部件,也说明把‘Agent’等同于‘必定写 JSONL’是错误前提。”
| 导出层 | 调用者能依赖的东西 | 不能从这里推出的结论 |
|---|---|---|
pi-ai 根入口 | Models、事件流、类型、懒加载工具 | 已注册哪些 Provider |
pi-ai/providers/* | 某个 Provider 工厂 | 认证已配置或模型一定可用 |
pi-ai/api/* | 某种 API 的请求/转换实现 | 其他 API 也具备相同行为 |
pi-agent-core 根入口 | 低层循环与高层 Harness | 宿主采用哪种 store 或 UI |
pi-coding-agent 二进制 | pi CLI 的产品入口 | 远程模式或所有扩展已启用 |
一条主路径,而非完整架构图
小a:“那我到底沿着哪条线读?总不能把每个包都读一遍。”
“接下来的十二章按控制流走,不按目录念文件名。”老z画了一条带站号的主路径——每一站就是一章,从模型请求出发,经过循环和会话,最后拼回完整请求:
text
①模型请求 ②流式适配 ③循环推动 ④会话存活
┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐
│ch17 │──▶│ch18 │──▶│ch19 │──▶│ch20 │
│pi-ai │ │pi-ai │ │pi-agent│ │pi-agent│
│models.ts│ │lazy.ts │ │loop.ts │ │harness │
└────────┘ └────────┘ └────────┘ └────────┘
│
⑤终端刷新 ⑥产品启动 ⑦工具信任 ▼
┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐
│ch21 │ │ch22 │──▶│ch23 │ │ch27 │
│pi-tui │ │coding- │ │coding- │ │端到端 │
│main-scr│ │agent │ │agent │ │完整请求│
└────────┘ └────────┘ └────────┘ └────────┘
│ │ ▲
⑧协议字节 ⑨远程一致 ⑩评测判断 │
┌────────┐ ┌────────┐ ┌────────┐ │
│ch24 │──▶│ch25 │ │ch26 │──────┘
│protocol│ │server │ │evals │
│codec.ts│ │sessions│ │harness │
└────────┘ └────────┘ └────────┘“这张图告诉你三件事。”老z说:
- 主线是 ①→②→③→④——模型请求出发,流穿过适配层,循环推动状态,会话活过进程。这是 pi-ai 和 pi-agent-core 两个核心包的主路径,先读这四章。
- ⑤⑥⑦是产品层——终端怎么刷新、产品怎么启动、工具和信任怎么接。它们搭在核心层之上,第二组读。
- ⑧⑨⑩是远程和评测——协议怎么变字节、远程怎么保持一致、评测怎么判断。它们是独立的能力边界,按需读。最后 ⑪(ch27)把所有站拼成一次完整请求。
每章读哪个包的哪些文件
小a:“那具体到文件呢?我打开 ch17,到底该翻开哪个文件?”
老z列了一张完整对照表:“这张表是你读源码篇的导航仪——打开任何一章,先看它在哪个包、读哪些核心文件。”
| 章 | 你在读哪个包 | 核心文件(打开这几个就够) | 这一站回答什么 |
|---|---|---|---|
| 16 地图 | 全部九包 | package.json × 9 | 九包怎么分层、主路径怎么走 |
| 17 模型请求 | pi-ai | models.ts、auth/resolve.ts | 一次请求怎么从认证走到 Provider |
| 18 流式适配 | pi-ai | api/lazy.ts、api/transform-messages.ts | 厂商事件怎么变成统一事件 |
| 19 循环 | pi-agent-core | agent.ts、agent-loop.ts | 模型输出怎么推动状态前进 |
| 20 会话 | pi-agent-core | harness/agent-harness.ts、session/jsonl-store.ts | 会话怎么活过一次进程 |
| 21 终端 | pi-tui | tui-main-screen.ts、stdin-buffer.ts | 终端为什么能稳定刷新 |
| 22 启动 | pi-coding-agent | main.ts、core/agent-session-services.ts | 命令行产品怎么启动 |
| 23 工具信任 | pi-coding-agent | tools/file-mutation-queue.ts、extensions/runner.ts | 工具和扩展怎么安全接入 |
| 24 协议 | pi-protocol | codec.ts、framing.ts、schemas.ts | 消息怎么变成可靠字节 |
| 25 远程 | pi-server + pi-client | sessions.ts、snapshots.ts | 远程会话怎么保持一致 |
| 26 评测 | pi-evals | pi-harness.ts、vitest-evals/setup.ts | 评测怎么判断一次修改 |
| 27 端到端 | 跨全部包 | (回顾 17-26 的文件) | 一次完整请求怎么走完 |
“注意,这条线上有两条数据,别混成一条。”老z又列了一张表:
| 数据 | 在主路径中的形状 | 关键边界 |
|---|---|---|
| 配置 | model、headers、认证、signal、重试选项 | ModelsImpl.applyAuth 合并后才交 Provider |
| 上下文 | AgentMessage[],调用模型前转为 AI Message[] | streamAssistantResponse 的 convertToLlm |
| 生成 | AssistantMessageEventStream 的 start、delta、done/error | adapter 产生,loop 消费 |
| 副作用 | tool result、session append、终端刷新 | Agent event 与 Harness 持久化并非同一件事 |
小a:“为什么不直接从 CLI 入口开始,一路读到网络请求?那不是更直观吗?”
“那条线会同时带入配置、交互模式、终端和扩展。”老z说,“**先锁住跨包稳定的流与循环边界,才不会把某个产品装配误当成核心机制。**之后的 第24章 户口本上的住址——pi-coding-agent 会再把 CLI 接回这条线。”
阅读时如何保持证据强度
小a翻开笔记本,准备开始读代码。老z拦住他:“先记住——源码走查容易犯三类错误,我踩过,别踩。”
阅读时三类常见错误
- 把注释或描述扩张为运行时保证:
pi-server的描述含 experimental,只能确认它自称实验性,不能自行补成“不适用于任何生产场景”。- 把类型可选性误读为实现一定缺失:
Provider.refreshModels?是可选方法,代表静态 Provider 可以没有刷新路径;不表示所有动态 Provider 都会成功刷新。- 把一个错误结果压扁成
false:未知 Provider、未配置认证、流中止、工具失败、JSONL 解析失败所在层不同,恢复策略也不同。后续章节会把它们放在同一张失败语义表里比较。
“推荐按这个顺序读固定快照:先读导出和类型;再读入口函数的完整分支;随后跟到下一包的接口实现;最后用测试或一个可控调用验证‘正常、错误、中止’三条路径。遇到跨层推断时,标记为推断,不替源码补作者动机。”
这套切分的收益与代价
“这样切分,值吗?”小a问。
“**工程观点:**以包边界加调用链组织阅读,收益是替换模型适配器、会话后端或 UI 时可以先找契约,不必先理解所有产品代码。”老z说,“代价是读者要在多处源文件之间往返,且不看具体宿主时不会知道默认配置从哪里来。”
“那本章故意没讲什么?”小a问。
“各 Provider 的完整模型目录、每个 API 的协议字段、工具定义、TUI 的事件订阅实现、Protocol 的 framing、Server/Client 的重连,以及评测用例。”老z说,“这不是遗漏声明为已读,而是把它们留给对应的调用链章节。”
把主路径拆成可停下来的阅读站
小a:“那我每章读多长?一口气跟完整条链?”
“别。**源码阅读不应把一条链当作必须一次读完的长函数。**每到一个公开类型或事件边界,都可以停下来写下输入、输出与失败结果。这比只记录‘这个包做什么’更容易发现跨层假设。”老z给了一张“阅读站”表:
| 阅读站 | 最小问题 | 下一站 | 失败先看哪里 |
|---|---|---|---|
| 模型选择 | Model.provider 指向谁 | ModelsImpl | 未知 Provider |
| 请求准备 | credential 与 options 如何合并 | Provider.stream* | auth、header transform |
| 流适配 | 上游事件如何成为统一事件 | runLoop | error、aborted、pending |
| Agent turn | 什么进入下一次 context | 工具执行/队列 | length、tool error、terminate |
| 会话持久化 | 哪些 event 被追加 | repository/store | session、解析、写入失败 |
| 宿主显示 | 谁订阅事件并刷新 | TUI | 渲染、终端、输入边界 |
“举个例:一个网络失败,不该直接从‘请求准备’跳到‘终端显示’解释。先确认 Provider 是否将其归一为 error event;再确认 loop 是否把最终 error message 写入 context;最后才看 Harness 或 UI 是否记录或显示它。”老z说,“这是一种阅读顺序,不是源码承诺的运行时重试链。”
小a:“那每章只读一个函数,会不会错过跨文件状态?”
“不会只读一个函数;关键是每章规定一个入口、一个出口和几条分支。”老z说,“当函数跨文件调用时,继续跟其直接契约,而不是先把整个包读完。在固定提交上,git grep 可以验证符号存在——但**符号存在不能代替从调用点读到返回点,类型名相同也不代表语义相同。**例如 AI 层的 Context 与会话树条目处在不同抽象层,本书会在每个边界显式标出这一点。”
小结
这一章解决的是源码篇第一站的问题:面对 pi-mono 几十个包,该从哪下手。答案不是按目录读,而是先固定三个坐标——版本、依赖方向、主路径。
- 版本坐标:本书选取的九个包都是
0.83.0(pi-evals是私有工作区包),根 monorepo 是0.0.3,两者不能混用。九个包是阅读范围,不是仓库全貌。 - 依赖坐标:九包分三层——基础层(ai、tui、protocol、evals)互不依赖;核心层(agent-core、client、sqlite-node)搭在基础上;产品层(coding-agent、server)只是装配。通用循环被设计成不必直接知道终端或远程协议。
- 路径坐标:主路径把"输入到流、流到循环、循环到会话/UI"拆为可分别验证的边界,每一站就是一章。
记住:包边界不是进程边界。 工作区名和依赖图能证明"可独立导入的单元",不能证明运行时一定是独立进程。读源码时,先按这三层坐标定位自己,再沿调用链走,而不是逐个目录念文件名。
源码走查
- 在
pi-mono根目录执行git rev-parse HEAD;预期完整输出是583f153d502aa8e958eefdb9af0fbd3344e68f95。 - 执行
node -p 'require("./packages/ai/package.json").version'和同样的 agent 包命令;确认它们是0.83.0,再对照根package.json的version,避免混用版本。 - 打开
packages/ai/package.json的exports;分别记录根入口、./providers/*、./api/*与./compat,不要把子路径当成根导出。 - 打开
packages/agent/package.json的dependencies;确认其中有@earendil-works/pi-ai,再检查它没有pi-tui,以验证上表的直接依赖说法。 - 逐行读
packages/ai/src/index.ts的开头注释及导出;区分运行时export *和只导出类型的export type。 - 在
packages/agent/src/index.ts搜索createJsonlSessionStore、compact、loadSkills;确认它们是公开可组合 API,而不是从导出关系推断它们必然同时运行。