2025-11-30

这不算什么,但它是我的
过去三年里,我一直用 LLM 来辅助写代码。如果你读到了这里,你大概率也经历过类似的演化:从把代码复制粘贴进 ChatGPT,到 Copilot 的自动补全(对我来说从来没好用过),再到 Cursor,最终来到 2025 年成为日常主力的那一批“编码代理外壳(harness)”,比如 Claude Code、Codex、Amp、Droid、opencode。
我在大多数工作里更偏爱 Claude Code。它是我在四月从 Cursor 用了一年半后转过去尝试的第一个工具。那时它还简单得多,刚好贴合我的工作流——因为我是个喜欢简单、可预测工具的简单男孩。可过去几个月,Claude Code 变成了一艘宇宙飞船,80% 的功能我根本用不上。更糟的是,系统提示词和工具也会在每次发布时变化,这会打碎我的工作流,还会改变模型行为。我讨厌这种事。另外,它还会闪烁。
这些年我也做过一堆不同复杂度的 agent。比如 Sitegeist——我那个小小的 browser-use 代理——本质上就是住在浏览器里的编码代理。在这些工作里,我学到:上下文工程(context engineering)至关重要。精确控制哪些内容进入模型上下文,往往能得到更好的输出,尤其是在写代码时。现有的外壳通常会在你背后塞进一堆东西,而且 UI 里甚至不展示,让这件事变得极其困难甚至不可能。
说到“展示”,我想检查我与模型交互的每一个方面。基本没有任何外壳能做到这一点。我还想要一种干净、文档化的 session 格式,便于我做自动后处理;以及一种简单的方法,在 agent core 之上构建替代 UI。现有外壳里也不是完全做不到,但它们的 API 闻起来像“有机演化”的味道:一路堆了行李,最终体现在开发体验上。我不怪任何人。要是有一堆人用你的破玩意儿,还得做点向后兼容,那就是你要付的代价。
我也折腾过自托管,不管是本地还是放到 DataCrunch 上。虽然像 opencode 这样的外壳支持自托管模型,但通常体验并不好。主要原因是它们依赖像 Vercel AI SDK 这样的库,而这玩意儿出于某些原因对自托管模型“不太友好”,尤其是在工具调用这块。
所以,一个对着 Claude 大喊大叫的老家伙要干嘛?当然是写自己的编码代理外壳,并给它取一个完全不可被 Google 到的名字,这样就永远不会有用户——也就永远不会有 GitHub issue。能有多难?
为此,我需要做:
- pi-ai:一个统一的 LLM API,支持多 provider(Anthropic、OpenAI、Google、xAI、Groq、Cerebras、OpenRouter,以及任何 OpenAI 兼容端点)、流式输出、基于 TypeBox schema 的工具调用、thinking/reasoning 支持、跨 provider 的无缝上下文交接、token 和成本追踪。
- pi-agent-core:一个 agent loop,负责工具执行、校验与事件流。
- pi-tui:一个极简终端 UI 框架,支持差分渲染、同步输出(几乎无闪烁的更新),以及带自动补全和 markdown 渲染的编辑器等组件。
- pi-coding-agent:真正的 CLI,把一切串起来:session 管理、自定义工具、主题,以及项目上下文文件。
我在这一切里的哲学是:不需要的东西,我就不会做。而我不需要的东西其实很多。
pi-ai 和 pi-agent-core
我不打算用 API 细节来折磨你。你可以在 README.md 里看个够。这里我更想记录:在做统一 LLM API 时我遇到的坑,以及我是怎么解决的。我不说我的方案是最好的,但它们在各种 agentic 和非 agentic 的 LLM 项目里一直工作得不错。
There. Are. Four. Ligh... APIs
你跟几乎任何 LLM provider 打交道,其实只需要会四个 API: OpenAI 的 Completions API、更新的 Responses API、Anthropic 的 Messages API,以及 Google 的 Generative AI API。
它们功能上都差不多,所以在它们上面做一层抽象并不是什么火箭科学。当然,你得关心一些 provider 特有的怪癖。尤其是 Completions API:几乎所有 provider 都“说”这个 API,但每家对“这个 API 应该做什么”的理解都不一样。比如,OpenAI 的 Completions API 不支持 reasoning traces,但其他 provider 的“Completions API 版本”可能支持。同样的情况也会出现在推理引擎上,比如 llama.cpp、Ollama、vLLM、LM Studio。
举个例子,在 openai-completions.ts 里:
- Cerebras、xAI、Mistral、Chutes 不喜欢
store字段 - Mistral 和 Chutes 用
max_tokens而不是max_completion_tokens - Cerebras、xAI、Mistral、Chutes 不支持用于 system prompt 的
developerrole - Grok 模型不喜欢
reasoning_effort - 不同 provider 会把 reasoning 内容塞在不同字段里(
reasoning_contentvsreasoning)
为了确保这些功能在一大票 provider 上都能真的跑起来,pi-ai 做了一个相当完整的测试套件,覆盖 image input、reasoning traces、工具调用,以及你会期望统一 LLM API 支持的其他能力。测试会跑遍所有支持的 provider 和常见模型。就算这样,也仍然无法保证新模型、新 provider 都能开箱即用。
另一个巨大差异是:各家如何报告 token 以及 cache 读写。Anthropic 的做法最“正常”,但总体来说这块完全是狂野西部。有些会在 SSE 流一开始就报告 token,有些只在结束时报告——如果你中途 abort 了请求,就根本无法准确追踪成本。更气的是,你没法提供一个唯一 ID,之后再去对账它们的 billing API,搞清楚到底哪个用户消耗了多少 token。所以 pi-ai 的 token 和 cache 追踪只能做到 best-effort。个人用够了,但如果你有终端用户在你的服务里烧 token,想精确计费就不行。
特别点名 Google:直到今天似乎都不支持 tool call streaming,真是非常 Google。
pi-ai 还能在浏览器里工作,这对做 web UI 很有用。有些 provider 让这事尤其简单,因为它们支持 CORS,尤其是 Anthropic 和 xAI。
上下文交接(handoff)
跨 provider 的上下文交接是 pi-ai 从一开始就按“必须支持”来设计的功能。因为每家 provider 跟踪 tool call 和 thinking trace 的方式都不同,这只能做到 best-effort。比如你在一个 session 中途从 Anthropic 切到 OpenAI,Anthropic 的 thinking trace 会被转换为 assistant message 里的 content block,用 <thinking></thinking> 标签包起来。这样做合不合理不一定,因为 Anthropic 和 OpenAI 返回的 thinking trace 并不真的代表它们幕后发生的事情。
这些 provider 还会在事件流里塞进签名 blob,你在后续请求里重放同一组消息时必须把它们原样带上。即便是在同一 provider 内切换模型也一样。这会让背后的抽象和转换流水线非常笨重。
但我很高兴地报告:在 pi-ai 里,跨 provider 的上下文交接,以及上下文的序列化/反序列化,整体跑得挺好:
import { getModel, complete, Context } from '@mariozechner/pi-ai';
// Start with Claude
const claude = getModel('anthropic', 'claude-sonnet-4-5');
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);
// Switch to GPT - it will see Claude's thinking as <thinking> tagged text
const gpt = getModel('openai', 'gpt-5.1-codex');
context.messages.push({ role: 'user', content: 'Is that correct?' });
const gptResponse = await complete(gpt, context);
context.messages.push(gptResponse);
// Switch to Gemini
const gemini = getModel('google', 'gemini-2.5-flash');
context.messages.push({ role: 'user', content: 'What was the question?' });
const geminiResponse = await complete(gemini, context);
// Serialize context to JSON (for storage, transfer, etc.)
const serialized = JSON.stringify(context);
// Later: deserialize and continue with any model
const restored: Context = JSON.parse(serialized);
restored.messages.push({ role: 'user', content: 'Summarize our conversation' });
const continuation = await complete(claude, restored);
我们活在一个多模型的世界
说到模型,我想在 getModel 调用里用一种 typesafe 的方式来指定它们。为此我需要一个模型注册表,能转成 TypeScript types。我会从 OpenRouter 和 models.dev(opencode 那帮人做的,感谢,真的很好用)解析数据,生成 models.generated.ts。其中包含 token 成本、以及 image input、thinking 支持等能力信息。
而如果我需要加一个注册表里没有的模型,我也希望类型系统能让创建新模型变得简单。这对自托管模型、新发布但尚未进入 models.dev/OpenRouter 的模型,或者一些更冷门的 LLM provider 都很有用:
import { Model, stream } from '@mariozechner/pi-ai';
const ollamaModel: Model<'openai-completions'> = {
id: 'llama-3.1-8b',
name: 'Llama 3.1 8B (Ollama)',
api: 'openai-completions',
provider: 'ollama',
baseUrl: 'http://localhost:11434/v1',
reasoning: false,
input: ['text'],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 32000
};
const response = await stream(ollamaModel, context, {
apiKey: 'dummy' // Ollama doesn't need a real key
});
很多统一 LLM API 完全不提供 abort 请求的方法。如果你想把 LLM 集成进任何生产系统,这是完全不可接受的。很多统一 LLM API 也不会把 partial result 返回给你,这就更离谱了。pi-ai 从一开始就把 abort 贯穿到整条流水线里,包括工具调用。用法是这样:
import { getModel, stream } from '@mariozechner/pi-ai';
const model = getModel('openai', 'gpt-5.1-codex');
const controller = new AbortController();
// Abort after 2 seconds
setTimeout(() => controller.abort(), 2000);
const s = stream(model, {
messages: [{ role: 'user', content: 'Write a long story' }]
}, {
signal: controller.signal
});
for await (const event of s) {
if (event.type === 'text_delta') {
process.stdout.write(event.delta);
} else if (event.type === 'error') {
console.log(`${event.reason === 'aborted' ? 'Aborted' : 'Error'}:`, event.error.errorMessage);
}
}
// Get results (may be partial if aborted)
const response = await s.result();
if (response.stopReason === 'aborted') {
console.log('Partial content:', response.content);
}
结构化地拆分工具结果
我在任何统一 LLM API 里都没见过的另一个抽象是:把工具结果拆成“给 LLM 的部分”和“给 UI 展示的部分”。给 LLM 的那部分通常就是 text 或 JSON,但它不一定包含 UI 想展示的全部信息。而从工具输出的文本里再去解析并重结构成 UI 需要的数据,体验非常糟。pi-ai 的工具实现允许你同时返回:一组交给 LLM 的 content block,以及另一组专供 UI 渲染的 content block。工具还可以返回诸如图片之类的附件,并以各 provider 的原生格式附加。工具参数会用 TypeBox schema 和 AJV 自动校验,在校验失败时给出细致错误信息:
import { Type, AgentTool } from '@mariozechner/pi-ai';
const weatherSchema = Type.Object({
city: Type.String({ minLength: 1 }),
});
const weatherTool: AgentTool<typeof weatherSchema, { temp: number }> = {
name: 'get_weather',
description: 'Get current weather for a city',
parameters: weatherSchema,
execute: async (toolCallId, args) => {
const temp = Math.round(Math.random() * 30);
return {
// Text for the LLM
output: `Temperature in ${args.city}: ${temp}°C`,
// Structured data for the UI
details: { temp }
};
}
};
// Tools can also return images
const chartTool: AgentTool = {
name: 'generate_chart',
description: 'Generate a chart from data',
parameters: Type.Object({ data: Type.Array(Type.Number()) }),
execute: async (toolCallId, args) => {
const chartImage = await generateChartImage(args.data);
return {
content: [
{ type: 'text', text: `Generated chart with ${args.data.length} data points` },
{ type: 'image', data: chartImage.toString('base64'), mimeType: 'image/png' }
]
};
}
};
目前还缺的是工具结果的流式返回。想象一下一个 bash 工具,你想边跑边把 ANSI 序列显示出来。现在做不到,但修起来很简单,以后会加进包里。
在工具调用流式过程中做 partial JSON 解析对好的 UX 至关重要。当 LLM 流式输出工具调用参数时,pi-ai 会逐步解析它们,这样你就能在调用完成前,在 UI 里展示部分结果。比如 agent 在重写文件时,你可以看到 diff 是怎么一点点流出来的。
最小化的代理脚手架
最后,pi-ai 提供了一个 agent loop,负责完整编排:处理用户消息、执行工具调用、把结果喂回 LLM、重复直到模型输出不包含工具调用的回复为止。这个 loop 还支持通过回调进行消息队列:每一轮结束后,它会询问是否有排队消息,然后在下一次 assistant 回复前把它们注入进去。loop 会为所有事件发出 event stream,让构建响应式 UI 很容易。
这个 loop 不让你设置 max steps 之类在其他统一 LLM API 里常见的旋钮。我从来没遇到过需要它的场景,所以为什么要加?它就一直 loop,直到 agent 说自己完成了。不过在 loop 之上,pi-agent-core 提供了一个 Agent 类,里面才是些真正有用的东西:状态管理、简化的事件订阅、两种模式的消息队列(一次一个或一次全部)、附件处理(图片、文档),以及传输层抽象——让你既可以直接运行 agent,也可以通过代理运行。
我对 pi-ai 满意吗?大体上是的。像所有统一 API 一样,抽象总会漏(leaky abstractions),所以它不可能完美。但它已经被用在七个不同的生产项目里,并且一直非常好使。
为什么不直接用 Vercel AI SDK 来做?Armin 的博文 完全复刻了我的体验。直接在各 provider 的 SDK 上构建能让我掌控全局,让我能按自己想要的方式设计 API,且表面积更小。Armin 的文章对“为什么要自己造”有更深入的论述。去读。
pi-tui
我是在 DOS 时代长大的,所以终端用户界面(TUI)就是我从小的东西:从 Doom 那些花里胡哨的安装程序到 Borland 的产品,TUI 一直陪我到 90 年代末。然后当我切换到 GUI 操作系统时,我真是他妈的开心。TUI 大多可移植、也容易流式传输,但它的信息密度很烂。话虽如此,我还是觉得:给 pi 先做一个终端 UI 最合理。以后想要 GUI 的时候,我随时可以再绑上去。
那为什么要自己写一个 TUI 框架?我看过一些替代方案,比如 Ink、Blessed、OpenTUI 等。我相信它们各有各的好,但我绝对不想把 TUI 写成一个 React app。Blessed 似乎基本没人维护,而 OpenTUI 明确说了不适合生产。再说,在 Node.js 上写一个自己的 TUI 框架看起来是个有趣的小挑战。
两种 TUI
写终端 UI 本身不算火箭科学。你只需要选好你的毒。基本上有两条路:
第一种是接管终端视口(viewport,也就是你实际能看到的那块),把它当像素缓冲区来对待。只不过不是像素,而是一格一格的 cell:字符、背景色、前景色、以及斜体/加粗等样式。我把这种叫“全屏 TUI”。Amp 和 opencode 用的就是这个方案。
缺点是你会失去 scrollback buffer,所以你得自己实现搜索。你也会失去滚动,所以你得在视口里自己模拟滚动。这并不难,但意味着你要重做一遍终端模拟器已经提供的所有功能。尤其是鼠标滚轮,在这种 TUI 里通常都怪怪的。
第二种是像普通 CLI 一样直接往终端输出,把内容追加到 scrollback buffer 里,只在少数情况下把“渲染光标”往上移动一点,在可见视口里重画一些东西,比如动画 spinner 或文本编辑框。事情当然没这么简单,但你理解就行。这是 Claude Code、Codex、Droid 走的路。
编码代理有个很好的性质:它基本就是一个聊天界面。用户写一个 prompt,接着是 agent 的回复、工具调用以及结果。一切都线性展开,所以特别适合利用“原生终端模拟器”。你能直接用内建的滚动和搜索;也会在一定程度上限制你的 TUI 能做什么——我很喜欢这种限制,因为约束会催生极简程序:只做该做的事,不搞多余花活。这就是我为 pi-tui 选的方向。
保留模式 UI
如果你写过 GUI,你大概听过 retained mode vs immediate mode。保留模式里,你构建一棵组件树,它们跨帧持久存在;每个组件知道怎么渲染自己,并且在无变化时能缓存输出。即时模式里,你每帧从头画一遍(不过现实里即时模式也会做缓存,不然性能会崩)。
pi-tui 用的是一个很简单的保留模式:一个 Component 就是一个对象,提供 render(width),返回一个字符串数组(每个字符串是一行,保证水平不超出视口宽度,包含 ANSI escape code 用于颜色和样式),以及一个可选的 handleInput(data) 用来处理键盘输入。Container 持有按垂直布局排列的 component 列表,负责收集它们渲染出来的所有行。TUI 类本身也是一个 container,负责编排整体。
当 TUI 需要更新屏幕时,它会让每个组件渲染一次。组件可以缓存输出:比如一条 assistant 消息完整流完后,就没必要每次都重新解析 markdown、重新渲染 ANSI。它只需要返回缓存的行。Container 把所有子组件的行收集起来;TUI 把这些行聚合,然后与上一次渲染的行进行比较。它维护着一种 backbuffer,记住自己写进 scrollback buffer 的内容。
接着它只重画发生变化的部分,我把这叫“差分渲染”。我很差劲地取名,这大概有更正式的叫法。
差分渲染
这里有个简化 demo,能说明到底重画了什么。
算法很简单:
- 首次渲染:直接把所有行输出到终端
- 宽度变化:清屏并重渲染全部(软换行会变化)
- 正常更新:找出第一行发生变化的位置,把光标移动到那一行,然后从那里一直重渲染到末尾
有一个坑:如果第一条变化的行在可见视口之上(用户向上滚动了),就必须全清并重渲染。终端不允许你往视口上方的 scrollback buffer 里写。
为了避免更新时闪烁,pi-tui 把所有渲染包在同步输出的 escape sequence 里(CSI ?2026h 和 CSI ?2026l)。这会告诉终端把输出先缓冲起来,最后一次性显示。大多数现代终端都支持。
它到底效果如何、闪烁多少?在 Ghostty 或 iTerm2 这种靠谱的终端里,它表现非常好,几乎看不到闪烁。在比较不幸的实现里,比如 VS Code 内置终端,你会看到一点闪烁——取决于一天中的时间、显示器大小、窗口大小等等。考虑到我已经非常习惯 Claude Code,我就没再花时间优化这件事。我对 VS Code 那一点点闪烁很满意。不然我反而不习惯。而且它仍然比 Claude Code 闪得少。
这种做法浪费吗?我们会保存一整个 scrollback buffer 规模的“上次渲染行”,并且每次 TUI 被要求渲染时都要重新渲染行。通过前面提到的缓存,这个重渲染成本并不高。我们还是要比较大量行。现实里,在 25 岁以下的电脑上,这点开销无论性能还是内存(很大 session 也就几百 KB)都不算什么。感谢 V8。作为交换,我得到的是一个死简单的编程模型,让我可以快速迭代。
pi-coding-agent
编码代理外壳应该有什么功能,我不需要解释。pi 也有你在其他工具里习惯的多数“舒适功能”:
跑在 Windows、Linux、macOS(或任何有 Node.js runtime 和终端的环境)
多 provider 支持,且支持 session 中途切换模型
session 管理:continue、resume、branch
项目上下文文件(AGENTS.md)层级加载:从全局到项目目录
常用操作的 slash 命令
自定义 slash 命令:以 markdown 模板形式,支持参数
用于 Claude Pro/Max 订阅的 OAuth 认证
通过 JSON 配置自定义 provider 和模型
可定制主题,支持热重载
编辑器:模糊文件搜索、路径补全、拖拽、以及多行粘贴
agent 工作时的消息排队
支持能看图的模型(vision)
session 的 HTML 导出
通过 JSON streaming 和 RPC 模式进行 headless 运行
完整的 token 与成本追踪
如果你想看完整介绍,去读 README。更有趣的是:pi 在哲学和实现上与其他外壳的偏离。
极简系统提示词
系统提示词长这样:
You are an expert coding assistant. You help users with coding tasks by reading files, executing commands, editing code, and writing new files.
Available tools:
- read: Read file contents
- bash: Execute bash commands
- edit: Make surgical edits to files
- write: Create or overwrite files
Guidelines:
- Use bash for file operations like ls, grep, find
- Use read to examine files before editing
- Use edit for precise changes (old text must match exactly)
- Use write only for new files or complete rewrites
- When summarizing your actions, output plain text directly - do NOT use cat or bash to display what you did
- Be concise in your responses
- Show file paths clearly when working with files
Documentation:
- Your own documentation (including custom model setup and theme creation) is at: /path/to/README.md
- Read it when users ask about features, configuration, or setup, and especially if the user asks you to add a custom model or provider, or create a custom theme.
就这样。底部唯一被注入的是你的 AGENTS.md 文件:一个全局的(作用于所有 session),以及一个存放在项目目录里的项目级 AGENTS.md。你可以用它们把 pi 调得很合你胃口。你甚至可以完全替换系统提示词。对比一下:Claude Code 的系统提示词、Codex 的系统提示词、或 opencode 的按模型拆分的提示词(Claude 那份是它们复制过来的精简版,源头是原版 Claude Code prompt)。
你可能会觉得这很疯狂。很可能模型在它们的“原生编码外壳”上有一定训练,所以用原生 prompt 或接近的东西(比如 opencode)更理想。但事实是:所有前沿模型都被 RL 训练得飞起,所以它们本来就懂“编码代理”是什么。至少从基准测试部分你会看到(以及我过去一段时间只用 pi 的主观体验),根本不需要 10,000 token 的系统提示词。Amp 虽然复制了部分原生 prompt,但用自己的 prompt 也照样跑得不错。
极简工具集
工具定义是这样的:
read
Read the contents of a file. Supports text files and images (jpg, png,
gif, webp). Images are sent as attachments. For text files, defaults to
first 2000 lines. Use offset/limit for large files.
- path: Path to the file to read (relative or absolute)
- offset: Line number to start reading from (1-indexed)
- limit: Maximum number of lines to read
write
Write content to a file. Creates the file if it doesn't exist, overwrites
if it does. Automatically creates parent directories.
- path: Path to the file to write (relative or absolute)
- content: Content to write to the file
edit
Edit a file by replacing exact text. The oldText must match exactly
(including whitespace). Use this for precise, surgical edits.
- path: Path to the file to edit (relative or absolute)
- oldText: Exact text to find and replace (must match exactly)
- newText: New text to replace the old text with
bash
Execute a bash command in the current working directory. Returns stdout
and stderr. Optionally provide a timeout in seconds.
- command: Bash command to execute
- timeout: Timeout in seconds (optional, no default timeout)
如果你想限制 agent 不去修改文件或执行任意命令,还可以提供额外的只读工具(grep、find、ls)。默认它们是禁用的,所以 agent 只拿到上面四个工具。
结果证明,这四个工具就足够让编码代理变得非常有效。模型本来就知道怎么用 bash,也在类似 schema 的 read/write/edit 工具上受过训练。对比一下 Claude Code 的工具定义 或 opencode 的工具定义(明显从 Claude Code 衍生出来:同样结构、同样示例、同样的 git 提交流程)。值得注意的是,Codex 的工具定义 也同样极简。
pi 的系统提示词加工具定义,总体不到 1000 token。
默认 YOLO
pi 默认全 YOLO 模式运行,假设你知道自己在干什么。它对你的文件系统有无限制访问,并能在没有权限检查或安全护栏的情况下执行任何命令。文件操作/命令执行没有任何确认提示。不像某些工具会让 Haiku 之类的东西预检查 bash 命令是否恶意。它拥有完整文件系统访问权,并能用你的用户权限执行命令。
如果你看其他编码代理里的安全措施,它们大多是安全戏法(security theater)。只要你的 agent 能写代码并运行代码,就基本完蛋了。唯一能防止数据外泄的方法,是切断 agent 执行环境的所有网络访问——但那会让 agent 大部分时候没啥用。另一种做法是 allow-list 域名,但也能被其他方式绕过。
Simon Willison 已经写了很多关于这个问题。他的“dual LLM”模式试图解决 confused deputy 和数据外泄,但连他自己也承认“这个方案挺烂”,而且会引入巨大的实现复杂度。核心问题仍然是:如果一个 LLM 能使用工具读取私密数据、并发起网络请求,你就在跟攻击面打地鼠。
既然我们无法解决这个能力三角(读数据、执行代码、网络访问),pi 就干脆投降。为了有效产出,大家本来也都在 YOLO 模式运行,所以为什么不把它做成默认且唯一的选项?
默认 pi 没有 web search 或 fetch 工具。但它可以用 curl,也可以读磁盘文件——这两者都提供了足够大的 prompt injection 攻击面。文件或命令输出中的恶意内容都可能影响行为。如果你对这种全权限不舒服,就把 pi 跑在容器里,或者如果你需要(伪)护栏,就用别的工具。
不内置待办事项
pi 不支持、也永远不会支持内置的 to-do。以我的经验,to-do 列表通常会把模型搞糊涂,弊大于利。它们引入了一种模型需要跟踪并更新的状态,从而增加出错机会。
如果你需要任务追踪,把它外置成一个文件:
# TODO.md
- [x] Implement user authentication
- [x] Add database migrations
- [ ] Write API documentation
- [ ] Add rate limiting
agent 可以按需读写这个文件。用 checkbox 记录完成与否。简单、可见、且完全在你掌控之下。
没有计划模式
pi 没有、也不会有内置 plan mode。你只要告诉 agent:先和你一起把问题想清楚,不改文件不跑命令,通常就够了。
如果你需要跨 session 的持久规划,把它写进一个文件:
# PLAN.md
## Goal
Refactor authentication system to support OAuth
## Approach
1. Research OAuth 2.0 flows
2. Design token storage schema
3. Implement authorization server endpoints
4. Update client-side login flow
5. Add tests
## Current Step
Working on step 3 - authorization endpoints
agent 可以读、改、引用这份 plan。与只存在于某次 session 的“计划模式”不同,基于文件的计划可以跨 session 共享,还能跟代码一起版本化。
有趣的是,Claude Code 现在也有个 Plan Mode,基本就是只读分析,然后最终会写一个 markdown 文件到磁盘。并且你基本没法在 plan mode 下不批准一大堆命令调用——否则几乎不可能做规划。
pi 的区别是:我对一切都有可观测性。我能看到 agent 实际看了哪些来源、又完全漏掉了哪些。在 Claude Code 里,负责编排的 Claude 往往会生成一个子代理,而你对那个子代理做了什么完全不可见。pi 里我能立刻看到 markdown 文件,我还能和 agent 协作编辑它。简而言之:我需要“对规划过程的可观测性”,而 Claude Code 的 plan mode 给不了。
如果你真的必须在规划阶段限制 agent,你可以用 CLI 指定它能用的工具:
pi --tools read,grep,find,ls
这会给你一个只读模式,用于探索和规划,agent 不能改文件也不能跑 bash。但你不会开心的。
不支持 MCP
pi 不支持、也永远不会支持 MCP。我已经在另一篇文章里详细写过,但 TL;DR 是:对大多数场景来说 MCP server 过度设计,而且会带来显著的上下文开销。
流行的 MCP server,比如 Playwright MCP(21 个工具,13.7k token)或 Chrome DevTools MCP(26 个工具,18k token),会在每个 session 里把它们完整的工具描述塞进上下文。你还没开始干活,context window 就先没了 7–9%。而很多工具在一个 session 里根本不会用到。
替代方案很简单:写 CLI 工具 + README。agent 只有在需要这个工具时才读 README,把 token 成本按需支付(progressive disclosure),再用 bash 去调用 CLI。这个方案更可组合(管道、链式命令),扩展也容易(再加一个脚本就行),并且更省 token。
我就是这样给 pi 加 web search 的:
我维护了一套这类工具,放在 github.com/badlogic/agent-tools。每个工具都是一个简单 CLI,配一个 README,agent 需要时再读。
如果你非要用 MCP server,看看 Peter Steinberger 的 mcporter,它能把 MCP server 包装成 CLI。
没有后台 bash
pi 的 bash 工具是同步执行命令的。它没有内置方法启动一个 dev server、在后台跑测试,或在命令还在跑时与 REPL 交互。
这是故意的。后台进程管理会引入复杂度:你需要进程跟踪、输出缓冲、退出时清理、以及向运行中进程发送输入的方式。Claude Code 用它的 background bash 解决了一部分,但可观测性很差(Claude Code 的常见主题),而且它强迫 agent 自己在脑子里跟踪运行中的实例,却不给你一个查询它们的工具。早期的 Claude Code 版本在上下文压缩后会忘掉所有后台进程,并且没法查询,于是你只能手动杀掉。后来这点修了。
直接用 tmux 就行。下面是 pi 用 LLDB 调试一个会崩的 C 程序:
观察性如何?同样的方法适用于长跑的 dev server、观察日志输出等场景。如果你愿意,你甚至可以自己进那个 tmux 里的 LLDB session,跟 agent 一起协作调试。tmux 还提供一个 CLI 参数列出所有活动 session。多好。
完全没必要做后台 bash。你知道 Claude Code 也能用 tmux 吧。bash 就够了。
没有子代理
pi 没有专门的子代理工具。当 Claude Code 需要做复杂事时,经常会生成一个子代理来处理某一部分。你对那个子代理做了什么完全不可见——黑箱套黑箱。agent 之间的上下文传递也很差:编排的 agent 决定给子代理什么初始上下文,而你通常对这个几乎没控制。如果子代理犯错,debug 会非常痛苦,因为你看不到完整对话。
如果你需要 pi 生成一个“自己”,就让它用 bash 跑它自己。你甚至可以让它在 tmux 里生成那个子进程,以获得完全可观测性,以及你随时插进去互动的能力。

但更重要的是:修正你的工作流,至少是那些关于“收集上下文”的部分。人们在一个 session 里用子代理做上下文收集,以为能省上下文空间——这确实没错。但这是思考子代理的错误方式。你在 session 中途用子代理来收集上下文,说明你一开始没有规划好。如果你需要收集上下文,先在一个独立 session 里做。产出一个 artifact,之后你可以在一个全新 session 里把它喂给 agent,让它获得所需上下文,而不是用一堆工具输出污染 context window。那个 artifact 也能在下一个功能里复用;同时你获得了完全的可观测性和可操控性,而这些在上下文收集阶段非常重要。
因为尽管大家相信模型“很强”,它们在找到实现新功能/修 bug 所需的全部上下文方面仍然很差。我把这归因于:模型被训练成只读文件的一部分而不是整份文件,所以它们不太愿意把所有东西都读完。结果它们会漏掉关键上下文,看不到自己真正需要的东西,于是任务完成得就不对。
看看 pi-mono 的 issue tracker 和 PR 就知道了:很多 PR 会被关闭或需要大改,因为 agent 没能充分理解需求。这不是贡献者的错——我真心感激,因为就算是不完整 PR 也能让我更快。但这意味着我们对 agent 的信任过头了。
我也不是完全否定子代理。确实有合理场景。我最常用的一个是 code review:我让 pi 通过 bash 生成一个“自己”,用 code review prompt(通过一个自定义 slash 命令),然后拿它的输出。
---
description: Run a code review sub-agent
---
Spawn yourself as a sub-agent via bash to do a code review: $@
Use `pi --print` with appropriate arguments. If the user specifies a model,
use `--provider` and `--model` accordingly.
Pass a prompt to the sub-agent asking it to review the code for:
- Bugs and logic errors
- Security issues
- Error handling gaps
Do not read the code yourself. Let the sub-agent do that.
Report the sub-agent's findings.
下面是我如何用它来 review GitHub 上的一个 PR:
通过一个简单 prompt,我就能选我想 review 的具体点,以及要用哪个模型。我甚至可以设置 thinking level。我还可以把完整 review session 存成文件,然后在另一个 pi session 里打开继续。如果我愿意,也可以把它标记成临时 session,不落盘。所有这些都会翻译成一段 prompt,被主 agent 读到,然后它通过 bash 再跑自己一遍。虽然我仍然看不到那个“子代理”的内心活动,但我能完全看见它的输出。这就已经比其他外壳强太多了——它们往往连这个都做不到,令人费解。
当然,这多少有点“模拟的用例”。现实里我通常直接开一个新 pi session,让它 review PR,必要时把分支拉下来本地看。看到初步 review 后,我再给我自己的 review,然后我们一起把它修到好为止。这就是我不把垃圾代码合进去的工作流。
在一个 session 里并行生成多个子代理去实现各种功能,在我看来是反模式,基本不会成功,除非你不在乎代码库最终退化成一坨垃圾。
基准测试
我说了一堆宏大主张,但我有没有数字证据证明这些反直觉的观点真的有效?我有自己的日常体验,但那很难在博客里传递,你只能选择信或不信。所以我做了一次 Terminal-Bench 2.0 的测试跑法:用 Claude Opus 4.5 的 pi 去参赛,对抗 Codex、Cursor、Windsurf 等外壳及其各自的原生模型。当然,大家都知道基准测试不代表真实世界表现,但这是我能给的、相对“可验证”的证据。
我做了一次完整跑测:每个任务 5 次 trial,满足提交 leaderboard 的条件。之后我又启动了第二次跑测,只在 CET 时区运行,因为我发现 PST 上线后错误率(以及基准成绩)会变差。第一轮的结果如下:

以及截至 2025 年 12 月 2 日,pi 在当前 leaderboard 上的位置:

这里是我提交给 Terminal-Bench 团队的 results.json。如果你想复现结果,pi 的 bench runner 在这个仓库。我建议你用你的 Claude 订阅计划,而不是按量计费。
最后,这是 CET-only 跑测的一点点预览:

这个还要再跑一天左右才会完成。我会在完成后更新这篇文章。
同时注意一下 leaderboard 上 Terminus 2 的排名。Terminus 2 是 Terminal-Bench 团队自己的极简 agent:它只给模型一个 tmux session。模型以文本形式向 tmux 发送命令,然后自己解析终端输出。没有花哨工具,不做文件操作,只有原始终端交互。它仍然能和工具链更复杂的 agent 掰手腕,并且适配多种模型。这进一步说明:极简方案同样可以做到很强。
总结
基准测试很搞笑,但真正的证明在布丁里。而我的布丁是我的日常工作:pi 在其中表现得相当不错。Twitter 上有一堆关于上下文工程的帖子和博客,但我感觉我们现在的外壳没有一个真的让你做上下文工程。pi 是我试图给自己造的一个工具:尽可能让我保持掌控。
我对 pi 的现状很满意。还有一些我想加的功能,比如压缩(compaction) 或工具结果流式返回,但我觉得不会再需要太多了。缺 compaction 对我个人并不是问题。出于某种原因,我能把我和 agent 的数百次交互塞进一个 session,而在 Claude Code 里如果不 compaction 我做不到。
尽管如此,我仍欢迎贡献。但就像我所有开源项目一样,我往往很独裁。这是这些年在更大的项目上吃过亏后学到的。如果我关闭了你提的 issue 或你送的 PR,希望你不要介意。我也会尽量给出理由。我只是想把它保持得聚焦且可维护。如果 pi 不符合你的需求,我强烈建议你 fork 它。我是真心的。如果你做出了一个更符合我需求的东西,我也很乐意加入你的努力。
我觉得上面这些收获也能迁移到其他外壳上。你试试,然后告诉我结果。
本页面尊重你的隐私:不使用 cookies 或类似技术,也不收集任何个人可识别信息。