构建一个有主见且极简的编码代理:我学到的东西

2026-02-17 · 原文链接

2025-11-30

这不算什么,但它是我的

过去三年里,我一直用 LLM 来辅助写代码。如果你读到了这里,你大概率也经历过类似的演化:从把代码复制粘贴进 ChatGPT,到 Copilot 的自动补全(对我来说从来没好用过),再到 Cursor,最终来到 2025 年成为日常主力的那一批“编码代理外壳(harness)”,比如 Claude CodeCodexAmpDroidopencode

我在大多数工作里更偏爱 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 和 pi-agent-core

我不打算用 API 细节来折磨你。你可以在 README.md 里看个够。这里我更想记录:在做统一 LLM API 时我遇到的坑,以及我是怎么解决的。我不说我的方案是最好的,但它们在各种 agentic 和非 agentic 的 LLM 项目里一直工作得不错。

There. Are. Four. Ligh... APIs

你跟几乎任何 LLM provider 打交道,其实只需要会四个 API: OpenAI 的 Completions API、更新的 Responses APIAnthropic 的 Messages API,以及 Google 的 Generative AI API

它们功能上都差不多,所以在它们上面做一层抽象并不是什么火箭科学。当然,你得关心一些 provider 特有的怪癖。尤其是 Completions API:几乎所有 provider 都“说”这个 API,但每家对“这个 API 应该做什么”的理解都不一样。比如,OpenAI 的 Completions API 不支持 reasoning traces,但其他 provider 的“Completions API 版本”可能支持。同样的情况也会出现在推理引擎上,比如 llama.cppOllamavLLMLM Studio

举个例子,在 openai-completions.ts 里:

为了确保这些功能在一大票 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。我会从 OpenRoutermodels.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 框架?我看过一些替代方案,比如 InkBlessedOpenTUI 等。我相信它们各有各的好,但我绝对不想把 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,能说明到底重画了什么。

算法很简单:

  1. 首次渲染:直接把所有行输出到终端
  2. 宽度变化:清屏并重渲染全部(软换行会变化)
  3. 正常更新:找出第一行发生变化的位置,把光标移动到那一行,然后从那里一直重渲染到末尾

有一个坑:如果第一条变化的行在可见视口之上(用户向上滚动了),就必须全清并重渲染。终端不允许你往视口上方的 scrollback buffer 里写。

为了避免更新时闪烁,pi-tui 把所有渲染包在同步输出的 escape sequence 里(CSI ?2026hCSI ?2026l)。这会告诉终端把输出先缓冲起来,最后一次性显示。大多数现代终端都支持。

它到底效果如何、闪烁多少?在 Ghostty 或 iTerm2 这种靠谱的终端里,它表现非常好,几乎看不到闪烁。在比较不幸的实现里,比如 VS Code 内置终端,你会看到一点闪烁——取决于一天中的时间、显示器大小、窗口大小等等。考虑到我已经非常习惯 Claude Code,我就没再花时间优化这件事。我对 VS Code 那一点点闪烁很满意。不然我反而不习惯。而且它仍然比 Claude Code 闪得少。

这种做法浪费吗?我们会保存一整个 scrollback buffer 规模的“上次渲染行”,并且每次 TUI 被要求渲染时都要重新渲染行。通过前面提到的缓存,这个重渲染成本并不高。我们还是要比较大量行。现实里,在 25 岁以下的电脑上,这点开销无论性能还是内存(很大 session 也就几百 KB)都不算什么。感谢 V8。作为交换,我得到的是一个死简单的编程模型,让我可以快速迭代。

pi-coding-agent

编码代理外壳应该有什么功能,我不需要解释。pi 也有你在其他工具里习惯的多数“舒适功能”:

如果你想看完整介绍,去读 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 Steinbergermcporter,它能把 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 或类似技术,也不收集任何个人可识别信息。