Pi 主链路解析 · 交互式科普

一条消息的旅行

Pi 是一个最小、可扩展的 agent harness。你跟它说一句「帮我修个 bug」,按下回车——接下来它要过四道门、装一身零件、转若干圈、写一本账。这个网站带你跟进去看一遍,全用大白话。

基于 Pi v1.4.0 源码 · 往下滚,跟着这条消息钻进它的身体里

SCROLL ▾

出发前 · 30 秒认识主角

Pi 是个什么?

你用的 AI 编程助手,聪明的是大模型;让聪明变成干活的,是大模型外面那台发动机——行话叫 agent harness。

Pi 就是一台发动机,而且是一台可以拆开重装的发动机。它的信条只有一句:最小内核,其他全部可换。

准备好,跟着你这条「帮我修个 bug」,进去走一趟。

第 1 幕 · 门

一句话进门,先过四道门

你在终端敲下 pi,输入那句话。Pi 做的第一件事不是找模型,而是判断:这句话从哪个门进来?

四个门:交互式终端、一次性打印、JSON 事件流、RPC 长连接。外面还有 SDK,把门直接开在别人的程序里。

门不一样,进门后的手续一模一样。

解析参数 → 跑配置迁移 → 判断模式 → 处理 @文件 和管道输入 → 项目信任 → 登录检查 → 选模型 → 选会话 → 交给 createAgentSession()。

四道门只是四个前台。真正干活的房间,只有一间。

main.ts · resolveAppMode interactive print json rpc AgentSession 同一个内核

这是第一个设计决定。

所有接口共享同一个 AgentSession 内核。TUI 里能做的事,RPC 和 SDK 也能做;内核修一次,四个门一起受益。

很多工具死在「CLI 一套逻辑,API 又一套」上。Pi 从门口就堵死了这条路。

想翻代码?src/main.ts src/cli/args.ts src/modes/
入口可以很多,内核只能有一个

第 2 幕 · 装配

开机不是初始化,是装配

进门之后,Pi 不会运行一个写死的主程序。它先摆一张工作台,把七个零件一个一个装上去。

每个零件只管一件事,装完再拧成一台 AgentSession。

七个零件,各有各的活。

  • ModelRuntime 模型、认证、凭据
  • SettingsManager 全局设置 + 项目设置
  • ResourceLoader 扩展、skills、模板、主题、AGENTS.md
  • SessionManager 那本只加不删的账
  • Tools read / bash / edit / write / grep / find / ls
  • Agent 心跳,也就是 agent loop
  • ExtensionRunner 把所有挂钩接上
Agent Session model settings resources session tools agent extensions core/sdk.ts · createAgentSession()

最狠的一刀:连「能力」都是插件。

MCP、codemode、tool-search 在 Pi 里都是内置扩展,而且标了 replaceable:第三方可以整块换掉。内核只留骨架,肌肉想换就换。

项目信任是装配前的门禁:不信任这个仓库,就不加载它的设置、扩展、skills、MCP。但它只管「装不装」,不是沙箱——工具还是用 Pi 进程的 OS 权限跑。

想翻代码?core/sdk.ts core/agent-session-services.ts core/resource-loader.ts extensions/index.ts
内核只留骨架,能力全部可换

第 3 幕 · 心跳

它干活的节奏,是转圈

你修东西:看一眼 → 想一下 → 拧一下 → 再看一眼。Pi 一模一样,只是扳手换成了工具调用。

这个节奏叫 agent loop,是整台发动机的心脏。

一圈是这么走的:

消息进队列 → 从账本投影历史 → 拼系统提示、工具清单、模型设置 → 发给模型 → 流式回话 → 要工具就执行 → 结果写回账本 → 还有活就再转一圈。

一圈叫一个 turn:一次 assistant 回复 + 它发起的工具调用。

收件 投影 请求 流式 工具 记账 一个 turn 一次回复 + 它的工具调用

你看到的「打字机效果」,是事件流。

agent_start → turn_start → message_start / update / end → tool_execution_* → turn_end → agent_end。

模型每蹦出一个字,就有一条 message_update;界面立刻画出来,不是等半天再一次性甩给你。

运行中想插话?steering 队列在当前 turn 后插进去;follow-up 队列等它手头的活干完再追加。

想翻代码?packages/agent/src/agent-loop.ts agent.ts types.ts
一次只转一圈,每圈都从真相重建

第 4 幕 · 手

先安检,再并发

模型说「跑一下 npm test,顺便看看这个文件」。一句话可能带好几个工具调用。Pi 不会一个一个傻等。

顺序安检,并发干活,按源顺序入账。

先按顺序做 preflight:校验参数、跑 beforeToolCall、该拦的拦掉。能并发的再并发跑。谁先跑完,谁先发 tool_execution_end。

但写进账本的工具结果,永远按模型当初下发的顺序排。速度是速度,真相是真相。

assistant 工具调用 ×3 bash read edit 1 2 3 入账顺序 1 → 2 → 3(完成顺序可以是 2 → 3 → 1)

工具和执行后端,是两件事。

每个内置工具都有一层 *Operations 接口:bash 可以是本地子进程,也可以是云端沙箱;read / write 可以是本地磁盘,也可以是远程环境。

换执行环境,工具说明书一个字不用改。同一文件的并发写还会被 file-mutation-queue 串起来,避免 edit 和 write 互相覆盖。

读文件太大?read 默认截断 2000 行或 50KB,先到先算,超了就给一句「用 bash 读剩下的」。

想翻代码?core/tools/ agent-loop.ts truncate.ts file-mutation-queue.ts
并发拿速度,顺序保真相

第 5 幕 · 账本

一本只加不删的树

你说的每句话、它的每句回答、每次工具调用和结果、每次换模型、每次压缩——全按顺序写进一个 JSONL 文件。一行一条,永不涂改。

不是列表,是树。

每条账都有 id 和 parentId。当前那条账叫 leaf;从根到 leaf 的路径就是 active branch——下一次请求的历史,只从这条路径来。

账的类型很多:message、model_change、thinking_level_change、usage、compaction、branch_summary、custom、label、session_info、custom_message、context_edit。

session.jsonl id / parentId root leaf active branch

树能干什么?

断点续聊:翻到末尾接着写。分叉:从旧条目再长一条新枝,两个方向各聊各的。回放、导出、审计:整棵树都在,一秒不落。

账的格式写得很死:每条先声明类型;坏行会被跳过,不会硬猜成别的类型。

连「删除」都是追加:想改一条旧消息在上下文里的内容,不撕原账,追加一条 context_edit 指向它。

想翻代码?core/session-manager.ts docs/session-format.md
只信账本,不信内存

第 6 幕 · 投影

每一圈,现场重建模型看到的世界

Pi 不在内存里另存一份「聊天记录」。每次要问模型之前,它翻账本,沿 active branch 现场投影出这次请求的上下文。

三步:投影 → 转换 → 发车。

buildSessionProjection():取出 active branch,处理压缩和分支摘要,得到「这次该给模型看什么」。

convertToLlm():把 Pi 的内部消息翻译成模型认识的 user / assistant / toolResult。bash 记录、custom 消息、压缩摘要,都会在这里变成模型能读的文本。

prepareRequest:用投影结果替换 Agent 手里的上下文,再交给 provider。

active branch buildSessionProjection + convertToLlm 投影 user assistant toolResult UI 专用消息被过滤

为什么费这个劲?

因为模型会换、provider 会换、上下文会被压缩、分支会被切换——但账本不变。只要投影逻辑在,换哪颗大脑都能从同一本账接着聊。

系统提示也是每轮现拼的:基础指令 + AGENTS.md 等上下文文件 + skills 描述(正文按需加载)+ 工具定义。

账本记「发生过什么」,请求只带「这次需要什么」。中间那层投影,就是 Pi 做上下文工程的地方。

想翻代码?core/agent-session.ts core/messages.ts core/system-prompt.ts
账本是真相,请求只是投影

第 7 幕 · 前情提要

账太厚了?写一页前情提要

模型的上下文窗口有限。账越记越厚,总有一天装不下。Pi 的解法跟电视剧一样:开头来一段「上回说到……」。

什么时候写?

每轮请求前量一量:contextTokens > contextWindow - reserveTokens(默认预留 16,384 tokens)就压缩。provider 报 context overflow 或 stopReason: length,也会压缩再重试,最多一次。

保留最近 keepRecentTokens(默认 20,000 tokens)不摘要,前面的旧账请模型读一遍,写成一页摘要。

上下文快满了 写摘要 摘要 compaction entry 最近保留 旧账原件,一页没撕

提要盖不住原件。

压缩会追加一条 compaction entry,记着摘要和 firstKeptEntryId——从哪条账开始原样保留。之后的投影只读「摘要 + 保留段」。

旧账一页没撕。你想翻,永远翻得到。

切换分支时也一样:branch_summary 把离开那条枝的上下文写成提要,带到新枝上。

剪的时候不能剪断因果:不会把一条工具调用和它的结果从中间撕开——撕开了模型就看不懂。

想翻代码?core/compaction/compaction.ts branch-summarization.ts docs/compaction.md
细节可以让路,原件不能丢

第 8 幕 · 挂钩

整条链路,处处是挂钩

前面七幕,每一幕的接缝上都有一排挂钩。Pi 的扩展系统就挂在这些钩子上——30 多个事件,覆盖从启动到收工的每一步。

挂钩有多密?

会话:session_start、session_before_compact、session_compact、session_before_tree……

请求:context、context_with_system、before_provider_request、after_provider_response……

循环:before_agent_start、turn_start、turn_end、agent_end……

工具与输入:tool_call、tool_result、input、user_bash……

30+ 事件 输入 agent loop 工具 账本 压缩 context tool_call request compact

挂上去能干什么?

注册工具、slash 命令、快捷键、provider、虚拟模型、MCP server、渲染器;改系统提示、改工具清单、改上下文、改压缩策略;甚至弹一个自己的 TUI。

扩展是 TypeScript 模块,用 jiti 在进程内加载,支持 /reload 热重载。写一个文件,存盘,就能试。

连 MCP、codemode、tool-search 都是内置但可替换的扩展。默认值很强,但默认值不是天花板——你可以把整条链路改成自己的样子。

想翻代码?core/extensions/types.ts runner.ts loader.ts docs/extensions.md
最小内核,最大接缝

终章 · 把机器拆开

现在,Pi 的主链路你懂了

回顾这趟旅行:一句话从四个门进来,装上七个零件,进心脏转圈;工具先安检再并发,结果按源顺序入账;账本是只加不删的树;每轮请求都从账本现场投影;账太厚就写前情提要,原件永存;整条链路布满挂钩,连心跳的每个节点都能插手。

Pi 的信条不是「功能多」,而是「接缝多」。最小内核,其他全部可换——所以它能变成你的,而不是你变成它的。

主线地图 src/main.ts → core/sdk.ts → packages/agent/src/agent-loop.ts → core/session-manager.ts → core/compaction/ → core/extensions/
设计参考

滚动叙事、分幕结构与卡片节奏参考了 DeepSeek Harness 原理解析 · 一条消息的旅行 ↗ 本站的文案、示意图和代码是针对 Pi 主链路的独立实现。