INDEX / NO.030 — 2026.08.06 — 技术原理

Pi Coding Agent 源码拆解:792 行的 agent-loop.ts,才是 Agent 应用的真正心脏

有人把Pi概括成“几百行代码的Agent内核”。这句话容易让人产生两种误解:要么觉得Agent不过如此,要么以为读完一个文件,就学会了AI应用开发。

有人把 Pi 概括成“几百行代码的 Agent 内核”。这句话容易让人产生两种误解:要么觉得 Agent 不过如此,要么以为读完一个文件,就学会了 AI 应用开发。

整个 Pi 当然不止几百行。

在本文固定的源码版本中,仅承载主循环、工具调度和异常处理的 agent-loop.ts 就有 792 行。

Pi 还要处理多家模型接口、终端界面、会话持久化、上下文压缩、Extension、CLI、RPC 和 SDK。

但我认为 Pi 真正值得读的,不是代码少,而是它把 Agent 最关键的控制流——模型提动作、程序执行、结果回上下文——保持在一个还能追踪的规模里。至于哪些部分会随时间贬值、哪些不会,放到结尾再展开。

所以这篇文章我不依次介绍零件,而是跟着一次请求穿过 Pi,看它怎样从终端的一行输入,走到模型、工具、上下文和程序控制权之间的配合。

需要先说明:Pi 适合学习 Agent 怎样运行,不是一套生产级 AI 应用的完整答案。下面的源码观察基于 earendil-works/pi commit 73414d0

Pi 仍在快速变化,文件和接口可能调整,本文关注的是 Pi 相对稳定的运行机制。

一次请求怎样穿过 Pi

先用一张图把整条路径立起来:

一次请求从终端输入穿过 AgentSession、Agent 和 runLoop,最终到达 agent_end 的调用链

图 1:一次请求怎样穿过 Pi

下面一层层拆。

从终端到 Agent

Pi 有几种入口模式:Interactive(终端交互界面)、Print、JSON 事件输出、RPC。它们输入输出不同,但最终都进入同一个会话对象 AgentSession

用户在终端敲下一行话,调用链是:

AgentSession.prompt(text)
→ _runAgentPrompt()
→ agent.prompt(messages)

AgentSessionAgent 之上加了一层编排:会话持久化、Extension、自动压缩和重试。Agent 本身只维护当前状态(系统提示、消息、工具、模型)并启动主循环。

Agent.prompt() 做三件事:把输入标准化成消息,拿当前状态做一份上下文快照,然后启动 runAgentLoop

runAgentLoop(
  messages,              // 这次的用户消息
  createContextSnapshot(), // { 系统提示, 当前消息数组, 已启用工具 }
  createLoopConfig(),     // 模型、各种 Hook、回合间刷新配置
  processEvents,          // 事件回调
  signal,                 // 取消信号
  streamFunction,         // 流式请求模型的函数
)

注意 createLoopConfig 里塞了一串回调:beforeToolCallafterToolCallconvertToLlmtransformContextprepareNextTurn。Extension 通过 beforeToolCall/afterToolCall 介入工具执行;prepareNextTurn 在回合之间刷新系统提示、工具、模型和思考级别。要特别说明,自动压缩不在这里发生——它在 Agent Loop 整个结束之后,由 AgentSession 检查触发,后面单独讲。

主循环 runLoop:一轮里发生了什么

runAgentLoop 发出 agent_startturn_start 事件后,进入真正的 runLoop。每一轮的核心是 streamAssistantResponse,这一步是整条链上最关键的边界。

它先把内部的消息数组(Pi 称为 AgentMessage[],比发给模型的内容更丰富)转换成模型能理解的 Message[]

context.messages(AgentMessage[],较完整)
  → transformContext(可选,AgentMessage[] → AgentMessage[])
  → convertToLlm(AgentMessage[] → Message[],每轮只转一次)
  → llmContext { 系统提示, messages, tools }
  → streamFunction(请求模型,流式返回)

这里要分清两个容易混的概念:Session 和 Context 不是一回事。

  • Session 是应用持久化的完整工作历史:用户消息、模型回复、工具调用、工具结果、摘要,全在 JSONL 文件里。
  • 到循环手里时,已经是 AgentSession 从 Session 当前分支重建出的 AgentContext,其中包含系统提示、AgentMessage[] 和已启用工具。Session 的解析和分支重建发生在更外层。
  • Context 是某一轮真正发给模型的内容。convertToLlmAgentContext 的消息转成模型能理解的 Message[],加上系统提示和工具。它不一定包含全部历史。

根据 session-manager.tsagent-session.tsagent-loop.ts 整理分析;Session 中持久化的是 compaction entry,重建上下文时才投影成 compactionSummary 消息。

Session、AgentContext 与模型 Context 的三层关系,以及 convertToLlm 的位置

图 2:Session、AgentContext 与模型 Context

convertToLlm 每轮只发生一次,就在请求模型之前。这个边界说明“完整保存的”和“发给模型的”是两层东西。后面讲压缩时会看到 Pi 怎么利用这个边界。

转换完,streamFunction 把请求发出去,模型流式返回。Pi 边收边发出 message_startmessage_update(文字增量、工具调用增量)、message_end 事件,同时把片段拼成一条完整的助手消息。

模型返回了工具调用

模型这一轮的回复里,可能带一个或多个 toolCall。有工具调用,就进入 executeToolCalls;没有工具调用,只能说明这一轮不需要执行工具。只有也没有 steeringfollowUp 排队消息时,整个循环才结束。

Pi 会先决定并行还是顺序:如果配置了顺序执行,或者这批调用里有工具被标记为 executionMode: "sequential",就走顺序路径;否则并行执行。

每个工具调用的处理是一条小管线:

发出 tool_execution_start 事件
→ prepareToolCall
   ├─ prepareToolCallArguments:工具自定义的参数预处理
   ├─ validateToolArguments:按 Schema 校验参数
   └─ beforeToolCall Hook:可以放行、修改或阻断
→ executePreparedToolCall
   └─ tool.execute(args):真正读文件、跑命令、改代码
→ finalizeExecutedToolCall
   └─ afterToolCall Hook
→ 发出 tool_execution_end 事件
→ 把结果包成 ToolResult 消息

这里能看清“工具”到底是什么。关键是把“给模型看的”和“在机器上跑的”分开:

名称、说明、参数 Schema   → 交给模型,模型据此决定调不调、怎么调
execute()                 → 在宿主程序里实际执行,接触文件系统、Shell 或外部服务
ToolResult                → 回到上下文,交给模型解释

简化后大致长这样:

const readTool = {
  // 下面三样会交给模型
  name: "read",
  description: "读取文件内容",
  parameters: {
    type: "object",
    properties: { path: { type: "string" } },
    required: ["path"],
  },
  // 这个才在机器上真正执行,接触文件系统
  async execute(args: { path: string }) {
    return await fs.readFile(args.path, "utf8");
  },
};

模型负责提出动作,程序决定是否允许和怎样执行,执行结果再交给模型解释——这条边界贯穿整条链。

Pi 默认启用的活动工具是 readbasheditwrite 四个;源码里还定义了 grepfindls 三个只读工具,可以按配置启用。四个工具之所以能完成大量开发任务,是因为 bash 可以调用宿主环境里已有的 Git、搜索、测试和构建命令。

Pi 减少的是模型面对的工具接口,不是计算机能执行的动作。

结果回填,进入下一轮

工具执行完,结果被包成 ToolResult 消息,压回 context.messages,并发出 turn_end 事件。接着 runLoop 调用 prepareNextTurn 回调——它在这里刷新系统提示、工具、模型和思考级别(比如用户中途换了模型,下一轮就生效),但不动历史消息本身。然后检查 shouldStopAfterTurn,没有要求停就进入下一轮,再次请求模型。

用一个具体例子把这条链走完。

假设用户让 Agent 修复一个失败的测试,下面是说明性的消息序列,用来对照上面的函数,不是 Pi 的逐字日志:

用户:修复 src/utils.test.ts 里失败的测试

runLoop 第 1 轮
  streamAssistantResponse → 模型返回 toolCall: bash("npm test")
  executeToolCalls → bash 执行,退出码 1
  ToolResult(错误):FAIL ... Command exited with code 1   ← 回填 Context

第 2 轮
  streamAssistantResponse → 模型读到报错,返回 toolCall: read("src/utils.ts")
  ToolResult:export function add(a, b) { return a - b; }

第 3 轮
  模型返回 toolCall: edit(...) → ToolResult:已修改

第 4 轮
  模型返回 toolCall: bash("npm test") → ToolResult:PASS ... exit 0

第 5 轮
  模型没有 toolCall,也没有排队消息 → runLoop 结束 → agent_end

第 1 轮的测试失败作为错误 ToolResult 回到上下文,第 2 轮模型读到的就是这条报错,而不是被粉饰过的“成功”。所谓 Agent“会使用工具”“会自己修正”,底层依赖的就是这个把真实结果喂回模型的循环。

还有两个队列值得提一句:steering 是用户在模型正在跑时插话的队列,会在当前回合结束后注入;followUp 是 Agent 本来要停时、用户排进来的后续消息。它们让 Agent 不必每次都“跑完即停”。

事件被谁接收

runLoop 一路发出的事件,最终回到 AgentprocessEvents,由它更新自己的状态。对于用户消息、助手消息和 ToolResultAgentSession 在收到 message_end 后调用 sessionManager.appendMessage() 写入 JSONL;Extension 自定义消息、压缩摘要等内容走各自的 entry 方法。

事件是 Pi 把“循环内部发生了什么”暴露给外部的统一方式:UI 靠它刷新界面,持久化靠它落盘,Extension 靠它介入。InteractivePrintJSONRPC 几种模式的差别,本质上就是消费同一套事件的不同方式。

生产边界:错误、截断、Hook、压缩、取消

主循环本身不复杂:请求模型、执行工具、回填结果、继续。但决定它能不能成为稳定产品的,是这些边界处理。

工具失败。 参数写错、文件不存在、命令退出码非零,都很常见。Pi 不把错误伪装成成功,也不让循环立刻崩溃:失败原因被包成带错误标记的 ToolResult 加入上下文。模型能不能修好要看它的能力,但程序至少给了它继续调整的机会。

输出截断。 当模型回复因为输出长度被截断(stopReason"length"),其中所有工具调用的参数都可能是残缺的。Pi 不会去猜、也不会执行这些可能坏掉的调用,而是用 failToolCallsFromTruncatedMessage 把它们全部标记为错误,要求模型重新发出完整的调用。流式解析能勉强拼出一段看起来合法的 JSON,但“能解析”不等于“完整”,所以这里选择不信任。

Hook。 工具执行前的 beforeToolCall 发生在内置参数校验之后。Extension 可以在这里修改输入或阻断执行,比如拦截危险命令:

// 发生在 validateToolArguments 之后、tool.execute 之前
pi.on("tool_call", async (event, ctx) => {
  if (event.toolName === "bash" && event.input.command?.includes("rm -rf")) {
    const ok = await ctx.ui.confirm("危险操作", "是否允许执行?");
    if (!ok) return { block: true, reason: "用户拒绝执行" };
  }
});

Prompt 可以告诉模型“不要执行危险命令”,但模型仍可能判断错误。Hook 位于实际执行路径上,能在动作发生前加一道检查。要注意:Extension 可以修改输入,但 Pi 不会对修改后的参数再跑一次 Schema 校验,扩展作者仍要为修改后的参数安全负责。

也要清楚 Extension 不是沙箱。它和 Pi 宿主进程拥有相同权限,能执行任意代码。拦截逻辑写错、或 Extension 本身不可信,照样会产生风险。换句话说,影响模型行为和限制程序权限,是两件事:前者靠 Prompt,后者靠 Hook、权限和沙箱。把两者混为一谈,是 Agent 应用出问题的常见来源。

上下文压缩。 压缩不在 Agent Loop 内部发生,而是在一整轮 Loop 结束、agent_end 之后,由 AgentSession._handlePostAgentRun_checkCompaction 检查触发。触发分两种:overflow 表示检测到上下文溢出;失败响应压缩后最多重试一次,成功响应只压缩、不重试。threshold 表示超过阈值,压缩后不自动继续,交给用户手动接着来。压缩会向 Session 追加一个 compaction entry,其中保存摘要;后续重建 Context 时,这个 entry 才投影成 compactionSummary 消息,用摘要替代被覆盖的旧消息。旧 entry 仍留在 JSONL 文件里——压缩是模型输入策略,不应该顺手变成删除用户历史。Pi 的 Session 用 idparentId 把消息组织成树,回到早期节点继续工作时,是从那里长出一条新分支,而不是覆盖后面的历史。

取消。 用户中途取消时,取消信号会同时交给模型请求和正在执行的工具。但对于已经启动的外部命令或文件操作,能不能立即停下、是否已经产生副作用,仍取决于具体工具怎么响应这个信号。

这些边界说明,真正的 Agent Loop 不是把模型调用塞进 while 循环就完了。循环简单,边界处理决定它能成为什么。

哪些东西不能照着 Pi 直接搬

Pi 是本地 Coding Agent。它默认继承当前用户权限,没有内置沙箱。这种取舍让个人工具保持简单,不等于多用户服务也该这样设计。

如果目标是多用户或云端服务,下面这些工程边界还要自己补,不是 Pi 的功能承诺或合规清单:

  • 用户与租户隔离;
  • 凭据存储和最小权限;
  • 容器、VM 或其他执行隔离;
  • 队列、定时任务和失败重试;
  • 日志、Tracing、告警和成本统计;
  • Agent 评测与回归测试;
  • 数据保留和审计;
  • 消息渠道与产品交互。

一个优秀的本地 Harness,不等于完整的云端 Agent 平台。读源码时容易忽略这条边界。

自己动手:一个建议的最小顺序

只读源码容易产生“我看懂了”的错觉。更有效的办法,是自己实现一个逐步长大的最小 Agent。下面是一个建议的顺序,不是 Pi 官方教程:

  1. 只做模型、read、bash 和循环——跑通“模型生成工具调用 → 程序执行 → 结果返回模型”,就理解了 Agent 和聊天应用的核心差别。
  2. 加 write、edit 和参数校验——让模型能改环境,再故意传错参数、读不存在的文件,确认错误能作为 Tool Result 返回。
  3. 用 JSONL 保存 Session——把每条消息和工具结果追加到文件,重启能恢复。先别做树和压缩,只要亲手处理一次持久化,就会意识到 Context 数组和完整 Session 不是同一层东西。
  4. 加一个执行前 Hook——先做最简单的规则:危险 Shell 命令要求确认。到这一步,Agent 才开始从固定脚本变成可修改的 Harness。
  5. 接第二家 Provider——等一个 Provider 跑通再接第二家,这时会实际撞上消息格式、Tool Call 表达、流式事件、Reasoning 内容和 Token 统计的差异,才能真正理解 Provider 抽象为什么存在、又必然在哪里泄漏。

Pi 值得读,不是因为代码少

读 Pi 真正值得的,不是它代码少,而是它把一件会快速过时的事和一件没那么快过时的事分开了。

会过时的是表面:工具叫什么名字、接哪家模型的接口、系统提示怎么写、框架 API 长什么样。这些隔几个月就可能变。

没那么快过时的是这条链上的一组关系:模型只能提出动作,真正执行的是程序;工具结果要以明确的 ToolResult 回到上下文、失败不能被伪装成成功,模型才有机会修正;Session 是完整历史,Context 是某一轮真正发给模型的切片,两者不能混成一回事;想改模型行为靠 Prompt,想限制程序能力靠 Hook 和权限。

这些关系不依赖 Pi,也不依赖某一代模型。看懂它们,换一个框架、换一家 Provider,你仍然知道一个 Agent 应用要在哪里做决定。Pi 恰好把这套关系放在一个还能追踪的规模里,所以适合拿来学。

至于它怎样成为安全、稳定、可治理的生产应用,那是下一层的问题。

参考资料

源码行为以 commit 73414d08b94d7db46d3fa66582c8fe3b02dabf72 为准;官方文档链接反映抓取日的当前产品说明。

扫码关注公众号
扫码关注公众号
扫码加群交流
扫码加群交流