跳到主内容
创建时间:最后更新:作者:白川

扩展包分层与依赖选择

按职责边界把 Pi 拆成模型层、Agent 运行时和编码智能体三层,只装当前用得上的那一层。

扩展包分层与依赖选择

打开 Pi 的 package.json,你会看到一长串 @earendil-works/pi-*。它们不是并列的功能开关,而是分层的。

分层的意思是:pi-ai 不认识智能体,pi-agent-core 不认识终端,pi-coding-agent 才认识文件编辑。这条边界决定了你能替换什么、能复用什么,以及——更重要的是——你需不需要某一层。

很多人在这一步犯的错是"看起来完整":把能装的包全装上。结果是打包体积失控、攻击面扩大、升级被一个根本没用到的实验性包绑架。

这篇讲清楚三件事:每个包的一句话职责和不负责什么;实际依赖方向决定了什么会连带被装上;以及按你的真实需求该怎么选。读完你能写出一个"只包含真正需要的那几层"的 package.json,并解释每一项为什么在。

读这篇需要的前置只有一条:知道 npm 依赖树的基本概念(直接依赖、传递依赖、dependencies 与 devDependencies 的区别)。

三个核心层

Pi 的主干是三层,箭头表示依赖方向:

text
# 方框从上到下是依赖方向:上层依赖下层
┌─────────────────────────────────────────────┐
│  @earendil-works/pi-coding-agent            │  编码智能体:CLI + SDK
│  交互式编码工具、会话管理、扩展系统、资源发现    │
├─────────────────────────────────────────────┤
│  @earendil-works/pi-agent-core              │  Agent 运行时
│  工具执行循环、事件流、队列、上下文转换         │
├─────────────────────────────────────────────┤
│  @earendil-works/pi-ai                      │  模型层
│  多 provider 接入、认证解析、流式接口、用量统计   │
└─────────────────────────────────────────────┘

下层不知道上层。pi-ai 里没有"智能体"这个概念,pi-agent-core 里没有"终端"也没有"会话文件"。这条约束是 Pi 能被各种宿主复用的原因。

pi-ai:模型层

一句话:把"和某个模型说话"这件事封装成统一接口。

它提供 Models 集合、provider 工厂、模型目录、认证解析、流式事件和用量统计。四十多个内置 provider 都在这一层,认证方式各不相同(API key、OAuth、环境变量、云凭据),这一层把它们统一成同一套调用。

它不负责:工具执行、循环编排、会话持久化、终端界面。

一个入口细节值得注意:pi-ai 的 chat 目录只收录支持工具调用的模型,因为工具调用是 agentic 工作流的基础。图像和分类模型走各自的能力目录。

pi-agent-core:Agent 运行时

一句话:在模型之上跑一个会自己转的工具调用循环。

它提供 Agent 类、agentLoop 低层接口、AgentTool 工具模型、beforeToolCall / afterToolCall / finishTurn 控制点、steering / follow-up 队列。它依赖 pi-ai,但不依赖终端。

它不负责:模型目录和凭据管理(那是模型层)、会话文件、扩展系统、CLI。

pi-coding-agent:编码智能体

一句话:把上面两层组装成一个能改代码的终端智能体。

它提供 pi 命令行、内置编码工具(read、bash、edit、write、grep、find、ls、powershell)、会话树与压缩、扩展系统、Skills、提示模板、项目信任、MCP 与 codemode 内置扩展,以及 SDK。

它不负责:通用的模型目录管理(那在 pi-ai)、通用智能体循环(那在 pi-agent-core)。

一个容易误解的点:SDK 不等于 Coding Agent。 SDK 嵌在同一个包里,但它是 Coding Agent 的编程入口。你用 SDK 时,createAgentSession() 默认会做资源发现、读设置、加载凭据——这些是 Coding Agent 的行为。

其余各包

这些包不是"可选功能",是各自独立的关注点。

包一句话职责什么时候需要它
@earendil-works/pi-mcp
独立的 MCP 客户端,不依赖官方 MCP SDK,也不依赖其他 pi 包
要接 MCP server,但不想引入整个 Coding Agent
@earendil-works/pi-codemode
在 QuickJS 沙箱里跑模型写的 JavaScript,它唯一的权限是调用注入的工具
要让模型用脚本批量编排工具调用,嵌套调用不进 LLM 上下文
@earendil-works/pi-tui
终端 UI 框架,差分渲染、同步输出、防闪烁
要写自己的终端界面
@earendil-works/pi-durable
持久化的对话、任务和文档运行时,进程崩了能接着跑
要跨重启恢复工作;官方标注为实验性,API 会随版本变动
@earendil-works/pi-env
给 pi-durable 用的远程执行环境:SSH 部署的小守护进程
用 pi-durable 且工具要跑在另一台机器上
@earendil-works/pi-telemetry
与厂商无关的遥测契约和类型化 schema,无 exporter、无全局状态
要接 OpenTelemetry、Sentry 或日志后端
@earendil-works/chord
跨进程对象的类型化宿主运行时,支持热重载和 JSON 链路上的 RPC
要把对象放在多个进程里(服务器、worker、浏览器标签页)

注意 pi-mcp 的自我描述:它"不依赖官方 MCP SDK,也不依赖其他 pi 包"。你可以只用它接 MCP。

pi-telemetry 的取向也很特别——只定义契约和 schema,不带 exporter,也没有"当前 span"这样的全局状态。它附带一个内存参考实现(InMemoryTelemetryContext),但真正的上报要你自己提供 adapter。遥测上下文是显式传递的,这是为了让 pi 包之间的遥测不互相污染。

依赖关系

实际依赖方向(来自各包 package.json):

包直接依赖的 pi 包
pi-ai
pi-telemetry
pi-agent-core
pi-ai
pi-coding-agent
pi-agent-core、pi-ai、pi-codemode、pi-mcp、pi-tui
pi-durable
chord、pi-ai、pi-codemode
pi-env
chord、pi-durable
pi-evals
pi-ai、pi-coding-agent
pi-mcp
无 pi 依赖
pi-codemode
无 pi 依赖
pi-tui
无 pi 依赖
pi-telemetry
无 pi 依赖
chord
无 pi 依赖

这张表解释了一个关键事实:装 pi-coding-agent 会自动带来 pi-agent-core、pi-ai、pi-codemode、pi-mcp、pi-tui。 你没法只要 Coding Agent 而不要 MCP 客户端。反过来,pi-mcp、pi-codemode、pi-tui、pi-telemetry、chord 都是叶子包,单独使用不牵动其余部分。

顺带说明 pi-evals 的位置:它同时依赖 pi-ai 和 pi-coding-agent,所以它属于"要写行为评估才装"的那一类,位置比 pi-durable 更靠主干。

输入、输出、状态、责任边界

每个包都要能说清楚这四件事,否则它就是个黑箱。

包输入输出持有的状态不负责
pi-ai
模型 + Context(消息、工具声明)
AssistantMessage、流式事件、用量成本
provider 集合、模型目录、凭据存储
工具执行、循环
pi-agent-core
AgentMessage、工具清单、模型
事件流、AgentMessage 序列
消息列表、工具清单、队列、流式消息
凭据、模型目录、会话文件
pi-coding-agent
用户输入、CLI 选项
终端界面、JSONL 事件、RPC 响应
会话树、设置、资源缓存、扩展运行时
通用模型接入、通用循环
pi-mcp
MCP server 地址与凭据
工具列表、工具结果
连接、会话
把工具接进智能体
pi-codemode
脚本 + 注入的工具
脚本输出与返回值
沙箱实例
工具本身的实现
pi-tui
组件树、按键事件
终端渲染
视口、焦点、滚动位置
应用逻辑
pi-durable
输入条目、存储后端
已提交的条目与任务
持久化存储、任务图
模型调用细节
pi-telemetry
显式传入的上下文
span 事件
无全局状态
导出到任何后端
chord
对象契约与状态
JSON 帧、快照与增量
复制状态、契约注册表
具体业务逻辑

一个能说明问题的对比:pi-ai 的状态在进程内,pi-durable 的状态在存储里。 差别不是功能多寡,是"进程崩了会怎样"。

选型决策表

按你的实际需求选,不要按"以后可能用得上"选。

你的需求装什么不用装什么
只做对话:带流式输出的问答接口
pi-ai + 一个 provider 工厂
pi-agent-core、pi-coding-agent
要嵌入现有应用:你的应用已有 UI、状态和工具
pi-agent-core + pi-ai
pi-coding-agent(除非要内置编码工具)
要接 MCP
已有 Coding Agent:pi-coding-agent(MCP 是内置扩展);没有:pi-mcp + pi-agent-core
不要为了 MCP 装整套 CLI
要写终端界面
pi-tui + pi-agent-core
pi-coding-agent(除非要完整 CLI)
要跨重启恢复
pi-durable + pi-ai + chord
不要在 pi-agent-core 上自己造持久化
要接遥测后端
pi-telemetry + 你自己的 adapter
不要指望 pi 包自动上报
要改代码的编码智能体
pi-coding-agent
其余按需

三种最小依赖组合

只做对话。 只需要模型层:

json
{
  "dependencies": {
    "@earendil-works/pi-ai": "^1.3.0"
  }
}

dependencies 放运行时真正要用的包;devDependencies 放只在开发期用的(类型检查器、测试框架)。版本前缀 ^ 表示允许同主版本的次版本和补丁版本升级,~ 只允许补丁版本。

provider 不是独立的包,它从 @earendil-works/pi-ai/providers/anthropic 这样的子路径引入,同一个包就够。代码长这样:

typescript
import { createModels } from "@earendil-works/pi-ai/models";
import { anthropicProvider } from "@earendil-works/pi-ai/providers/anthropic";

// createModels() 返回空集合,必须逐个 setProvider();没用到的不会进打包产物
const models = createModels();
models.setProvider(anthropicProvider());

const model = models.getModel("anthropic", "claude-sonnet-4-6");
if (!model) throw new Error("模型未找到或该 provider 未注册");

// 用 AbortController 掌握取消时机
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 30_000);

const stream = models.streamSimple(
	model,
	{
		systemPrompt: "你是一个助手。",
		messages: [{ role: "user", content: "你好", timestamp: Date.now() }],
	},
	{ signal: controller.signal },
);

try {
	// 流上失败不抛异常,而是走 error 事件
	for await (const event of stream) {
		if (event.type === "text_delta") {
			process.stdout.write(event.delta);
		} else if (event.type === "error") {
			console.error(event.reason === "aborted" ? "请求已取消" : "请求失败", event.error.errorMessage);
		}
	}

	// 拿到终态消息,成功与否看 stopReason,而不是看有没有抛异常
	const response = await stream.result();
	if (response.stopReason === "aborted") {
		console.warn("拿到了部分内容:", response.content);
	}
} finally {
	// 一定要清掉定时器
	clearTimeout(timer);
	controller.abort();
}

四个细节:createModels() 返回空集合,必须 setProvider() 逐个注册;getModel() 返回 Model 或 undefined,不检查就是运行时空指针;取消靠 AbortController 的 signal 传进请求选项,中止后的响应 stopReason 是 "aborted";clearTimeout 必须在 finally 里清掉,否则定时器会让进程挂住不退出。

嵌入现有应用。 加上 Agent 运行时:

json
{
  "dependencies": {
    "@earendil-works/pi-ai": "^1.3.0",
    "@earendil-works/pi-agent-core": "^1.3.0"
  }
}

pi-agent-core 本身会传递依赖 pi-ai,这里把 pi-ai 也显式列出来,是因为你的代码直接 import 了它的符号——直接依赖要写进 dependencies,不要依赖传递依赖恰好暴露出来。版本前缀 ^ 表示允许同主版本的次版本和补丁版本升级;要不要锁死精确版本,取决于你能否接受上游的破坏性变更。

要接 MCP 但不想要 CLI。 pi-mcp 是独立包:

typescript
import { McpClient, StdioTransport } from "@earendil-works/pi-mcp";

// 传输层决定 MCP server 怎么起:这里用 stdio,让 Pi 拉起一个子进程
const transport = new StdioTransport({
	command: "npx",
	args: ["-y", "@modelcontextprotocol/server-filesystem", "/workspace"],
});

// 客户端身份由宿主给:name 和 version 是 MCP 握手时要自报家门的信息
const client = new McpClient({
	name: "my-client",
	version: "1.0.0",
	// roots 声明这次会话允许 server 看到的根目录,是给 server 的边界提示
	roots: [{ uri: "file:///workspace", name: "workspace" }],
});

try {
	// 必须先连上才能列工具:未连接的 client 拿不到任何工具
	await client.connect(transport);
	const tools = await client.listTools();
	const result = await client.callTool("search", { query: "MCP" });
	console.log(tools.length, result);
} finally {
	// 漏掉 close() 连接不会干净断开
	await client.close();
}

client.close() 一定要放在 finally 里——它会关闭传输、标记连接已关闭,并唤醒所有挂起的请求。

反例:不要为了"看起来完整"把不需要的包装进来

下面这个 package.json 看起来专业、覆盖全面、像是"把 Pi 的能力都接进来了":

json
{
  "dependencies": {
    "@earendil-works/pi-ai": "^1.3.0",
    "@earendil-works/pi-agent-core": "^1.3.0",
    "@earendil-works/pi-coding-agent": "^1.3.0",
    "@earendil-works/pi-mcp": "^1.3.0",
    "@earendil-works/pi-codemode": "^1.3.0",
    "@earendil-works/pi-tui": "^1.3.0",
    "@earendil-works/pi-durable": "^1.3.0",
    "@earendil-works/pi-telemetry": "^1.3.0",
    "@earendil-works/chord": "^1.3.0"
  }
}

如果这个项目只需要"一个能回答问题的 HTTP 接口",这段配置会带来什么:

问题具体表现
依赖膨胀
pi-coding-agent 直接依赖了 jiti、undici、yaml、chalk、highlight.js、quickjs-wasi 等一串运行时依赖;pi-tui 又带进 marked
打包体积失控
pi-ai 的 providers/all 会导入全部内置 provider 目录;pi-coding-agent 还带着 TUI 和 QuickJS 沙箱
攻击面扩大
你没用的 MCP 客户端、脚本沙箱、扩展加载器都进了依赖树
升级被绑架
pi-durable 官方标注实验性、API 会随版本变动;你不用它,却要跟着它的破坏性变更升级
真正的 bug 被淹没
你的接口 500 了,栈里可能经过四个你根本不认识的包

反过来的正确做法是从最小集合开始:

json
{
  "dependencies": {
    "@earendil-works/pi-ai": "^1.3.0"
  }
}

然后按真实需求往上加。每加一个包,都能说清"哪个需求逼我加的"。

这里有一条官方支持的做法可以直接用:pi-ai 的 provider 工厂是子路径导入,每个 provider 一个模块,不导入其它 provider。需要代码分割时,各家的 SDK(@anthropic-ai/sdk、openai、@google/genai)会留在懒加载的 chunk 里,第一次请求到该 API 时才加载。对应的三条规则:

规则做法
按需注册 provider
用 createModels() + setProvider(),别用 builtinModels()
只引需要的子路径
@earendil-works/pi-ai/models 提供运行时,不带 TypeBox、内置目录和 SDK 实现
不碰 compat
@earendil-works/pi-ai/compat 保留旧的全局 API 并引入全部内置目录,官方明确说新项目别用

判断标准很简单:如果一个包在你的代码里没有被 import,它就不该在 dependencies 里。

三类信息要分清

改造这类文章时有一条容易被忽略的原则:明确区分下面三类东西。看不懂时,先问"这句话属于哪一类"。

类别判断依据例子
官方已定义的行为
能在官方文档或 README 里逐字找到
getModel() 返回 Model 或 undefined;dispose() 会中止进行中的工作并移除事件监听器
源码事实
文档没写,但源码里确实是这样
ModelRuntime 默认读 agent 目录下的 auth.json 和 models.json;tool_execution_end 的 durationMs 用单调时钟测量
教学设计
为了讲清楚某个概念而构造的做法
本文里"最小练习只引入所需层"这个验收标准;样例代码里的包名和版本号写法

第三类最容易变成"官方推荐"。本文的选型决策表是教学设计——它基于官方包清单和依赖关系推导,但不是 Pi 官方发布的选型指南。遇到和官方文档冲突的地方,以官方文档为准。

同理,样例代码里的 ^1.3.0 这类版本写法是教学选择,不是官方要求。你实际安装的版本要以你用的发布渠道为准。

你现在能做什么

  • 说出 Pi 每个包的一句话职责,以及它不负责什么(模型层不跑循环、Agent 层不读终端、Coding Agent 不自己实现 LLM 协议)。
  • 查各包的 package.json,画出当前的依赖方向图,并回答"装 pi-coding-agent 会连带装进哪五个包"。
  • 按你的真实需求,从决策表里选出该装哪些包,并写出理由。
  • 写出一个"最小练习只引入所需层"的 package.json,并解释每一项为什么在。
  • 用 grep 检查你的项目里实际 import 了哪些 @earendil-works/pi-* 符号,把没被 import 的包从 dependencies 里移出去。
  • 用 createModels() + setProvider() 写一个只依赖 pi-ai 的流式对话程序,并在 finally 里释放流。
  • 用 pi-mcp 独立接一个 MCP server,在 finally 里 close() 掉客户端。
  • 识别出一个反例:一个看起来更完整、实际更糟的依赖组合,并说清它的四个具体代价。

排障速查

现象检查点处理
打包体积远超预期
provider 导入方式
用了 providers/all?换成按需 setProvider() 子路径导入
装了 pi-ai 却用不了 Type
导入入口
Type 从 @earendil-works/pi-ai 导出;从 @earendil-works/pi-ai/models 导入时不含 TypeBox
找不到旧版全局 API
compat 入口
旧版的全局 getModel() 在 @earendil-works/pi-ai/compat;新代码用集合上的 models.getModel()
getModel() 返回 undefined
provider 是否注册
createModels() 不会自动注册任何 provider;先 setProvider()
MCP 工具不出现
是否注册了扩展
SDK 会话默认不加载 MCP,要加 createMcpExtension() 并调 session.bindExtensions()
codemode / tool_search 没启用
默认工具设置
它们默认注册为未激活,用 defaultTools: ["+codemode", "+tool_search"] 打开
依赖里有装但没用的包
依赖清单
逐个 grep 一下,没被 import 的移出去
升级时被无关的破坏性变更卡住
实验性依赖
pi-durable 是实验性包,官方说明 API 会随版本变动
遥测没有上报
adapter
pi-telemetry 只定义契约,不含 exporter,要自己提供 adapter
跨进程状态不一致
状态归属
跨进程对象和复制状态是 chord 的职责,不要在业务层自己同步
进程跑完不退出
资源释放
MCP 客户端要 close();模型流要 abort();SDK 会话要 dispose()

参考资料