
一、切层:三条判断规则
1. 按变化方向切层,不按功能切层。
Pi 切出的不是"功能模块",而是三条变化速率不同的线:AI 访问(跟随各家 SDK 变)、循环编排(相对稳定)、交互呈现(跟随产品变)。切完的验收标准就是 Pi 依赖图的样子------两条栈互不相识 (pi-tui 对 pi-ai 零引用),只在组装层会合。你自己的项目哪怕最后是一个包,也可以在目录级执行这条规则:ai/、loop/、ui/、app/,ui 不许 import ai。
2. 把"每层不许知道什么"当成正式设计产物写下来。
Pi 每层的无知都是刻意维护的资产:循环不知道凭证存哪、不知道 UI 存在、不知道具体工具。项目初期就写一份负面清单("loop 不 import 任何带业务名的模块"),比事后解耦便宜一个数量级。
3. 新概念归属用"三面法"判断。
拿 Pi 的 Tool 做尺子:协议面 (LLM API 报文里长什么样)放最底层;执行面 (怎么取消、怎么并发、怎么流式汇报进度)放中层;产品面(具体有哪些工具、权限怎么管)放应用层。任何新概念(记忆、子代理、检查点)都先问这三个问题,答案自然告诉你它归哪层。
二、中层:最值得直接抄的一批接口形状
4. 依赖倒置的"默认值"手法------抽象不牺牲开箱即用。
this.streamFn = options.streamFn ?? streamSimple(agent.ts:205)是整个设计里性价比最高的一行:循环只依赖 StreamFn 这个签名,但把下层实现设为缺省值。你的项目照抄:LLM 调用、认证解析、消息序列化都定义成可注入函数 + 合理默认。得到的性质是"换实现不用改循环,不换实现也不用写装配代码"。
5. 内部消息模型要富于 LLM API 格式,在边界处转换。
循环全程操作 AgentMessage[](含 UI 专用消息、压缩摘要),只在发请求前经 convertToLlm 过滤/转换成 Message[]。反面做法是让会话历史的结构被 LLM API 的消息格式绑架------那样"用户取消了某命令""这里压缩过"这类信息就没地方放。你的项目从第一天就该有两个消息模型:一个是完整的事实记录,一个是发给模型的视图。
6. 核心能存储它不认识的东西。
CustomAgentMessages 空接口 + 声明合并让循环持有四种它一无所知的消息。不用 TypeScript 也能实现等价物:核心消息的 role 字段预留自定义值,核心只负责存储和转发,解释权(渲染、转换)归应用层。这是"核心稳定、应用演进"的关键一步。
7. 错误契约写进接口文档,循环因此不死。
Pi 给每个钩子写了 "must not throw or reject":失败必须返回兜底值或编码进事件流(stopReason: "error")。你的项目把这条定为全库军规后,会得到一个强性质------应用代码的任何 bug 都打不死循环,最坏情况是产生一条错误事件。这对长期跑的 Agent 是生存属性。
8. 事件用判别联合按生命周期分层,一套词汇多处消费。
AgentEvent 分四层(agent → turn → message → tool_execution),UI 渲染、RPC 协议、HTML 导出、日志全消费同一套事件。你只要定义一次,就免费得到了可观测性和可测试性(测试断言事件序列即可)。
9. 状态防御性封装。
AgentState 的数组 setter 赋值即拷贝、运行时标志全 readonly。循环状态被外部引用污染是 Agent 项目常见的诡异 bug 来源,这几十行 accessor 是廉价保险。
10. 钩子面照单全收。 这组扩展点是被产品需求逼出来的成熟形状,直接抄:
| 钩子 | 解决的问题 |
|---|---|
getApiKey(每次调用解析) |
长工具执行中 OAuth token 过期 |
getSteeringMessages / getFollowUpMessages |
运行中插话 / 排队等它干完再说 |
shouldStopAfterTurn + prepareNextTurn |
上下文将满时优雅停;中途换模型、换上下文 |
beforeToolCall / afterToolCall |
权限拦截、结果改写(审计、脱敏都挂这) |
execute(signal, onUpdate) |
工具可取消、可流式汇报进度 |
toolExecution + 单工具 override |
并行为默认、写操作强制串行 |
三、类型:两条纪律
11. 词汇表单一来源。 Message、Model 在最底层定义一次,全员从源头 import,中间层不 re-export、不复制。小项目里就是约定一个 types.ts(或一个包)当"周期表",任何层想定义消息相关类型都得去那里登记。这条防的是最阴险的病:两份结构相同但身份不同的类型在边界上突然不兼容。
12. 组合优于复制。 extends 保兼容(AgentTool extends Tool------需要协议面的地方,执行面永远可用);泛型留空位(AgentToolResult<T> 把 details 留给应用填)。少用"复制一份再改"。
四、工程:三条配套
13. 版本策略分层。 内部包 ^ 联动发版,第三方精确锁定 + shrinkwrap------monorepo 一起动、外部世界冻结。即使你只有一个包,"锁死外部、放开内部"的原则也成立。
14. 重依赖用子路径隔离、按需注册。 pi-ai 把各家 SDK 拆进 ./providers/*,主入口不背,Bedrock 由应用在需要时注册。你的项目接了多家供应商时照做,启动速度和依赖树都会感谢你。
15. 要有扩展生态,就规定宿主共享类型的方式。 Pi 强制扩展把核心包列为 peerDependencies: "*" 且禁止打包------保证扩展和宿主拿到同一运行时里的同一份类型(声明合并、instanceof、类型兼容全依赖这一点)。你若做插件系统,这是第一块地基。
落地检查单------新项目起手时过一遍:
- LLM 调用藏在一个可替换函数后面了吗?(有默认值)
- 会话历史比 LLM API 格式富吗?转换发生在边界吗?
- 错误传播模型定了吗?(不许 throw,编码进事件)
- 事件词汇表定了吗?UI 和测试消费同一套吗?
- 每层的"不许知道什么"写下来了吗?
- 类型的"周期表"在哪个文件,全员知道吗?
一句话总结:**Pi 真正可复用的是它的"软硬分工"------类型和契约做成硬的(单一来源、不许 throw、判别联合),行为和实现做成软的(注入 + 默认值 + 声明合并),切层按变化方向而不是功能,负面清单和正面设计一样正式。**这四条不依赖 monorepo、不依赖 TypeScript 高级特性,任何规模的 Agent 项目都能用。