pi(earendil-works/pi)是一个开源的 AI Agent 工具箱,包含统一的多模型 LLM 接口、Agent 运行时、终端 UI 库,以及基于它们打造的编程助手 CLI。这个项目本身已经小有名气,GitHub 上有 10 万+ star,社区里甚至出现了 Rust、Go、Python 的独立移植版本。
抛开「AI」这个话题热度,这个项目真正值得学的是一系列扎实的工程决策,而且大多数跟 AI 没有直接关系。下面每个主题都配一段示意代码(不是原项目源码,是根据它的设计思路写的简化示例),帮助把抽象的原则落到具体写法上。
一、把系统拆成「薄」而「正交」的层
不要把所有逻辑塞进一个大工具里,而是按职责拆包:协议层(和 LLM 说话)、运行时层(工具调用、状态管理)、界面层(终端渲染)、产品层(把前三层组装成 CLI)。
拆分之后,协议层要足够「薄」,薄到可以只靠一个字段做路由,而不是为每个厂商单独写一套代码:
// pi-ai: 用一个 api 字段分发到具体 provider,
// 而不是为每个「品牌」单独写适配器
async function streamSimple(model: Model, context: Context, options: StreamOptions) {
switch (model.api) {
case "anthropic-messages":
return new AnthropicProvider().stream(model, context, options);
case "openai-completions":
// OpenRouter / Together / Groq / DeepSeek / xAI 等几十个「品牌」
// 全部走这一条分支,只是 model.baseUrl 不同
return new OpenAiProvider().stream(model, context, options);
case "google-generative-ai":
return new GoogleProvider().stream(model, context, options);
}
}
关键点:先去分辨真正不同的协议形态有几种(这里只有 3 种),而不是按表面上的品牌数量去写适配器。少写几十倍的重复代码。
拆分之后每一层都可以单独被复用、单独测试、单独维护版本号。事实也证明了这一点——pi-ai 这个「薄」的协议层,已经被完整移植成了 Rust 版本,复用同一套消息/工具类型定义。一套接口设计能不能脱离原来的编程语言存活,是检验它是不是「真的解耦」的一个很实用的标准。
二、把「运行过程」建模成一棵可订阅的事件树
Agent 执行一次任务的过程,被建模成固定的三段式事件:start → update*(可重复)→ end,层层嵌套。
type AgentEvent =
| { type: "run_start"; runId: string }
| { type: "turn_start" }
| { type: "message_start"; role: "assistant" }
| { type: "message_update"; delta: string } // 可以触发很多次
| { type: "message_end" }
| { type: "tool_start"; toolName: string; args: unknown }
| { type: "tool_update"; progress: unknown } // 可以触发很多次
| { type: "tool_end"; result: unknown }
| { type: "turn_end" }
| { type: "run_end" };
// 任何一方——终端界面、Web 界面、日志系统——只需要订阅事件流,
// 就能重建完整状态,不需要理解 Agent 内部实现
function onEvent(event: AgentEvent) {
switch (event.type) {
case "message_update":
ui.appendToCurrentBubble(event.delta);
break;
case "tool_start":
ui.showSpinner(event.toolName);
break;
case "tool_end":
ui.hideSpinner();
ui.showToolResult(event.result);
break;
// ...
}
}
这就是「事件溯源」思路在 UI 状态同步上的应用,能用在任何需要多端同步、断线重连恢复状态的系统上——不只是 AI。
三、抢占/排队:先定「不可分割的操作单元」,再设计规则
用户在 Agent 正在执行工具调用时发来新消息,该怎么处理?规则很克制:绝不打断正在执行的工具调用,只在「一轮模型调用结束后」才检查排队消息。
class AgentLoop {
private steeringQueue: UserMessage[] = [];
async runTurn() {
const assistantMsg = await this.callModel();
if (assistantMsg.toolCalls.length > 0) {
// 工具调用批次是原子单元,执行期间绝不插队
for (const call of assistantMsg.toolCalls) {
await this.executeTool(call); // 不会被打断
}
}
this.emit("turn_end");
// 只有到了这里——模型调用之间的安全边界——才把排队消息
// 当作新的 user message 追加进去
const queued = this.steeringQueue.splice(0);
if (queued.length > 0) {
this.context.messages.push(...queued.map(toUserMessage));
}
}
// 用户发消息时,不会直接打断当前执行,而是先入队
onUserMessage(msg: UserMessage) {
this.steeringQueue.push(msg);
}
}
原因很直接:assistant 发起 tool_use → tool_result 必须配对完整,中途硬插东西会产生「孤儿」工具结果,让模型困惑甚至报错。先想清楚系统里「不可分割的最小操作单元」是什么,再围绕它设计抢占/排队规则,而不是反过来。这条原则用在后台任务系统、审批流程、协作编辑器上同样成立。
四、测试:把「请求对不对」和「对方服务器给不给对结果」分开测
// ✅ 好的做法:用本地 mock server 验证「我发的请求格式对不对」
// 完全不连真实的模型服务,零成本、零网络抖动
test("anthropic provider builds correct request payload", async () => {
const mockServer = createMockHttpServer((req) => {
expect(req.body.model).toBe("claude-sonnet-5");
expect(req.body.messages).toEqual([{ role: "user", content: "hi" }]);
return mockSSEResponse(["Hello", " there"]);
});
const events = await collectEvents(
new AnthropicProvider(mockServer.url).stream(model, context, options)
);
expect(events).toContainEqual({ type: "message_update", delta: "Hello" });
});
// 🟡 真正连线上服务的 E2E 测试单独隔离、默认不跑
test.skipIf(!process.env.LIVE_API_KEY)(
"real anthropic API smoke test",
async () => { /* ... */ }
);
前者本地秒跑、可以跑几百个用例覆盖各种边界情况;后者留给少量、昂贵、按需触发的验证。很多团队图省事把两者混在一起写集成测试,结果 CI 又慢又不稳定又烧钱。
「我发出去的东西对不对」和「对方服务愿不愿意正常响应」是两类完全不同的风险,应该用两套完全不同代价的测试去覆盖——这适用于任何依赖外部 API(尤其是收费、限流、不稳定的 API)的项目。
五、供应链安全:依赖变更当代码变更去审
不只是态度,是几条配置层面的具体做法:
# .npmrc
save-exact=true # 直接依赖锁死精确版本,不用 ^/~ 范围
min-release-age=2 # 包发布不满 2 天不允许安装,避免拉到刚发布、
# 还没被社区验证过的可疑版本
# CI 里
npm ci --ignore-scripts # 安装时不执行任何生命周期脚本
npm audit --omit=dev # 定期扫描已知漏洞
npm audit signatures --omit=dev # 校验包签名
# pre-commit hook 思路:lockfile 被当成唯一真相
# 未经允许的 lockfile 变动直接拦截
if git diff --cached --name-only | grep -q package-lock.json; then
if [ -z "$PI_ALLOW_LOCKFILE_CHANGE" ]; then
echo "lockfile 变动需要显式确认,设置 PI_ALLOW_LOCKFILE_CHANGE=1"
exit 1
fi
fi
一个能执行 shell 命令、改文件的工具,依赖链一旦被污染,攻击面就是用户整台机器。「依赖升级当成正常代码改动一样走审查」,是任何被广泛安装的工具/库都应该有的态度。
六、诚实地承认没做安全沙箱
// 核心里不存在这样的东西:
// function checkPermission(action: "read_file" | "exec" | "network"): boolean
// 官方文档直接说明:核心默认以启动它的用户权限运行,
// 需要更强边界,请自己用容器/沙箱包裹整个进程:
//
// docker run --rm -v $(pwd):/workspace --network=none pi-image pi
//
// 而不是在核心里塞一套自己都测不完整的伪权限系统
很多项目为了「看起来安全」,会在核心里塞一套自己都测不完整的权限系统,结果给用户一种虚假的安全感。「没做的事情诚实地说没做,并指明正确的做法」,比「看起来做了但其实没做全」要专业得多。
七、成本(Token)是运行时的一等公民
interface CompactionResult {
summary: string;
tokensBefore: number;
tokensAfterEstimate: number; // 压缩效果直接算进结果里,
// 不是事后靠日志拼凑
}
interface Usage {
inputTokens: number;
outputTokens: number;
cachedTokens: number; // 缓存命中的部分单独统计
cacheHitCostSaved: number;
}
// 调用时用 session ID 做缓存键,复用命中的 prompt cache
const response = await provider.stream(model, context, {
...options,
promptCacheKey: session.id,
});
成本核算和实际状态变化绑在一起产生,而不是靠外部埋点去凑,数据的准确性和及时性会好很多。任何按量计费或对成本敏感的系统(不止 AI,云资源调度、批处理任务都类似),把成本核算前置进状态模型本身,收益会很明显。
八、边界情况:该报错就报错,别悄悄生成空壳结果
async function compactSession(messages: Message[]): Promise<CompactionResult> {
const eligible = messages.filter(isEligibleForCompaction);
// ❌ 早期版本的隐患写法:
// if (eligible.length === 0) return { summary: "", tokensAfterEstimate: 0 };
// 悄悄吐出一个空摘要,表面上「能跑」,实际污染了下游上下文
// ✅ 修复后:没有可处理的消息就直接拒绝
if (eligible.length === 0) {
throw new Error("No eligible messages to compact — refusing to summarize.");
}
return await summarize(eligible);
}
任何「生成式」的处理流程——摘要、聚合、汇总——遇到空输入,默认应该 fail loud,而不是 fail silent 地吐出一个看似正常的结果。后者短期内「看起来能跑」,但会悄悄污染下游数据,而且因为不报错,事后极难排查。
九、上下文组装用分层文件,而不是代码里拼字符串
项目根目录/
├── AGENTS.md # 基础规则(项目通用,人和 Agent 都读)
├── SYSTEM.md # 整体替换默认系统提示词(可选)
├── APPEND_SYSTEM.md # 在默认系统提示词基础上追加(可选)
└── .pi/skills/ # 可插拔的技能模块
// 最终的系统提示词由几层显式叠加而成,而不是这样写死在代码里:
// const systemPrompt = `你是一个助手...` + userConfig + moreStuff + "..."
function buildSystemPrompt(project: ProjectConfig): string {
const base = project.systemMdExists
? readFile("SYSTEM.md") // 完全替换默认值
: DEFAULT_SYSTEM_PROMPT;
const appended = project.appendSystemMdExists
? readFile("APPEND_SYSTEM.md") // 在基础上追加
: "";
return [base, appended, ...loadSkills(project)].join("\n\n");
}
使用者不用碰代码,只改配置文件就能精确控制最终效果,还能被 git diff、被非工程师维护。这个思路能推广到任何「内容组装」场景,不止是 prompt:配置生成、模板渲染、多环境部署配置……把「内容由哪几层、按什么顺序叠加而成」显式建模成文件系统里的层级结构,比在代码里维护一堆字符串拼接和模板变量更易读。
十、一套运行时,三种入口
// 核心运行时只有一份,三种「外壳」复用同一个 AgentHarness
// 1. 交互式终端
const tui = new TerminalUI(new AgentHarness(config));
tui.start();
// 2. 无头模式,走 JSONL RPC,方便脚本/CI 调用
const rpc = new JsonlRpcServer(new AgentHarness(config));
rpc.listen(process.stdin, process.stdout);
// 3. 直接作为 SDK 嵌入你自己的应用
const harness = new AgentHarness(config);
harness.on("message_update", (delta) => myApp.streamToUser(delta));
await harness.run("帮我重构这个函数");
先把「核心运行时」和「某一种具体的交互界面」彻底解耦,界面永远只是运行时的一种呈现方式——反过来做的项目,事后剥离出来的 SDK 往往千疮百孔。
配合这一点,pi 甚至把「给 Agent 用的组件间通信、状态同步、插件加载」这类基础设施,单独抽成了一个完全独立、不带任何「AI」业务语义的通用包(chord),高层的远程会话协议是构建在它之上的。当你发现自己在为业务系统写「组件通信/状态复制/插件系统」这类基础设施代码时,值得停下来想一想:这段代码里有多少是跟具体业务强相关的?大概率很少——那就值得单独拆出来,当一个没有业务语义的基础库去设计。
写在最后
这十条放在一起看,主线很一致:每一个「看起来只是产品细节」的决定,背后都有一个更通用、能脱离「AI Agent」复用的工程原则——
- 事件驱动的状态同步
- 并发设计里先定原子边界
- 测试要分层隔离外部依赖
- 依赖链要当代码审
- 安全边界要诚实划清
- 成本要建模进状态本身
- 边界情况要 fail loud
- 配置要分层而不是拼字符串
- 引擎和界面要解耦
这也是为什么这个项目的核心逻辑能被不同语言社区反复、忠实地移植——干净的抽象自然会被人愿意搬去别的技术栈验证,这本身就是对架构质量最诚实的一次代码评审。