01

从命令行到大脑

你敲下的 12 个字,到底经过了哪些机器?

想象一个再普通不过的瞬间

你打开终端,敲下 pi,按下回车,输入:"帮我把 README 翻译成中文"

三秒钟后,AI 开始流式输出。代码被读取、被改写、被保存。一切自然得像魔法。

但魔法是骗人的。在那短短三秒里,你的 12 个字翻越了至少 5 道关卡。本课的目标是:把每一道关卡拆给你看

🎯
为什么要拆这台机器?

当 AI 卡住、幻觉、用错工具时,你需要知道是哪一道关卡坏了。看不见机器,就只能干瞪眼。

本课的"解剖对象"

我们要拆的是 monorepo pi-monogithub.com/badlogic/pi-mono)—— 一个开源 AI 编程 Agent

⌨️
用户接触到的是 pi 命令

来自 @mariozechner/pi-coding-agent 这个 npm 包,安装后就能在终端里跟 AI 对话写代码。

🧠
它能用 4 个默认工具

read(读文件)、write(写文件)、edit(精准修改)、bash(执行命令)。看起来少,组合起来威力惊人。

🔌
背后接着 20+ 家 LLM 提供商

OpenAI、Anthropic、Google、DeepSeek、Bedrock、OpenRouter…… 全部可切换。秘诀在模块 3 揭晓。

启动那一刻:5 个角色集合

当你敲 pi 回车的瞬间,pi 内部有一段类似这样的"开机自检"对话发生:

系统 prompt 是"现拼出来"的

大多数人以为系统提示词是一段写死的文字。其实在 pi 里,它是每次启动时,根据你当前项目动态拼接的。

CODE
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 改完代码,要走这条路:

⌨️
终端 (pi-tui)
🔁
Agent 循环
🧠
LLM API
🔧
工具集
📁
文件系统
点击"下一步"开始追踪你的消息
💡
这个循环有个名字:Agent Loop

"模型说话 → 工具执行 → 结果回模型 → 再说话"——直到模型说"我说完了"。本课后面 5 个模块全在拆这个循环的不同切片。

先测一下你的"地图意识"

下面 3 题不是考你记忆,是让你试着用刚才学的"5 角色 + Agent 循环"去推理新场景。错了也别气馁——每题的解释会教你新东西。

你的 pi 突然告诉你 "我无法执行 bash 命令"。最可能的原因?

你输入"读一下 package.json",这段文字会先到达谁?

为什么 pi 把系统 prompt 做成动态拼接,而不是写一段固定文字?

02

认识五位主角

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-coding-agent (终端版前端) → 使用 pi-agent-core + pi-tui
pi-agent-core (Agent 循环内核) → 使用 pi-ai
pi-ai (LLM 协议层) → 直接调云端 API(OpenAI/Anthropic/...)
pi-tui (终端 UI 库) → 独立模块,不依赖任何 LLM 包
pi-web-ui (浏览器版前端) → 也使用 pi-agent-core + pi-ai,浏览器里跑
📚
单一职责原则(Single Responsibility Principle)

这是软件工程教科书里最古老的原则之一:每个包只做一件事,做精。pi 把"和 LLM 通信"、"管理 Agent 状态"、"绘制终端"分成三个独立的事——任何一个出 bug 都能精准定位。

包之间的"自我介绍"

把 5 个包拟人化,让它们各自出来说一句:

真实代码:上层怎么用下层

不只是嘴上说"依赖"——下面是 pi-agent-core 真实代码的第一行,从 pi-ai 拿走它需要的所有类型:

CODE
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 Modeltype 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 循环?

03

统一万家 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() 函数内部有几千行复杂逻辑。打开源码,结果:

CODE
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 stream()
☁️
Anthropic 服务器
点击"下一步"看翻译过程

惊艳的招数:跨模型接力对话

pi-ai 还有一招让人拍大腿的——你可以聊到一半换模型,对话不丢

CODE
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 透明刷新,业务代码无感知。

为什么 error 是事件而不是异常?

流式应用里,错误来临前可能已经收到了一半内容(GPT-4 写到一半超时)。把错误也变成事件,UI 就能优雅地"显示已收到的部分 + 红色错误条",而不是把一切扔掉。这是流式系统设计的金科玉律。

用"统一 API"思维拆 3 个场景

你的产品要支持 OpenRouter 上的随便哪个模型。基于 pi-ai 的设计,最优做法?

你的应用流式打字突然变成"全部一次性出现,没有打字机效果"。最可能哪里出问题?

产品要做"让用户从 GPT-4 切到 Claude 继续聊"。pi-ai 是否原生支持?

04

Agent 循环 —— 会用工具的大脑

所有 AI 编程工具的"心脏跳动"

手术室里发生的事

你跟 Cursor 说"帮我重构这个文件",AI 不是一口气写完——它read 一下、write 一下、再 read、再 edit、再 bash、再问你一句。这一连串动作背后只有一件事:Agent 循环

把它想成手术室:模型是主刀医生("我现在需要 read 这个文件 / 现在需要 write 那段代码"),Agent 循环是器械护士(递工具、收回器械、记录每一步、随时听主刀的下一句话)。

🫀
Agent 循环 = AI 编程工具的"心脏跳动"

Cursor、Claude Code、Aider、pi —— 你看到任何能"连续推理 + 调工具"的产品,内部都是同一个 while 循环。学会这一段,整个赛道都能拆开。

循环的真身:一段简单的伪代码

真实代码 700 多行(在 packages/agent/src/agent-loop.ts 里),但骨架就是这样的:

CODE
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 工具的真实定义:

CODE
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 任何文件。"

⚠️
execute 出错时一定要 throw,不要 return 错误对象

pi-agent-core 会捕获 throw 自动标 isError: true,模型在下一轮看到错误后会自动重试或换思路。如果你 return 一个"错误内容",模型会以为成功了,继续犯同样的错。这是写 AI 工具最容易踩的坑。

练 4 个真实调试场景

你的 Agent 一直在 read 同一个文件 20 次然后才回答你。最可能的根因?

用户在 Agent 跑工具时按下 Enter,输入"停下,先别改这个文件"。这条消息何时被处理?

你想给 AI 加一个"危险操作要审计"的需求:每次它要 rm 文件,先记录到日志再执行。最该用什么?

下面哪种写法最符合 pi-agent-core 对 execute 的"错误处理契约"?

05

终端魔法 —— 差分渲染与 4 件工具

为什么 pi 的终端 UI 比 git diff 还流畅?

你见过会闪的命令行吗?

大多数老式终端工具刷新整屏时——文字会跳一下,光标会乱蹦,上面的内容会被滚走。pi 不会。它能在 80 列宽的终端里画 Markdown、彩色 diff、流式打字机效果,全程不闪烁。

秘密只有 80 行 JavaScript 的差分算法

💡
隐喻:舞台幕布的灯光师

一台戏不是每一秒都在换灯——只在演员动的瞬间补灯。差分渲染就是这件事:屏幕上 1000 行字,只有第 700 行在变,那就只刷第 700 行。

差分算法的"心脏"

下面是 pi-tui 真实代码(packages/tui/src/tui.ts 第 985-1014 行),找出"哪几行变了"的核心逻辑:

CODE
// 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 会根据情况选不同策略:

$ pi --print "hello"
[模型启动…]
读取 README.md…
loading...

策略 1:First Render。屏幕上还什么都没有,pi-tui 直接把全部内容写出来,不擦不擦。这是最简单的情况。

极简工具集:4 件做天下

pi 默认只给模型 4 个工具。看似少,组合起来威力惊人:

📖

read

读文件全部内容。无副作用——再 read 一万次也不破坏什么,最安全。

✍️

write

覆盖整个文件。慎用——一不小心整个文件被冲掉。pi 一般引导模型优先用 edit。

✂️

edit

精准字符串替换。要求 oldString 在文件里唯一——比 write 安全 10 倍,是默认改文件方式。

bash

执行任意命令。权限最大——能装包、能 git、能 rm。强烈建议加 beforeToolCall 拦截。

极简到震撼:bash 工具的全部参数

你以为"能执行任意命令"的工具会有几十个参数?看 pi 实际的 bash schema:

CODE
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 件工具里哪个是"无副作用"——再调用一万次也不破坏什么?

🔑
差分思维(diffing)是性能优化的祖师爷

React 用它(Virtual DOM diff)、Git 用它(快照 + 差分)、视频压缩用它(只存帧间变化)。看到任何"高性能 UI",背后大概率是 diff。学会从"上一帧 vs 这一帧"的角度看问题,你能拆开 90% 的渲染优化方案。

06

会话分支与扩展

长期对话、回滚老分支、给 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":"..."}
📝
JSONL = JSON Lines

每一行是一个 JSON 对象的纯文本格式。JSONL 适合"边写边追加",不会越来越慢。

🌿
parentId = 树的指针

每条消息知道自己的"上一条"。同一个 parentId 可以有多个 child = 多个分支。整个文件是一棵

分支 = 一根新指针,零复制

"嫁接"不会复制整段历史——只是新消息的 parentId 指向某个老节点。100 个分支也只是 100 行新文本。

/tree 操作现场

看一下用户怎么"穿越回过去":

🎯
Git 教了我们:分支 + 历史不丢,是工程师生产力的核心

pi 把 Git 的心智模型搬进了 LLM 对话——你的每个"走错路"都是宝贵分支,永远不会真的失去。

长对话工具箱:4 件武器

🌳

/tree

浏览整棵会话树,跳到任意节点继续。"30 分钟前那个思路是对的,回去。"

🪝

/fork

从某条 user 消息开一个全新会话文件。适合"我要从这里分两条完全独立的路探索"。

🗜️

/compact

上下文快爆 token 上限了,让 LLM 自己把老消息总结成一段。完整原文还在 JSONL,可 /tree 找回。

▶️

pi -c / -r

-c 接着最近一次会话,-r 从历史里挑一个。重启电脑、第二天来——对话从昨天那一秒接上。

1
上下文接近 token 上限

比如已经堆了 150k token,模型马上要超出 200k 的上下文窗口。

2
pi 调用一个"压缩 prompt"

让 LLM 把老消息总结成一段简洁的摘要(保留关键决策、文件路径、错误教训)。

3
用总结替换老消息,token 数降下来

新消息继续。完整历史还在 JSONL 文件里——任何时候 /tree 找回都行。

扩展点:给 pi 装上自己的能力

pi 的另一个工程巧思——你不需要 fork pi 改源码,就能加新工具、新命令、新 UI。一个 extension = 一个 TypeScript 文件:

CODE
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 的全部内脏:

1
整体地图

5 个角色(终端/Agent循环/LLM/工具/文件)协作完成一次对话

2
包结构

5 个 npm 包 + 上层依赖下层 + 任意一个能单买

3
LLM 层

注册表 + 标准事件流 + Cross-Provider Handoff,20+ 家 LLM 一个 API

4
Agent 循环

while 循环 + 工具三件套 + steering/follow-up/beforeToolCall 三种插队

5
终端魔法

差分算法找变化行 + CSI 2026 同步输出 + 4 件极简工具

6
长期对话与扩展

JSONL 树状会话 + /tree 穿越历史 + Skill/Extension/Package 三层定制

🚀
下次再用 Cursor 或 Claude Code,你眼里看到的不再是黑盒

而是 5 个互相协作的包 + 一个 while 循环 + 一棵会话树。当 AI 卡住、幻觉、用错工具,你能精准定位是哪一道关卡坏了。这就是把 vibe coding 升级成"工程师式 AI 协作"的入门钥匙。继续往前走。