从命令行到大脑
你敲下的 12 个字,到底经过了哪些机器?
想象一个再普通不过的瞬间
你打开终端,敲下 pi,按下回车,输入:"帮我把 README 翻译成中文"。
三秒钟后,AI 开始流式输出。代码被读取、被改写、被保存。一切自然得像魔法。
但魔法是骗人的。在那短短三秒里,你的 12 个字翻越了至少 5 道关卡。本课的目标是:把每一道关卡拆给你看。
当 AI 卡住、幻觉、用错工具时,你需要知道是哪一道关卡坏了。看不见机器,就只能干瞪眼。
本课的"解剖对象"
我们要拆的是 monorepo pi-mono(github.com/badlogic/pi-mono)—— 一个开源 AI 编程 Agent。
pi 命令
来自 @mariozechner/pi-coding-agent 这个 npm 包,安装后就能在终端里跟 AI 对话写代码。
read(读文件)、write(写文件)、edit(精准修改)、bash(执行命令)。看起来少,组合起来威力惊人。
OpenAI、Anthropic、Google、DeepSeek、Bedrock、OpenRouter…… 全部可切换。秘诀在模块 3 揭晓。
启动那一刻:5 个角色集合
当你敲 pi 回车的瞬间,pi 内部有一段类似这样的"开机自检"对话发生:
系统 prompt 是"现拼出来"的
大多数人以为系统提示词是一段写死的文字。其实在 pi 里,它是每次启动时,根据你当前项目动态拼接的。
let prompt = `You are an expert coding assistant operating inside pi,
a coding agent harness. You help users by reading files,
executing commands, editing code, and writing new files.
Available tools:
${toolsList}
Guidelines:
${guidelines}
Pi documentation (read only when the user asks about pi itself):
- Main documentation: ${readmePath}
- Additional docs: ${docsPath}`;
第 1-3 行:开场白固定 ——"你是一位跑在 pi 里的编程助手"。这部分是 pi 的人格底色。
${toolsList}:动态注入当前可用的工具清单。如果你只开了 read,模型只看到 read;如果加了自定义工具,也会自动出现在这里。
${guidelines}:行为准则也是动态拼的——"答案要简洁"、"文件路径要清晰" 等等,根据你装的工具自动调整。
最后几行:把 pi 自己的文档路径告诉模型。模型如果想"读 pi 的源码理解 SDK",直接 read 这些路径就行。
关键洞察:系统 prompt 不是文字,是模板。这就是为什么 pi 能"按项目自适应"。
完整旅程:5 步看清一次对话
把上面所有片段串起来——你的一句话从输入到 AI 改完代码,要走这条路:
"模型说话 → 工具执行 → 结果回模型 → 再说话"——直到模型说"我说完了"。本课后面 5 个模块全在拆这个循环的不同切片。
先测一下你的"地图意识"
下面 3 题不是考你记忆,是让你试着用刚才学的"5 角色 + Agent 循环"去推理新场景。错了也别气馁——每题的解释会教你新东西。
你的 pi 突然告诉你 "我无法执行 bash 命令"。最可能的原因?
你输入"读一下 package.json",这段文字会先到达谁?
为什么 pi 把系统 prompt 做成动态拼接,而不是写一段固定文字?
认识五位主角
Pi 不是一个怪物,是 5 个能单买的 npm 包
乐队是怎么排兵布阵的
把 pi 看作一支摇滚乐队。一首歌好听,不靠主唱一个人——靠分工:
贝斯打底(pi-ai 抗住所有 LLM 协议)、鼓手稳节奏(pi-agent-core 控制 Agent 循环)、主唱站台前(pi-coding-agent 是用户能看到的命令)、键盘手做特效(pi-tui 让终端流畅)、客串歌手(pi-web-ui 把同样的戏搬到浏览器)。
你不一定要用整支乐队。只想做"前端调 LLM" → 单装 pi-ai。只想写终端工具 → 单装 pi-tui。这种"乐高式"切分是 pi 的核心工程哲学。
五张身份证
每个包都能 npm install 单独使用,下面是它们的"身份证":
@mariozechner/pi-coding-agent
主唱。用户能看到的 pi 命令行工具就是它。负责命令解析、会话管理、4 件默认工具。
@mariozechner/pi-agent-core
鼓手。Agent 循环的节拍器。负责"模型 ↔ 工具"来回调度、状态管理、事件分发。
@mariozechner/pi-ai
贝斯手。LLM 协议层。让一段代码能切换 OpenAI、Anthropic、Google、Bedrock 等 20+ 家服务商。
@mariozechner/pi-tui
键盘手。终端 UI 库。差分渲染让屏幕不闪烁,文本/Markdown/图片都能丝滑显示。
@mariozechner/pi-web-ui
客串歌手。同样的内核,皮换成浏览器。让 pi 能在 Web 应用里复用,做聊天界面。
谁依赖谁?
这是 pi 内部的"乐队站位图"——上层依赖下层,下层不知道上层存在:
这是软件工程教科书里最古老的原则之一:每个包只做一件事,做精。pi 把"和 LLM 通信"、"管理 Agent 状态"、"绘制终端"分成三个独立的事——任何一个出 bug 都能精准定位。
包之间的"自我介绍"
把 5 个包拟人化,让它们各自出来说一句:
真实代码:上层怎么用下层
不只是嘴上说"依赖"——下面是 pi-agent-core 真实代码的第一行,从 pi-ai 拿走它需要的所有类型:
import {
type ImageContent,
type Message,
type Model,
type SimpleStreamOptions,
streamSimple,
type TextContent,
type ThinkingBudgets,
type Transport,
} from "@mariozechner/pi-ai";
import { runAgentLoop } from "./agent-loop.js";
第 1-9 行:从 @mariozechner/pi-ai 这个包导入一堆类型和一个函数。
type Model、type Message:这些是 pi-ai 定义的"通用类型"。agent-core 直接复用,不重复造类型——这是 TypeScript 项目里"类型作为契约"的经典做法。
streamSimple:万能调用函数。传一个 模型对象 + 上下文,吐回流式事件。这就是模块 3 要拆的。
第 10 行:从自己的 ./agent-loop.js 导入循环主体。一行代码暴露了"鼓手内部的鼓棒在哪"。
关键洞察:依赖在第一行就显现。读任何 npm 包源码,先看 import——你立刻知道它"骑在谁的肩膀上"。
用"分层意识"拆 3 个真实场景
你想做一个网页 AI 聊天界面,前端用 React,后端逻辑自己掌控。最该选哪几个 pi 包?
你的 agent 调用 OpenAI 时报 401,但调 Anthropic 正常。这个 bug 最可能藏在哪个包?
为什么 pi-coding-agent 和 pi-web-ui 都依赖 pi-agent-core,而不是各自重写一份 Agent 循环?
统一万家 LLM
pi-ai 怎么用一套 API 抽象 20 多家服务商
"换模型"为什么这么难?
大多数项目第一次"从 GPT-4 换 Claude"要花一周。原因:每家 LLM 服务商有自己的 API 协议——OpenAI 用 Chat Completions、Anthropic 用 Messages、Google 用 Generative AI、Bedrock 用 Converse……
每换一家,HTTP 请求格式、流式响应格式、工具调用语法都得重写。
pi-ai 把这件事变成一行代码:
// 用 OpenAI
const model = getModel('openai', 'gpt-4o-mini');
// 换成 Claude——只改 2 个字符串
const model = getModel('anthropic', 'claude-sonnet-4');
// 后面所有调用代码完全不变
const response = await complete(model, context);
每家 LLM 说不同的语言(API 协议)。pi-ai 是那个戴耳机的同传——一进一出,对外只输出一种"标准事件流"。换说话人,对你的耳朵毫无影响。
11 种标准事件 = 一切的语言
不管底下是哪家服务商,pi-ai 最终只吐出 11 种事件。掌握这 11 种,你就掌握了 LLM 应用的 80% 脉搏:
start · done · error
流的开头、正常结尾、错误结尾。为什么 error 也是事件?
text_start · text_delta · text_end
模型说人话。text_delta 每次几个字符——这就是打字机效果的源头。
thinking_start · thinking_delta · thinking_end
思考型模型(Claude with thinking、OpenAI o1)的"内心独白"。可显示也可隐藏。
toolcall_start · toolcall_delta · toolcall_end
模型决定调用工具。toolcall_delta 流式吐参数 JSON,UI 能在还没解析完时就提前显示文件路径。
"统一 API"的真相:7 行代码
你可能以为 stream() 函数内部有几千行复杂逻辑。打开源码,结果:
export function stream<TApi extends Api>(
model: Model<TApi>,
context: Context,
options?: ProviderStreamOptions,
): AssistantMessageEventStream {
const provider = resolveApiProvider(model.api);
return provider.stream(model, context, options as StreamOptions);
}
第 1-5 行:函数签名——传一个模型对象 + 上下文(消息列表 + 工具)+ 选项,返回一个事件流。
第 6 行:resolveApiProvider(model.api)——根据 model 的 api 字段(比如 "anthropic-messages"、"openai-completions"),去注册表里查"谁来实现"。
第 7 行:把活儿派给具体 provider。这一行是 anthropic.ts 或 openai-completions.ts 等具体协议处理代码。
关键洞察:这就是工厂模式 + 策略模式。上层 stream() 永远不需要改,新增第 21 家服务商只是往注册表里加一行。这种"开放封闭"是大型框架的核心设计。
7 行代码 + 一张表 = 20+ 家 LLM 的统一接口。这就是好抽象的样子。
一次调用的 6 步旅程
看一下 stream() 调用 Anthropic 时,事件是怎么"翻译"的:
惊艳的招数:跨模型接力对话
pi-ai 还有一招让人拍大腿的——你可以聊到一半换模型,对话不丢:
const claude = getModel('anthropic', 'claude-sonnet-4-20250514');
const context: Context = { messages: [] };
context.messages.push({ role: 'user', content: 'What is 25 * 18?' });
const claudeResponse = await complete(claude, context, {
thinkingEnabled: true
});
context.messages.push(claudeResponse);
// 换 GPT-5——上下文里的 Claude 思考块自动转成文字
const gpt5 = getModel('openai', 'gpt-5-mini');
context.messages.push({ role: 'user', content: 'Is that calculation correct?' });
const gptResponse = await complete(gpt5, context);
第 1-2 行:拿 Claude 模型,准备一个空对话。
第 4-7 行:用户问数学题,开启 Claude 的"思考模式"。Claude 会先在内心独白里推理,再给答案。
第 8 行:把 Claude 的完整回复(含思考块)存进上下文。
第 11 行:换成 GPT-5——只改一个变量名!
第 12-13 行:把同一个 context 喂给 GPT-5,问"那个计算对吗?"。
关键魔法:pi-ai 内部会自动把 Claude 的 思考块转成 <thinking>...</thinking> 标签的纯文本——GPT-5 能读懂,理解 Claude 的思路。
这叫 Cross-Provider Handoff。两个完全不同协议的模型,无缝接力同一段对话。
pi-ai 的 4 个秘诀
注册表(Registry)
模型只声明 api 名字,stream() 自动查表派单。新增 LLM = 加一行,不改主流程。
标准事件流
11 种事件覆盖文本/思考/工具调用三种内容。前端只需识别这 11 种,不用碰具体协议。
Cross-Provider Handoff
上下文跨模型流动,思考块自动降级为 <thinking> 标签文本,新模型能理解。
OAuth 扩展点
Anthropic Pro/Codex/Copilot 用 OAuth 而不是 API 密钥。pi-ai 用 getOAuthApiKey 透明刷新,业务代码无感知。
流式应用里,错误来临前可能已经收到了一半内容(GPT-4 写到一半超时)。把错误也变成事件,UI 就能优雅地"显示已收到的部分 + 红色错误条",而不是把一切扔掉。这是流式系统设计的金科玉律。
用"统一 API"思维拆 3 个场景
你的产品要支持 OpenRouter 上的随便哪个模型。基于 pi-ai 的设计,最优做法?
你的应用流式打字突然变成"全部一次性出现,没有打字机效果"。最可能哪里出问题?
产品要做"让用户从 GPT-4 切到 Claude 继续聊"。pi-ai 是否原生支持?
Agent 循环 —— 会用工具的大脑
所有 AI 编程工具的"心脏跳动"
手术室里发生的事
你跟 Cursor 说"帮我重构这个文件",AI 不是一口气写完——它read 一下、write 一下、再 read、再 edit、再 bash、再问你一句。这一连串动作背后只有一件事:Agent 循环。
把它想成手术室:模型是主刀医生("我现在需要 read 这个文件 / 现在需要 write 那段代码"),Agent 循环是器械护士(递工具、收回器械、记录每一步、随时听主刀的下一句话)。
Cursor、Claude Code、Aider、pi —— 你看到任何能"连续推理 + 调工具"的产品,内部都是同一个 while 循环。学会这一段,整个赛道都能拆开。
循环的真身:一段简单的伪代码
真实代码 700 多行(在 packages/agent/src/agent-loop.ts 里),但骨架就是这样的:
while (true) {
let hasMoreToolCalls = true;
while (hasMoreToolCalls || pendingMessages.length > 0) {
// 1) 把"打断消息"插进上下文
if (pendingMessages.length > 0) {
for (const message of pendingMessages) {
currentContext.messages.push(message);
}
pendingMessages = [];
}
// 2) 调一次 LLM,拿到模型回复
const message = await streamAssistantResponse(currentContext, ...);
// 3) 看模型有没有调用工具
const toolCalls = message.content.filter((c) => c.type === "toolCall");
hasMoreToolCalls = false;
if (toolCalls.length > 0) {
const batch = await executeToolCalls(currentContext, message, ...);
hasMoreToolCalls = !batch.terminate;
}
// 4) 用户在工具运行时按 Enter 插的话——steering
pendingMessages = (await config.getSteeringMessages?.()) || [];
}
// 5) 看用户排队的下一句话——follow-up
const followUp = (await config.getFollowUpMessages?.()) || [];
if (followUp.length > 0) { pendingMessages = followUp; continue; }
break;
}
外层 while (true):Agent 一直跑。终止条件在最后的 break——只有"没有工具要调 + 没有打断 + 没有排队任务"时才退出。
内层 while:只要还有工具要调用,或有打断消息,就继续转一圈。
第 1 步:插入 pendingMessages(打断消息)。这是用户在 AI 跑工具时按下 Enter 输入的话,等 AI 当前工具结束后立刻"塞队"。
第 2 步:调一次 LLM——这就是模块 3 讲的 streamSimple。模型回复里可能有 0 个、1 个或 N 个工具调用。
第 3 步:拆出工具调用并执行。batch.terminate = true 表示工具喊"任务完成,别再让 LLM 说话了",循环退出。
第 4 步(steering):用户在 AI 写代码时插一句话"等等先别 commit"——这条会在工具结束后、下次 LLM 调用之前插进去。
第 5 步(follow-up):等所有工具结束、模型完整说完之后,再插下一条排队任务。适合"先做完 A 再做 B"的串行需求。
关键洞察:循环本身没有"智能"——智能全在 LLM 决定调什么工具。Agent 循环只是把"调一次 LLM → 执行工具 → 再调一次"自动化到无穷。
"工具"长什么样:三件套
每一个工具就是一个对象,由三件套组成:名字 + 参数 schema + execute 函数。下面是 read_file 工具的真实定义:
const readFileTool: AgentTool = {
name: "read_file",
label: "Read File",
description: "Read a file's contents",
parameters: Type.Object({
path: Type.String({ description: "File path" }),
}),
executionMode: "sequential",
execute: async (toolCallId, params, signal, onUpdate) => {
const content = await fs.readFile(params.path, "utf-8");
onUpdate?.({ content: [{ type: "text", text: "Reading..." }] });
return {
content: [{ type: "text", text: content }],
details: { path: params.path, size: content.length },
};
},
};
name: "read_file":模型在它的世界里就用这个名字喊。这个名字会拼进系统 prompt 的工具清单。
description:写给模型看的功能说明,告诉它"什么时候该用我"。这是模型决定"要不要用这个工具"的唯一依据。
parameters: Type.Object(...):用 TypeBox 写的参数说明书。模型按照这个 schema 生成 JSON 参数。
executionMode: "sequential":这个工具不能跟别的并发跑(怕两个 read 同时改文件指针)。如果设为 parallel,pi 会把这一批工具调用全部并发执行。
execute:真正干活的函数。拿到 path → 读文件 → 返回内容。第三个参数 signal 是 中止信号,第四个 onUpdate 是流式进度回调。
关键洞察:工具不是函数,是一个"自描述对象"——名字 + 文档 + schema 都给了模型,模型才能精准调用。这就是为什么 pi 能让 LLM 用对几十种工具。
手术室对话现场
把"模型主刀 + Agent 循环护士"拟人化,看一台典型的"读文件 → 改文件"手术:
三种"插队"方式
Agent 循环的厉害不只是基础结构,还在于打断 / 排队 / 拦截三种插队机制:
Steering(实时打断)
用户在 AI 跑工具时按 Enter 输入。当前工具结束后、下次 LLM 调用前插入。"等等,先别 commit。"
Follow-up(排队任务)
等所有工具完成、模型说完整段后再插。"做完之后顺手把测试也跑一遍。"
beforeToolCall(权限拦截)
工具调用启动前的最后一道闸门。可以审计、改写、彻底拦下。"AI 不许 rm 任何文件。"
pi-agent-core 会捕获 throw 自动标 isError: true,模型在下一轮看到错误后会自动重试或换思路。如果你 return 一个"错误内容",模型会以为成功了,继续犯同样的错。这是写 AI 工具最容易踩的坑。
练 4 个真实调试场景
你的 Agent 一直在 read 同一个文件 20 次然后才回答你。最可能的根因?
用户在 Agent 跑工具时按下 Enter,输入"停下,先别改这个文件"。这条消息何时被处理?
你想给 AI 加一个"危险操作要审计"的需求:每次它要 rm 文件,先记录到日志再执行。最该用什么?
下面哪种写法最符合 pi-agent-core 对 execute 的"错误处理契约"?
终端魔法 —— 差分渲染与 4 件工具
为什么 pi 的终端 UI 比 git diff 还流畅?
你见过会闪的命令行吗?
大多数老式终端工具刷新整屏时——文字会跳一下,光标会乱蹦,上面的内容会被滚走。pi 不会。它能在 80 列宽的终端里画 Markdown、彩色 diff、流式打字机效果,全程不闪烁。
秘密只有 80 行 JavaScript 的差分算法。
一台戏不是每一秒都在换灯——只在演员动的瞬间补灯。差分渲染就是这件事:屏幕上 1000 行字,只有第 700 行在变,那就只刷第 700 行。
差分算法的"心脏"
下面是 pi-tui 真实代码(packages/tui/src/tui.ts 第 985-1014 行),找出"哪几行变了"的核心逻辑:
// Find first and last changed lines
let firstChanged = -1;
let lastChanged = -1;
const maxLines = Math.max(newLines.length, this.previousLines.length);
for (let i = 0; i < maxLines; i++) {
const oldLine = i < this.previousLines.length ? this.previousLines[i] : "";
const newLine = i < newLines.length ? newLines[i] : "";
if (oldLine !== newLine) {
if (firstChanged === -1) {
firstChanged = i;
}
lastChanged = i;
}
}
// No changes - just update cursor and exit
if (firstChanged === -1) {
this.positionHardwareCursor(cursorPos, newLines.length);
return;
}
第 1-3 行:准备两个游标——firstChanged 找"第一处变化",lastChanged 找"最后一处变化"。-1 是"还没找到"的占位符。
previousLines vs newLines:上次画的内容数组(每行一个字符串)和这次要画的内容数组。
第 5-15 行:逐行对比。如果某一行不一样,记下行号。第一次记 firstChanged,最后一次更新 lastChanged。
第 18-21 行:如果整屏都没变,什么也不画,直接退出。这是性能优化的精髓——大多数事件只触发"光标位置变化",根本不需要重画。
后续逻辑(没贴出来):只刷新 firstChanged 到 lastChanged 之间的行,其他行原封不动。这就是"只补会变的灯"。
关键洞察:算法本身简单——逐行对比 + 只刷范围内。但思维方式(diffing)是性能优化的祖师爷:React 用它(Virtual DOM)、Git 用它、视频压缩用它。
第二个秘密:同步输出协议
找到要刷新的行只是一半。另一半是怎么把指令发给终端,让它一次性刷新而不是边发边画。pi-tui 用了一个鲜为人知的终端协议——CSI 2026:
let buffer = "\x1b[?2026h"; // 开启同步输出
// ...这里拼一堆"移动光标 / 清除一行 / 写新内容"的 ANSI 指令...
buffer += "\x1b[10;1H"; // 移到第 10 行第 1 列
buffer += "\x1b[2K"; // 清除整行
buffer += "新内容"; // 写新内容
buffer += "\x1b[?2026l"; // 关闭同步输出 = 一次性刷新
this.terminal.write(buffer);
翻译成大白话:\x1b[?2026h 是"终端,从这一刻起先别画了,把我后面发的所有指令统一处理"。中间不管发多少 ANSI 转义序列(移动 / 清除 / 写入),终端都暂存。\x1b[?2026l 是"好了,可以画了,一次性刷新"。
没有这一对包装:用户会看到屏幕一格一格地刷新(闪烁感)。包了之后:所有变化一帧出现,视觉上就是 GUI 的丝滑感。
看一个真实例子:三种渲染策略
同样一段终端输出"loading..."变"done",pi-tui 会根据情况选不同策略:
极简工具集:4 件做天下
pi 默认只给模型 4 个工具。看似少,组合起来威力惊人:
read
读文件全部内容。无副作用——再 read 一万次也不破坏什么,最安全。
write
覆盖整个文件。慎用——一不小心整个文件被冲掉。pi 一般引导模型优先用 edit。
edit
精准字符串替换。要求 oldString 在文件里唯一——比 write 安全 10 倍,是默认改文件方式。
bash
执行任意命令。权限最大——能装包、能 git、能 rm。强烈建议加 beforeToolCall 拦截。
极简到震撼:bash 工具的全部参数
你以为"能执行任意命令"的工具会有几十个参数?看 pi 实际的 bash schema:
const bashSchema = Type.Object({
command: Type.String({
description: "Bash command to execute"
}),
timeout: Type.Optional(Type.Number({
description: "Timeout in seconds (optional, no default timeout)"
})),
});
整个 bash 工具只有 2 个参数:command(要跑的 shell 命令)+ 可选的 timeout(超时秒数)。
为什么这么极简?因为 shell 本身已经是一种高度通用的"小语言"。你想 grep?让模型自己写 grep。想 sed?让模型自己写 sed。想统计行数?wc -l。pi 不需要给模型 100 个特殊工具。
这就是 Unix 哲学:每个工具做好一件事,让组合产生威力。
关键洞察:你设计自己的 AI 工具时,第一反应往往是"加一个超复杂的工具"。看完 pi 的 bash schema,你会发现——把 4 件简单工具给模型,比给 50 件复杂工具更智能。
3 题检验你的"终端工程"思维
你做了一个终端 LLM 应用,每次 token 来了就 console.log 一行,结果屏幕一直闪。根因是什么?
为什么 pi 默认只给模型 read/write/edit/bash 4 个工具,而不是 50 个?
4 件工具里哪个是"无副作用"——再调用一万次也不破坏什么?
React 用它(Virtual DOM diff)、Git 用它(快照 + 差分)、视频压缩用它(只存帧间变化)。看到任何"高性能 UI",背后大概率是 diff。学会从"上一帧 vs 这一帧"的角度看问题,你能拆开 90% 的渲染优化方案。
会话分支与扩展
长期对话、回滚老分支、给 pi 装上自己的能力
"我刚才那个分支思路其实是对的"
你和 AI 聊了 2 小时,准备 commit。突然想:30 分钟前那个分支思路其实更好。
普通工具:完蛋,得重打。
pi:/tree → 选 30 分钟前那条 → 从那里继续 —— 历史一根都没丢。
每个 pi 会话是一棵树:你和 AI 的每条消息都是一圈年轮(有 id、有 parentId)。你可以从任何一圈"嫁接出新枝"——这就是 /fork 和 /tree。整棵树存在一个文件里,永远不丢。
秘密:会话存成"树状 JSONL"
大多数聊天工具把会话存成一根线(一条消息接一条)。pi 不一样——它把每条消息存成带父节点的节点,整体是一棵树:
// session-2024-05-05-abc.jsonl 文件内部
{"id":"m1","parentId":null,"role":"user","content":"重构这个文件"}
{"id":"m2","parentId":"m1","role":"assistant","content":"先 read 一下..."}
{"id":"m3","parentId":"m2","role":"toolResult","content":"..."}
{"id":"m4","parentId":"m3","role":"assistant","content":"我用方案 A"}
// 你回到 m3,从这里开新分支
{"id":"m5","parentId":"m3","role":"assistant","content":"我用方案 B"}
{"id":"m6","parentId":"m5","role":"toolResult","content":"..."}
每一行是一个 JSON 对象的纯文本格式。JSONL 适合"边写边追加",不会越来越慢。
每条消息知道自己的"上一条"。同一个 parentId 可以有多个 child = 多个分支。整个文件是一棵树。
"嫁接"不会复制整段历史——只是新消息的 parentId 指向某个老节点。100 个分支也只是 100 行新文本。
/tree 操作现场
看一下用户怎么"穿越回过去":
pi 把 Git 的心智模型搬进了 LLM 对话——你的每个"走错路"都是宝贵分支,永远不会真的失去。
长对话工具箱:4 件武器
/tree
浏览整棵会话树,跳到任意节点继续。"30 分钟前那个思路是对的,回去。"
/fork
从某条 user 消息开一个全新会话文件。适合"我要从这里分两条完全独立的路探索"。
/compact
上下文快爆 token 上限了,让 LLM 自己把老消息总结成一段。完整原文还在 JSONL,可 /tree 找回。
pi -c / -r
-c 接着最近一次会话,-r 从历史里挑一个。重启电脑、第二天来——对话从昨天那一秒接上。
比如已经堆了 150k token,模型马上要超出 200k 的上下文窗口。
让 LLM 把老消息总结成一段简洁的摘要(保留关键决策、文件路径、错误教训)。
新消息继续。完整历史还在 JSONL 文件里——任何时候 /tree 找回都行。
扩展点:给 pi 装上自己的能力
pi 的另一个工程巧思——你不需要 fork pi 改源码,就能加新工具、新命令、新 UI。一个 extension = 一个 TypeScript 文件:
export default function (pi: ExtensionAPI) {
pi.registerTool({ name: "deploy", ... });
pi.registerCommand("stats", { ... });
pi.on("tool_call", async (event, ctx) => { ... });
}
第 1 行:默认导出一个函数,pi 启动时会自动调用,把 ExtensionAPI 对象传给你。
第 2 行:registerTool——给 Agent 加一个全新工具。比如 deploy 工具,让模型可以"AI,帮我部署一下"。
第 3 行:registerCommand——给用户加一个斜杠命令。比如 /stats 显示当前会话用了多少 token、花了多少钱。
第 4 行:pi.on("tool_call", ...)——监听器(钩子)。每次工具调用时触发,可以审计、改写参数、加权限拦截。
关键洞察:3 个 register/on 方法 + 一个 TypeScript 文件 = 你能改 pi 90% 的行为,从不需要 fork 仓库。这是扩展性极强的设计。
三层定制:从轻到重
pi 提供了三层定制能力,按需选用:
Skill(说明书)
一段 Markdown 文档,告诉模型"什么时候该用我"。纯文档,不写代码。最轻量,跨工具兼容(Claude Code 也支持 Agent Skills 标准)。
Extension(行为)
一个 TypeScript 文件。能加工具、命令、钩子、UI。会改 pi 的行为。中等重量。
Pi Package(共享)
把 skills + extensions + 提示词模板 + 主题打包成 npm 模块。给团队共享。pi install npm:@foo/pi-tools。
// 一个 Skill 的真实样子(一段 Markdown)
<!-- ~/.pi/agent/skills/code-review/SKILL.md -->
# Code Review
Use this skill when the user asks for a code review.
## Steps
1. Run the linter and tests first
2. Look for security issues, performance, and clarity
3. Group findings by severity
最后一关:4 题检验全套理解
和 AI 聊了 200 条消息,30 分钟前那个 commit 思路其实是对的,现在的方向走偏了。pi 的解决方案?
JSONL 树状会话格式相比"每个分支一个文件"有什么好处?
你想给团队加"每次 AI 改 .env 文件就 Slack 通知"的能力。最该用 pi 的哪个机制?
Skill 和 Extension 有什么本质区别?
恭喜,你已经能拆开任何 AI 编程 Agent
回头看这 6 个模块,你现在掌握了 pi 的全部内脏:
5 个角色(终端/Agent循环/LLM/工具/文件)协作完成一次对话
5 个 npm 包 + 上层依赖下层 + 任意一个能单买
注册表 + 标准事件流 + Cross-Provider Handoff,20+ 家 LLM 一个 API
while 循环 + 工具三件套 + steering/follow-up/beforeToolCall 三种插队
差分算法找变化行 + CSI 2026 同步输出 + 4 件极简工具
JSONL 树状会话 + /tree 穿越历史 + Skill/Extension/Package 三层定制
而是 5 个互相协作的包 + 一个 while 循环 + 一棵会话树。当 AI 卡住、幻觉、用错工具,你能精准定位是哪一道关卡坏了。这就是把 vibe coding 升级成"工程师式 AI 协作"的入门钥匙。继续往前走。