翻完 Pi 源码:它和 Codex、Claude Code 有何不同

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 {}

核心就两行。Messagepi-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.tsprepareNextTurn 配置项:

typescript 复制代码
prepareNextTurn?: (
  context: PrepareNextTurnContext,
  signal?: AbortSignal,
) => Promise<AgentLoopTurnUpdate | undefined>;

调用方返回新的 AgentLoopTurnUpdate,包含 contextmodelthinkingLevel,下一个 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.tsrunLoop 函数里处于不同的循环层级:

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.tsAgentOptions

typescript 复制代码
beforeToolCall?: (
  context: BeforeToolCallContext,
  signal?: AbortSignal,
) => Promise<BeforeToolCallResult | undefined>;

afterToolCall?: (
  context: AfterToolCallContext,
  signal?: AbortSignal,
) => Promise<AfterToolCallResult | undefined>;

完整的工具调用生命周期在 agent-loop.tsprepareToolCallexecutePreparedToolCallfinalizeExecutedToolCall 三个函数中:

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.tsAgentToolResult

typescript 复制代码
export interface AgentToolResult<T> {
  content: (TextContent | ImageContent)[];
  details: T;
  usage?: Usage;
  addedToolNames?: string[];
  terminate?: boolean;
}

流程:Agent 识别到缺少工具 → 自己写工具代码 → 执行结果中通过 addedToolNames 注册 → 下一个 turn 感知到新工具可用。

agent-loop.tscreateToolResultMessage 函数检查 addedToolNames,非空时拼入 ToolResultMessagerunLoop 主循环消费这个字段,把新工具合并到当前 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,你会抄哪套?


参考资源

相关推荐
_Jimmy_7 小时前
AI应用开发工程师面试题库
人工智能·python·深度学习·机器学习·知识图谱
阿里云大数据AI技术7 小时前
FalconSeek 技术解析:阿里云 Elasticsearch 云原生内核如何让查询性能飙升600%
人工智能·elasticsearch
了不起的云计算V7 小时前
破局“Demo陷阱”:中国电子云以“新星”AI重构关键行业智能决策体系
人工智能·重构
Elastic 中国社区官方博客7 小时前
将你的 Grafana Kubernetes 仪表板迁移到 Elastic Observability:相同的 PromQL,30 倍更快的查询
大数据·人工智能·elasticsearch·搜索引擎·容器·kubernetes·grafana
中微极客7 小时前
2026主流AI Agent框架技术选型与性能对比
运维·网络·人工智能
码哥字节7 小时前
Google 上周推了个 agents-cli,我装完发现 Claude Code 多了 7 个超能力
google·agent·claude
灵机一物7 小时前
合伙制律所分红和提成律师个税怎么合规优化?
大数据·人工智能
dreamer_83997 小时前
AI智能合同比对系统:从零搭建实战教程
人工智能·python
Microvision维视智造7 小时前
10ppm是什么概念?锂电生产管控国标给AI视觉下了硬指标
人工智能·计算机视觉·视觉检测·机器视觉