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-core | Agent 运行时:工具调用、状态管理、消息编排 |
pi-coding-agent | 交互式编码 agent CLI(类似 Claude Code / Aider) |
pi-tui | 终端 UI 库,差分渲染 |
pi-web-ui | Web 聊天组件 |
pi-mom | Slack bot |
pi-pods | vLLM 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 不需要看到所有这些。
有了双层消息,你可以:
- 往应用层塞任何自定义事件(UI 通知、计时、系统状态),不影响 LLM 上下文
- 在转换时做上下文修剪——令牌快满了?
transformContext把早期消息压缩成摘要 - 跨模型切换时保持应用状态——换了模型,应用消息还在,只是发给 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 格式,每条消息有 id 和 parentId:
{"id":"m1","type":"user","content":"重构这个函数"}
{"id":"m2","parentId":"m1","type":"assistant","content":"方案 A..."}
{"id":"m3","parentId":"m1","type":"assistant","content":"方案 B..."}
m2 和 m3 有同一个 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.ts 和 packages/agent/src/agent.ts。不多,加起来 200 行,但设计密度极高。
pi-mono 开源在 github.com/badlogic/pi-mono,MIT 协议。我从中学到的架构模式,正在反哺到自己的 toolkit/ai 项目。