返回首页
02
分层架构monorepo依赖方向类型设计monorepo拆分

三层架构:Pi-Agent 项目的骨骼

包怎么分工、依赖方向为什么不能反向、类型如何层层递进

带着问题读

读完本章,你应该能回答这三个问题:

  1. 1Pi 的 coding-agent 直接依赖了最底层的 pi-ai,这算不算破坏了分层?为什么?答案见正文中对应的「面试题 1」气泡
  2. 2Pi 的 Tool 类型在三个包里分别是 Tool / AgentTool / ToolDefinition,这种递进扩展比直接改底层类型好在哪里?答案见正文中对应的「面试题 2」气泡
  3. 3如果让你验证一个分层架构是否健康,你会用什么可操作的方法来检查?答案见正文中对应的「面试题 3」气泡

第 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 在 commit b141e1fa 中移除该 workspace。

为什么这样拆?每个包到底在干什么?能不能合并? 先逐个看。

五个包,各管各的

pi-ai:管"调模型"

package.json 自述:"Unified LLM API with automatic model discovery and provider configuration"。它做三件事:

  1. 定义统一类型——不管 OpenAI、Anthropic、Google 还是 Bedrock,消息都是 UserMessage / AssistantMessage / ToolResultMessage,模型都是 Model<TApi>;
  2. 统一流式调用——所有提供商统一成一个 streamSimple(),返回可逐 token 读取的 AssistantMessageEventStream;
  3. 适配 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"
}

注意两个设计:

  1. AgentMessage 是 Message 的超集——用联合类型(|)扩展,而不是修改原类型;
  2. 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 同类的编程助手 直接用,或写扩展

层数取决于复杂度。但无论几层,有一条规则不能违反:底层的代码里不能出现任何对上层的引用。 这条规则确保你可以把任何一层换成自己的实现而不影响其他层。

三个可以带走的方法

  1. "依赖漏斗"分层法:底层是"不知道外面世界的",中间层"知道底层但不知道业务",顶层"知道一切"。验证方法:问自己"去掉上层,这一层还能跑吗?"能,则方向正确;不能,则上层的东西泄漏到了下层。
  2. "类型递进扩展"模式:底层定义最小类型接口,上层用联合类型和继承扩展,绝不修改底层。好处是底层可以独立发布和复用。
  3. "可独立使用"测试:每层设计完后,试着在 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 许可的开源教程。