Agent · 2026.09.01

Pi Coding Agent 调研学习

有人说:pi 是最好的 Agent 学习工具,就像 Arch Linux 是最好的 Linux 教具,适合想学 Agent 编排和 LLM 使用的人,但不适合拿来干活。

这个说法挺有意思,我把 pi 的源码翻了一遍(v0.84.4),这篇把翻完之后学到的东西一次讲完:pi 是什么,它的循环、上下文和扩展体系各自怎么设计,最后回头看这个类比哪一半成立。

agent harness

pi 不叫自己 coding agent,叫 agent harness。harness 是 LLM API 外面那层让模型能实际干活的程序:循环调用模型、执行工具、管理上下文和会话。

flowchart LR U[用户] -->|任务| H["agent harness
循环调用模型 · 执行工具
管理上下文与会话"] H -->|"prompt + 工具定义"| M["LLM API"] M -->|"文本 / 工具调用"| H H -->|读文件、跑命令、改代码| W[你的工作区] classDef user fill:#dbeafe,stroke:#2563eb,color:#111827 classDef harness fill:#ede9fe,stroke:#7c3aed,stroke-width:2px,color:#111827 classDef llm fill:#dcfce7,stroke:#16a34a,color:#111827 classDef ws fill:#fef3c7,stroke:#d97706,color:#111827 class U user class H harness class M llm class W ws

Claude Code、Codex CLI、Cursor 的 agent 模式本质上都是 harness,只是它们同时也是产品,harness 藏在产品下面。pi 把这层单独拿出来给你,README 开头第一句就是 “Adapt pi to your workflows, not the other way around”。

作者 Mario Zechner,网名 badlogic,写过 libGDX 游戏框架的那位奥地利工程师。2025 年 8 月开始写 pi,动机在他 11 月底的随笔里说得很直接:Claude Code 变成了一艘 80% 功能他用不上的宇宙飞船;harness 在背后往上下文里塞他看不见的东西;系统提示词和工具随版本变化,稳定的个人工作流说崩就崩。他此前专门写过 cchistory 追踪 Claude Code 每个版本的提示词变更,追踪的结果是决定自己写一个。

建造原则只有一句:“if I don’t need it, it won’t be built.”

你不需要 10,000 token 的系统提示词

pi 所有设计决定都建立在同一个判断上:前沿模型已经被 RL 训练得足够理解 coding agent 是什么,教学的部分模型公司在训练时做完了,harness 再写一万字只是在重复,甚至在干扰。这个判断在源码里落成三个事实。

系统提示词约 1.4 KBsrc/core/system-prompt.ts 全文 169 行,模板本身 18 行,渲染后五六百 token,加上工具定义不到 1000 token;而主流 coding agent 普遍在 8k 到 15k token。

这 1.4 KB 还是拼出来的:每个工具导出自己的一句说明和几条使用守则,构建器只拼当前激活工具的贡献。停用 write,它的守则自动消失;"用 bash 做 ls、rg、find"这条只在 bash 在场而 grep、find、ls 都不在场时出现。提示词尾部挂着 pi 自己的文档路径,开头限定只有用户问到 pi 自身时才去读,平时只占十几个 token。项目指令走 AGENTS.md(兼容 CLAUDE.md),包进 <project_context> 附在最后。

定义 8 个工具,默认只激活 4 个,read、bash、edit、write。grep、find、ls、powershell 也在仓库里,只是默认不开,提示词直接让模型用 bash 做这些事。

循环约 120 行,模型自己知道怎么干活,harness 剩下的事就是循环调用模型、执行工具。

那 120 行里有什么

packages/agent/src/agent-loop.tsrunLoop 从 156 行到 273 行,结构是两层 while:

flowchart TD A([prompt 进入]) --> B["prepareNextTurn 钩子
可以换上下文、换模型
自动压缩在这里发生"] B --> C[注入 steering 插话] C --> D[流式调用 LLM] D --> E{stopReason} E -->|"error / aborted"| X([直接退出:循环内没有重试]) E -->|toolUse| F["执行工具调用
结果追加进上下文"] E -->|stop| G F --> G["turn_end 事件
shouldStopAfterTurn 钩子可优雅停止"] G --> H{还有工具调用,
或 steering 队列有消息?} H -->|有| B H -->|没有| I{follow-up 队列
有新消息?} I -->|有| B I -->|没有| Z([agent_end]) classDef hook fill:#ede9fe,stroke:#7c3aed,color:#111827 classDef step fill:#dbeafe,stroke:#2563eb,color:#111827 classDef decision fill:#fef3c7,stroke:#d97706,color:#111827 classDef bad fill:#fee2e2,stroke:#dc2626,color:#111827 classDef good fill:#dcfce7,stroke:#16a34a,color:#111827 class B,G hook class C,D,F step class E,H,I decision class X bad class A,Z good

一个 turn 里工具执行走固定管线:查找工具、校验参数、beforeToolCall 钩子、执行、afterToolCall 钩子,结果追加进上下文,下一轮模型就能看到。循环自己不做的事全挂在配置对象 AgentLoopConfig 的钩子上,唯一必需的行为字段是 convertToLlm,其余九个全部可选:

钩子 拿来做什么
transformContext 发给模型之前改写整个消息数组
prepareNextTurn 每轮开始前换上下文、换模型,自动压缩就在这里换掉 context
getSteeringMessages / getFollowUpMessages 循环在固定点拉取用户排队的消息
beforeToolCall / afterToolCall 工具执行前后拦截,用户侧的权限系统长在这里
shouldStopAfterTurn 每轮之后决定要不要优雅停止
getApiKey 每次调用现取 key,会过期的 OAuth 令牌因此能轮换
toolExecution 这一批工具并行还是串行

压缩、重试、持久化、权限、子代理全都不在循环里,在应用层的 agent-session.ts,那个文件 3500 多行。重试的做法是 agent.prompt(...) 返回后判断要不要续跑,该重试就 continue(),该压缩就先压缩再续,循环退出得干脆,恢复策略留给应用层。留在循环里的规矩只有三条,都在保护同一样东西:发给模型的消息历史。

  1. **失败也是数据。**模型流式调用永不 throw,网络错误、限流、用户中断全部编码成一条 stopReason 为 error 或 aborted 的 assistant 消息,已产出的内容和 token 消耗都在里面,照常持久化。工具参数校验失败,错误文本作为 tool result 还给模型,下一轮它自己改。输出被截断时那条消息里的工具调用全部作废,各合成一条"参数可能不完整,请重发"的结果,因为流式 JSON 靠补全解析器兜底,截断时可能解析出能过校验、其实缺了尾巴的参数,执行比拒绝危险。
  2. **插话只落在 turn 边界。**用户中途插话(pi 叫 steering)永远注入在这一批工具结果全部就位之后、下一次调模型之前,因为几乎所有供应商的 API 都会拒绝有 tool call 没有对应 result 的历史。想立刻打断走另一条路,AbortSignal 硬中断。
  3. **并行执行,按序落盘。**tool_execution 事件按完成顺序发出,UI 实时显示谁先跑完;tool result 消息按 assistant 消息里的源顺序写入,重放一次结果不变。文件写入按规范化路径排队,改不同文件的工具照常并行。

六个 No

pi 文档最有性格的一节列的全是没做的功能。README 的 Philosophy 一节六条 No,每条给了替代方案:

没做 理由 替代
MCP 流行的浏览器类 MCP server 一接上要吃 13.7k 到 18k token CLI 工具加 README,用到才读
子代理 隐藏的子代理是黑盒 tmux 里再开一个 pi,或用扩展自建
权限弹窗 能读写文件又能跑命令,弹窗只是安全剧场 容器或沙箱隔离,或用扩展写自己的确认流程
计划模式 只读模式不如显式文件可观测 PLAN.md,还能一起编辑
待办列表 给模型增加要追踪的状态,干扰大于帮助 TODO.md
后台 bash 可观测性差 tmux

理由里的 token 数字来自作者的随笔:他用四个脚本(启动 Chrome、导航、页内执行 JS、截图)加一份 README 覆盖了日常用浏览器类 MCP 的场景,一共 225 token。

examples/extensions/ 下近 80 个示例扩展,上表每一项都有对应实现,权限门几十行,计划模式几十行,子代理也是。这些示例在集体论证同一个命题:只要在循环的关键位置留出拦截点,这些功能都能在用户侧几十行代码复现,harness 就没必要替所有人预装一套决定。

安全上 pi 默认不弹任何确认框。作者的理由是大家为了干活最后都会开 YOLO 模式,不如设为默认;边界该由容器提供,README 给了三种隔离方案:Gondolin 把 pi 和供应商凭证留在宿主机、工具调用路由进 Linux 微虚拟机,Docker 把整个进程容器化,OpenShell 跑策略沙箱。

历史是一棵树

会话存在 ~/.pi/agent/sessions/ 下,一个项目一个文件夹,一次会话一个 JSONL 文件。每行一个 entry,带 id 和 parentId,文件本身就是一棵树,当前对话只是从某个叶子走到根的一条路径。换模型、换思考等级、压缩、分支摘要也各是一行 entry,这轮用的什么模型不用单独存,沿路径回放就知道。

树上的移动有三个动作:/tree 在同一个文件里任意移动叶子,选中历史上某条 user 消息,叶子退回它的父节点、原文放回输入框让你重问;/fork 从某条 user 消息开一个新文件;/clone 把当前分支复制成新文件。旧分支从不删除。/tree 离开一条分支时 pi 找到共同祖先,把被放弃的那段路摘要成一条 branch_summary 挂在新位置上,死胡同里学到的教训留下来,重放它的 token 成本不留。undo、分支、重放、审计,在别的 harness 里是四个功能,在 append-only 树上是同一个数据结构的四种读法。

pi 仓库的 issue 流程就建在这上面:CI 把复现会话传成 gist,维护者本地一条 /ir 导入,接着现场调试。作者还维护着 pi-share-hf,把开源工作里的 pi 会话发布到 Hugging Face,带着真实工具调用、失败和修复过程的会话数据,比玩具基准更能改进 coding agent。

压缩是一个追加的指针

触发条件是 contextTokens > contextWindow - reserveTokens,默认保留 16384。触发之后 pi 生成一段摘要,往 JSONL 里追加一条 compaction entry,两个关键字段,summary 和 firstKeptEntryId,什么都不删。模型下次看到什么,是读取时沿叶子往根走算出来的:碰到 compaction entry,就用摘要替换 firstKeptEntryId 之前的所有消息。

flowchart TD subgraph disk["磁盘上的 JSONL:只追加,不删除"] direction LR O["消息 1 … 40"] --> K["消息 41 … 60"] --> C["compaction entry
摘要 + firstKeptEntryId 指向消息 41"] end subgraph ctx["模型实际看到的上下文"] direction LR S["摘要"] --> K2["消息 41 … 60"] end C -.->|读取时计算| S classDef old fill:#f3f4f6,stroke:#9ca3af,color:#6b7280 classDef kept fill:#dbeafe,stroke:#2563eb,color:#111827 classDef comp fill:#fef3c7,stroke:#d97706,color:#111827 class O old class K,K2 kept class C,S comp

三个细节各有工程原因。切点永远不落在 tool result 上,从最新消息往回攒够 keepRecentTokens(默认 2 万)为止,tool call 和它的 result 永远绑在一起进退。单轮超预算就劈开这一轮做两段摘要再合并。摘要之前先把对话拍平成 [User]: / [Assistant]: / [Tool result]: 的纯文本,直接把消息数组喂给模型,它会把这当成一场要继续的对话。摘要有固定骨架:Goal、Constraints、Progress、Key Decisions、Next Steps,最后跟 <read-files><modified-files> 两张清单,压缩之后 agent 不会忘记自己动过哪些文件。第二次压缩从上一个 firstKeptEntryId 开始重新摘要,信息逐级变粗,不会某一段突然消失。

这几个设计顺带守住了 prompt cache。各家 API 对逐字节稳定的上下文前缀打折,Anthropic 的缓存读取按原价一成计费。pi 的系统提示词只随激活工具集变化,历史消息从不改写,压缩只追加 entry,前缀在压缩后的第一次调用里整体换成摘要,之后又稳定下来。压缩请求本身单独开路由、关掉缓存写入,一次性请求写缓存纯付费不收益。

会话中途换模型甚至换供应商,同一份历史发给另一家 API,转换逻辑集中在 packages/ai/src/api/transform-messages.ts 约 160 行:目标模型不支持视觉就把图片换成占位文本;thinking 块要 provider、api、model 三者都对上才保留,否则降级成普通文本;tool call id 按各家格式归一化;stopReason 为 error 或 aborted 的 assistant 消息整条跳过,孤儿 tool call 补一条 “No result provided”。不管这份历史经历过什么,发出去的每一版都结构合法,循环里那条不变量在这一层还是它。

技能:按需付 token 的说明书

No MCP 的替代方案是 CLI 工具加 README,技能(Skills,遵循 Agent Skills 标准)是这个思路的制度化版本:一个目录、一份 SKILL.md,frontmatter 只要 name 和 description 两个字段,想放脚本就在旁边放脚本。

token 账是这么算的:系统提示词里只放每个技能的 name 和 description,一行一个;完整的 SKILL.md 要等模型自己判断这活需要它时调 read 才进上下文。接一个 Playwright MCP,会话还没开始 13.7k token 已经花出去了,不管这次用不用得上浏览器。

pi 仓库自己放着一个技能 add-llm-provider.md,给 pi-ai 添加新供应商的完整操作清单。机构知识写成 agent 能按需读的文件,wiki 页面做不到这一点。

提示词模板是最不起眼的一条路:.pi/prompts/*.md 一个文件一条 /命令,正文支持 $1$@ 参数展开。pi 仓库自己放着五条,/cl 审计变更日志、/pr 审 PR、/wr 收尾提交,AGENTS.md 里写的发版流程第一步就是跑 /cl

还有一条反向承诺:项目里的 .pi/ 资源(扩展、技能、模板)在你明确信任这个项目之前一律不加载,信任状态记在 ~/.pi/agent/trust.json。克隆一个陌生仓库、一打开就自动执行里面带的扩展代码,这种事在 pi 里不会发生。

一切皆扩展

扩展是一个 TypeScript 模块,默认导出一个函数,放进 ~/.pi/agent/extensions/ 或项目的 .pi/extensions/,启动时被 jiti 直接执行,不需要编译。函数拿到的 pi: ExtensionAPI 能注册工具、斜杠命令、快捷键、CLI 参数、消息渲染器,能弹确认框、画自定义 TUI 组件,能换模型、换思考等级、动态换工具表,也能订阅三十多种事件。

官方示例里的权限门,全文 34 行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";

export default function (pi: ExtensionAPI) {
const dangerousPatterns = [/\brm\s+(-rf?|--recursive)/i, /\bsudo\b/i, /\b(chmod|chown)\b.*777/i];

pi.on("tool_call", async (event, ctx) => {
if (event.toolName !== "bash") return undefined;

const command = event.input.command as string;
const isDangerous = dangerousPatterns.some((p) => p.test(command));

if (isDangerous) {
if (!ctx.hasUI) {
// In non-interactive mode, block by default
return { block: true, reason: "Dangerous command blocked (no UI for confirmation)" };
}

const choice = await ctx.ui.select(`⚠️ Dangerous command:\n\n ${command}\n\nAllow?`, ["Yes", "No"]);

if (choice !== "Yes") {
return { block: true, reason: "Blocked by user" };
}
}

return undefined;
});
}

这就是 pi 语境下的权限系统:危险命令的定义是你的正则,确认交互是你的弹窗,无界面时的保守策略也是你写的。装上就有,删掉就无,规则不藏在产品里。

三十多种事件里改变游戏规则的是主干上这六个:

事件 拦截能力
input 用户输入先过这里:放行、改写,或整个接管
before_agent_start 每轮开始前换系统提示词、注入消息
context 发给模型前重写整个消息数组
before_provider_request 线级:直接改发给供应商的请求体和请求头
tool_call 工具执行前最后一道关:放行、拦截、原地改参数
session_before_compact 用自己的摘要替换内置压缩
flowchart TD IN[用户输入] --> E1{{input}} E1 --> CTX[组装本轮上下文] CTX --> E2{{before_agent_start}} E2 --> E3{{context}} E3 --> E4{{before_provider_request}} E4 --> LLM[调用 LLM] LLM --> E5{{tool_call}} E5 --> T[执行工具] T --> E6{{tool_result}} E6 --> NEXT[结果进历史,进入下一轮] classDef step fill:#dbeafe,stroke:#2563eb,color:#111827 classDef evt fill:#ede9fe,stroke:#7c3aed,color:#111827 class IN,CTX,LLM,T,NEXT step class E1,E2,E3,E4,E5,E6 evt

紫色六边形都是扩展能插手的位置。对照前面那张六个 No 的表,每一项都能报出对应组合:权限弹窗是 tool_call 加确认框;计划模式是 tool_call 只放行只读工具,加一个切换命令;子代理是注册一个工具、在里面再起一个无头 pi。示例目录里还有更野的:user_bash! 命令路由到 SSH 远端,gondolin 把工具执行整个搬进微虚拟机,以及在终端里跑 DOOM。

agent 给自己写扩展

根目录 README 管 pi 叫 self extensible coding agent,文档每页开头都写着 pi can create X, ask it to build one for your use case。这句话能兑现,靠三件东西咬合:

  1. **规格在本地。**约 1.2 万行文档和全部示例随 npm 包发货,系统提示词里写着它们的绝对路径和主题路由表。agent 写扩展前能读到完整、和当前版本一致的 API 文档,不用联网猜。
  2. **零构建。**jiti 直跑 TypeScript,写完即生效。
  3. 热重载。/reload 重跑整条资源管线,扩展 API 里还有 ctx.reload(),agent 写完扩展自己触发重载,同一个会话里新工具就上线了。

这条链路还被评测守着:packages/evals/src/extensions.eval.ts 用真实模型端到端跑"让 agent 创建一个扩展、重载、然后用它"。

pi 仓库的 .pi/extensions/ 里放着四个项目扩展:redraws.ts 注册 /tui 看渲染器的重绘统计,tps.ts 挂在消息事件上量 tokens/s,prompt-url-widget.ts 识别输入里的 PR 链接画悬浮部件,import-repro.ts 导入复现会话。他们用 pi 的扩展调试 pi 自己。

分发也是现成的:package.json 里一个 pi 字段把扩展、技能、模板、主题打成一个包,pi install npm:@foo/bar 或 git 源装进全局或项目目录。

四种模式一个核心

TUI 只是四种运行模式之一。--mode rpc 把 pi 变成子进程,stdin/stdout 上走 JSONL;--mode json 输出结构化事件流;管道进来的输入自动走 print 模式;再加上直接 import 的 SDK。四条路共享同一个运行时,扩展、技能、会话树、压缩在无头模式里全部照常生效。给 CI 写一个自动修 issue 的机器人,和在终端里交互式干活,用的是同一套核心和同一批扩展。

当开发者也是 agent

pi 仓库由多个 pi 会话并发开发,AGENTS.md 为此立了一套纪律(大意):

多个 pi 会话可能同时在这个目录工作,各自改不同的文件。只提交你这个会话改过的文件,暂存必须点名具体路径;git add -Agit add .git reset --hardgit checkout .git stash 一律禁止,它们会摧毁其他会话的成果。碰到不属于自己改动的 rebase 冲突:中止,去问人。

同一套哲学贯穿到底层

pi-ai 在最低一层用同样的思路对付 40 家供应商。

差异是数据,40 家供应商只共享 10 种线协议,xAI、Groq、DeepSeek、OpenRouter 复用同一个 openai-completions 实现,各家的怪癖记在 Model.compat 能力表里:max_tokens 字段叫什么名、工具结果后面要不要补一条 assistant 消息、thinking 输出走哪种格式。新接一家,常见情况是加几行数据。模型目录也是数据:构建时从 models.dev、OpenRouter 等源拉取合并,叠一层带注释的手工修正,生成物带哈希清单,再推导出 TypeScript 字面量类型,模型 ID 在编辑器里能自动补全。

错误是值stream() 从不 throw,认证失败、网络断开、限流、用户中断全部变成流里的最后一个事件,里面是一条带着已产出内容和 token 消耗的 assistant 消息。循环里失败也是数据的底气,就是这一层给的。

从循环的九个钩子,到扩展的三十多种事件,再到 compat 能力表,pi 每一层都把机制写死、把策略留给数据和回调。

教具,也是工具

回到开头的说法。最好的教具这半句我完全同意,源码给了三个理由:pi 的全部主张就是让你看见并控制进出模型的每一个字节,系统提示词 18 行摆在那里,会话是本地 JSONL,没有背后注入,学 Agent 最怕黑盒,pi 没有盒;四个核心包分层严格,pi-ai 管说话、pi-agent-core 管循环、pi-coding-agent 管产品逻辑、pi-tui 管终端渲染,依赖单向,想搞懂 agent 循环直接读那 120 行就行;pi 自解释,装好之后可以直接问它自己怎么工作,让它给你写扩展。

不适合干活这半句需要修正。pi 仓库本身就是用 pi 开发的,AGENTS.md 里的多会话 git 纪律就是证据。更准确的版本是:pi 对愿意自己配置工作流的人是生产力工具,对想开箱即用的人是学习工具。这恰好还是 Arch 的处境,Arch 用户从不觉得 Arch 拖慢了自己,被拖慢的是还没决定要不要自己管理系统的人。

装好之后值得做的第一件事,是让它给自己写一个扩展,你在旁边看完整个过程。

参考

输入关键词开始搜索

Image 01

滚轮或双指缩放 · 拖动查看 · 双击复位 · Esc 关闭