先说范围:这篇拆的是哪一层 Pi

先把边界说清楚。这篇不是在讲 pi.dev 的品牌故事,也不是在讲 OpenClaw 的产品体验,而是在拆 badlogic/pi-monopackages/coding-agent 这一层运行时。换句话说,我们关心的是 Pi 作为 coding harness 到底怎么把模型、工具、上下文、会话状态和多种运行模式组织起来。

这里还有一个必要的限定:下面的实现细节,基于我在 2026-03-15 读取到的 main 分支源码整理。Pi 现在演化很快,所以更稳妥的理解方式不是“记住每一个函数名”,而是先看懂它的稳定结构。结构大致有六块:装配入口、资源加载、工具层、会话状态、自动压缩、运行模式。

如果只想先记一句总判断,我会这样概括:Pi 并没有发明一个完全不同的 Agent 理论,它真正厉害的地方,是把 Agent 真正需要的那几件事收得很紧,而且每一层都保留了替换和嵌入的可能。

Pi 是如何装配起来的:从 createAgentSession() 开始

从源码上看,Pi 的装配入口非常清楚,核心在 src/core/sdk.ts 里的 createAgentSession()。这个函数不是 UI 逻辑,而是运行时装配器:它把 AuthStorageModelRegistrySettingsManagerSessionManagerResourceLoader、工具集合以及真正的 Agent 实例组装到一起,最后交给 AgentSession 接管。

这一步非常关键,因为它说明 Pi 的内核不是终端界面,而是一层可复用的 session runtime。终端、print、RPC、SDK 嵌入,只是不同外壳。

Mermaid 启动装配流程

源码主线其实很直白。下面这个片段是依据 sdk.ts 裁剪后的装配骨架,省略了错误处理和部分分支,但保留了最重要的顺序。

对做系统的人来说,这里最值得注意的不是“它用了哪些类”,而是这个顺序。Pi 不是先建一个大而全的全局应用状态,再把所有模式塞进去;它更像先装好 runtime,再让不同模式来消费这个 runtime。这样做的好处是,终端模式和 RPC 模式不会各自长出一套不同的核心逻辑。

上下文是怎么进入系统的:ResourceLoader 比想象中更关键

很多人一谈 Agent,会先盯住模型和工具,但从 Pi 的实现看,真正决定它像不像“真实开发环境里的助手”的,是资源加载层。src/core/resource-loader.ts 做的事情非常多:加载扩展、技能、prompt templates、themes,也加载 AGENTS.md / CLAUDE.md 这类上下文文件。

其中最值得注意的是上下文文件发现方式。Pi 并不只读当前目录,而是会先看全局 agent 目录,再从当前工作目录一路向上回溯,把祖先目录里的上下文文件拼接起来。这意味着 Pi 天生支持“全局规则 + 仓库规则 + 子目录局部规则”的叠加。

TypeScript 根据 resource-loader.ts 整理的上下文发现逻辑
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 里。这一步非常像“提示词工程”,但从工程角度看,它其实更像一层运行时配置展开器。

Mermaid 上下文拼装流程

为什么这点重要?因为它解释了 Pi 的一个核心取向:它不是假设“模型已经知道怎么在你的仓库里工作”,而是默认要把仓库规则显式注入进去。对真实工程场景来说,这比单纯追求模型更强通常更有价值。

工具层怎么工作:默认很少,但每个都是真正能落地的动作

Pi 的 built-in 工具非常克制。tools/index.ts 把默认 coding tools 定成了四个:readbasheditwrite。而 grepfindls 属于可选补充。这个选择很像 Pi 整体的产品哲学:默认先给最能闭环的动作集合,而不是上来塞一个巨大工具市场。

TypeScript 来自 tools/index.ts 的默认工具集合
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。也就是说,它不是在追求“把所有内容一把喂给模型”,而是在追求“把可继续、可恢复、可控的读取动作喂给模型”。

TypeScript 根据 read.ts 整理的读取主线
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 感知和位置感知的精确编辑器。

TypeScript 根据 edit.ts 整理的替换逻辑
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 执行命令,但把 BashOperationsspawnHookcommandPrefix 都做成了注入点。换句话说,如果你想把 Pi 接到容器、远程 SSH、沙箱或者别的执行后端,不需要重写 Agent 本身,只需要替换 command execution layer。

Mermaid 单轮请求与工具执行

所以从内部实现看,Pi 的工具层并不是“工具越多越强”,而是“默认工具要能稳定闭环,同时底层执行接口必须能换”。这也是它作为运行时比作为单一终端工具更有意思的地方。

会话为什么是树:SessionManager 才是 Pi 最有辨识度的状态层

如果你只看表面,Pi 的 /tree/fork/resume 像是很方便的交互功能;但从源码看,这些功能之所以成立,是因为 SessionManager 从一开始就把 session 当成树,而不是平铺消息列表。

session-manager.ts 里的每条 session entry 都带 idparentId。文件本身是 JSONL,但逻辑结构是一棵树。messagemodel_changethinking_level_changecompactionbranch_summarycustom_message 等条目都挂在同一棵树上。

TypeScript 来自 session-manager.ts 的关键类型
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,而不是额外的外部缓存。
Mermaid 会话树与上下文重建

buildSessionContext() 里最漂亮的一段,就是 compaction 的处理。它不是简单把旧消息删除,而是先发一条 compaction summary,再接上 firstKeptEntryId 之后保留的消息,以及 compaction 之后的新消息。也就是说,Pi 的压缩是在 message graph 里留下了边界标记。

TypeScript 根据 buildSessionContext() 整理的 compaction 路径恢复
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 时提前触发。这就是典型的运行时思维,因为它把“溢出恢复”前移成“尽量不溢出”。

TypeScript 来自 compaction.ts 的触发判断
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 里都被当成主循环的一部分,而不是某个独立的后台线程。

Mermaid 自动 compaction 流程

compaction.ts 还有一个非常工程化的小细节:它会额外跟踪 readFilesmodifiedFiles,把文件操作信息塞进 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 进程里。

TypeScript 根据 rpc-mode.ts 整理的输出骨架
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。这是一种非常成熟的嵌入式思路。

Mermaid RPC / OpenClaw 接入路径

这也解释了我为什么一直把 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 集成做源码级拆解。