Blog / · 8 min read

18K Star 的 AI Agent 全家桶,藏着 5 个值得偷的架构决策

拆解 pi-mono 的 5 个架构决策:运行时 Provider 注册、双层消息、依赖注入工具、差分渲染、会话树。每个都能直接搬到你的 AI agent 项目里。

如果你正在构建 AI agent 产品,或者在纠结要不要用 LangChain / Vercel AI SDK,这篇文章拆解了一个不依赖任何框架、从零自研的全栈方案。5 个架构决策,每个都能直接搬到你的项目里。

pi-mono 是 libGDX 作者 Mario Zechner 的新作品——一个全栈 AI agent 工具链,从 LLM API 抽象层到 agent 运行时,再到一个完整的终端编码助手。18K star,7 个包。我花了一下午读完核心代码,每一层都有让我重新思考自己代码的设计决策。

这个项目是什么

pi-mono 是一个 TypeScript monorepo,包含 7 个包:

做什么
pi-ai统一多家 LLM 提供商的流式 API
pi-agent-coreAgent 运行时:工具调用、状态管理、消息编排
pi-coding-agent交互式编码 agent CLI(类似 Claude Code / Aider)
pi-tui终端 UI 库,差分渲染
pi-web-uiWeb 聊天组件
pi-momSlack bot
pi-podsvLLM GPU Pod 管理

从最底层的 LLM 调用到最上层的用户交互,每一层都自己做,不依赖 LangChain 或 Vercel AI SDK。这种”全栈自研”的选择本身就值得讨论——但今天我想聚焦在 5 个具体的架构决策上。

下面 5 个决策从底层往上走——LLM 调用层 → Agent 运行时 → UI 渲染 → 会话持久化。你可以只偷一个,但理解它们的分层关系会让你偷得更准。

决策 1:运行时注册 Provider,而不是编译时硬编码

大多数 LLM 库的做法是在代码里 import 所有支持的提供商:

// 典型做法:编译时硬编码
import { openai } from './providers/openai';
import { anthropic } from './providers/anthropic';
import { google } from './providers/google';
// ... 再加 20 个

pi-ai 不这样。它用一个运行时注册表:

// api-registry.ts
const apiProviderRegistry = new Map<string, RegisteredApiProvider>();

export function registerApiProvider<TApi extends Api>(
  provider: ApiProvider<TApi, TOptions>,
): void {
  apiProviderRegistry.set(provider.api, { provider });
}

内置的 20+ 提供商通过 register-builtins.ts 在启动时自注册。但关键是——用户也可以调用 registerApiProvider() 注册自己的提供商。

为什么这很重要? 企业场景里,你可能跑着自建的 vLLM 实例,或者用的是某个国内的模型 API。在硬编码的库里,你得 fork 代码或者等上游支持。在 pi-ai 里,你写一个扩展就行。这和我之前在 LLM 容错层设计 中讨论的 provider 抽象问题一脉相承——区别在于 pi-ai 更强调扩展性,而容错层更强调可靠性。

我在自己的 toolkit/ai 里用的是另一种路线——config.json 声明式地定义提供商链:

{
  "models": {
    "smart": {
      "chain": [
        { "provider": "anthropic", "model": "claude-sonnet-4-20250514" },
        { "provider": "openai", "model": "gpt-4.1" }
      ]
    }
  }
}

声明式更简单,但不可扩展到未知提供商。pi-ai 的命令式注册更灵活,代价是用户需要写代码。

我的判断:对于工具库,运行时注册是更好的选择。对于产品(调用方固定),声明式配置足够。

决策 2:双层消息类型——应用消息 ≠ LLM 消息

这是我觉得最精妙的设计。

pi-agent-core 里,Agent 维护的消息列表不是 LLM 格式的 Message[],而是一个更宽泛的 AgentMessage[]

// 应用层可以有自定义消息类型
type AgentMessage = Message | CustomAgentMessages[keyof CustomAgentMessages];

// 每次 LLM 调用前,转换为标准格式
convertToLlm: (messages: AgentMessage[]) => Message[]

为什么要多这一层?

想象你在做一个编码助手。用户的操作不只是”发消息”——他们可能切换了文件、运行了测试、看到了错误弹窗。这些事件对应用很重要(需要展示在 UI 上、需要影响后续行为),但 LLM 不需要看到所有这些。

有了双层消息,你可以:

  1. 往应用层塞任何自定义事件(UI 通知、计时、系统状态),不影响 LLM 上下文
  2. 在转换时做上下文修剪——令牌快满了?transformContext 把早期消息压缩成摘要
  3. 跨模型切换时保持应用状态——换了模型,应用消息还在,只是发给 LLM 的格式变了
// 两级转换管线
AgentMessage[]
transformContext()   // 可选:修剪、注入外部上下文
convertToLlm()      // 必需:转为 LLM 兼容格式
  → Message[]
streamSimple()      // 调用 LLM

大多数 agent 框架把消息当作一个扁平列表,塞进去什么就发给 LLM 什么。pi-mono 的分层让应用层和 LLM 层真正解耦。

决策 3:可注入的工具操作接口

pi-coding-agent 的每个工具(read、write、bash、grep…)都不是直接调用 fs.readFile()。它们通过一个操作接口间接调用:

export interface ReadOperations {
  readFile: (path: string) => Promise<Buffer>;
  access: (path: string) => Promise<void>;
  detectImageMimeType?: (path: string) => Promise<string | null>;
}

// 创建工具时注入实现
const readTool = createReadTool(cwd, {
  operations: myCustomReadOps
});

默认实现用 Node.js 的 fs 模块。但你可以注入任何实现——SSH 远程文件系统、Docker 容器内的文件系统、甚至 S3。

同一套工具代码,因为操作接口的解耦,可以跑在:

  • 本地终端
  • Slack bot(通过 pi-mom)
  • 远程 GPU pod(通过 pi-pods)
  • Web UI(通过 pi-web-ui)

这是经典的依赖注入模式,但用在 agent 工具上特别合适。因为 agent 工具天然需要适配多种运行环境——你今天在本地跑,明天可能想在云端跑,后天可能想在浏览器里跑。把”做什么”(工具逻辑)和”怎么做”(文件系统访问)分开,是一个前瞻性很强的决策。

决策 4:终端 UI 的差分渲染 + 同步输出

如果你用过任何 CLI AI 工具,大概都经历过屏幕闪烁——整个终端清空再重绘。pi-tui 用两个技巧解决了这个问题。

技巧一:三策略差分渲染

不是每次都清屏重画。TUI 保留上一帧的每一行,和新一帧逐行比较:

  • 首次渲染:直接输出,不清任何东西
  • 宽度变化:清屏全渲染(resize 时不可避免)
  • 正常更新:只从第一个变化的行开始重绘,上面的行完全不动
// 伪代码:找到第一个变化的行
for (let i = 0; i < lines.length; i++) {
  if (lines[i] !== previousLines[i]) {
    // 从这里开始重绘,上面的不动
    moveCursorTo(i);
    clearFromHere();
    renderFrom(i);
    break;
  }
}

技巧二:CSI 2026 同步输出

write("\x1b[?2026h");  // 告诉终端:开始缓冲,别急着画
// ... 输出所有变化 ...
write("\x1b[?2026l");  // 好了,现在一次性画出来

这个 ANSI 转义序列让终端在收到结束信号前不刷新屏幕。结果是:即使要更新很多行,用户看到的也是一帧完整的画面,没有中间状态的闪烁。

大多数 Node.js CLI 库(ink、blessed 等)做不到这个级别的控制。pi-tui 是为 AI 场景(频繁的流式文本更新)专门设计的。

决策 5:会话树——单文件支持分支

编码助手的一个常见场景:你让 AI 试了方案 A,不满意,想回到同一个点试方案 B。

pi-coding-agent 的会话存储用 JSONL 格式,每条消息有 idparentId

{"id":"m1","type":"user","content":"重构这个函数"}
{"id":"m2","parentId":"m1","type":"assistant","content":"方案 A..."}
{"id":"m3","parentId":"m1","type":"assistant","content":"方案 B..."}

m2m3 有同一个 parentId——这就是一个分支。单个 JSONL 文件就是一棵树。用 /tree 命令可以可视化:

m1 (重构这个函数)
├── m2 (方案 A...)
│   └── m4 (继续 A 的迭代)
└── m3 (方案 B...)
    └── m5 (继续 B 的迭代)

不需要为每个分支创建新文件,不需要”复制粘贴会话”。分支是会话的一等公民。

总结:什么值得带走

模式适用场景复杂度
运行时 Provider 注册做给别人用的 LLM 库
双层消息转换任何有 UI 的 agent 应用
可注入工具操作接口需要多环境适配的 agent
差分渲染 + CSI 2026高频更新的 CLI 工具
会话树需要试错/分支的 AI 工具

这五个决策不是孤立的。它们组合在一起,形成了一个高度可组合的系统——同一个 agent core,通过不同的工具操作注入、不同的消息转换、不同的 UI 层,适配完全不同的产品形态。

这也是 monorepo 结构的价值所在:每一层都可以独立使用(你可以只用 pi-ai 做 LLM 调用),但组合使用时有 1+1>2 的效果。

如果你也在构建 AI agent 相关的产品,建议至少读一下 packages/ai/src/api-registry.tspackages/agent/src/agent.ts。不多,加起来 200 行,但设计密度极高。


pi-mono 开源在 github.com/badlogic/pi-mono,MIT 协议。我从中学到的架构模式,正在反哺到自己的 toolkit/ai 项目。

相关文章

订阅邮件通讯

AI 时代的导航指南 — 原创研究、判断型分析、实战经验。每周一封,不水。

评论