第 2 章:三层架构 —— Pi-Agent 项目的骨骼
口径说明:本章说的"三层堆栈"指
pi-ai → pi-agent-core → pi-coding-agent这条 SDK 复用链;"四个核心包"再加上正交的 UI 库pi-tui;而仓库的 npm workspaces 里实际有五个包——第五个是外围实验性的pi-orchestrator(多 Agent 编排)。三个数字不矛盾,只是数的东西不同。
五个包的目录结构
第一次克隆 Pi 的代码库,ls 看到的是标准的 npm workspaces monorepo:
repo/
├── packages/
│ ├── ai/ ← @earendil-works/pi-ai
│ ├── agent/ ← @earendil-works/pi-agent-core
│ ├── coding-agent/ ← @earendil-works/pi-coding-agent
│ ├── orchestrator/ ← @earendil-works/pi-orchestrator(实验性)
│ └── tui/ ← @earendil-works/pi-tui
├── package.json ← 根配置,"workspaces": ["packages/*"]
└── tsconfig.json
注:历史上存在过
pi-web-ui(浏览器端 Lit 组件库),官方已于 2026-05-20 在 commitb141e1fa中移除该 workspace。
为什么这样拆?每个包到底在干什么?能不能合并? 先逐个看。
五个包,各管各的
pi-ai:管"调模型"
package.json 自述:"Unified LLM API with automatic model discovery and provider configuration"。它做三件事:
- 定义统一类型——不管 OpenAI、Anthropic、Google 还是 Bedrock,消息都是
UserMessage/AssistantMessage/ToolResultMessage,模型都是Model<TApi>; - 统一流式调用——所有提供商统一成一个
streamSimple(),返回可逐 token 读取的AssistantMessageEventStream; - 适配 30+ 提供商——每个提供商一个适配器文件。
它的 index.ts 导出列表里,没有 agent、没有 tool、没有 loop:
// packages/ai/src/index.ts(v0.80.x 节选)
// 顶部注释明确写:Core only, side-effect free: no generated catalogs,
// no provider factories, no api-registry, no OAuth implementations, no compat.
// 全局 API 注册表、stream/complete 函数等已迁至 ./compat.ts
export type { Static, TSchema } from "typebox";
export { Type } from "typebox";
export * from "./api/lazy.ts" // 各 Provider API 的懒加载入口
export * from "./auth/context.ts" // 认证上下文
export * from "./models.ts" // 模型定义(KnownProvider 35 个)
export * from "./types.ts" // 统一类型
export * from "./utils/event-stream.ts" // 事件流基类
它只管一件事:把 LLM API 的差异抹平,对外暴露统一接口。
pi-agent-core:管"跑循环"
package.json 自述:"General-purpose agent with transport abstraction, state management, and attachment support"。关键词是 general-purpose——这个包不知道自己在做编程 Agent 还是客服 Agent,它只知道:
- 怎么维护对话状态(
AgentState) - 怎么跑"调 LLM → 执行工具 → 再调 LLM"的循环(
agentLoop) - 怎么发事件让外部感知过程(
AgentEvent) - 怎么管理会话历史、做上下文压缩(
Session、compact)
它的导出里没有 read、bash、edit——它不关心具体做什么事,只关心"怎么把一个 Agent 跑起来"。
pi-coding-agent:管"具体业务"
这是最"厚"的一层,上百个源文件,比前两层加起来还多。因为它知道所有具体的事:7 个编程工具怎么实现、扩展系统怎么加载、会话怎么持久化、CLI 怎么解析参数、认证怎么存储。入口是极简的 cli.ts,背后是一整条启动链路:
你输入: pi "帮我改个 bug"
│
├── cli.ts ← 解析命令行参数
│ └── main.ts ← 创建会话、选择运行模式(交互/打印/RPC)
│ └── AgentSession ← 组装工具、加载扩展
│ └── Agent ← 管理状态、跑循环
│ └── agentLoop() ← 核心循环开始
pi-tui:管"显示"
终端 UI 库,负责渲染 Markdown、代码高亮、差分显示。运行时依赖仅 marked + get-east-asian-width,没有任何 AI 相关的包。它和"Agent 怎么工作"没有直接关系,只是把过程展示给用户看。
pi-orchestrator:管"多 Agent 编排"(实验性)
v0.80.x 新增,依赖 pi-coding-agent,站在 coding-agent 之上,本身不实现任何 Agent 内核逻辑(循环、状态、压缩仍由 agent-core 提供),只是把若干 coding-agent 实例"编"起来——子 Agent 生命周期监控、基于 RPC 的进程间通信、编排边界控制、状态持久化等职责分属不同模块(具体文件名以源码为准)。实验性能力,API 可能调整,学习主线只看核心三件套即可。
打开 package.json,事情没那么简单
如果你的分层理解是"上层只能依赖相邻的下层",打开 packages/coding-agent/package.json 会愣一下:
"dependencies": {
"@earendil-works/pi-agent-core": "^0.80.2", // ← 依赖中间层,合理
"@earendil-works/pi-ai": "^0.80.2", // ← 也直接依赖底层?
"@earendil-works/pi-tui": "^0.80.2",
}
coding-agent 跨层直接依赖了 pi-ai,这是不是破坏了分层?
答案藏在类型系统里。打开 packages/agent/src/types.ts 第一行:
// packages/agent/src/types.ts:1-14
import type {
Api, AssistantMessage, AssistantMessageEvent,
AssistantMessageEventStream, Context, ImageContent,
Message, Model, SimpleStreamOptions, TextContent,
Tool, ToolResultMessage,
} from "@earendil-works/pi-ai";
pi-agent-core 大量基础类型都从 pi-ai 导入——Message、Model、Tool 是整个系统的"原子概念",就像化学元素,不管哪一层都需要原子的定义。coding-agent 也一样:用户贴一张截图,它需要 pi-ai 里的 ImageContent 类型才知道图片怎么表示。
所以跨层引用不是设计失误,而是必然——某些基础类型必须在一处统一定义,所有层都引用这一处。
分层的真正规则:依赖方向单向向上
关键不在"能不能跨层引用",而在依赖方向:
| 包 | 依赖了谁 | 有没有反向依赖? |
|---|---|---|
| pi-ai | @anthropic-ai/sdk, openai, @google/genai 等 | 没有,不依赖任何 pi-xxx 包 |
| pi-agent-core | pi-ai, typebox, yaml | 只向上依赖 |
| pi-coding-agent | pi-ai, pi-agent-core, pi-tui | 只向上依赖 |
所有箭头都朝上。底层永远不知道上层的存在——pi-ai 里没有任何一个 import 指向 pi-agent-core 或 pi-coding-agent。这就是分层的真正规则:不是限制引用层级,而是控制依赖方向必须单向向上。
pi-tui 则是完全独立的:运行时零 pi-xxx 依赖,被 coding-agent 单向使用,不存在循环依赖。
类型在层间的流转:从原子到分子
用化学类比:pi-ai 定义"原子",pi-agent-core 组合成"分子",pi-coding-agent 再组合成"材料"。
第一层,pi-ai 定义原子:
// packages/ai/src/types.ts(节选)
type Message = UserMessage | AssistantMessage | ToolResultMessage
interface Model<TApi> {
id: string // 如 "claude-sonnet-4-6"
name: string
api: TApi // 如 "anthropic-messages"
contextWindow: number // 如 200000
}
interface Tool<TSchema> {
name: string
description: string
parameters: TSchema
}
Message、Model、Tool 三个类型就是整个系统的原子。
第二层,pi-agent-core 组合成分子:
// packages/agent/src/types.ts(节选)
import type { Message, Model, Tool, ImageContent, ... } from "@earendil-works/pi-ai";
// 扩展消息:标准消息之外允许自定义消息(压缩摘要、分支信息等)
type AgentMessage = Message | CustomAgentMessages[keyof CustomAgentMessages]
// 扩展工具:在 schema 之上加执行能力(types.ts:371-394)
interface AgentTool<TParameters extends TSchema = TSchema, TDetails = any>
extends Tool<TParameters> {
label: string // 显示名称
prepareArguments?: (args: unknown) => Static<TParameters> // 参数预处理
execute: (toolCallId: string, params, signal?: AbortSignal,
onUpdate?: AgentToolUpdateCallback<TDetails>) => Promise<AgentToolResult<TDetails>>
executionMode?: ToolExecutionMode // "sequential" | "parallel"
}
注意两个设计:
AgentMessage是Message的超集——用联合类型(|)扩展,而不是修改原类型;AgentTool继承Tool——底层Tool只知道"工具叫什么、参数是什么"(LLM 需要的信息),上层加上"怎么执行、串行还是并行"(Agent 循环需要的信息)。
第三层,pi-coding-agent 组合成材料:
// packages/coding-agent/src/core/extensions/types.ts:435-482(节选,简化签名)
// 注意:ToolDefinition 是独立 interface 重新声明,
// 与 AgentTool 是"结构兼容"而非用 extends 继承
interface ToolDefinition<TParams extends TSchema, TDetails = unknown, TState = any> {
name: string
label: string
description: string
promptSnippet?: string // 自动拼到 system prompt 的片段
promptGuidelines?: string[] // 工具使用守则
parameters: TParams
renderShell?: "default" | "self" // 渲染模式
prepareArguments?: (args: unknown) => Static<TParams>
executionMode?: ToolExecutionMode
execute: (toolCallId, params, signal, onUpdate, ctx: ExtensionContext)
=> Promise<AgentToolResult<TDetails>> // 比 AgentTool.execute 多 ctx 参数
renderCall?: ... // 自定义调用渲染
}
三层对照:底层 Tool 只知道"长什么样"(LLM 视角)→ AgentTool 知道"怎么执行"(Agent 视角)→ ToolDefinition 加上"怎么显示"(产品视角)。底层类型从不被修改——pi-ai 的 Tool 里没有 execute 字段,因为 LLM 不需要知道工具怎么执行。
写个简单 Agent 真的需要三层吗?
看三个场景:
场景 A:不分层,全部写一个文件。 循环逻辑和 OpenAI SDK 调用耦合在一起。能用,但想换成 Claude 就得改 Agent 循环里的调用代码。
场景 B:只用 pi-ai + pi-agent-core。 完全可行。agent-core 不知道什么是 read 工具、bash 工具——它只定义接口规范(AgentTool),注册什么工具由你决定,甚至可以不注册任何工具纯聊天。这说明 coding-agent 层不是必须的,它的上百个文件只是在 agent-core 上"添砖加瓦"。
场景 C:只用 pi-ai。 也完全可以,调用 LLM、流式返回结果,不需要任何 Agent 框架。但你就得自己写循环、自己管理消息状态、自己处理工具调用——这正是 pi-agent-core 存在的意义:它帮你做了 Agent 最难的那部分,你只需告诉它用什么工具。
| 场景 | 适合什么 | 你自己做什么 |
|---|---|---|
| 只用 pi-ai | 只需调 LLM | 自己管状态、写循环(如需要) |
| pi-ai + pi-agent-core | 完整 Agent 能力 + 独特业务场景 | 写自己的工具和入口 |
| 全部三层 | 做 Pi 同类的编程助手 | 直接用,或写扩展 |
层数取决于复杂度。但无论几层,有一条规则不能违反:底层的代码里不能出现任何对上层的引用。 这条规则确保你可以把任何一层换成自己的实现而不影响其他层。
三个可以带走的方法
- "依赖漏斗"分层法:底层是"不知道外面世界的",中间层"知道底层但不知道业务",顶层"知道一切"。验证方法:问自己"去掉上层,这一层还能跑吗?"能,则方向正确;不能,则上层的东西泄漏到了下层。
- "类型递进扩展"模式:底层定义最小类型接口,上层用联合类型和继承扩展,绝不修改底层。好处是底层可以独立发布和复用。
- "可独立使用"测试:每层设计完后,试着在
package.json里移除上层依赖,看底层包的编译和测试还能不能通过。报错了,说明底层泄漏了对上层的依赖。
小结
- Pi 分三层:pi-ai(管模型)→ pi-agent-core(管循环)→ pi-coding-agent(管业务),外加正交的 pi-tui 和外围的 pi-orchestrator;
- 分层核心规则是依赖方向单向向上,底层对上层一无所知;
- 类型层层递进:
Tool→AgentTool→ToolDefinition; - 三层不是教条,层数取决于复杂度;但依赖方向控制是必须的。
下一章,我们钻进 Agent 的心脏——Agent Loop:LLM 怎么反复思考、调工具、看结果、再思考?
版本说明:本章基于 Pi v0.80.2 编写,代码分析以源码为准。本文改编自 CC-BY-SA-4.0 许可的开源教程。