项目链接:GitHub: earendil-works/pi
这篇文章会从一个比较特别的角度来写——不只是介绍 Pi 是什么,而是带你钻进它的源码里,看看一个"极简但完整"的 AI coding agent harness 到底是怎么设计出来的。
一、这是什么鬼东西?
先讲一下背景。Pi 是 Mario Zechner(没错,就是 libgdx 那个 Mario)做的 terminal coding agent。它最近很火,因为它是 OpenClaw 背后的底层 harness——那个在 Hacker News 上火了一整天的"AI 自己写代码改自己的代码改到停不下来"的项目。
但 Pi 本身其实很克制。它不是一个"什么都帮你做好的框架",而是一套可以让你自己组装 agent 的积木。
整个项目是一个 TypeScript monorepo,核心包只有 4 个:
┌──────────────────────────────────┐
│ pi-coding-agent (CLI 工具层) │ 46,859 行
├──────────────────────────────────┤
│ pi-tui (终端 UI 库) │ 11,228 行
├──────────────────────────────────┤
│ pi-agent-core (agent 运行时) │ 7,964 行
├──────────────────────────────────┤
│ pi-ai (统一 LLM API) │ 30,157 行 (含模型数据)
└──────────────────────────────────┘
注意虽然总行数看起来很多,但 pi-ai 有 16,400 行是自动生成的模型数据列表。真正的核心逻辑其实相当精简。
最让人意外的是:Pi 的 coding agent 默认只有 4 个内置工具——read、write、edit、bash。再加上可选的 grep、find、ls。没了。
没有 MCP,没有 sub-agent,没有 plan mode,没有 permission popup。这些都不是"还没做",而是故意的。Mario 说得很直白:"如果你需要这些,自己去写 extension。"
这种设计哲学让 Pi 的源码变得非常值得学习——因为它小到能读懂,但又完整到能跑起来。
二、第一层:pi-ai 的统一 LLM API
先从最底层开始看。pi-ai 要解决的问题很简单:让上层代码不用关心背后用的是 Anthropic 还是 OpenAI 还是 Google。
2.1 Provider 注册表模式
核心是 api-registry.ts 里的一个 Map:
const apiProviderRegistry = new Map<string, RegisteredApiProvider>();
function registerApiProvider(provider: RegisteredApiProvider, sourceId?: string) {
apiProviderRegistry.set(provider.api, provider);
}
function getApiProvider(api: string): RegisteredApiProvider | undefined {
return apiProviderRegistry.get(piApi);
}
就这么简单。每个 provider 通过一个 Api 标识符注册(比如 "anthropic-messages"、"openai-completions"),然后上层通过这个标识符查找。
每个注册的 provider 必须暴露两个函数:stream 和 streamSimple,签名完全一致:
type StreamFunction<TApi, TOptions> = (
model: Model<TApi>,
context: Context,
options?: TOptions
) => AssistantMessageEventStream;
这意味着只要你实现了这个接口,你的 provider 就能无缝接入 pi 的整个生态。这是整个 pi-ai 最核心的抽象。
2.2 每个 provider 都长一样
我翻了几个 provider 的实现,发现它们的结构高度一致,像是照着模板写的:
1. 创建 AssistantMessageEventStream
2. 创建 provider 特定的 SDK client(Anthropic SDK / OpenAI SDK / Google genai SDK...)
3. 把统一的 Context(UserMessage / AssistantMessage / ToolResultMessage)转成 provider 特定的请求格式
4. 调 API
5. 把 provider 特定的流事件转成统一的 AssistantMessageEvent
6. push 到 EventStream
拿 Anthropic 的 provider 举个例子,它大概长这样:
export const streamAnthropic: StreamFunction<"anthropic-messages", AnthropicOptions> = (
model, context, options?
): AssistantMessageEventStream => {
const stream = new AssistantMessageEventStream();
(async () => {
const client = new Anthropic({ apiKey, baseURL: model.baseUrl });
const params = buildAnthropicParams(model, context, options);
const response = await client.messages.stream(params);
// 把 Anthropic 的事件转成统一事件
for await (const event of response) {
switch (event.type) {
case 'content_block_delta':
stream.push({ type: 'text_delta', delta: event.delta.text, contentIndex });
break;
// ...
}
}
})();
return stream;
};
跨 provider 最难搞的是啥? 我看了 transform-messages.ts 才知道——是 tool call ID 的格式差异。OpenAI 的 tool call ID 可以很长(~450 字符),但 Anthropic 只接受 64 字符以内。如果你先调了 OpenAI 拿到 tool result,再切到 Anthropic 继续对话,tool call ID 就会被截断。pi-ai 会在消息转换时自动做这些兼容处理。
2.3 统一事件流协议
pi-ai 定义了一套 LLM 流式响应的事件协议,所有 provider 都遵守:
start → (text_start / text_delta / text_end)*
(thinking_start / thinking_delta / thinking_end)*
(toolcall_start / toolcall_delta / toolcall_end)*
→ done | error
这套协议的实现是 EventStream 类,只有 88 行:
class EventStream<T, R> implements AsyncIterable<T> {
push(event: T): void // 生产者 push 事件
end(result?: R): void // 结束信号
[Symbol.asyncIterator]() // for await...of 消费
result(): Promise<R> // 等待最终结果
}
它既可以被 for await...of 拉取式消费,也可以被 push 式消费,两端都支持。
thinking 事件这里要多说一句。各个 provider 对"推理过程"的处理完全不同:
- Anthropic 用
thinking/signature块 - OpenAI 用
reasoning_effort参数,结果在content里 - Google Gemini 有原生的
thinking块
pi-ai 统一成 thinking_start / thinking_delta / thinking_end 事件,上层完全不用关心底层差异。
2.4 Model 注册表
models.generated.ts 有 16,400 行,全是结构化的模型数据。每个模型长这样:
{
id: "claude-sonnet-4-6",
name: "Claude Sonnet 4.6",
api: "anthropic-messages",
provider: "anthropic",
baseUrl: "https://api.anthropic.com/v1",
reasoning: true,
cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
contextWindow: 200000,
maxTokens: 8192,
compat: { /* provider 特定的兼容覆盖 */ }
}
注意 compat 字段——它处理的是那些"看起来兼容 OpenAI API 但其实有些差异"的第三方服务。比如有些服务不支持 developer role,有些用不同的 max_tokens 字段名,这些差异都记录在模型数据里,而不是写死在 provider 逻辑中。
三、第二层:pi-agent-core 的 Agent 运行时
pi-agent-core 是在 pi-ai 上面加了一层:状态管理 + tool 执行编排 + 事件系统。
3.1 Agent 类:有状态的包装器
agent.ts(557 行)定义了一个 Agent 类,它包装了底层的 agent loop,提供:
class Agent {
state: AgentState // 可读写状态
prompt(message) // 发起新对话
continue() // 从当前上下文继续
steer(message) // 在当前 turn 完成后插入消息
followUp(message) // 在 agent 停止前插入消息
subscribe() // 注册事件监听器
abort() // 终止当前 run
waitForIdle() // 等当前 run 结束
}
Agent 的状态管理有个小细节——所有数组赋值都会 slice():
set messages(msgs: AgentMessage[]) {
this.#state.messages = msgs.slice(); // 防外部突变
}
这避免了上层代码不小心修改了 agent 内部状态的引用。
3.2 事件系统:9 种事件描述完整生命周期
Agent 的事件流覆盖了从开始到结束的每一个步骤:
agent_start
└── turn_start
├── message_start / message_end(用户消息)
├── message_start → message_update × N → message_end(assistant 流式响应)
├── tool_execution_start → tool_execution_update × N → tool_execution_end
├── message_start / message_end(tool result)
└── turn_end
└──(检查 steering queue → 如果有,回到 turn_start)
agent_end
这套事件系统不是用 EventEmitter 实现的,而是监听器模式——Agent 维护一个监听器列表,事件来的时候逐个调用:
async processEvents(event: AgentEvent) {
// 先更新内部状态
this.#updateStateFromEvent(event);
// 再通知监听器
for (const listener of this.#listeners) {
await listener(event);
}
}
关键区别:监听器是 await 的,按注册顺序执行。这保证了事件的有序性——前面的监听器处理完了,后面的才会收到。
3.3 Tool 执行的生命周期
当 LLM 返回 tool call 时,Agent 要执行这些 tool。这里的设计很有意思:
两种执行模式:
- 顺序模式:一个一个执行,等前一个完成再启动下一个
- 并行模式:三个 phase——先顺序 prepare(参数校验 + hook),然后并行 execute,最后按原始顺序 emit tool result messages
// 并行模式的 Phase 2
const results = await Promise.all(
preparedCalls.map(call => executePreparedToolCall(call))
);
注意这里有个重要的 edge case:tool 执行顺序和 tool result 消息的顺序是不同的。tool result 消息必须按 assistant 原始请求的顺序排列,否则 LLM 会搞混。所以并行模式下,即使 tool B 比 tool A 先完成,它在消息列表中的位置仍然在 A 之后。
每个 tool 执行有 4 个步骤:
prepareToolCall()
1. 按 name 查找 tool
2. prepareArguments() — 参数兼容性转换
3. validateToolArguments() — TypeBox schema 校验
4. beforeToolCall Hook — 可 block 执行
→ 失败则立即返回错误结果
executePreparedToolCall()
→ 实际调 tool.execute()
finalizeExecutedToolCall()
→ afterToolCall Hook — 可覆盖 content / isError
emit 事件
这套流程有个巧妙的屏障语义:当 assistant 的 message_end 事件发出后,所有事件监听器都处理完了,才会进入 tool 执行的 preflight。这意味着在 beforeToolCall hook 里看到的 agent state 已经包含了完整的 assistant 消息。
3.4 Steering / Follow-up 队列
这个机制解决了"agent 正在跑的时候用户想插话"的问题:
用户输入 steer("等一下,先看看这个文件")
→ 消息进入 steeringQueue
→ 当前 tool 执行完成后,注入这条消息
→ LLM 在新的 turn 里处理它
用户输入 followUp("做完之后记得提交 commit")
→ 消息进入 followUpQueue
→ agent 完成所有 tool 调用,准备结束
→ 检查 followUpQueue,发现有消息,继续一轮
→ 处理完 follow-up 消息,真的结束
Steering 是"我现在就要说",follow-up 是"你先忙完再处理这个"。区别在于插入的时机不同。
底层 agent loop 的核心循环逻辑大概是这样:
outer loop:
while true: // inner loop: tool-call loop
turn_start
streamAssistantResponse() // LLM 调用
if error/aborted → break
executeToolCalls() // tool 执行
turn_end
if shouldStopAfterTurn → break
getSteeringMessages() → 有就继续 inner loop
getFollowUpMessages() → 有就继续 outer loop
agent_end
四、第三层:pi-tui 的终端差分渲染
这层我觉得是最酷的部分之一。Mario 没有用现成的终端 UI 框架,而是自己写了一个极简的 TUI 库。
4.1 Retained mode 组件
每个组件只有一个接口:
interface Component {
render(width: number): string[]; // 对给定宽度渲染成字符串数组
handleInput?(data: string): void;
invalidate(): void; // 清除缓存
}
没错,没有虚拟 DOM,没有 diff 算法。render() 就是一个纯函数,输入宽度,输出行数组。
这跟 React 的 reconcile 思路完全不同。React 是"声明式→虚拟 DOM→diff→patch 真实 DOM",而这里的做法是"每次重新渲染整棵组件树,然后和上一次的结果做行级比较"。
4.2 差分渲染的实现
渲染管道在 tui.ts 的 doRender() 里:
1. 递归调用所有子组件的 render(width) → 得到 newLines[]
2. 合成 overlay 图层
3. 逐行对比 previousLines[i] !== newLines[i]
4. 找到第一个不同的行和最后一个不同的行
5. 只发送 changed 区域给终端
核心就是字符串的引用比较——previousLines[i] !== newLines[i]。由于组件会缓存渲染结果(文本没变就不重新渲染),这个比较非常快。
渲染有节流控制:16ms 最小间隔,跟 60fps 对齐。
有个很赞的细节:Mario 用了 DEC 2026 同步输出 转义序列包裹整个刷新区域,防止终端在渲染过程中闪烁。
4.3 CURSOR_MARKER 技巧
组件怎么告诉终端"光标应该放在这里"?Pi TUI 用了一个很巧妙的方法——在渲染输出中嵌入一个特殊的 APC 序列作为标记:
const CURSOR_MARKER = '\x1b_CURSOR\x1b\\';
// 编辑器在渲染时嵌入光标位置
render(width) {
lines[cursorRow] = text + CURSOR_MARKER;
return lines;
}
// TUI 在渲染后提取光标位置
function extractCursorPosition(lines: string[]): { row: number, col: number } {
for (let i = 0; i < lines.length; i++) {
const idx = lines[i].indexOf(CURSOR_MARKER);
if (idx >= 0) {
lines[i] = lines[i].replace(CURSOR_MARKER, '');
return { row: i, col: idx };
}
}
}
这样 组件就完全不用关心"光标怎么移动"这件事。它只需要在渲染时标记"光标应该在这里",TUI 引擎会在渲染完成后统一把硬件光标移过去。组件和光标管理完全解耦。
4.4 输入处理的去块化
终端输入是流式的,一个转义序列可能被拆成多个 chunk 到达。StdinBuffer 处理这个问题:
stdin chunk 1: "\x1b[200~" (bracketed paste 开始)
stdin chunk 2: "hello wor"
stdin chunk 3: "ld\x1b[201~"(bracketed paste 结束)
StdinBuffer 不会立即吐出部分序列,
而是等一段时间(超时驱动),
确认序列完整后再交给键盘解析器。
五、第四层:pi-coding-agent 的完整 CLI
最上层就是把前面三层组装起来,加入具体的工具实现和会话管理。
5.1 CLI 入口流程
main.ts 的启动流程非常清晰:
1. 参数解析 (含离线模式/诊断)
2. resolveAppMode() → interactive / print / json / rpc
3. 运行会话数据迁移
4. createSessionManager() → 新建/恢复/继续/分支会话
5. 创建 AgentSessionServices (模型注册表+设置+资源加载器+扩展)
6. 分发到对应模式
5.2 四个内置工具的实现
每个工具都遵循统一的 ToolDefinition 接口:
interface ToolDefinition<TInput, TDetails> {
name: string;
label: string;
description: string; // 给 LLM 看
parameters: TSchema; // TypeBox schema
executionMode: ToolExecutionMode;
execute(params, signal, onUpdate, ctx) → Promise<ToolResult>;
}
read 工具(363 行):可插拔的 ReadOperations(支持本地和 SSH),自动检测图片 MIME 并缩放,行范围读取,语法高亮,输出截断(默认 200 行 / 50KB)。
write 工具(281 行):自动创建父目录,文件变更队列去重(同一个文件的多次写入会合并),语法高亮预览。
edit 工具(493 行):核心是 SEARCH/REPLACE 模式,使用 edit-diff.ts(454 行)的 diff 引擎计算编辑距离,生成统一 diff。同一个路径的变更通过 FileMutationQueue 去重。
bash 工具(447 行):child_process.spawn() 执行,流式输出 stdout/stderr,AbortSignal 超时控制,进程树终止,输出截断。
每个工具都实现了 BashOperations / ReadOperations 这样的策略接口,使得远程执行(SSH)只需要提供不同的策略实现即可,工具本身的逻辑完全不变。
5.3 扩展系统
扩展系统的设计类似 VS Code API——通过事件钩子 + 上下文对象来扩展:
扩展可以做的事情:
1. 添加工具(addTool)
2. 添加命令(addCommand)
3. 添加快捷键(addKeybinding)
4. 添加上下文(addContext)→ 注入到 LLM 的 system prompt
5. 添加渲染器(addRenderer)→ 自定义消息在 TUI 中的显示
6. 监听生命周期事件(sessionStart, turnStart, agentStart...)
扩展加载器用 jiti(开发时)或虚拟模块(Bun 二进制)加载 TypeScript 扩展,支持从 ~/.pi/agent/extensions/、项目目录 .pi/extensions/ 和 npm 包发现。
5.4 树结构会话管理
Pi 的会话不是线性的,而是树结构。每个会话文件是一个 JSONL 文件:
{"type":"session","id":"s1","version":3,"cwd":"/project","timestamp":"..."}
{"type":"message","id":"m1","parentId":null,"message":{...}}
{"type":"message","id":"m2","parentId":"m1","message":{...}}
{"type":"message","id":"m3","parentId":"m1","message":{...}} ← 分支点!
id 和 parentId 形成了一棵树。这意味着你可以:
- 分支:回到某个历史节点,从那里重新开始
- 导航:在
tree视图中查看所有分支 - 分支摘要:分支时自动生成摘要,记录为什么分岔
- 压缩:上下文超限时自动压缩历史,但不丢失 JSONL 中的原始数据
会话管理器(session-manager.ts,1458 行)构建了一个 byId 索引,支持 O(1) 的节点查找。getTree() 遍历构建整棵树,buildSessionContext(leafId) 从叶子走到根,收集消息、模型变更、压缩摘要。
六、怎么学 Pi 的源码?
看到这里,你可能会想:这么多代码,从哪里开始啃比较好?
我建议的阅读路线:
第一阶段:从最薄的层入手
先读 pi-agent-core 的 types.ts(418 行)。理解了 AgentMessage、AgentTool、AgentEvent 这些核心类型,你就能把握整个系统的数据结构设计。这是最"薄"但也最关键的一层。
第二阶段:看 agent loop
读 agent-loop.ts(742 行)的 runLoop() 函数。这是整个 Pi 的大脑——理解了它,你就理解了"LLM 调用→tool 执行→继续循环"这个最基本的 agent 模式。
第三阶段:看 pi-ai 的 provider
读一个 provider 的实现,比如 openai-completions.ts 或 anthropic.ts。不需要读完所有 provider,读一个你就明白了"如何把不同 API 抽象成统一接口"这个模式。
第四阶段:看一个内置工具的实现
读 read.ts 或 bash.ts。看看一个"LLM 可以调用的 tool"在代码层面到底是什么样——参数定义、执行逻辑、结果返回。
第五阶段:看 TUI 的渲染
读 tui.ts 的 doRender() 方法。差分渲染在终端中的实现思路,可以迁移到很多其他地方。
第六阶段(可选):看扩展系统
读 types.ts + runner.ts。Pi 的扩展 API 设计得很像 VS Code,如果你以后想做 agent 框架的插件系统,这部分很有参考价值。
总的来说,Pi 是一个用最少的抽象做最多的事的项目。它没有用任何 fancy 的设计模式,没有过度工程化,每一层解决一个具体的问题,然后通过清晰的接口组合起来。
整个项目 TypeScript + MIT 授权,52k stars,代码质量很高(有完整的 CI、锁文件完整性检查、husky pre-commit hook)。想学 agent harness 怎么写的朋友,Pi 真的是眼下最好的学习材料之一。
参考链接: