Pi 架构理解:从 LLM 调用到 Coding Agent Harness
TL;DR:Pi 是一个"极简、可扩展的 Agent Harness",核心是三层库叠起来跑在同一个 Node 进程里。
pi-ai解决"怎么调用不同的大模型":把各家 Provider 的差异收进适配器,对上只暴露一次流式 LLM 调用和一套统一事件。pi-agent-core解决"怎么让模型通过工具循环完成任务":用双层循环串起多次调用和工具执行,内层处理工具调用和 steering,外层处理 follow-up,产品策略全部通过 Hook 从上层注入。pi-coding-agent解决"怎么把通用 Runtime 变成能长期使用的 Coding Agent":以AgentSession为中心,加上 JSONL 会话树、配置与资源发现、内置编码工具、自动压缩与重试、扩展系统,以及 TUI / Print / JSON / RPC / SDK 几种入口。1.x 最值得说的一点是:会话树是模型上下文的唯一来源,每次请求都从会话文件重新投影出上下文。
这篇是全书的精简版,目标是先建立整体理解,再能在面试里讲清楚。每一层的细节都有单独的章节,文中给了链接。本书内容对齐 Pi v1.5.0。
Pi 是什么、怎么运行
Pi 的定位写在官方 README 第一句:a minimal, extensible agent harness that you can make your own。它内置一套够用的默认能力(读写文件、执行命令、会话管理、上下文压缩),但刻意不内置 sub-agent、plan mode、to-do、权限弹窗这类"工作流",希望用户通过扩展、Skills、Prompt 模板或第三方 Pi 包按自己的方式加。这个取舍决定了它的架构:核心要薄,扩展点要多而清楚。
运行形态很简单。执行 pi 时只有一个 Node 进程,没有常驻 daemon。三层是这个进程里的三个库,按依赖从下往上:pi-ai → pi-agent-core → pi-coding-agent,终端 UI 用的是独立的 pi-tui 库。会话、配置、凭据都是磁盘上的文件(会话在 ~/.pi/agent/sessions/ 下按工作目录分组的 JSONL,凭据在 auth.json),进程退出就结束,下次用 --continue / --resume 打开文件恢复。扩展是加载进同一进程的 TypeScript 模块,和内置工具一样拥有进程的全部 OS 权限,所以 Pi 自己不提供沙箱,需要隔离就放进容器或用沙箱类扩展。RPC 模式也不是服务,而是被宿主程序拉起的子进程;SDK 模式则直接在宿主自己的进程里创建会话。
到 1.5,仓库里还多了一些外围包:pi-durable(持久化的会话/任务运行时,1.0 从 agent-core 里拆出来)、pi-mcp 和 pi-codemode(MCP 客户端和"让模型写 JS 调工具")、pi-schema(工具参数的 JSON Schema 校验)、chord(插件宿主运行时)、pi-telemetry 等。面试讲 Pi 的核心设计,抓住三层就够了,这些外围包知道它们存在、各管什么即可。
一句话:越往下越通用、越接近模型调用;越往上越产品化、越接近具体的 Coding Agent 场景。
一、pi-ai:统一的 LLM 调用层
OpenAI、Anthropic、Google 等厂商的 API 在请求字段、system prompt 的位置、工具 schema 格式、流式事件、thinking 参数、usage 和错误结构上都不一样。pi-ai 把这些差异收进各个 API 适配器,对上层提供统一的模型、上下文、消息和事件。调用方传入一个 Model 和一份上下文(消息、工具声明、system prompt),立刻拿到一条事件流:start,然后是文本、thinking、工具调用参数各自的 start / delta / end,最后是 done 或 error。流结束后可以拿到完整的 AssistantMessage,里面有文本块、tool call 块、usage 和 stopReason。
内部分三层。Models 集合负责找 Provider、解析认证、合并请求选项。Provider 把模型目录、认证方式和 API 实现装成一个单元。api/* 适配器才真正构造 HTTP 请求、解析 SSE 或 WebSocket,再翻译成统一事件。适配器按需动态加载,用到哪家才加载哪家的 SDK。模型目录在构建时从 models.dev 生成并打包进来,所以离线也能用;运行时再叠加 pi.dev 的远程目录。
有两个边界要讲清楚。第一,pi-ai 能声明工具、能解析模型产出的 tool call,但不执行工具,也不自动发起下一次请求。它的执行边界永远是一次独立的 LLM 请求,执行工具、把结果送回模型属于上一层。第二,错误是事件而不是异常。网络错误、认证失败、用户中止都会变成一条带着已生成部分内容和 usage 的 error 事件,所以调用方处理成功、失败和取消只需要一条路径。
1.x 还有一个容易被旧资料误导的变化:适配器拿到的是归一化后的 TranscriptContext,system prompt 和工具声明作为 system 消息放在消息序列里,而不是单独的字段。这是为了配合上层"会话里记录 prompt 和工具变化"的设计。切换模型时,上一个模型的 thinking 会被丢掉,不会当成普通文本喂给新模型。
面试表述:
pi-ai是 Pi 最底层的统一 LLM 调用层。它通过 API 适配器屏蔽各家模型在消息格式、工具协议、流式事件和推理参数上的差异,对上提供统一的流式接口:调用方传入模型、上下文和工具声明,得到标准化的事件流和最终的 AssistantMessage。它能让模型产生 tool call,但只负责模型 I/O,不执行工具,也不负责多轮循环;失败、取消也都编码成事件,而不是抛异常。
详见 pi-ai(一):统一模型调用层 和 pi-ai(二):流式事件架构。
二、pi-agent-core:Agent Runtime 与双层循环
如果说 pi-ai 只完成一次模型请求,pi-agent-core 就负责把多次请求和工具执行连起来,形成真正的 Agent Loop:调用 LLM,模型返回 tool call,执行工具,把结果写回上下文,再调用 LLM,直到模型不再调用工具。它的本质是一个带状态管理、工具执行和消息队列的 Agent Runtime,而且边界划得很窄:不懂 coding、不懂会话持久化、不碰文件系统。1.0 甚至把早期放在这个包里的实验性 harness(会话存储、压缩、skills 等)整体删掉了,现在包里只剩 Agent 类、agent loop、proxy 流函数和类型。
它分两层。无状态的 agentLoop 只做"调模型、执行工具、决定要不要继续",给它上下文和配置,它产出一串事件。有状态的 Agent 类在外面包一层,持有 transcript、模型、工具、steering 和 follow-up 两个队列,提供 prompt()、steer()、followUp()、abort()、subscribe()。把循环做薄是有意的:持久化、重试、压缩、UI 都交给上层,同一个循环才能被终端、Slack bot、Web UI、测试随意复用。
先分清三个粒度。一次 LLM 请求加上这次响应里所有工具调用的执行,叫一个 Turn。一次 agent.prompt() 从 agent_start 到 agent_end 的完整运行,叫一个 Run,里面可能有多个 Turn。跨越多次用户输入的长期对话叫 Session,而 agent-core 里根本没有 Session 这个概念,那是 coding-agent 的事。所以 Agent.prompt() 不等于一次 pi-ai 调用,它是"加入输入、零到多次 LLM 请求、零到多次工具执行、处理插话、更新状态"。
双层循环是为了区分用户的两种插话。Steering 是 agent 正在干活时插一条指令,比如"别改公共 API",它在当前这批工具执行完之后、下一次 LLM 请求之前注入,不会跳过已经发出的工具调用。Follow-up 是追加一个后续任务,比如"做完顺便更新 CHANGELOG",只在 agent 本来要停下时才被消费。内层循环负责"持续工作",外层循环负责"被唤醒":
while (true): # 外层:只管 follow-up
while (还有工具调用 or 有待注入消息): # 内层:工具调用链 + steering
注入 pending 消息(steering / follow-up)
prepareRequest → 调 LLM(流式)
error / aborted → 直接结束整个 run
执行工具(beforeToolCall / afterToolCall)
finishTurn → turn_end
pending = 拉取 steering
pending = 拉取 follow-up
没有 → break
agent_end
follow-up 进来后循环继续,而不是递归调用 prompt(),所以它和之前的工作共享同一个 run、同一个 abort signal,agent_end 只发一次。真要立刻停下,用的是 abort()。
这一层对上有两种接口,方向相反。生命周期事件(agent_start、turn_start、message_start/update/end、tool_execution_start/end、turn_end、agent_end)自下而上报告"发生了什么",UI 渲染和会话持久化都靠订阅它们。运行时 Hook 在明确的控制点自上而下注入"接下来怎么做":prepareRequest(每次请求前替换上下文)、transformContext 和 convertToLlm(发给模型前的转换)、beforeToolCall / afterToolCall(拦截或改写工具调用)、finishTurn(一轮结束时决定结束还是继续,0.87 起取代了 shouldStopAfterTurn)、prepareNextTurn(轮与轮之间换上下文或模型)。持久化、压缩、权限、重试这些产品策略,全都是上层通过这些 Hook 塞进来的。
面试表述:
pi-agent-core是建在pi-ai上的通用 Agent Runtime。Agent维护 transcript、模型、工具和两个消息队列,agent.prompt()启动 Agent Loop。循环分两层:内层处理 LLM 调用、工具执行和 steering 消息,让任务连续跑多个 Turn;内层结束后外层检查 follow-up,决定是否继续处理后续任务。它向上广播生命周期事件,同时在请求前、工具前后、每轮结束等位置提供 Hook,让上层注入持久化、压缩、权限和重试等策略,自己保持与产品无关。
详见 pi-agent-core:Agent Runtime。
三、pi-coding-agent:产品级 Coding Agent Harness
pi-coding-agent(CLI 名 pi)不重写 agent loop,而是通过 agent-core 预留的 Hook 把产品逻辑接进去。它要补上的是一个每天在真实仓库里用的 coding agent 缺的东西:对话要能落盘、恢复、分叉,长对话要能压缩;用户级和项目级配置要合并,项目里的 AGENTS.md、skills、模板、扩展要被发现;要有好用的文件和命令工具;网络抖动和上下文溢出要能自动恢复;同一套能力既要给人用,也要给脚本和其他程序用。
启动链与 AgentSession。 启动时 main.ts 先确定最终的工作目录和会话文件,再解决项目信任,然后创建与工作目录绑定的服务(ModelRuntime、SettingsManager、ResourceLoader),由 createAgentSession() 工厂装出 Agent 和 AgentSession。外面还有一层 AgentSessionRuntime 负责换会话:/new、/resume、/fork 时工作目录可能变,配置和资源都要按新目录重建。核心是 AgentSession,它是总协调者:订阅底层事件做持久化和转发,在 prepareRequest、beforeToolCall、finishTurn 等 Hook 上挂产品逻辑,把会话、配置、资源、压缩、扩展串起来。准确的说法不是"AgentSession 持有一个 Agent",而是它通过订阅和 Hook,把一个通用 Agent 升级成了产品会话。
会话树是唯一事实来源。 会话存成 append-only 的 JSONL,每条记录用 id / parentId 指向前一条,整个文件是一棵树,从当前叶子回溯到根就是活动分支。回到旧节点重试只需要移动叶子再追加,旧路径原样保留;/fork 才会复制出新文件。0.87 起,每次模型请求前 prepareRequest 都从活动分支重新投影出上下文,内存里的 agent.state.messages 只是投影的缓存。system prompt 和工具声明也作为 system 消息记在会话里,后续变化只追加补丁,不重写开头。这样做一是保住 Provider 的前缀缓存,二是让 prompt 和工具状态跟着分支走,fork 或回到旧节点时自然恢复。
上下文压缩不删历史。 上下文快满时,Compaction 用一次额外的 LLM 调用把旧消息总结成一条 compaction 记录,保留最近约 20K token 的原文,切点不会把 tool call 和它的结果拆开。摘要只在之后的请求里替代旧消息,原始记录仍在树里。自动触发有两处:一轮工具执行完后超过阈值,就在下一次请求前先压缩;Provider 报上下文溢出时,先压缩再重试一次。
资源与提示词。 ResourceLoader 从全局目录、项目 .pi/、Pi 包和命令行参数四类来源发现扩展、skills、prompt 模板、主题和 context files(AGENTS.md / CLAUDE.md)。项目目录可能来自陌生仓库,所以项目信任要在加载任何项目设置和资源之前决定,未信任就跳过。system prompt 不是写死的字符串,而是按段重建的:基础说明、工具、上下文文件、skills 列表、工作目录等。Skills 用的是渐进披露:system prompt 里只放每个 skill 的名字、描述和路径,模型判断需要时再用 read 工具读全文,所以 skill 再多也只占几行。
内置工具。 有 read、bash、edit、write、grep、find、ls,默认只启用前四个。值得讲的设计有三点。edit 是精确文本替换,不是 diff:每段 oldText 必须在文件里唯一匹配,匹配不上就失败并把错误返回给模型修正,而不是悄悄改错位置。输出有上限,read 截断时告诉模型用 offset 继续读,bash 保留输出尾部、完整输出写进临时文件。并行工具调用时,同一文件的写操作会串行化。
扩展系统。 扩展通过 pi.on() 和一组 register* API 给 Pi 加工具、命令、Provider、UI 和策略。分层是:agent-core 只暴露通用的运行时 Hook,完全不知道扩展存在;AgentSession 把这些 Hook 桥接到 ExtensionRunner;ExtensionRunner 对外统一成 pi.on(name, handler),内部再按事件选择分发策略,比如通知、链式改写、任一阻断即停、出错即拒绝。统一的是注册方式,不是运行语义。
其他模块。 SettingsManager 合并用户级和项目级 settings.json(项目覆盖用户,对象递归合并,数组整体替换,写回时在文件锁里只合并改过的字段)。模型与认证拆成三件互不越界的事:凭据存储、模型目录、模型解析。1.x 还以内置扩展的形式加入了 MCP 客户端和 Codemode:MCP 工具默认不直接塞给模型,而是让模型写一段 JavaScript,在沙箱里搜索并并行调用工具,只有脚本输出进入上下文。这也是 Pi 对早期"不做 MCP"立场的修正:问题本来是工具 schema 太占上下文,Codemode 正是为了绕开这一点。
多种入口共用一个核心。 Interactive(全屏 TUI)、Print(只输出最终文本)、JSON(把事件流写成 JSONL)、RPC(stdin 收 JSONL 命令,stdout 回响应和事件)、SDK(宿主进程内直接 createAgentSession())用的是同一个 AgentSession,区别只在输入从哪来、事件怎么输出、进程活多久。
面试表述:
pi-coding-agent把通用 Runtime 产品化,核心是AgentSession。它通过 Hook 和事件订阅挂在 agent-core 上,协调会话、配置、资源、压缩和扩展。会话用 append-only 的 JSONL 树保存,并且是模型上下文的唯一来源,每次请求从活动分支重新投影;压缩通过摘要记录改变投影,不删除历史;资源加载在项目信任之后进行,Skills 渐进披露;扩展通过统一的pi.on()注册,但不同事件有不同的分发语义。几种运行模式共用同一个会话核心。
详见 pi-coding-agent 主笔记,以及其中的模块:会话与压缩、资源与提示词、Skills、扩展系统、包管理、配置、模型与认证、MCP 与 Codemode、运行模式、错误处理。
四、一次请求的完整链路
用一个场景把三层串起来:会话已经很长,用户在终端里输入"把 utils.ts 里的 parseDate 改成支持时区"。
请求方向先发生在 coding-agent 里,这时还没碰模型。AgentSession.prompt() 先看输入是不是扩展命令,再发 input 事件给扩展改写,展开 /skill:name 和 prompt 模板。如果 agent 正在运行,调用方必须指明这条消息是 steer 还是 follow-up。接着准备这次 run:会话太长就先压缩,发 before_agent_start 让扩展追加消息或调整工具,prompt 或工具有变化就追加一条补丁 system 消息。然后调用 agent.prompt() 进入 agent-core 的双层循环。每次请求前,prepareRequest 从会话树投影出上下文,经 pi-ai 的适配器转成 Provider 请求,以 SSE 流式发出去。
返回方向是反过来的。Provider 的流被 pi-ai 归一成统一事件,agent-core 把它们变成 message_update,AgentSession 先转给扩展,再转给订阅者(TUI 渲染、JSON 输出、RPC 转发都在这里)。模型返回 tool call 时,agent-core 先过 beforeToolCall(扩展的 tool_call 可以拦截),执行 read、edit、bash,再过 afterToolCall(扩展的 tool_result 可以改写),然后进入下一个 Turn;轮与轮之间超过阈值就先压缩。message_end 时完整消息追加进 JSONL:流式 delta 只用于显示,落盘的是 message_end 那条完整消息。
底层 agent_end 之后 run 还没结束。假如中途网络抖了一下,AgentSession 会把失败的回复用 context_edit 从投影里省略(原始记录保留),按 2s、4s、8s 退避后 continue();如果是上下文溢出,就先压缩再重试一次。全部处理完才发 agent_settled,prompt() 的 Promise 这时才 resolve。
用户输入
→ AgentSession:命令/input/模板 → 压缩检查、before_agent_start、prompt 补丁
→ Agent(双层循环):prepareRequest 从会话树投影上下文
→ pi-ai:适配器 → HTTP/SSE → Provider
← pi-ai:统一事件流
← Agent:message_* / tool_execution_* / turn_end,执行工具后回到内层循环
← AgentSession:扩展通知 → UI 渲染 → message_end 写入 JSONL
← agent_end 后:重试 / 溢出压缩 / 队列 → agent_settled
五、三层之间最重要的边界
| 能力 | pi-ai | pi-agent-core | pi-coding-agent |
|---|---|---|---|
| 适配不同 Provider、发起一次 LLM 请求 | 负责 | 通过注入的 streamFn 调用 | 用 ModelRuntime 包装认证、超时、重试 |
| 解析 tool call | 负责 | 使用 | 使用 |
| 真正执行工具、多 Turn 循环 | 负责 | 提供具体工具和拦截策略 | |
| Steering / Follow-up | 负责队列和注入时机 | 提供交互入口和模式设置 | |
| 会话持久化、分支、恢复 | 负责(JSONL 树,上下文唯一来源) | ||
| 上下文压缩 | 提供摘要调用 | 提供轮间 Hook | 负责策略和时机 |
| 自动重试 | 请求级重试(默认关闭) | 不负责,出错即结束 run | 负责回合级重试 |
| 资源、配置、扩展、运行模式 | 提供通用 Hook | 负责 |
这张表背后的思想是:下层只提供机制,上层决定策略。pi-ai 不知道有循环,agent-core 不知道有会话和扩展,所以下层都能脱离 Pi 单独使用。
六、最终面试版总结
30 秒版本
Pi 是一个三层架构。最底层
pi-ai统一封装 OpenAI、Anthropic、Google 等 Provider 的 API,对上提供标准化的流式 LLM 调用;中间的pi-agent-core在单次调用之上实现带工具执行、状态管理、steering 和 follow-up 的双层 Agent Loop,并通过 Hook 让上层注入策略;最上层pi-coding-agent以AgentSession为中心,加上 JSONL 会话树、配置和资源加载、编码工具、自动压缩与重试、扩展系统,以及 TUI / RPC / SDK 等入口,把通用 Agent Runtime 封装成完整的 Coding Agent 产品。
1–2 分钟版本
我把 Pi 理解成从 LLM API 到 Agent Runtime、再到产品级 Coding Agent Harness 的三层架构,三层都是同一个进程里的库。
第一层
pi-ai负责统一不同的模型 Provider。调用方传入模型、上下文和工具声明,得到标准化的流式事件和最终的 AssistantMessage。它能解析模型产生的 tool call,但不执行工具,执行边界始终是一次独立的 LLM 请求;错误和取消也编码成事件,而不是抛异常。第二层
pi-agent-core在此之上实现 Agent Runtime。Agent维护 transcript、模型、工具等状态,agent.prompt()启动 Agent Loop。循环分内外两层:内层负责 LLM、工具调用和 steering 消息,让任务连续跑多个 Turn;内层结束后外层检查 follow-up,决定是否继续。它向上广播生命周期事件,同时在请求前、工具前后、每轮结束等控制点提供 Hook,自己不包含任何产品策略。第三层
pi-coding-agent把通用 Runtime 产品化,核心是AgentSession。会话用 append-only 的 JSONL 树保存,并且是上下文的唯一来源,每次请求从活动分支重新投影;Compaction 用摘要记录改变投影而不删除历史;ResourceLoader 在项目信任之后加载AGENTS.md、Skills、模板和扩展;扩展系统通过统一的pi.on()把处理函数接到生命周期事件和控制 Hook 上。最终这些模块提供一个可恢复、可配置、可扩展的 Coding Agent,并通过 TUI、Print、JSON、RPC、SDK 几种入口暴露出去。
一句话版本
pi-ai把不同模型统一成一次 LLM 调用,pi-agent-core把多次调用和工具执行组织成 Agent Loop,pi-coding-agent再把 Agent Loop 封装成具备会话、资源、配置、压缩和扩展能力的完整 Coding Agent。
常见追问
为什么 Pi 不内置权限确认、sub-agent、plan mode? 作者认为这些是工作流选择,不同团队差异很大,内置一种就等于替用户做决定。Pi 只保证这些都能用扩展实现:权限确认就是在 tool_call 上拦截,plan mode 可以写文件或做成扩展。代价是开箱即用的护栏少,安全边界要靠容器或扩展自己搭。
为什么上下文要从会话树重新投影,而不是维护一个内存数组? 内存数组和磁盘会分叉:分支导航、重试省略失败回复、压缩、扩展编辑上下文,都要同时改两处。只认会话文件,所有这些操作都变成"追加一条记录,再重新投影",恢复和 fork 天然正确。代价是每次请求多一次投影计算,以及投影逻辑本身更复杂。
事件和 Hook 为什么要分开,不能都用一种? 事件只报告事实,可以有很多订阅者,互不影响,适合 UI 和日志;Hook 会改变主流程,必须明确触发时机、返回值协议和多个处理函数怎么合并。全用事件就没法拦截工具调用,全用 Hook 就会让每个观察者都可能阻塞或改写主流程。
源码入口(v1.5):packages/ai/src/models.ts、packages/agent/src/agent-loop.ts、packages/agent/src/agent.ts、packages/coding-agent/src/main.ts、packages/coding-agent/src/core/agent-session.ts、packages/coding-agent/src/core/session-manager.ts