先说范围:这篇拆的是哪一层 Pi
先把边界说清楚。这篇不是在讲 pi.dev 的品牌故事,也不是在讲 OpenClaw 的产品体验,而是在拆 badlogic/pi-mono 里 packages/coding-agent 这一层运行时。换句话说,我们关心的是 Pi 作为 coding harness 到底怎么把模型、工具、上下文、会话状态和多种运行模式组织起来。
这里还有一个必要的限定:下面的实现细节,基于我在 2026-03-15 读取到的 main 分支源码整理。Pi 现在演化很快,所以更稳妥的理解方式不是“记住每一个函数名”,而是先看懂它的稳定结构。结构大致有六块:装配入口、资源加载、工具层、会话状态、自动压缩、运行模式。
如果只想先记一句总判断,我会这样概括:Pi 并没有发明一个完全不同的 Agent 理论,它真正厉害的地方,是把 Agent 真正需要的那几件事收得很紧,而且每一层都保留了替换和嵌入的可能。
Pi 是如何装配起来的:从 createAgentSession() 开始
从源码上看,Pi 的装配入口非常清楚,核心在 src/core/sdk.ts 里的 createAgentSession()。这个函数不是 UI 逻辑,而是运行时装配器:它把 AuthStorage、ModelRegistry、SettingsManager、SessionManager、ResourceLoader、工具集合以及真正的 Agent 实例组装到一起,最后交给 AgentSession 接管。
这一步非常关键,因为它说明 Pi 的内核不是终端界面,而是一层可复用的 session runtime。终端、print、RPC、SDK 嵌入,只是不同外壳。
flowchart TD
A["CLI / SDK / RPC 入口"] --> B["createAgentSession()"]
B --> C["创建 AuthStorage / ModelRegistry"]
B --> D["创建 SettingsManager / SessionManager"]
B --> E["创建或复用 DefaultResourceLoader"]
E --> F["reload() 读取扩展、技能、提示词、上下文文件"]
B --> G["恢复已有 session 的 model / thinking level"]
B --> H["new Agent(...)"]
H --> I["new AgentSession(...)"]
I --> J["构建工具注册表与 system prompt"]
I --> K["进入 interactive / print / RPC / embedded 模式"]
源码主线其实很直白。下面这个片段是依据 sdk.ts 裁剪后的装配骨架,省略了错误处理和部分分支,但保留了最重要的顺序。
const settingsManager = options.settingsManager ?? SettingsManager.create(cwd, agentDir);
const sessionManager = options.sessionManager ?? SessionManager.create(cwd);
let resourceLoader = options.resourceLoader;
if (!resourceLoader) {
resourceLoader = new DefaultResourceLoader({ cwd, agentDir, settingsManager });
await resourceLoader.reload();
}
const agent = new Agent({
initialState: { systemPrompt: "", model, thinkingLevel, tools: [] },
convertToLlm: convertToLlmWithBlockImages,
sessionId: sessionManager.getSessionId(),
});
const session = new AgentSession({
agent,
sessionManager,
settingsManager,
resourceLoader,
cwd,
modelRegistry,
});
对做系统的人来说,这里最值得注意的不是“它用了哪些类”,而是这个顺序。Pi 不是先建一个大而全的全局应用状态,再把所有模式塞进去;它更像先装好 runtime,再让不同模式来消费这个 runtime。这样做的好处是,终端模式和 RPC 模式不会各自长出一套不同的核心逻辑。
上下文是怎么进入系统的:ResourceLoader 比想象中更关键
很多人一谈 Agent,会先盯住模型和工具,但从 Pi 的实现看,真正决定它像不像“真实开发环境里的助手”的,是资源加载层。src/core/resource-loader.ts 做的事情非常多:加载扩展、技能、prompt templates、themes,也加载 AGENTS.md / CLAUDE.md 这类上下文文件。
其中最值得注意的是上下文文件发现方式。Pi 并不只读当前目录,而是会先看全局 agent 目录,再从当前工作目录一路向上回溯,把祖先目录里的上下文文件拼接起来。这意味着 Pi 天生支持“全局规则 + 仓库规则 + 子目录局部规则”的叠加。
function loadContextFileFromDir(dir: string) {
const candidates = ["AGENTS.md", "CLAUDE.md"];
for (const filename of candidates) {
const filePath = join(dir, filename);
if (existsSync(filePath)) {
return { path: filePath, content: readFileSync(filePath, "utf-8") };
}
}
return null;
}
再往后,buildSystemPrompt() 会把工具说明、guidelines、上下文文件和技能内容拼到 system prompt 里。这一步非常像“提示词工程”,但从工程角度看,它其实更像一层运行时配置展开器。
flowchart TD
A["DefaultResourceLoader.reload()"] --> B["发现扩展 / skills / prompts / themes"]
A --> C["读取全局 AGENTS.md"]
A --> D["沿 cwd 向上回溯目录"]
D --> E["收集项目与祖先目录的 AGENTS.md / CLAUDE.md"]
C --> F["buildSystemPrompt()"]
E --> F
B --> F
F --> G["生成最终 system prompt"]
G --> H["写入 AgentSession 的 base system prompt"]
为什么这点重要?因为它解释了 Pi 的一个核心取向:它不是假设“模型已经知道怎么在你的仓库里工作”,而是默认要把仓库规则显式注入进去。对真实工程场景来说,这比单纯追求模型更强通常更有价值。
工具层怎么工作:默认很少,但每个都是真正能落地的动作
Pi 的 built-in 工具非常克制。tools/index.ts 把默认 coding tools 定成了四个:read、bash、edit、write。而 grep、find、ls 属于可选补充。这个选择很像 Pi 整体的产品哲学:默认先给最能闭环的动作集合,而不是上来塞一个巨大工具市场。
export const codingTools: Tool[] = [readTool, bashTool, editTool, writeTool];
export function createAllTools(cwd: string, options?: ToolsOptions) {
return {
read: createReadTool(cwd, options?.read),
bash: createBashTool(cwd, options?.bash),
edit: createEditTool(cwd),
write: createWriteTool(cwd),
grep: createGrepTool(cwd),
find: createFindTool(cwd),
ls: createLsTool(cwd),
};
}
从实现细节上看,这几类工具的写法都很“运行时友好”。它们不是写死成本地命令,而是都保留了 operations 这样的可替换接口。这一点很容易被忽略,但它其实是 Pi 能嵌入不同环境的关键。
read 工具:不是简单读文件,而是带边界感地读
read.ts 最有意思的地方,不是“能读文本和图片”,而是它在设计上非常强调受控读取。大文件会被截断并提示 offset 如何续读;图片会根据配置压缩;超长单行会直接建议改用 bash。也就是说,它不是在追求“把所有内容一把喂给模型”,而是在追求“把可继续、可恢复、可控的读取动作喂给模型”。
const absolutePath = resolveReadPath(path, cwd);
await ops.access(absolutePath);
if (mimeType) {
const buffer = await ops.readFile(absolutePath);
content = [
{ type: "text", text: `Read image file [${mimeType}]` },
{ type: "image", data: base64, mimeType },
];
} else {
const buffer = await ops.readFile(absolutePath);
const truncation = truncateHead(selectedContent);
content = [{ type: "text", text: outputTextWithOffsetHint }];
}
edit 工具:默认要求精确替换,但为真实世界留了模糊匹配
edit.ts 的设计也很能说明 Pi 的风格。描述里强调 oldText 必须精确匹配,但实现里并不是天真地只做字符串替换,而是先读文件、处理 BOM、统一换行符,再通过 fuzzyFindText() 去尝试更稳的匹配。随后它还会检查唯一性、生成 diff,并把 firstChangedLine 返回给上层。
这意味着 Pi 的 edit 不是“盲写”,而是一个带 diff 感知和位置感知的精确编辑器。
const { bom, text: content } = stripBom(rawContent);
const normalizedContent = normalizeToLF(content);
const normalizedOldText = normalizeToLF(oldText);
const normalizedNewText = normalizeToLF(newText);
const matchResult = fuzzyFindText(normalizedContent, normalizedOldText);
if (!matchResult.found) {
throw new Error("Could not find the exact text");
}
const newContent =
baseContent.substring(0, matchResult.index) +
normalizedNewText +
baseContent.substring(matchResult.index + matchResult.matchLength);
bash 工具:保留本地默认实现,但把远程代理接口留出来
bash.ts 则最能说明 Pi 为什么适合作为 harness。它默认用本地 shell 执行命令,但把 BashOperations、spawnHook、commandPrefix 都做成了注入点。换句话说,如果你想把 Pi 接到容器、远程 SSH、沙箱或者别的执行后端,不需要重写 Agent 本身,只需要替换 command execution layer。
flowchart TD
A["用户 prompt() / 终端输入"] --> B["AgentSession.prompt()"]
B --> C["buildSystemPrompt + buildSessionContext"]
C --> D["Agent 推理生成 tool call"]
D --> E["beforeToolCall hook"]
E --> F["read / bash / edit / write / 扩展工具"]
F --> G["afterToolCall hook"]
G --> H["tool result 回写到消息流"]
H --> I["Agent 继续推理或结束 turn"]
I --> J["SessionManager 持久化消息和状态"]
所以从内部实现看,Pi 的工具层并不是“工具越多越强”,而是“默认工具要能稳定闭环,同时底层执行接口必须能换”。这也是它作为运行时比作为单一终端工具更有意思的地方。
会话为什么是树:SessionManager 才是 Pi 最有辨识度的状态层
如果你只看表面,Pi 的 /tree、/fork、/resume 像是很方便的交互功能;但从源码看,这些功能之所以成立,是因为 SessionManager 从一开始就把 session 当成树,而不是平铺消息列表。
session-manager.ts 里的每条 session entry 都带 id 和 parentId。文件本身是 JSONL,但逻辑结构是一棵树。message、model_change、thinking_level_change、compaction、branch_summary、custom_message 等条目都挂在同一棵树上。
export interface SessionEntryBase {
type: string;
id: string;
parentId: string | null;
timestamp: string;
}
export interface CompactionEntry extends SessionEntryBase {
type: "compaction";
summary: string;
firstKeptEntryId: string;
tokensBefore: number;
}
这个选择带来的后果非常大。因为一旦会话天然是树,下面这些能力都会变成状态层能力,而不是界面技巧:
- 在任意历史节点继续往下写,实际上只是换 leaf。
/fork新建会话文件,本质上是复制到某个分支点为止的历史。buildSessionContext()给模型重建上下文时,可以只取当前 leaf 到 root 的那条路径。compaction可以插入为一类正式 session entry,而不是额外的外部缓存。
flowchart TD
A["session.jsonl"] --> B["SessionHeader"]
A --> C["SessionEntry(id,parentId,...)"]
C --> D["message"]
C --> E["model_change / thinking_level_change"]
C --> F["compaction"]
C --> G["branch_summary / custom_message"]
D --> H["buildSessionContext() 按 leaf 向上回溯"]
E --> H
F --> H
G --> H
H --> I["得到当前分支的 messages + thinkingLevel + model"]
buildSessionContext() 里最漂亮的一段,就是 compaction 的处理。它不是简单把旧消息删除,而是先发一条 compaction summary,再接上 firstKeptEntryId 之后保留的消息,以及 compaction 之后的新消息。也就是说,Pi 的压缩是在 message graph 里留下了边界标记。
if (compaction) {
messages.push(createCompactionSummaryMessage(compaction.summary, compaction.tokensBefore, compaction.timestamp));
for (let i = 0; i < compactionIdx; i++) {
const entry = path[i];
if (entry.id === compaction.firstKeptEntryId) {
foundFirstKept = true;
}
if (foundFirstKept) appendMessage(entry);
}
for (let i = compactionIdx + 1; i < path.length; i++) {
appendMessage(path[i]);
}
}
如果你在做自己的 Agent,这里其实是 Pi 最值得学的一层。很多系统把“分叉”“压缩”“恢复”都做成 UI 功能,最后状态层很快混乱。Pi 反过来:先把状态层做对,再让 UI 去消费它。
自动压缩是怎么工作的:它不是附属功能,而是主循环的一部分
很多 Agent 都有“上下文太长时总结一下”的能力,但 Pi 把 compaction 做得更彻底。agent-session.ts 不只提供手动 compact(),还会在 turn 结束后检查 usage,决定是否触发 auto-compaction。对应的纯函数逻辑则放在 compaction/compaction.ts。
shouldCompact() 的判断很克制:不是到了 100% 再说,而是当 contextTokens > contextWindow - reserveTokens 时提前触发。这就是典型的运行时思维,因为它把“溢出恢复”前移成“尽量不溢出”。
export function shouldCompact(contextTokens: number, contextWindow: number, settings: CompactionSettings): boolean {
if (!settings.enabled) return false;
return contextTokens > contextWindow - settings.reserveTokens;
}
更值得注意的是它的两个目标:
- 处理 overflow:模型已经因为上下文过长失败时,先 compact,再尝试恢复。
- 处理 threshold:还没溢出,但已经逼近阈值时,主动 compact。
这两个目标在 agent-session.ts 里都被当成主循环的一部分,而不是某个独立的后台线程。
flowchart TD
A["assistant message / usage 返回"] --> B["AgentSession._checkCompaction()"]
B --> C{"context overflow?"}
C -- yes --> D["触发 overflow auto-compaction"]
D --> E["写入 compaction entry"]
E --> F["必要时 compact-and-retry"]
C -- no --> G{"接近 contextWindow - reserveTokens?"}
G -- yes --> H["触发 threshold auto-compaction"]
H --> E
G -- no --> I["继续正常 session"]
compaction.ts 还有一个非常工程化的小细节:它会额外跟踪 readFiles 和 modifiedFiles,把文件操作信息塞进 CompactionEntry.details。这说明 Pi 不把“总结”理解成单纯压缩自然语言,而是尽量让历史行为的结构也能被保留下来。
为什么说 AgentSession 是真正的运行时中枢
如果前面几节分别拆的是装配、资源、工具、状态,那把它们真正拧在一起的,就是 src/core/agent-session.ts。这个文件非常长,但你可以把它理解成 Pi 的 runtime kernel。
它至少干了五件事:
- 订阅 agent 事件,并把消息、工具调用、结果、错误和结束状态统一落进 session persistence。
- 维护工具注册表,处理内建工具和扩展工具的组合。
- 管理 queue、retry、auto-compaction、fork、tree、export 等 session 级动作。
- 在每轮开始前重建 system prompt 和上下文,在每轮结束后决定是否重试或压缩。
- 给 interactive、print、RPC 这些模式提供统一的 session API。
从这个角度看,Pi 的“极简”不是代码少,而是职责非常聚焦。它没有试图把所有交互都塞进 Agent,而是很清楚地多放了一层 AgentSession。这层的价值,就是让不同模式共享同一套生命周期管理。
RPC 模式与 OpenClaw 接入:为什么 Pi 很适合做别人的执行内核
Pi 的另一个关键点,是它不只适合人在终端里直接用,也适合被别的应用托管。src/modes/rpc/rpc-mode.ts 很清楚地定义了 headless 模式:JSON 命令从 stdin 进来,响应和事件从 stdout 以 JSONL 形式持续流出。
这就是为什么 OpenClaw 这类系统会对 Pi 感兴趣。OpenClaw 负责聊天入口、消息连接和 Gateway;Pi 负责真正的 coding session。二者不需要混在一个 UI 进程里。
const output = (obj) => {
process.stdout.write(serializeJsonLine(obj));
};
const success = (id, command, data) => ({
id,
type: "response",
command,
success: true,
data,
});
更有意思的是,RPC 模式连 extension UI 都做了桥接:扩展如果需要选择器、确认框、输入框,Pi 不会强迫自己一定有 TUI,而是把 extension_ui_request 发给宿主,由宿主返回 extension_ui_response。这是一种非常成熟的嵌入式思路。
flowchart TD
A["聊天前端 / OpenClaw"] --> B["Gateway 或宿主进程"]
B --> C["Pi RPC mode"]
C --> D["AgentSession"]
D --> E["模型调用"]
D --> F["read / bash / edit / write"]
E --> G["事件流 JSONL"]
F --> G
G --> B
B --> A
这也解释了我为什么一直把 Pi 看成 harness,而不是单纯的 terminal app。只要一套 runtime 既能 interactive,又能 print,又能 RPC,还能 SDK embedding,它就已经是一层基础设施了。
为什么它不内建 plan mode、subagent 和 MCP:因为这些被明确放到了扩展层
Pi README 里最容易引发讨论的几句话,恰恰是那些“不做什么”的声明:不内建 MCP、不内建 plan mode、不内建 subagents、不做 permission popups、不做 background bash。
如果只看口号,这些话像在刻意反主流;但看源码和 examples 之后,你会发现 Pi 的逻辑其实很一致:这些能力不是不存在,而是不进 core。
最直接的证据就在 examples 里:
examples/extensions/plan-mode展示了 plan mode 可以作为扩展接入。examples/extensions/subagent展示了 subagent 可以通过独立pi子进程来做。
换句话说,Pi 的态度不是“这些能力没价值”,而是“这些能力不该成为运行时内核的默认负担”。这背后有一个很重要的系统判断:core 负责稳态执行闭环,工作流策略和协作模式交给扩展层。
对做工程的人来说,这个选择很值钱。因为一旦把 plan mode、subagent、审批 UI、MCP 都塞进 core,系统就会迅速从 harness 长成 platform。而 Pi 明显不想太早走到那一步。
从 Pi 学到什么:真正稀缺的是分层,而不是功能表
如果把前面所有实现细节收成几个更抽象的结论,我会提三点。
第一,Agent 系统最好先有清楚的运行时中枢。Pi 用 AgentSession 把事件、工具、状态、压缩、模式都聚到一起,这比把逻辑散在多个 UI 模块里健康得多。
第二,session 最好一开始就设计成图,而不是平铺消息日志。只要状态层还是 list,后面 fork、resume、tree、summary 都会越做越别扭。Pi 的 JSONL tree 设计,非常像是提前为复杂会话留好了地基。
第三,真正值得克制的是内核边界。Pi 的内核不大,但它把外层可扩展性做得很强。对今天的 Agent 工程来说,这种“薄内核 + 强边界 + 可嵌入”通常比“什么都内建”更有生命力。
最后一句判断:Pi 的价值,不只在于轻,而在于轻得有结构
很多工具都想做“极简”,但最后极简变成了“功能还没长出来”。Pi 比较不一样的地方,是它的轻并不空。你沿着源码看一遍,会发现它每一层都不是随便省掉,而是做了有意识的取舍。
它有装配入口,有资源加载,有工具层,有会话树,有 compaction,有 RPC,有扩展机制。它缺的不是能力,而是那些它认为不该默认进 core 的能力。也正因为这样,Pi 才能同时适合终端开发者、适合被 OpenClaw 这类产品接入,也适合被当成 SDK runtime 嵌进别的系统里。
如果你正在自己做 Agent,这篇文章真正想留下的问题其实只有一个:你现在写的,究竟是一个看起来很聪明的界面,还是一层真正可以被别的产品复用的运行时?
更新附注
- 版本:v1.0
更新日期:2026-03-15 更新原因:首发版本,基于 Pi 官方仓库与 OpenClaw 文档,对运行时装配、工具执行、会话树、compaction 与 RPC 集成做源码级拆解。
还没有评论,你可以写下第一条。