跳转到内容
Go back

构建一个有主见且精简的编程代理我所学到的

更新于:

构建一个有主见且精简的编程代理我所学到的

2025-11-30

不多,但这是我的

过去三年,我一直使用 LLMs 进行辅助编程。如果你读到这,你可能经历了同样的演变:从将代码复制粘贴到 ChatGPT,到 Copilot 自动补全(对我而言从未有效),到 Cursor,最后到 2025 年成为我们日常使用的全新编程代理,如 Claude Code、Codex、Amp、Droid 和 opencode。

我大多数工作都使用 Claude Code。这是我在使用 Cursor 一年半后,四月份尝试的第一件事。当时它还非常基础。这完美符合我的工作流程,因为我是一个喜欢简单、可预测工具的人。在过去几个月里,Claude Code 变成了一艘太空船,其中 80% 的功能对我来说毫无用处。系统提示和工具在每次发布时都会改变,这破坏了我的工作流程并改变了模型行为。我讨厌这一点。此外,它还会闪烁。

多年来,我也构建了各种复杂程度的代理。例如,我那个小型的浏览器使用代理 Sitegeist,本质上是一个存在于浏览器中的编程代理。在这些工作中,我认识到上下文工程至关重要。精确控制输入模型上下文的内容能产生更好的输出,尤其是在编写代码时。现有的代理机制通过在后台注入用户未在 UI 中看到的元素,使得这一点变得极其困难甚至不可能。

说到展示内容,我想检查与模型交互的每一个方面。基本上没有哪个代理机制允许这样做。我还需要一个清晰记录的会话格式,可以自动进行后处理,以及一个简单的方法来在代理核心之上构建替代 UI。虽然其中一些功能可以通过现有代理实现,但这些 API 看起来像是自然进化。这些解决方案在发展过程中积累了包袱,这在开发者体验中体现出来。我并不是在责怪任何人。如果很多人使用你的产品,并且你需要某种程度的向后兼容性,那么这就是你需要付出的代价。

我也尝试过本地和 DataCrunch 上的自托管,虽然像 opencode 这样的工具支持自托管模型,但通常效果不佳。这主要是因为它们依赖于像 Vercel AI SDK 这样的库,而该库与自托管模型在某些方面不兼容,特别是工具调用方面。

那么一个老爷爷对着 Claudes 大喊大叫能做什么呢?他会自己编写一个编程代理工具,并给它取一个完全无法与谷歌关联的名字,这样就不会有任何用户。这意味着 GitHub 问题追踪器上也不会有任何问题。这有多难呢?

为了实现这个目标,我需要构建:

我在所有这些方面的哲学是:如果我不需要它,就不会去构建。而我并不需要很多东西。

Pi-ai 和 Pi-agent-core

我不会在这里详细讲解这个包的 API 细节。你可以在 README.md 中找到所有相关信息。相反,我想记录在创建统一的 LLM API 过程中遇到的问题以及我是如何解决它们的。我并非声称我的解决方案是最好的,但它们在各种具有代理性和非代理性的 LLM 项目中运行得相当不错。

有。四。个。轻量级… API

与几乎所有 LLM 提供商进行交互,你实际上只需要了解四个 API:OpenAI 的 Completions API,他们更新的 Responses API,Anthropic 的 Messages API,以及 Google 的 Generative AI API。

它们在功能上都很相似,所以在它们之上构建抽象并不难。当然,你必须注意特定提供者的特殊性。对于 Completions API 来说尤其如此,几乎所有提供者都在使用它,但每个提供者对这一 API 应该做什么的理解都不同。例如,虽然 OpenAI 的 Completions API 不支持推理跟踪,但其他提供者的 Completions API 版本中却支持。对于 llama.cpp、Ollama、vLLM 和 LM Studio 等推理引擎来说,情况也是如此。

例如,在 openai-completions.ts 中:

为确保所有功能在数以百万计的提供者中都能正常工作,pi-ai 拥有一个相当全面的测试套件,涵盖图像输入、推理跟踪、工具调用以及其他预期中的 LLM API 功能。测试覆盖所有支持的提供者和流行模型。尽管这是一个很好的努力,但它仍然无法保证新的模型和提供者能够即插即用。

另一个显著差异在于供应商如何报告 token 和缓存读写情况。Anthropic 的做法最为合理,但总体而言,这仍是一片混乱之地。有些供应商在 SSE 流的开始报告 token 数量,有些则只在结束处报告,导致如果请求被中止,准确追踪成本变得不可能。更糟糕的是,你无法提供一个唯一 ID 来与他们的计费 API 关联,从而确定你的哪些用户消耗了多少 token。因此,pi-ai 在 token 和缓存追踪方面尽力而为。对于个人使用来说足够,但如果你有终端用户通过你的服务消耗 token,就不适用于准确计费。

特别鸣谢 Google,到今天似乎仍然不支持工具调用流式传输,这极其“谷歌”。

pi-ai 也可以在浏览器中运行,这对于构建基于网络的界面很有用。一些提供商通过支持 CORS(跨源资源共享)使这一点变得特别容易,特别是 Anthropic 和 xAI。

上下文交接

在提供商之间进行上下文传递是 pi-ai 从一开始就设计支持的功能。由于每个提供商都有自己追踪工具调用和思维轨迹的方式,这只能做到尽力而为。例如,如果你在会话中途从 Anthropic 切换到 OpenAI,Anthropic 的思维轨迹会被转换为助理消息内的内容块,并以 <thinking></thinking> 标签分隔。这可能是合理的,也可能是不合理的,因为 Anthropic 和 OpenAI 返回的思维轨迹实际上并不代表后台正在发生的事情。

这些提供者也向事件流中插入已签名的数据块,你必须在包含相同消息的后续请求中重新播放这些数据块。在切换提供者内部的模型时也是如此。这导致后台出现笨拙的抽象和转换管道。

我很高兴地报告,在 pi-ai 中,跨提供者的上下文交接以及上下文的序列化/反序列化工作得相当好:

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 调用中。为此,我需要一个可以转换为 TypeScript 类型的模型注册中心。我将 OpenRouter 和 models.dev(由 opencode 团队创建,感谢提供,非常实用)的数据解析到 models.generated.ts 中。这包括 token 成本以及图像输入和思考支持等能力。

如果我将来需要添加不在注册表中的模型,我希望有一个类型系统,可以轻松创建新的模型。这在处理自托管模型、尚未在 models.dev 或 OpenRouter 上发布的新版本,或尝试使用一些不太知名的 LLM 提供商时尤其有用:

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 完全忽略了提供中止请求的方式。如果你想把 LLM 集成到任何生产系统中,这是完全不可接受的。许多统一的 LLM API 也不返回部分结果,这有点荒谬。pi-ai 从一开始就设计用来在整个流程中支持中止,包括工具调用。它的工作原理如下:

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 部分通常只是文本或 JSON,不一定包含你希望在 UI 中显示的所有信息。解析文本工具输出并在 UI 中重新结构化以供显示也非常糟糕。pi-ai 的工具实现允许同时返回用于 LLM 的内容块和用于 UI 渲染的独立内容块。工具还可以返回图像等附件,这些附件以各自提供者的原生格式附加。工具参数使用 TypeBox 模式集和 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 序列。目前这还不支持,但这是一个简单的修复,最终会包含在包中。

在工具调用流式传输期间进行部分 JSON 解析对于良好的用户体验至关重要。当 LLM 流式传输工具调用参数时,pi-ai 会逐步解析它们,以便在调用完成之前在 UI 中显示部分结果。例如,当代理重写文件时,您可以显示正在流式传输的差异。

极简代理脚手架

最后,pi-ai 提供了一个处理完整编排的代理循环:处理用户消息、执行工具调用、将结果反馈给 LLM,并重复直到模型产生不包含工具调用的响应。该循环还支持通过回调进行消息排队:每轮结束后,它会请求排队消息并在下一次助手响应之前注入它们。该循环为所有事件发出信号,便于构建响应式 UI。

代理循环不允许你指定最大步数或其他统一 LLM API 中类似的调节旋钮。我从未找到使用该功能的用例,所以为什么要添加它?循环只是循环,直到代理表示完成。然而,pi-agent-core 在循环之上提供了一个 Agent 类,其中包含真正有用的功能:状态管理、简化的事件订阅、具有两种模式(一次一个或一次全部)的消息队列、附件处理(图像、文档),以及一个传输抽象层,允许你直接运行代理或通过代理运行。

我对 pi-ai 满意吗?大部分情况下是的。像任何统一 API 一样,由于抽象层不完善,它永远不可能完美。但它已经在七个不同的生产项目中使用,并且为我提供了极好的服务。

为什么选择构建这个而不是使用 Vercel AI SDK?Armin 的博客文章反映了我的经验。直接在提供者 SDK 上构建能让我完全掌控,并让我能按照自己的方式设计 API,具有更小的接触面。Armin 的博客为你提供了更深入的论述关于构建自己的原因。去读一读吧。

Pi-tui

我成长于 DOS 时代,所以终端用户界面就是我所熟悉的。从《毁灭战士》的华丽安装程序到 Borland 的产品,TUI(文本用户界面)一直陪伴我到 90 年代末。当我最终切换到图形操作系统时,我简直高兴极了。虽然 TUI(文本用户界面)大多便携且易于流式传输,但在信息密度方面却表现不佳。话虽如此,我认为为 pi(可能是“Pi”的缩写)从终端用户界面开始是最明智的选择。我可以在任何时候,当我觉得需要时,再添加 GUI(图形用户界面)。

为什么我要自己构建 TUI 框架呢?我研究过其他替代方案,比如 Ink、Blessed、OpenTUI 等。我相信它们都有各自的优势,但我绝对不想把我的 TUI 写成 React 应用的样子。Blessed 似乎已经很少维护了,而 OpenTUI 明确表示尚未准备好用于生产。此外,在 Node.js 上构建自己的 TUI 框架似乎是一个有趣的小挑战。

两种 TUI

编写终端用户界面并非难事。你只需选择适合自己的方法。基本上有两种方式。一种是掌控终端视口(你能实际看到的终端内容部分),将其视为像素缓冲区。这里不是像素,而是包含背景色、前景色以及斜体和粗体等样式的字符单元。我称这些为全屏 TUI。Amp 和 opencode 采用这种方法。

缺点是你会失去滚动回显缓冲区,这意味着你必须实现自定义搜索。你还失去了滚动功能,这意味着你必须自己模拟视口内的滚动。虽然这并不难实现,但这意味着你必须重新实现你的终端模拟器已经提供的所有功能。在这样的一些 TUI 中,鼠标滚动总是感觉有点不协调。

第二种方法是像任何 CLI 程序一样直接写入终端,将内容追加到滚动缓冲区中,偶尔将 ” 渲染光标 ” 在可见视口内向上移动一点,以重新绘制动画旋转器或文本编辑字段等内容。这并不完全那么简单,但你能明白这个意思。这就是 Claude Code、Codex 和 Droid 所做的事情。

编程代理具有一个很棒的特性,它们基本上就是一个聊天界面。用户输入一个提示,然后代理会回复,并调用工具及其结果。一切都非常线性,这很适合与“原生”终端模拟器配合使用。你可以使用所有内置功能,比如自然滚动和搜索滚动缓冲区。这也一定程度上限制了你的 TUI 能做什么,但我发现这很迷人,因为约束造就了简洁的程序,它们只做它们应该做的事情,没有多余的装饰。这就是我为 pi-tui 选择的方向。

保留模式 UI

如果你做过任何 GUI 编程,可能听说过保留模式和即时模式。在保留模式 UI 中,你构建一个跨帧持续存在的组件树。每个组件都了解如何渲染自己,如果内容没有变化,还可以缓存其输出。在即时模式 UI 中,每帧都从头开始重绘所有内容(尽管在实际中,即时模式 UI 也会进行缓存,否则它们将无法正常工作)。

pi-tui 使用一种简单的保留模式方法。一个 Component 就是一个带有 render(width) 方法的对象,该方法返回一个字符串数组(水平适配视口的行,包含用于颜色和样式的 ANSI 转义码),以及一个可选的 handleInput(data) 方法用于键盘输入。一个 Container 包含一个垂直排列的组件列表,并收集它们渲染的所有行。 TUI 类本身就是一个容器,负责协调所有内容。

当 TUI 需要更新屏幕时,它会要求每个组件进行渲染。组件可以缓存它们的输出:一个完全流式传输的助手消息不需要每次都重新解析 Markdown 和重新渲染 ANSI 序列。它只需返回缓存的行。容器从所有子组件收集行。TUI 收集所有这些行,并将它们与先前为上一个组件树渲染的行进行比较。它保持某种形式的回显缓冲区,记住写入滚动回显缓冲区的内容。

然后它只重绘发生变化的部分,使用了我称之为差异渲染的方法。我对命名很糟糕,这很可能有一个正式的名称。

差异渲染

这是一个简化的演示,说明了具体哪些部分会被重新绘制。

$ pi
╭─────────────────────────────────╮
 > _
╰─────────────────────────────────╯
 
 
 
 
 
 
 点击开始 | 绘制行数:0/10

算法很简单:

  1. 首次渲染:直接将所有行输出到终端
  2. 宽度变化:清屏并重新渲染所有内容(软换行变化)
  3. 正常更新:找到与屏幕上不同的第一行,将光标移动到该行,并从那里重新渲染到末尾

有一个问题:如果第一次更改的行在可见视口之上(用户向上滚动过),我们就必须执行完整的清除和重新渲染。终端不允许向视口上方的滚动缓冲区写入。

为了在更新过程中防止闪烁,pi-tui 将所有渲染包裹在同步输出转义序列( CSI ?2026hCSI ?2026l )中。这会告诉终端缓存所有输出并原子性地显示它们。大多数现代终端都支持这一功能。

它工作得怎么样,以及闪烁得有多频繁?在任何像 Ghostty 或 iTerm2 这样功能强大的终端中,它都能完美运行,你永远不会看到任何闪烁。在不那么幸运的终端实现中,比如 VS Code 自带的终端,你可能会根据一天中的时间、你的显示器尺寸、你的窗口尺寸等因素看到一些闪烁。鉴于我非常习惯于 Claude Code,我没有再花时间优化这个。我对 VS Code 中轻微的闪烁感到满意。否则我不会感到自在。而且它仍然比 Claude Code 闪烁得更少。

这种方法的浪费程度有多大?我们存储了整个滚动回显缓冲区中之前渲染过的所有行,并且每次当 TUI 被要求重新渲染自身时,我们都会重新渲染这些行。这通过我上面描述的缓存得到了缓解,因此重新渲染并不是什么大问题。我们仍然需要将很多行相互比较。实际上,在 25 岁以下年龄的计算机上,这在性能和内存使用方面都不是什么大问题(对于非常大的会话,只有几百 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 文件。包括适用于所有会话的全局文件以及存储在你项目目录中的项目特定文件。这就是你可以根据个人喜好定制 pi 的地方。如果你想要的话,甚至可以替换整个系统提示。与例如 Claude Code 的系统提示、Codex 的系统提示,或 opencode 的模型特定提示(Claude 的提示是它们复制原始 Claude Code 提示的精简版本)相比。

你可能觉得这很疯狂。很可能,这些模型在它们本地的编码框架上接受过一些训练。所以使用本地的系统提示或者像 opencode 这样接近它的东西会是最佳选择。但结果发现,所有的前沿模型都经过了大量强化学习训练,因此它们天生就理解什么是编码代理。似乎没有必要使用 10,000 个 token 的系统提示,正如我们稍后在基准测试部分会发现的那样,以及我在过去几周内专门使用 pi 时偶然发现的那样。Amp 虽然复制了一些本地的系统提示的部分,但似乎用它们自己的提示也能做得很好。

最小化工具集

这里是我的工具定义:

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)

如果你想要限制代理修改文件或运行任意命令,还有额外的只读工具(grep、find、ls)。默认情况下这些工具是禁用的,因此代理只会获得上面提到的四种工具。

事实证明,这四种工具足以构建一个高效的编程代理。模型知道如何使用 bash,并且已经在具有相似输入模式的读、写、编辑工具上进行过训练。相比之下,Claude Code 的工具定义或 opencode 的工具定义(显然源自 Claude Code,结构相同,示例相同,git 提交流程也相同)。值得注意的是,Codex 的工具定义与 pi 的同样非常简洁。

pi 的系统提示和工具定义加起来不到 1000 个 token。

YOLO 默认

pi 在全 YOLO 模式下运行,并假定你知道自己在做什么。它拥有对您文件系统的无限制访问权限,并且可以在无需权限检查或安全防护的情况下执行任何命令。文件操作或命令无需权限提示。Haiku 不会预先检查 bash 命令中的恶意内容。完全的文件系统访问权限。可以使用您的用户权限执行任何命令。

如果您查看其他编程代理的安全措施,它们大多是安全表演。一旦您的代理能够编写代码和运行代码,基本上就游戏结束了。唯一可以防止数据泄露的方法是为代理运行的执行环境断开所有网络访问,这使得代理几乎毫无用处。另一种替代方案是允许列出域名,但这也可以通过其他方式规避。

西蒙·威尔逊对此问题已撰写了大量文章。他的“双 LLM”模式试图解决混淆副官攻击和数据窃取问题,但他自己也承认“这个解决方案相当糟糕”,并引入了巨大的实施复杂性。核心问题仍然是:如果 LLM 可以访问能够读取私有数据和发起网络请求的工具,你就是在与攻击向量玩打地鼠游戏。

由于我们无法解决这组能力(读取数据、执行代码、网络访问)的三重奏,pi 只能选择妥协。无论如何,大家都在 YOLO 模式下运行以完成任何有生产性的工作,那么为什么不把它设为默认且唯一的选择呢?

默认情况下,pi 没有网络搜索或获取工具。然而,它可以使用 curl 或读取磁盘文件,这些都为提示注入攻击提供了大量的攻击面。文件或命令输出中的恶意内容可能会影响其行为。如果你对完全访问感到不适,可以在容器中运行 pi,或者如果你需要(虚假的)护栏,可以使用其他工具。

没有内置的待办事项

pi 不会也不支持内置的待办事项列表。根据我的经验,待办事项列表通常比它们有帮助更能让模型感到困惑。它们增加了模型需要跟踪和更新的状态,这引入了更多出错的机会。

如果你需要任务跟踪,通过写入文件使其成为外部有状态的:

# TODO.md

- [x] Implement user authentication
- [x] Add database migrations
- [ ] Write API documentation
- [ ] Add rate limiting

该代理可以根据需要读取和更新此文件。使用复选框可以跟踪已完成和剩余的工作。简单、可见,并且由你控制。

无计划模式

pi 没有也永远不会内置计划模式。告诉代理和你一起思考问题,而不修改文件或执行命令,通常就足够了。

如果你需要跨会话的持久规划,将其写入文件:

# 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

该代理可以在工作时读取、更新和引用计划。与只在会话中存在的短暂规划模式不同,基于文件的计划可以跨会话共享,并且可以与您的代码一起进行版本控制。

说来也巧,Claude Code 现在有了计划模式,本质上就是只读分析,最终会向磁盘写入一个 markdown 文件。基本上,如果不批准大量命令调用,就无法使用计划模式,因为没有这些批准,规划基本上是不可能的。

与π的不同之处在于我对所有事情都有完全的可观察性。我能看到代理实际查看了哪些来源,哪些来源完全错过了。在 Claude Code 中,通常由 Claude 实例生成一个子代理,而你对此子代理所做的任何事情都毫无可见性。我能立即看到 markdown 文件。我可以与代理协作编辑它。简而言之,我需要可观察性来进行规划,而 Claude Code 的计划模式无法提供这一点。

如果你必须在规划期间限制代理,可以通过 CLI 指定它有权访问哪些工具:

pi --tools read,grep,find,ls

这会给你提供只读模式,用于探索和规划,而代理不会修改任何内容或能够运行 bash 命令。不过你不会对此感到满意。

没有 MCP 支持

pi 不会也不支持 MCP。我对此已经详细写过,但核心要点是:对于大多数用例来说,MCP 服务器是过度设计的,并且它们带来了显著的上下文开销。

流行的 MCP 服务器,如 Playwright MCP(21 个工具,13.7k 个 token)或 Chrome DevTools MCP(26 个工具,18k 个 token),在每次会话中都将其所有工具描述全部导入你的上下文中。在你开始工作之前,就有 7-9% 的上下文窗口被占用。在许多情况下,你甚至永远不会在某个会话中使用这些工具。

另一种方法是构建带有 README 文件的 CLI 工具。代理在需要工具时会读取 README,仅在必要时支付 token 成本(渐进式披露),并且可以使用 bash 来调用该工具。这种方法是可组合的(可以管道输出、链式命令),易于扩展(只需添加另一个脚本),并且 token 高效。

这是我为 pi 添加网络搜索的方法:

我在 github.com/badlogic/agent-tools 上维护着这些工具。每个工具都是一个简单的 CLI,代理程序按需读取其 README。

如果你绝对必须使用 MCP 服务器,可以研究一下 Peter Steinberger 的 mcporter 工具,它将 MCP 服务器包装为命令行工具。

没有 Bash 背景

pi 的 bash 工具同步运行命令。没有内置方法可以在命令仍在运行时启动开发服务器、在后台运行测试或与 REPL 交互。

这是有意为之的。后台进程管理增加了复杂性:你需要进程跟踪、输出缓冲、退出时的清理,以及向运行中的进程发送输入的方法。Claude Code 通过其后台 bash 功能处理了其中一些问题,但它缺乏可观察性(这是 Claude Code 的一个常见问题),并迫使代理跟踪运行中的实例,却未提供查询它们的工具。在早期版本的 Claude Code 中,代理在上下文压缩后会忘记所有后台进程,并且没有查询它们的方法,因此你不得不手动杀死它们。这个问题现在已经得到了修复。

改用 tmux。以下是 pi 在 LLDB 中调试崩溃的 C 程序:

这可观测性怎么样?同样的方法也适用于长时间运行的开发服务器,监控日志输出等类似场景。如果你愿意,还可以通过 tmux 进入上面那个 LLDB 会话,与代理一起进行联合调试。Tmux 还提供了一个 CLI 参数来列出所有活动会话。多好啊。

完全不需要背景 bash。Claude Code 也能使用 tmux,你知道的。Bash 就是你需要的全部。

没有子代理

pi 没有专门的子代理工具。当 Claude Code 需要执行复杂任务时,它通常会创建一个子代理来处理部分任务。你无法了解该子代理具体做了什么。这是一个黑箱中的黑箱。代理之间的上下文传递也很差。协调代理决定要传递给子代理的初始上下文,而你通常无法控制这一点。如果子代理犯错误,调试会很痛苦,因为你无法看到完整的对话。

如果你需要π(pi)自我生成,只需通过 bash 让它运行自己。你甚至可以让它在 tmux 会话中自我生成,以实现完全的可观察性,并能够直接与那个子代理交互。

但更重要的是:修复你的工作流程,至少是那些关于收集上下文的。人们在一个会话中使用子代理,认为这样能节省上下文空间,这是真的。但这并不是思考子代理的正确方式。在会话中途使用子代理来收集上下文是一个你没有提前规划的迹象。如果你需要收集上下文,应该先在自己的会话中完成。创建一个你可以稍后在全新会话中使用的工件,以给你的代理提供所有它需要的上下文,而不会污染它的上下文窗口与工具输出。这个工件对下一个功能也有用,并且你将获得完整的可观察性和可控性,这在收集上下文时非常重要。

因为尽管普遍认为,模型在寻找实现新功能或修复错误所需的所有上下文方面仍然很糟糕。我认为这是由于模型被训练成只读取文件的一部分而不是整个文件,因此它们不愿意读取所有内容。这意味着它们会错过重要的上下文,并且无法看到需要正确完成任务的内容。

只需看看 pi-mono 的问题追踪器和拉取请求。许多请求被关闭或修改,因为代理无法完全理解需要什么。这不是贡献者的错,我真心感激,因为即使是不完整的 PR 也能帮助我更快地推进。这只是意味着我们过于信任我们的代理。

我并非完全否定子代理。确实存在一些合理的使用场景。我最常见的一个是代码审查:我告诉 pi 使用代码审查提示(通过一个自定义的斜杠命令)来生成子代理,然后它就能获取输出结果。

---
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.

这里是我如何使用它来审查 GitHub 上的拉取请求:

通过一个简单的提示,我可以选择要审查的具体内容以及要使用的模型。如果需要,我甚至可以设置思考级别。我还可以将完整的审查会话保存到文件中,并在另一个 pi 会话中进入该会话。或者我可以说明这是一个短暂的会话,不应该保存到磁盘上。所有这些都会被翻译成一个提示,主代理读取该提示并根据其通过 bash 再次执行自己。虽然我无法完全了解子代理的内部工作原理,但我可以完全观察其输出。其他 harnesses 真的无法提供这种全观测性,这对我来说毫无道理。

当然,这有点像是一个模拟的使用案例。实际上,我只需启动一个新的 pi 会话,并让它来评审拉取请求,可能将其拉取到本地的一个分支上。在我看到它的初步评审后,我会给出自己的评审意见,然后我们一起协作,直到它达到可接受的标准。这就是我用来避免合并劣质代码的工作流程。

在我的看法中,并行实现各种功能而生成多个子代理是一种反模式,而且行不通,除非你不在乎你的代码库最终变成一堆垃圾。

基准测试

我做了很多宏伟的声明,但我是否有数字证据证明我上面所说的所有反常规的事情确实有效?我有我亲身体验,但在博客文章中很难传达这一点,你只能相信我。所以我为π创建了一个 Terminal-Bench 2.0 测试运行,使用 Claude Opus 4.5,并让它与 Codex、Cursor、Windsurf 和其他编码工具及其各自的本地模型进行竞争。显然,我们都知道基准测试不能代表实际性能,但这是我能提供的某种证明,证明我所说的并非全是胡说八道。

我进行了完整的运行,每个任务进行五次试验,这使得结果有资格提交到排行榜。我还启动了第二次运行,仅在中欧时间运行,因为我发现一旦太平洋标准时间上线,错误率(以及随之而来的基准测试结果)就会变差。以下是第一次运行的结果:

以下是 pi 在 2025 年 12 月 2 日当前排行榜上的位置:

以下是提交给 Terminal-Bench 团队用于加入排行榜的 results.json 文件。pi 的测试程序可以在本仓库中找到,如果你想要复现结果的话。我建议你使用你的 Claude 计划而不是按量付费。

最后,这里有一小段关于仅使用 CET 运行的介绍:

这需要再花一两天时间才能完成。完成后我会更新这篇博客文章。

另外请注意排行榜上 Terminus 2 的排名。Terminus 2 是 Terminal-Bench 团队自己开发的一个极简代理,它只是给模型提供一个 tmux 会话。模型将命令作为文本发送给 tmux,并自行解析终端输出。没有花哨的工具,没有文件操作,只是纯粹的终端交互。它在与工具更复杂的代理竞争中依然表现不俗,并且能与多种不同的模型配合工作。这再次证明,极简的方法同样能取得优异成果。

总之

基准测试结果很有趣,但真正的证明在于实际应用。我的实际应用就是日常工作中,pi 的表现相当出色。推特上充斥着关于上下文工程的帖子和博客,但我感觉我们目前所有的工具实际上并没有真正实现上下文工程。pi 是我试图为自己打造的一个工具,尽可能掌握控制权。

我对 pi 的现状相当满意。我还有一些想添加的功能,比如压缩或工具结果流式传输,但我不认为我个人还需要更多。压缩功能的缺失对我个人来说并不是问题。不知为何,我能够将我和代理之间的数百次交互压缩到单个会话中,而使用 Claude Code 时没有压缩功能是无法做到的。

话虽如此,我欢迎贡献。但和我的所有开源项目一样,我倾向于独断专行。多年来,我在处理较大项目时深刻体会到了这一点。如果我关闭了你提交的问题或 PR,希望你不要介意。我会尽力给出原因。我只是想保持项目的专注性和可维护性。如果 pi 不符合你的需求,我恳请你进行分支。我说的都是真心话。如果你创建了一个更符合我需求的项目,我会很高兴地加入你的工作。

我认为上述的一些学习内容也适用于其他框架。告诉我你的进展如何。

本页面通过不使用 cookies 或类似技术以及不收集任何可识别个人身份的信息来尊重您的隐私。


分享文章至:

Previous Post
解决 Rclone 中断导致 Cloudflare R2 产生 " 尚未完成的多部分上传 "
Next Post
命令札记