2025 到 2026 年,编码 Agent 爆发了三款代表性项目:OpenAI 用 Rust 写的 Codex CLI(Apache-2.0,10 万 star)、Anthropic 的 Claude Code(闭源,13 万 star),以及 earendil-works 用纯 TypeScript 写的 Pi Agent(MIT)。
三款都做同一件事------终端里跑一个能读代码、写文件、执行命令的 AI Agent。但底层的架构取舍完全不同:
| 维度 | Codex CLI | Claude Code | Pi Agent |
|---|---|---|---|
| 语言 | Rust | TypeScript(闭源编译) | 纯 TypeScript 开源 |
| 模型绑定 | OpenAI Responses API | 仅 Anthropic 模型 | 30+ 供应商 |
| 架构 | App Server(JSON-RPC) | 插件化单体 | 四层解耦 Monorepo |
| 许可证 | Apache 2.0 | 闭源 | MIT |
写这篇的起因不算复杂。我用 Claude Code 写了大半年代码,也试过 Codex CLI,两个都绑死了各自的模型生态。想做点不依赖特定厂商的东西------搭一个自己的编码 Agent,可以换模型、能按需扩展、不要黑盒。翻了一圈开源项目,LangChain.js 太重,Vercel AI SDK 太偏框架,转到 Pi 上。
翻完 Pi 的源码后,最直接的感受是:它的设计哲学是"给你搭积木的接口,不替你做决定"。Claude Code 给你 27 种钩子,Pi 就给你 before/after 两个。Claude Code 塞了 5 层压缩,Pi 就一个 compact 函数加个文件追踪。这种克制和它 MIT 开源、纯 TS 的调性是一致的------你拿它当库用,不该被框架牵着走。
下面六个设计点,是我翻源码时觉得最值得拿出来和 Codex、Claude Code 对比的地方。
消息类型不定义死,留给用户扩展
多数编码 Agent 的消息类型在编译期就固定了。Codex 用 Rust enum,所有消息类型写在 protocol.rs 里,外部不能添加。Claude Code 闭源,消息类型不可见也不可改。
Pi 在 packages/agent/src/types.ts 里用了另一种做法:
typescript
export type AgentMessage = Message | CustomAgentMessages[keyof CustomAgentMessages];
export interface CustomAgentMessages {}
核心就两行。Message 是 pi-ai 层定义的标准 LLM 消息(user、assistant、toolResult)。CustomAgentMessages 是一个空 interface------利用 TypeScript 的 declaration merging,用户在自己的项目里写一句 declare module 就可以注入新的消息类型:
typescript
declare module "@earendil-works/pi-agent-core" {
interface CustomAgentMessages {
deploymentLog: { role: "deploymentLog"; content: LogData };
codeReview: { role: "codeReview"; content: ReviewData };
}
}
AgentMessage 自动包含了新的消息类型,核心库一行没动。
这个设计好在哪里?我最看重的是第三方不需要改核心源码就能扩展消息类型。如果你在做一个代码评审 Agent,想加一个 codeReview 消息类型来记录评审记录,在 Codex 里你得 fork 仓库改 protocol.rs 的 enum。Pi 这边一句 declare module 就够了。代价也有:类型推导不像 discriminated union 那么直白,需要理解 declaration merging 的语义------但这点学习成本对于一个要自定义消息的场景来说,完全值得。
Codex 的 Op enum 和 EventMsg enum 都是编译期固定结构,Claude Code 闭源没法改。这个维度上 Pi 的开放性高出不止一档。
pi-ai 用 Adapter 模式统一供应商
Codex CLI 虽然可以配置不同的 API 端点,但协议固定为 OpenAI Responses API。Claude Code 只走 Anthropic 的 Messages API。Pi 的 packages/pi-ai 层用了一组 Adapter,把 30+ 供应商的差异挡在统一接口后面。
核心接口定义是 types.ts 中的 StreamFn:
typescript
export type StreamFn = (
model: Model<Api>,
context: Context,
options?: SimpleStreamOptions,
) => AssistantMessageEventStream | Promise<AssistantMessageEventStream>;
Model<Api> 的 Api 是一个 discriminated union,pi-ai/src/types.ts 里定义了所有支持的 API 类型:
typescript
export type KnownApi =
| "openai-responses"
| "anthropic-messages"
| "bedrock-converse-stream"
| "google-generative-ai"
| "pi-messages"
// ...30+ 个
每个 API 对应一个 Adapter 文件,全部在 packages/ai/src/api/ 下。StreamFn 运行时根据 model.api 路由到对应 Adapter。
我看完这层代码后的判断是:Pi 的价值不在于它支持了多少供应商------那是苦力活,多接一个就多写一个 Adapter。真正的价值在 Agent 可以在 Turn 边界无损切换模型,同一个 Agent 实例不用重建。代码在 agent.ts 的 prepareNextTurn 配置项:
typescript
prepareNextTurn?: (
context: PrepareNextTurnContext,
signal?: AbortSignal,
) => Promise<AgentLoopTurnUpdate | undefined>;
调用方返回新的 AgentLoopTurnUpdate,包含 context、model、thinkingLevel,下一个 Turn 立即生效:
typescript
prepareNextTurn: (context) => {
const model = isComplexTask ? claudeSonnet : deepseekChat;
return { model, thinkingLevel: isComplexTask ? "high" : "off" };
};
Claude Code 虽然支持 /model 命令切模型,但那是用户手动操作,不是运行时自动决策。Codex 配置一次端点后不能按 Turn 动态切换。如果你做一个需要成本控制的 Agent------复杂推理用贵模型,简单操作切便宜模型------这个能力是刚需。
steer 和 followUp 是两个不同的循环层级
这是 Pi 在消息队列设计上和其他两个差异最大的地方。Codex 的 protocol.rs 定义了 SteerInput 操作类型,但它和普通 UserInput 走同一个 Submission → EventMsg 通道。Claude Code 没有区分"中途打断"和"追加任务"两种语义。
Pi 在 agent.ts 里定义了 PendingMessageQueue 类:
typescript
class PendingMessageQueue {
private messages: AgentMessage[] = [];
public mode: QueueMode; // "all" | "one-at-a-time"
enqueue(message: AgentMessage): void { this.messages.push(message); }
drain(): AgentMessage[] {
if (this.mode === "all") {
const drained = this.messages.slice();
this.messages = [];
return drained;
}
const first = this.messages[0];
this.messages = this.messages.slice(1);
return [first];
}
}
对应的两个公开方法:
typescript
steer(message: AgentMessage): void { this.steeringQueue.enqueue(message); }
followUp(message: AgentMessage): void { this.followUpQueue.enqueue(message); }
这两个队列在 agent-loop.ts 的 runLoop 函数里处于不同的循环层级:
arduino
外层循环(followUp):
内层循环(steer):
读 steering queue → 注入消息
LLM 调用 → 流式输出
有 tool calls → 执行 → 循环
没有 → 内层结束
读 followUp queue → 有则重置内层循环
没有则 agent_end
为什么我特别看重这个设计?因为在实际场景里,两种消息的语义完全不同。Agent 正在执行自动化部署,你发现配置错了------这时候你需要的是"立即打断",不是"等跑完再说"。前者对应 steer(),在内层循环每次 tool 执行完就检查,即时响应。后者对应 followUp(),等 Agent 所有事情做完再触发。
把两个场景塞进同一个队列,要么只能满足"即时打断"(但无法区分"做完再说"),要么只能做到"排队等"(但无法打断正在执行的任务)。Pi 用两个队列加两层循环把这个矛盾解决了。
beforeToolCall / afterToolCall 是工具粒度的 AOP
Claude Code 有 27 种全局事件钩子,覆盖面广,但每个钩子对所有工具都生效。Pi 的选择是精确到每次工具调用。
定义在 types.ts 的 AgentOptions:
typescript
beforeToolCall?: (
context: BeforeToolCallContext,
signal?: AbortSignal,
) => Promise<BeforeToolCallResult | undefined>;
afterToolCall?: (
context: AfterToolCallContext,
signal?: AbortSignal,
) => Promise<AfterToolCallResult | undefined>;
完整的工具调用生命周期在 agent-loop.ts 的 prepareToolCall → executePreparedToolCall → finalizeExecutedToolCall 三个函数中:
yaml
prepareToolCall:
根据 name 从 context.tools 中查找 AgentTool
prepareArguments 修正参数
validateToolArguments() 自动校验 schema
beforeToolCall 钩子 → 返回 { block: true } 阻断
返回 PreparedToolCall
executePreparedToolCall:
tool.execute(params, signal, onUpdate)
onUpdate 流式输出中间结果
返回 ExecutedToolCallOutcome
finalizeExecutedToolCall:
afterToolCall 钩子 → 改写 content/isError/terminate
返回 FinalizedToolCallOutcome
全局 hook 和工具级 AOP 是两种不同的粒度偏好。Claude Code 的 27 个钩子覆盖面大,但 hook handler 里必须自己判断当前是什么工具、什么上下文,容易写出巨大的 switch-case。Pi 把粒度压到单次工具调用------bash 工具配严格审核,read 工具不验证直接放行,两个的 beforeToolCall 互不干扰。
各有适用场景。如果你需要的是 session 级别的全量审计,Claude Code 的全局钩子更合适。如果你要的是按工具精确控制------生产环境里常见的需求------Pi 的每工具 AOP 更精准。
Compaction 带文件追踪,不只是摘要
三个编码 Agent 都面临上下文窗口限制的问题,但处理策略完全不同。
Codex CLI 靠 Responses API 的 /responses/compact 端点,返回 type=compaction 的 item,内部是 encrypted_content------用户看不到压缩了什么,也无法追溯。
Claude Code 的 5 层渐进压缩(budget reduction → snip → microcompact → context collapse → auto-compact)最复杂,具体实现在 query.ts 的 compaction pipeline。但它的压缩也是对话文本层面的,不追踪文件操作。
Pi 的做法在 packages/agent/src/harness/compaction/compaction.ts:
typescript
export interface CompactionDetails {
readFiles: string[];
modifiedFiles: string[];
}
压缩流程:estimateContextTokens() 估算 Token → findCutPoint() 找切割点 → extractFileOperations() 扫描压缩区间所有文件操作 → 用 LLM 生成摘要 → 把 CompactionDetails 作为结构化文件变更记录附带存储。
extractFileOperations 逐条遍历消息,检查 tool call 和 tool result 中的文件路径参数,Set 去重后分类为 read 和 modified。
这个设计有意思的地方在于:它把压缩从"丢掉旧消息"变成了"保留有用的元数据"。Codex 的 encrypted_content 你是读不了的。Claude Code 的五层压缩保证对话连贯,但压缩后的上下文不包含结构化的文件索引。Pi 的 CompactionDetails 最轻量,但开发者可以编程读取"这段压缩历史改了哪些文件"------在做文件级别的上下文恢复时,这行代码省掉一次全量回溯。
addedToolNames 让工具运行时自己进注册表
三个编码 Agent 里,Pi 是唯一允许工具在运行时动态注册新工具的。
定义在 types.ts 的 AgentToolResult:
typescript
export interface AgentToolResult<T> {
content: (TextContent | ImageContent)[];
details: T;
usage?: Usage;
addedToolNames?: string[];
terminate?: boolean;
}
流程:Agent 识别到缺少工具 → 自己写工具代码 → 执行结果中通过 addedToolNames 注册 → 下一个 turn 感知到新工具可用。
agent-loop.ts 的 createToolResultMessage 函数检查 addedToolNames,非空时拼入 ToolResultMessage。runLoop 主循环消费这个字段,把新工具合并到当前 context 的 tools 列表。
Codex 和 Claude Code 的工具集在启动时固定。MCP 服务器可以动态提供工具,但那是在 Agent 外部注册的,不是由工具执行结果触发的。
坦白说,这个设计目前还是"预留接口多于实际场景"。我现在想不到太多用例需要在运行时由工具自己注册工具------最典型的可能是 Agent 发现自己缺少某个格式化工具,当场写一个并注册。但方向是对的:长远来看 Agent 的能力不应该在启动时完全固定。
terminate 要求所有工具同意才停
停止权下放给工具------但带了一个约束条件。
agent-loop.ts 的判断逻辑:
typescript
function shouldTerminateToolBatch(finalizedCalls: FinalizedToolCallOutcome[]): boolean {
return finalizedCalls.length > 0 &&
finalizedCalls.every((f) => f.result.terminate === true);
}
all-or-nothing,不是 or-any。一个工具完成返回 terminate: true 不代表其他人也能停。两个并行工具,文件写入完成了想停,数据库迁移还在跑------如果有一个 terminate 就停,迁移还没开始就被截断了。
这个判断在 executeToolCalls 里决定内层循环是否继续。Codex 的 stop_reason 由模型返回,Agent 循环根据它判断是否停止。Claude Code 的停止决策也在 queryLoop 内部,工具没有独立的停止信号。
看完 Pi 的源码,对比 Codex 和 Claude Code,三者的选择偏好很清楚:
| 设计维度 | Codex CLI | Claude Code | Pi Agent |
|---|---|---|---|
| 消息扩展 | Rust enum 固定 | 闭源不可扩展 | Declaration Merging 可扩展 |
| 模型选择 | OpenAI 生态 | Anthropic 独占 | 30+ 供应商,Turn 热切换 |
| 消息注入 | 统一队列 | 统一队列 | 双队列 + 双循环层级 |
| 工具控制 | Sandbox 策略 | 27 全局钩子 | 每工具 before/after 钩子 |
| 上下文压缩 | API 黑盒加密 | 5 层渐进压缩 | 摘要 + 结构化文件索引 |
| 工具自注册 | 不支持 | 不支持 | addedToolNames |
| 停止信号 | 循环层判断 | 循环层判断 | 工具驱动 + 全部同意 |
Codex 选了 Rust + 安全沙箱 + Responses API------背靠 OpenAI 的协议生态。Claude Code 选了闭源 + 27 种钩子 + 5 层压缩------用复杂度换覆盖面的典型。Pi 选了纯 TS + 双队列 + 热切换 + 自扩展------让开发者自己组合的模块化路线。
从我翻源码的感受来说,Pi 最适合的场景是:你想自己搭一个编码 Agent,不绑死某家模型,需要精确控制工具行为,想留扩展余地。Claude Code 和 Codex 更适合"拿来就用,不关心底层"的日常开发。
如果自己搭编码 Agent,你会抄哪套?
参考资源
- Pi Agent 源码:github.com/earendil-wo...
packages/agent/src/agent.ts--- Agent 类 + 双队列 + 生命周期packages/agent/src/agent-loop.ts--- runLoop 主循环 + 工具执行packages/agent/src/types.ts--- AgentMessage / AgentTool / AgentEvent 类型定义packages/agent/src/harness/compaction/compaction.ts--- 上下文压缩packages/ai/src/types.ts--- KnownApi / Model / StreamOptions 定义
- Codex CLI 源码:github.com/openai/code...
- Codex 架构分析(OpenAI 官方):openai.com/index/unrol...
- Codex App Server 架构:openai.com/index/unloc...
- Claude Code 源码(闭源,仅构建脚本可见):github.com/anthropics/...
- Claude Code 架构文档:code.claude.com/docs/en/how...
- Claude Code 深度分析(VILA-Lab):arxiv.org/html/2604.1...