188 万行 Rust 对 84 万行 TypeScript:我把 Codex 和 ZCode 拆到了骨头,看见了 AI 编程助手的两种活法

0 阅读39分钟

一个把世界收窄,一个把世界织密。 一个相信编译器,一个相信契约。 这不是谁抄谁的故事,是两种世界观在同一个赛道上的正面相撞。


一、先讲个反常识的事

假设你招了两个人,都叫"AI 编程助手",都宣称能帮你改代码、跑测试、提交 PR。

第一个人,随身带了一整套保安系统。他每次想执行一条命令,先要过一道用 Starlark 写的策略引擎;想读写文件,先要过内核级的沙箱(macOS 上叫 Seatbelt,Linux 上是 landlock + seccomp);甚至连"修改文件"这个动作,他都不肯用标准的 diff 格式,自己发明了一套补丁语法。

第二个人,随身带了一整套行政体系。他关心的是:同一个任务,在桌面客户端、浏览器、手机远控、SSH 远程这四个地方,看到的进度必须完全一致;如果你在手机上点了"取消",桌面上的那个任务要立刻知道自己失去了所有权。为此他给每一次任务运行发了一张"租约",上面写着所有者的宿主机 ID、客户端 ID、设备名、运行 ID 和追踪 ID。

第一个人是 OpenAI 的 Codex。第二个人是 z.ai(智谱)的 ZCode。

这两个项目我在本地完整拆了一遍。拆完之后最大的感受是:大家嘴上说的都是"AI 编程助手",但骨子里在解的根本不是同一道题。

Codex 在解的是——如何让一个不可信的模型,在一个危险的真实系统里安全地干活。 所以它建墙、设关卡、写规则、做沙箱。它的敌人是"意外"。

ZCode 在解的是——如何让一个 Agent,在四种形态的客户端和三种远程环境下保持同一个自我。 所以它做协议、发租约、定契约、传 traceId。它的敌人是"分裂"。

把这两个敌人认清楚,后面所有代码细节就都顺了。

先上一组硬数据,感受一下量级:

CodexZCode
主语言Rust(edition 2024)TypeScript(Node 24)
代码文件4,707 个 .rs3,896 个 .ts / .tsx
代码行数约 1,877,593 行约 843,335 行
模块划分114 个 crate 目录14 个共享包 + 16 个 CLI 内部包
构建Cargo + Bazel 9.0.0 双轨pnpm workspace + turbo
工具链Rust 1.95.0Node 24.14.0 / pnpm 10.33.2
当前版本0.0.0-dev(工作区版本)3.14.0

188 万行对 84 万行。有意思的地方在于:行数多一倍的那个,反而在功能上"更窄"。

Codex 的窄是刻意的。它把大量的代码投在"如何安全"上——114 个 crate 里,光沙箱相关的就有 sandboxing、linux-sandbox、bwrap、mxc-sandbox、windows-sandbox-rs、execpolicy、network-proxy 这么一串。

ZCode 的宽也是刻意的。它把代码摊在"如何一致"上——桌面、Web、手机、远程、TUI、插件市场、动态工作流,每一块都要有人管。

好,接下来我们一层一层剥。


二、代码的母语,决定了它的性格

选 Rust 还是 TypeScript,表面上是性能取舍,实际上是在选**"你把不变量托付给谁"**。

Codex 把不变量托付给了编译器。

打开它的工作区配置,有一行特别能说明问题——Cargo.toml 的 workspace lints 里,unwrap_used 和 expect_used 是 deny 级别。意思是:在 Codex 的代码库里写 foo.unwrap(),编译直接不过。

这看着像洁癖,其实是一条安全设计原则的延伸。一个 Agent 每天要处理成千上万条不可信输入——模型的胡言乱语、用户粘贴的乱码、畸形 JSON、超长路径。每一处 unwrap() 都是一次"我赌这里不会出错"的赌博。Rust 的 Result 逼着开发者把失败路径显式写出来,unwrap_used = deny 则把这个逼问变成硬性门禁。

再加上 await_holding_lock(不许在持锁状态下 await)、redundant_clone、uninlined_format_args 这些规则,你能感觉到一种气质:这个项目不欢迎"我觉得应该没问题"。

ZCode 把不变量托付给了另一套东西——一份 YAML。

它根目录躺着一个 architecture-policy.yaml,长这样(节选):

version: 1
modules:
  - id: storage
    roots: [packages/services/src/storage]
    managed: true
    requires: [shared, rpc, services]
    publicEntrypoints: [packages/services/src/storage/contract.ts]
    layers: { domain: domain, app: app, adapters: adapters }
    layerOrder: [domain, app, adapters]
    owner: desktop-settings

global:
  maxFileLines: 400
  maxContractLines: 300
  maxPublicMethods: 12
  forbidCycles: true
  forbidDeepImports: true
  managedOnly: true

配一个用 ts-morph 写的检查器 scripts/architecture/architecture-check.mjs,跑起来会检查这些:

  • 任何源文件超过 400 行 → 违规

  • 任何契约文件超过 300 行 → 违规

  • 任何模块暴露超过 12 个公开方法 → 违规

  • 出现循环依赖 → 违规

  • 绕过 publicEntrypoints 直接深引用别人的内部文件 → 违规

  • storage 模块的 domain 层反向 import 了 adapters 层 → 违规

注意最后一条。它在 YAML 里写了 layerOrder: [domain, app, adapters],意思是依赖只能从 adapters 指向 domain,反过来就是架构违规。这是把"整洁架构"从 PPT 上的圈圈图,变成了 CI 里会红的东西。

所以我给这两个项目的性格下了个判断:

Codex 的外科医生是 rustc,ZCode 的外科医生是一个 Node 脚本。

这带来的后果很有意思。Rust 编译器管得住的东西(类型、所有权、未处理错误),Codex 管得极严;管不住的东西("这个模块该不该 import 那个模块"),它就靠人工约定。反过来,ZCode 的类型系统管不住"文件别写太长",于是它自己造了一个检查器,还给它配了 fingerprint 基线——每次检查算一个 sha256 前 16 位,跟基线比对,CI 永远不会自动刷新基线,想放宽标准必须人工评审。

一句话总结这一层:Codex 用语言特性兜底,ZCode 用可执行的政策兜底。


三、心跳:两种 Agent 主循环

一个 Agent 最核心的东西是它的循环——"想 → 做 → 看结果 → 再想"。这段代码决定了它是不是真的能跑长任务。

Codex:队列 + 事件流的经典 Actor 模型

Codex 的主循环是一个教科书式的队列结构。核心类型就三组:

// 提交:外界 → 内核
pub struct Submission { id, op, trace, parent_turn_id, root_turn_id }

// 命令:内核能做的事
pub enum Op {
    Interrupt,
    TurnInput { request, mode, reply },
    ExecApproval { id, turn_id, decision },
    PatchApproval { id, decision },
    Compact,
    Review { review_request },
    RunUserShellCommand { command, timeout_ms },
    // ... 几十个变体
}

// 事件:内核 → 外界
pub struct Event { id, msg: EventMsg }
pub enum EventMsg {
    TurnStarted, TurnComplete, TurnAborted,
    AgentMessage, AgentReasoning, TokenCount,
    ExecCommandBegin, ExecCommandOutputDelta, ExecCommandEnd,
    ApplyPatchApprovalRequest, PlanUpdate, TurnDiff,
    // ...
}

然后一个 submission_loop 串行消费队列:

pub(super) async fn submission_loop(sess, config, rx_sub: Receiver<Submission>) {
    while let Ok(sub) = rx_sub.recv().await {
        match sub.op {
            Op::Interrupt => interrupt(&sess).await,
            Op::TurnInput { .. } => turn_input::handle(...).await,
            Op::ExecApproval { .. } | Op::PatchApproval { .. } => ...,
            Op::Shutdown => ...,
            _ => false,
        }
    }
}

这个结构的好处是顺序天然确定。所有对会话状态的修改都排在一个队列里,谁先谁后由队列决定,不用抢锁。

它的流式处理也值得看一眼。模型返回的工具调用不是一个个等,而是塞进一个 FuturesOrdered 队列:

let mut in_flight: FuturesOrdered<InFlightFuture<'static>> = FuturesOrdered::new();
// ...
if let Some(tool_future) = output_result.tool_future {
    in_flight.push_back(tool_future);
}
// ...
drain_in_flight(&mut in_flight, ...).await;

FuturesOrdered 的关键特性是:并发执行,但按插入顺序返回结果。 这是"我要快,但我不能乱"的标准答案。

中断这块有个细节我很喜欢:

pub(crate) const GRACEFULL_INTERRUPTION_TIMEOUT_MS: u64 = 100;

按 Ctrl+C 的时候,Codex 不是一刀砍死,而是先取消 cancellation token,然后给 100 毫秒让工具优雅收尾,超时了才真的 abort。这 100 毫秒是为了让正在写的文件写完、正在跑的进程有机会清理。一个 AI 助手能考虑到"别把用户的项目搞成半截状态",说明它是认真在设计这个动作的。

还有"中途插话"(steering)的设计。TurnInputMode 有四个变体:

StartOrSteer                        // 有空就跑,没空就插进去
StartIfIdle                         // 只在空闲时开新 turn
ContinueIfIdle { expected_previous_turn_id }  // 空闲就接着上一轮
Steer { expected_turn_id }          // 必须插进指定的那一轮

Steer 带一个 expected_turn_id,意味着如果那一轮已经结束了,这次插话会被拒绝,而不是错误地开一轮新的。这就是分布式系统里常见的"乐观并发控制",用在了聊天交互上。

ZCode:状态机 + 无限循环

ZCode 的主循环是另一种味道。核心是两件事叠在一起:一个 while (true),和一个显式的状态机。

export async function runRegularTurnLoop(this: AgentRuntimeInternal, state) {
  while (true) {
    throwIfTurnAborted(state.turnAbortSignal);

    const compactPhase =
      state.modelStepCount === 0 ? CompactPhase.PreRequest : CompactPhase.MidTurn;

    await this.microcompactIfNeeded(...);       // 一级压缩:轻量
    const rapidRefill = evaluateRapidRefill(state.compactTracking);
    const autoCompactOutcome = await this.autoCompactIfNeeded(...);  // 二级压缩:重量
    if (autoCompactOutcome === "rapid_refill_blocked") {
      throw createCompactRapidRefillError(...);  // 熔断
    }

    await this.initializeMcp(state.turnTraceContext);   // 拉 MCP 工具
    const turnDisallowedTools = buildTurnDisallowedTools(state);
    const tools = this.getTools(state.model).filter(...);

    // ... 构建 system reminders、注入模式提示、todo 提醒

    await runModelBackedTurnStep(...);          // 真正问模型
  }
}

配套的状态机 TurnMachineImpl 把一轮的相位写得很清楚:

ProcessingInput → AwaitingModelResponse → Streaming
  → SchedulingTools → ExecutingTools → AggregatingResults
  → 回到 AwaitingModelResponse(如果还有工具要跑)
  → Completing

外加两个旁路:AwaitingPermission(等用户批)和 Error。

这个状态机最大的价值是它把"卡住"这件事显式化了。用户界面能直接告诉用户"现在在等模型"还是"现在在跑工具"还是"现在在等你点同意",而不是转一个没有意义的圈圈。

一个很实战的细节:rapid refill 熔断

这是我认为 ZCode 最"老练"的一处设计,值得单独说。

上下文压缩有个尴尬情况:你发现上下文快满了,于是压缩一遍;结果压缩完,模型又开始疯狂读文件,上下文立刻又被填满。这时候如果继续压缩,就会进入死循环——压缩、填满、压缩、填满,用户看着进度条转,token 账单往上跳。

ZCode 的解法是给它装了个断路器:

export const RAPID_REFILL_TOOL_TURN_THRESHOLD = 3;   // 压缩后 3 轮工具调用内
export const MAX_CONSECUTIVE_RAPID_REFILLS = 3;      // 连续 3 次

翻译成人话:如果压缩完,3 轮工具调用内又被填满,连续发生 3 次,那就别压了,直接报错。 因为这说明问题不在上下文长度,而在这条任务本身的形状不适合这么干。

这种"承认有些事情做不到,并且明确地失败"的设计,比无限重试要成熟得多。

而且注意 ZCode 在文档里写的一句话:

长程任务优先:核心 agent loop 默认面向可持续运行的复杂任务设计,不用 tool call 次数做硬停止。资源与安全边界应由 token/context limit 自动 compact、用户取消、权限拒绝、工具超时、输出截断、provider retry 上限等明确条件承担。

这句话挺重要的。很多早期 Agent 会设一个"最多 20 轮工具调用"之类的硬上限,简单粗暴。ZCode 明确拒绝这种做法,理由也很实在:一个重构任务可能需要 200 轮工具调用,砍在第 20 轮毫无意义。真正该管的是资源,不是轮数。

小结

CodexZCode
主循环形态队列驱动(Submission/Op/Event)无限循环 + 显式状态机
并发策略FuturesOrdered 并发执行按序返回ToolScheduler 拓扑排序分组并行
中断100ms 优雅期再 abortturnAbortSignal 协作式取消
终止条件资源与审批资源与审批 + rapid refill 熔断

一个是"所有事排队说清楚",一个是"所有状态显式标出来"。目标一样,姿势不同。


四、工具系统:身份证 vs 按需发现

工具是 Agent 的手。手越多越能干,也越容易打架。

两个项目在"工具多了怎么办"这件事上,给了完全不同的答案。

ZCode:给每个工具发一张身份证

ZCode 的做法是让每个工具自我描述。它的 ToolMetadata 大致长这样:

interface ToolMetadata {
  name: string;
  description: string;
  allowedInPlanMode?: boolean;
  readOnly?: boolean;           // 只读吗
  destructive?: boolean;        // 破坏性吗
  concurrentSafe?: boolean;     // 能并发吗
  requiresUserInteraction?: boolean;
  timeoutMs?: number;
  maxOutputBytes?: number;
  sideEffectScope?: ToolSideEffectScope;  // 副作用范围
  riskLevel?: RiskLevel;
  needsApproval?: boolean;
  providerVisible?: boolean;
  stopTurnOnSuccess?: boolean;
}

其中 sideEffectScope 是个枚举:

none | workspace | git | network | system | session | userInteraction

这七个词就是一张"这个工具会碰到什么"的清单。声明了 network 的工具,权限系统就知道要拦;声明了 workspace 的,调度器就知道不能随便并行。

然后 ToolScheduler 拿着这些声明做拓扑排序 + 分组并行:

private canRunInParallel(tool: ToolDependency): boolean {
  const readOnly = tool.readOnly ?? this.readOnlyTools.has(tool.toolName!);
  if (tool.destructive) return false;          // 破坏性的一律单独跑
  if (tool.concurrentSafe === true) return true;
  if (tool.concurrentSafe === false) return false;
  if (readOnly) return true;                   // 只读的随便并行
  return tool.sideEffectScope === "none";      // 没副作用的也随便并行
}

const DEFAULT_MAX_CONCURRENCY = 10;

看这个判断顺序:破坏性最高优先级,一票否决。 然后才是并发安全的声明,最后才是只读和副作用推断。

这个顺序反映了一种很务实的风险观:宁可慢,不可乱。读 10 个文件并行没问题;写 10 个文件,对不起,排队。

ZCode 还做了个挺细的事:内置工具里有个 TodoWrite,还有 CronCreate / CronUpdate / CronDelete 这套定时任务工具,以及 OffPeakCreate(闲时任务)。为了防止 Agent 自己给自己排定时任务然后无限递归,它专门定义了:

export const AUTOMATION_MUTATION_TOOL_NAMES = ["CronCreate", "CronUpdate", "CronDelete"];
export const OFF_PEAK_MUTATION_TOOL_NAMES = ["OffPeakCreate", "SendMessage", "Workflow"];

注释里写得很直白:"防止闲时任务递归自我派生、无限调度。"

一个 Agent 系统开始需要考虑"如何防止它自己繁殖",说明它真的被用在生产环境里了。

Codex:让工具"按需被发现"

Codex 的思路完全不同。它的核心痛点是——工具太多了,模型的注意力不够用。

一个真实场景:你装了 5 个 MCP server,每个暴露 20 个工具,加上内置工具,模型面前摆着 120 个工具定义。光把这些工具的 JSON Schema 塞进 prompt,可能就吃掉几万 token,而且模型的选择准确率会显著下降。

Codex 的答案是三层设计。

第一层,工具规范做成枚举,区分类型:

pub enum ToolSpec {
    Function(ResponsesApiTool),      // 普通函数工具
    Namespace(ResponsesApiNamespace),// 命名空间(一组工具的容器)
    ToolSearch { execution, description, parameters },  // 工具搜索器
    WebSearch { ... },               // 宿主侧托管的搜索
    Freeform(FreeformTool),          // 自由格式
}

第二层,给工具定义"暴露级别":

pub enum ToolExposure {
    Direct,             // 直接给模型看
    Deferred,           // 延后,需要时再拿出来
    DeferredModelOnly,
    DirectModelOnly,
    CodeModeOnly,       // 只在代码模式下可用
    Hidden,             // 藏起来
}

Deferred 是这里的关键。一个延后的工具,初始不出现在模型面前,只留下一条"目录项"。模型需要的时候,通过 tool_search 工具去检索,找到之后再"激活"。

这就是工具版的懒加载。它把"我有哪些工具"从"全部摆在桌上"变成了"先给你一本目录,要什么自己查"。

第三层,代码模式(Code Mode)——这可能是最激进的一招。

Codex 里有一个 code-mode 系列 crate,包含 code-mode、code-mode-host、code-mode-protocol、code-mode-runtime,运行时里内嵌了 V8(v8 = "=150.4.0",见 v8_init.rs)。

它的对外工具只有两个:

pub const PUBLIC_TOOL_NAME: &str = "exec";
pub const WAIT_TOOL_NAME: &str = "wait";

模型不再"调用工具",而是写一段 JavaScript,在 V8 里跑。这段脚本可以:

  • 循环调用其他工具

  • 用 if 判断结果再决定下一步

  • 把中间结果留在 JS 变量里,不占模型的上下文

  • 需要人工介入时调 wait 挂起

这个思路的妙处在于:它把"多轮工具调用"压缩成"一次脚本执行"。

传统模式下,模型想看 20 个文件里哪个包含某个函数,得来回 20 轮(调用、看结果、再调用……),每一轮的结果都要进上下文。代码模式下,模型写一段:

for (const f of files) {
  const c = await read_file(f);
  if (c.includes("targetFn")) results.push(f);
}
return results;

一次执行,只把 results 返回。上下文占用从"20 个文件全文"降到"一行结果"。

这是一种很本质的优化:把控制流从模型的语言(自然语言 + 多轮对话)搬到编程语言(循环、条件、变量)里。 自然语言表达循环很贵,编程语言表达循环几乎免费。

顺便说一句,Codex 的工具并发也很有意思。它用一个读写锁来区分并行和串行:

pub(crate) struct ToolCallRuntime {
    session, step_context,
    tracker: SharedTurnDiffTracker,
    parallel_execution: Arc<RwLock<()>>,
}
// ...
let supports_parallel = router.tool_supports_parallel(&call);

只读工具拿读锁(可以多个同时进行),写工具拿写锁(互斥)。跟数据库的读写锁一个道理。

小结

ZCode 问的是:"这个工具是什么?"——先声明身份,再决定怎么调度。 Codex 问的是:"模型现在需要这个工具吗?"——先按需供给,再决定给不给看。

一个是治理驱动,一个是注意力驱动。


五、安全:建墙的人 vs 建流程的人

这是两个项目分歧最大的地方,也是最值得细看的地方。

Codex:把安全做进内核

Codex 的沙箱抽象是这样的:

pub enum SandboxType {
    None,
    MacosSeatbelt,           // macOS:sandbox-exec + .sbpl 策略
    LinuxSeccomp,            // Linux:bubblewrap + landlock + seccomp
    WindowsRestrictedToken,  // Windows:受限令牌
    WindowsMxc,              // Windows:Microsoft MXC 容器
}

三个平台,五种实现,全都做。

macOS 那边,它维护了一份 Seatbelt 策略文件,开头第一句就是:

(version 1)
; start with closed-by-default
(deny default)

默认全禁,然后逐条放行。 注释里还写了灵感来自 Chrome 的沙箱策略。

Linux 那边更硬核,linux-sandbox 的模块注释写得很清楚:

applies in-process restrictions (no_new_privs + seccomp), and bubblewrap for filesystem isolation

意思是它用了两层:bubblewrap 负责文件系统视图隔离(让进程看不到不该看的路径),landlock + seccomp 负责进程内限制(系统调用级别的白名单)。而且 no_new_privs 这个标志,是防止进程通过 setuid 提权的经典手段。

landlock.rs 里的注释还提到了一个细节:

--apply-seccomp-then-exec

顺序是:先建立文件系统视图,再用 seccomp 收紧。 顺序反了就不行——seccomp 一旦生效,后面建立挂载点需要的系统调用可能就被自己拦掉了。这种细节只有真在裸机上跑过的人才会踩到。

用一门语言写策略

比沙箱更让我意外的是 execpolicy 这个 crate。

它的描述是:

Codex exec policy: prefix-based Starlark rules for command decisions.

它用 Starlark 写了一门 DSL 来管"哪些命令能跑"。

Starlark 你可能不熟,它就是 Bazel 用的那门配置语言(Python 的受限方言,没有副作用、可静态分析)。Codex 在里面注入了三个内置函数:

prefix_rule(
    pattern = ["git", "push"],
    decision = "prompt",
    justification = "Pushing to a remote is a side effect worth confirming.",
)

network_rule(
    host = "api.github.com",
    protocol = "https",
    decision = "allow",
)

host_executable(name = "python3", paths = ["/usr/bin/python3"])

注意 prefix_rule 还能带自测:

prefix_rule(
    pattern = ["rm"],
    decision = "forbidden",
    match = [["rm", "-rf", "/"]],
    not_match = [["rm", "build/output.txt"]],
)

策略里内嵌了测试用例。 定义规则的时候顺便声明"这些应该匹配、这些不该匹配",加载时自动校验。这是基础设施即代码(IaC)的思路用在了权限上。

决策只有三种:

pub enum Decision { Allow, Prompt, Forbidden }

允许、询问、禁止。简单,但够用。

再叠上审批档位:

pub enum AskForApproval {
    UnlessTrusted,              // 项目标记为不可信时,除非策略明确放行,否则都要问
    OnRequest,                  // 模型自己决定什么时候问(默认)
    Granular(GranularApprovalConfig),  // 细粒度开关
    Never,                      // 从不问,失败直接返回给模型
}

Granular 还能再细分成五个开关:sandbox_approval、rules、skill_approval、request_permissions、mcp_elicitations。每个开关为 true 表示允许该类审批弹窗,为 false 表示自动拒绝(不是自动通过)。

还有网络层。network-proxy crate 里包含 mitm.rs(中间人证书)、credential_broker.rs(凭据代理)、socks5.rs、connect_policy.rs。它甚至有一套凭据代理机制——让 Agent 能访问需要认证的服务,但看不到真实的凭据。

ZCode:把安全做进流程

ZCode 走的是另一条路。它没有内核级沙箱,它有的是审批流程。

权限服务的模式有五种:

yolo   // 什么都放行
auto   // 智能判断
edit   // 允许编辑类操作
build  // 允许构建类操作
plan   // 计划模式,只读

每种模式有自己的一条检查链,比如 build 模式会依次检查:只读工具、关键风险、高风险、会话状态、副作用、低风险。任何一个环节判定要问,就弹窗。

还有几个细节挺讲究:

一是 alwaysAsk 的结构化标记。

/**
 * 该 ask 来自工具的 alwaysAsk 声明,不是模式或规则推导出来的。下游(PreToolUse hook 的
 * allow 覆盖)靠这个结构化标记识别"不可抹掉的确认",而不是去匹配 ruleId 字符串。
 */
alwaysAsk?: boolean;

意思是:有些确认是不能被 hook 覆盖掉的。比如"删除整个目录"这种,即使用户配了 PreToolUse hook 说"都放行",也不生效。

而且注释里特别强调了一句"而不是去匹配 ruleId 字符串"——这句话暴露了一个真实踩过的坑。早期大概是靠规则 ID 的字符串前缀来判断的,后来发现规则改名就失效了,于是改成了结构化的布尔标记。

二是 7 类 hook 事件。

SessionStart        会话初始化后、首个提示词到达模型前,可以往上下文里加东西
UserPromptSubmit    用户提示词写入历史前,可以拦截或加料
PreToolUse          工具执行前,可以 deny / ask / allow / 替换输入
PermissionRequest   需要审批时,可以改权限或改待执行的输入
PostToolUse         工具成功后、结果返回模型前,可以加料
PostToolUseFailure  工具失败后、失败信息返回模型前,可以加恢复提示
Stop                一轮即将结束但没有新工具调用时,可以用 continue: true 再推一步

这七个点基本把 Agent 一轮的所有关键卡口都覆盖了。而且协议设计得很干净——每个 hook 进程从 stdin 收一个 JSON,往 stdout 吐一个 JSON。退出码 2 表示"明确阻止",其他非零退出码只记录失败不中断。

空 stdout 视为无操作,非 JSON stdout 视为失败,超时视为失败——所有异常路径都明确规定了行为,不是"出了事就崩"。

三是 bash 命令注册表。

ZCode 内置了一份从 @withfig/autocomplete 生成的命令注册表:

export const BASH_COMMAND_REGISTRY_VERSION = "fig-2.692.3";

它知道 rm 是高风险根命令,知道 sudo 是,知道 chmod 是,还知道哪些选项后面跟值(WRAPPER_OPTIONS_WITH_VALUES)。这样在判断"这条命令要不要拦"的时候,它是在做结构化的解析,而不是拿正则去猜。

一个耐人寻味的细节

ZCode 的路径策略里有一行注释:

当前版本故意不硬阻断 workspaceRoot 之外的路径

这句话如果只看表面,会觉得"这不安全啊"。但结合它整套设计看,逻辑是自洽的:它选择把"能不能碰这个路径"交给审批流程,而不是交给路径围栏。

原因是产品形态决定的。ZCode 是桌面工作台,用户经常需要 Agent 帮忙处理 ~/Downloads 里的文件、改 ~/.zshrc、操作 /tmp 下的临时目录。硬围栏会让这些正常需求全部报错,用户只能不断点"允许"——最后变成条件反射式放行,反而更危险。

而 Codex 的定位是命令行 Agent,更多在项目目录里工作,而且它的沙箱是 OS 级的,能精确表达"这个路径可读不可写、那个路径完全不可见"。所以它有条件把围栏收紧。

同一个问题,两个项目给了相反的答案,而且都是对的。 这大概就是"架构没有优劣,只有匹配"最好的例子。


六、协议与多端:内核 vs 分布式

这一层是 ZCode 的主场,也是它代码量堆到 84 万行的主要原因。

Codex:一个内核,三种外壳

Codex 的结构可以理解成"一个内核 + 三种外壳":

       tui          exec          app-server
        │             │                │
        └─────────────┼────────────────┘
                      │
                    core

core 是 Agent 引擎,tui 是终端界面,exec 是非交互模式(给 CI 用),app-server 是对外服务。

最有意思的演进是:TUI 现在是通过 app-server 连的,而不是直接内嵌 core。

TUI 里有个类型叫 AppServerTarget:

enum AppServerTarget {
    Embedded,                                   // 内嵌(同进程)
    LocalDaemon { endpoint: ... },              // 连本地守护进程(Unix socket)
}

连接逻辑:

pub(crate) async fn connect(target: &AppServerTarget) -> Result<AppServerClient> {
    match target {
        AppServerTarget::Embedded => bail!("embedded sessions have no remote connection"),
        AppServerTarget::LocalDaemon { endpoint: UnixSocket { socket_path }, .. } => {
            let app_server = RemoteAppServerClient::connect_local_daemon(...)
        }
    }
}

为什么要绕这一圈?因为 Codex 想变成平台而不只是工具。

一旦 TUI 只是 app-server 的一个客户端,那么 IDE 插件、Python SDK、TypeScript SDK、云端的 Codex Web,就都可以是"另一个客户端"。大家共享同一个 JSON-RPC 契约:

pub enum JSONRPCMessage {
    Request(JSONRPCRequest),        // { id, method, params, trace }
    Notification(JSONRPCNotification),
    Response(JSONRPCResponse),
}

方法名也是标准化的:thread/start、thread/resume、thread/fork、thread/compact/start、turn/start、turn/steer、turn/interrupt、review/start、skills/list、plugin/install、model/list、fs/readFile……

传输层支持三种:

stdio://          默认,本地进程
unix://[PATH]     Unix socket
ws://IP:PORT      WebSocket

sdk/python 里的代码就很直白:

args.extend(["app-server", "--listen", "stdio://"])

Python 进程 spawn 一个 codex 子进程,用 stdio 跑 JSON-RPC。TypeScript SDK 类似,走 exec --experimental-json。

这是很典型的"先做 CLI,再把 CLI 抽象成协议,最后协议长出生态"的路径。 VS Code 就是这么从编辑器长成平台的。

ZCode:从第一天就是多端

ZCode 的进程模型是这样的:

┌─────────────────────────────────────────────┐
│ Electron Main                               │
│  · 窗口、原生操作、进程调度、消息转发         │
│  · TaskRealtimeBus:owner/lease 裁决         │
│  · 不承载 task/session 业务状态               │
└──────────────┬──────────────────────────────┘
               │ utilityProcess.fork + MessageChannelMain
┌──────────────▼──────────────────────────────┐
│ Window-scoped Local Host(每窗口一个)        │
│  · local services + remote connection registry│
│  · windowHostAttachmentRegistry              │
│  · windowHostControllerService(聚合权威)     │
└──────────────┬──────────────────────────────┘
               │ MessagePort
┌──────────────▼──────────────────────────────┐
│ Renderer / Mobile Remote                     │
└─────────────────────────────────────────────┘

关键设计有三条:

第一,每个窗口一个 Host,而不是每个应用一个。

spawnHostProcess 用的是 utilityProcess.fork() 加 MessageChannelMain。窗口关了就回收,forceKillDelayMs 最小 3500ms。

好处是隔离——一个窗口里跑崩的 Agent 不会影响另一个窗口。代价是资源重复,所以又规定"本地 workspace 共享该 Host",同一个本地项目不会开两份。

第二,owner/lease:谁拥有这次运行。

TaskRealtimeBus 管理租约:

interface TaskRunLease {
  ownerHostId: string;
  ownerClientId: string;
  ownerDeviceLabel: string;
  runId: string;
  traceId: string;
}

租约请求的结果要么是 { acquired: true },要么是:

{ acquired: false, reason: "owned_by_other_host" }

这个设计解决的是一个非常具体的问题: 你在电脑上跑一个任务,然后掏出手机想看看进度。手机上看到的是同一份状态,但如果手机误发了一个"取消",桌面上的任务不能莫名其妙就死了。

所以所有权在宿主机(Host)手里,手机只是"观察者"(observer),或者通过明确的"转发"(relay_owner)方式临时接管。投递目的也被显式区分:

taskRealtimeDeliveryPurposeSchema = z.enum(["observer", "relay_owner"])

第三,两套协议,两种投递档位。

ZCode 同时存在 v1 协议(JSON-RPC 风格,ZCODE_PROTOCOL_VERSION = 1)和 v4 wire 协议(V4_WIRE_PROTOCOL_VERSION = 3,行式帧 + snapshot/delta)。

v4 里定义了两种投递档位:

DELIVERY_PROFILES = {
  continuous: { flushWindowMs: 30,  streamOutputCapBytes: 262144, toolProgress: false },
  replayable: { flushWindowMs: 150, streamOutputCapBytes: 0,      toolProgress: true  },
}

对应两种客户端:

  • desktop-continuous:桌面端,30ms 刷新一次,追求丝滑;不推送细粒度工具进度(桌面本地够快,不需要)

  • web-remote-replayable:Web / 手机远控,150ms 刷新,追求省流量;推送工具进度(因为远程延迟大,用户需要更多中间状态来建立信心)

这个细节特别好。 它不是简单地"给远程降低频率",而是针对两种场景的用户体验差异做了不同的信息密度设计。桌面用户不需要"正在读取第 3 个文件"这种粒度,因为响应快;远程用户需要,因为等待长。

而且 flushWindowMs 只有 30ms 和 150ms 两个值——非常克制,没有搞成可配置的。

帧格式:13 字节的头

ZCode 的 RPC 框架是自己的,packages/rpc 分了六层,从底层往上:

Layer 0  基础设施:Event / Emitter / Disposable / VSBuffer / CancellationToken
Layer 1  序列化(VQL + 类型标签)
Layer 2  IMessagePassingProtocol:send(buffer) / onMessage
Layer 3  ChannelServer / ChannelClient(call / listen)
Layer 4  IPCServer(1:N) / IPCClient(1:1 双向)
Layer 5  ProxyChannel(fromService ↔ toService)
Layer 6  Remote 远程连接

帧头是固定 13 字节:

export const HEADER_SIZE = 13; // 1 + 4 + 4 + 4

// 1 字节:消息类型
// 4 字节:id
// 4 字节:ack
// 4 字节:长度

消息类型有九种:

enum ProtocolMessageType {
  None = 0, Regular = 1, Control = 2, Ack = 3,
  Disconnect = 5, ReplayRequest = 6, Pause = 7, Resume = 8, KeepAlive = 9,
}

ReplayRequest、Pause、Resume 这三个的存在说明它考虑了断线重连。远程链路断了以后,客户端能请求补发丢失的帧,而不是从头拉一遍。

读取循环里有一段注释,写的是一个真实的 bug:

// 不能在 body 未到齐时提前消费 header,否则下一段数据拼上来后
// 已经找不到这帧的长度信息,调用方就会一直等待一个永远不会完成的 Promise。

这是典型的 TCP 粘包/半包问题。写下来当注释,说明踩过。

小结

Codex 的协议演进方向是"从工具到平台"——先有 CLI,再把内核抽象成服务,让更多客户端来接。 ZCode 的协议起点就是"多端一致性"——它一出生就假设有四个客户端,所以从第一天就在解决所有权、重放、投递档位这些问题。

一个是收敛,一个是铺开。


七、上下文经济学

上下文是 Agent 最贵的资源。两个项目都在这上面下了重注,但路子不同。

数字对照

ZCode 的压缩策略常量(compact/policy.ts):

export const DEFAULT_COMPACT_CONTEXT_WINDOW = 200_000;
export const DEFAULT_AUTOCOMPACT_OUTPUT_RESERVE_TOKENS = 32_000;
const PREFLIGHT_AUTOCOMPACT_OUTPUT_RESERVE_TOKENS = 21_000;
export const MAX_OUTPUT_TOKENS_FOR_SUMMARY = 20_000;
export const AUTOCOMPACT_BUFFER_TOKENS = 13_000;
export const MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES = 3;

翻译一下这套账:

  • 模型上下文窗口按 20 万 token 算

  • 但要预留 3.2 万给输出(因为输出也算在窗口里)

  • 所以真正能用来装历史的只有 16.8 万

  • 再留 1.3 万的缓冲,阈值就落在 15.5 万左右

  • 压缩本身也要输出,摘要最多 2 万 token

这就是"预算表"。 每一步都在扣减,最后得出一个能触发压缩的安全线。

而 Codex 那边,compact.rs 里:

pub use codex_prompts::SUMMARIZATION_PROMPT;
const COMPACT_USER_MESSAGE_MAX_TOKENS: usize = 20_000;

它把"要塞进摘要的历史"限制在 2 万 token 以内,然后把活儿交给一个专门的摘要 prompt。同时它还有一条更激进的路:服务端压缩(compact_remote_v2)。

服务端压缩意味着:把历史发到 OpenAI 的后端,后端帮你压好再返回。好处是不占本地算力、压缩质量可能更好(后端有更多上下文信息);代价是隐私和网络依赖。

Codex 还专门为这个做了降级链——compact_model_fallback.rs。如果远程压缩不可用(比如你用的是本地 ollama 模型),就回落到本地摘要。

ZCode 的两级压缩

ZCode 分了"轻"和"重"两级:

microcompact(轻):每轮循环开头都跑一次。处理的是"哪些消息可以就地裁剪"——比如超长的工具输出截断、重复的文件内容去重。它不调用模型,纯规则。

autoCompact(重):只在接近阈值时触发。它要调用模型生成摘要,把前面一大段历史替换成一段总结。

为什么分两级?因为重压缩是有代价的:

  1. 要花一次模型调用(钱 + 时间)

  2. 摘要是有损的,会丢信息

  3. 摘要完可能立刻又被填满(就是前面说的 rapid refill)

所以策略是:先用零成本的手段能省一点是一点,实在不行了再动大手术。

Skills 的渐进式披露

两边都有 skills 机制(加载 SKILL.md 文件),而且都做了预算控制。

ZCode 的 skills.ts:

const DEFAULT_SKILL_METADATA_BUDGET = 20_000;
const MAX_DESCRIPTION_CHARS = 250;

function buildSkillsContent(skills, budget) {
  const skillLines = sortedSkills.map((skill) => formatSkillLine(skill, MAX_DESCRIPTION_CHARS));
  const full = [...lines, ...skillLines].join("\n");
  if (full.length <= budget) return full;

  // 超预算:降级成只有名字
  const namesOnly = sortedSkills.map(
    (skill) => `- ${skillDisplayName(skill)}${bareAliasSuffix(skill)} (file: ${skill.path})`,
  );
  return [...lines, ...namesOnly].join("\n");
}

逻辑很干脆:技能元数据预算 2 万字符。装得下就带描述,装不下就只留名字和路径。

这保证了无论用户装了多少技能,注入 prompt 的部分都有上界。模型看到名字觉得需要,再去读 SKILL.md 全文。

Codex 那边的 SkillMetadata 更结构化一些:

pub struct SkillMetadata {
    pub name: String,
    pub description: String,
    pub short_description: Option<String>,
    pub interface: Option<SkillInterface>,
    pub dependencies: Option<SkillDependencies>,
    pub policy: Option<SkillPolicy>,
    pub path_to_skills_md: AbsolutePathBuf,
    pub scope: SkillScope,
    pub plugin_id: Option<String>,
}

注意 short_description 和 policy。short_description 是给"预算紧张时"用的备胎,policy 里有个 allow_implicit_invocation——控制这个技能能不能被模型自动调用(还是必须用户显式点名)。这是权限思路在技能系统里的延伸。

记忆:Codex 走得更远

Codex 有一个 memories crate,做的是跨会话的长期记忆,而且是两阶段管线:

  • Phase 1:Rollout Extraction(按线程)——从每个会话记录里抽取值得记住的东西

  • Phase 2:Global Consolidation(全局合并)——把各线程的抽取结果合并成全局记忆

产物是一堆 markdown 文件,放在 ~/.codex/memories/:

raw_memories.md
MEMORY.md
memory_summary.md
phase2_workspace_diff.md

而且这个目录是一个 git 仓库(~/.codex/memories/.git)。

这个设计很聪明。记忆是"会随时间演化、需要审计、可能出错要回滚"的东西——这正好是版本控制系统的强项。用 git 存记忆,天然得到:

  • 每次记忆变更都有 diff,可以看"它什么时候记住了这条"

  • 记错了可以 revert

  • 冲突可以 merge

  • 可以通过 remote 在多台机器间同步

比"塞进一个 JSON 文件"要负责任得多。

ZCode 那边也有记忆,但更轻:memory.ts 里用 frontmatter 给记忆分类(user | feedback | project | reference),MEMORY.md 当索引,[[name]] 做双链。思路更接近 Obsidian 那种知识库。


八、工程治理:两种"编译器"

这一章是我个人认为最被低估的部分。因为一个 Agent 项目能不能长期活下去,八成取决于这个。

Codex:让机器管机器

Codex 的构建是 Cargo + Bazel 双轨。这件事本身就很说明问题。

MODULE.bazel 里:

bazel_dep(name = "rules_rs", version = "0.0.96")
crate.from_cargo(...)

defs.bzl 里定义了一个宏 codex_rust_crate,把 rust_library / rust_binary / rust_test 打包成跟 Cargo 约定对齐的形式。每个 crate 的 BUILD.bazel 就一行调用。

官方文档把关系说得很清楚:

Cargo remains the source of truth for crates and features, while Bazel provides hermetic builds, toolchains, and cross-platform artifacts

Cargo 是事实来源,Bazel 提供可复现构建和跨平台产物。

为什么要两套?因为 Codex 要出 5 个平台的二进制(macOS arm64/x64、Linux x64/arm64 musl、Windows x64),还要做远程执行缓存(RBE)。Cargo 处理不了这么复杂的构建图,Bazel 能。但又不能让 114 个 crate 的开发体验被 Bazel 拖慢,所以日常还是 cargo,发布才走 Bazel。

这中间必然有摩擦——所以 justfile 里专门有 bazel-lock-update 和 bazel-lock-check,改 Cargo 依赖必须同步 Bazel 锁文件。AGENTS.md 里也写了这条规矩。

另外它还有一个自研的 lint 工具 tools/argument-comment-lint,专门检查函数参数注释的规范性。CI 里跑测试时还特意把它排除掉:

bazel test --test_tag_filters=-argument-comment-lint //...

因为它是 lint 不是 test,混在一起会拖慢测试。

连"哪种检查该归到哪一类"都要分清楚——这是大项目才有的自觉。

ZCode:把规范写成能跑的东西

ZCode 的做法前面提过:architecture-policy.yaml + architecture-check.mjs。

但真正让我停下来看的,是它配套的 skill 文件 .agents/skills/architecture-governance/SKILL.md。里面写了一个工作流:

并且明确写了一句:

The executable policy is architecture-policy.yaml; do not duplicate its rules in this file or in AGENTS.md

"别把规则抄到文档里,规则只有一份,在 YAML 里。"

这句话解决了一个特别常见的腐烂模式:文档写"文件不要超过 500 行",检查脚本写 400,实际执行是 400,然后文档慢慢变成谎言。

ZCode 还做了一件事:给违规记录 fingerprint 基线。

.architecture-baseline.json: { "version": 1, "violations": [] }

空数组,意思是"零容忍"。而且规则是——CI 永远不自动刷新基线,只有人工评审后才能跑 architecture:baseline:update。

这跟 Codex 的 unwrap_used = deny 是同一个精神:不让"暂时放一放"变成"永远放一放"。

一个值得玩味的反差

ZCode 的 AGENTS.md 里有一整节讲"外部 I/O 边界收敛":

除入口层、基础设施层和 adapter 外,业务模块不得直接调用 fetch、http、fs、child_process、process.env 等底层 I/O API,而应依赖项目内定义的接口、service 或 adapter。

还有一节讲"工具与副作用契约":

每个 tool 都应声明明确的 inputSchema、outputSchema、是否只读、是否破坏性、是否并发安全、最大输出大小、超时、取消语义和权限需求。

还有一节讲"错误处理优先":

默认让错误向上冒泡,直到到达真正有能力处理它的层。不要在低层模块随意吞掉错误。

以及一句特别有时代感的话:

agent 友好的项目,留好日志或者接口,让 agent 能完全接手操作

这些规范的第一读者是 AI,不是人类工程师。

对比 Codex 的 AGENTS.md,它讲的是 crate 命名前缀、just fmt 怎么跑、别往 codex-core 里乱加代码、模块别超过 500 行。更像是一份"给人类贡献者的风格指南",只不过碰巧 AI 也会读。

这个差异不是优劣,而是阶段。ZCode 的文档面向的是"AI 深度参与开发"的工作方式,Codex 的文档面向的是"人类为主、AI 辅助"的协作方式。

但如果让我赌一个方向——ZCode 那种写法的项目会越来越多。 因为当 AI 真的开始改代码,"文档的读者是谁"这个问题会重新被提出来。一份写给人看的规范,和一份写给 agent 看的规范,需要的精确度完全不同。前者可以靠默契,后者必须靠显式声明。


九、应用场景:到底该用哪个

聊了这么多架构,回到最实际的问题。

Codex 适合谁

如果你要把 Agent 嵌进自己的系统——选 Codex。

它的 SDK 是现成的。Python 那边:

args.extend(["app-server", "--listen", "stdio://"])

TypeScript 那边走 exec --experimental-json。你不需要理解它的内部结构,只需要跟 JSON-RPC 对话:thread/start 开一个线程,turn/start 跑一轮,turn/steer 中途插话,turn/interrupt 打断。

如果你在受监管环境里跑 Agent——选 Codex。

Starlark 策略引擎意味着"哪些命令能跑"这件事,你可以写成一个可审计、可测试、可以进代码评审的文件。合规部门看得懂 decision = "forbidden",比看懂一坨正则容易多了。

如果你需要多个平台的原生二进制——选 Codex。

macOS 用 Seatbelt,Linux 用 landlock + seccomp + bubblewrap,Windows 用受限令牌或 MXC。每个平台都用该平台的原生机制,而不是搞一个最低公约数。

如果你想让模型写脚本而不是调工具——Codex 的 Code Mode 是现在最激进也最有趣的实现。 内嵌 V8,exec + wait 两个工具,把多轮工具调用折叠成一次脚本执行。这个思路如果被验证,可能会改变整个 Agent 的工具设计范式。

ZCode 适合谁

如果你是一个人或者一个小团队,想要一个能天天用的 AI 工作台——选 ZCode。

桌面端、Web 端、终端 TUI 三套界面,数据在本地 SQLite(~/.zcode/cli/db/db.sqlite),插件市场自带 sha256 校验。它更像一个产品,而不是一个组件。

如果你需要在手机上盯着长任务——选 ZCode。

它的 owner/lease 设计就是为了这个场景。手机连上桌面已有的 Host,复用会话运行时,不另起 Agent。你在地铁上能看见电脑在跑的任务进度,也能安全地插话或取消。

如果你想接入国产模型——选 ZCode。

它的 provider 配置里内置了智谱的 GLM 系列(ZCODE_BASE_URL=https://zcode.z.ai),也支持 anthropic-messages 和 openai-chat-completions 两种端点协议。而且 packages/provider 用了 revision + 冻结视图的设计——provider 配置每次变更都会 +1 revision,读取时拿到一个 Object.freeze 过的快照。这保证了"同一次请求里看到的 provider 列表不会中途变化"。

如果你需要 hooks 做深度定制——ZCode 的 7 类事件比大多数工具都全。 尤其是 Stop 事件能用 continue: true 再推模型一步,这个能力可以用来做"强制检查清单"——比如"结束前必须确认测试覆盖率被提到过"。

一个都别选的情况

如果你只是想有个东西帮你补全代码——两个都太重了。

Codex 的 114 个 crate 和 ZCode 的 30 个包,解决的是"让 AI 在真实工程里长期干活"的问题。如果你只是要个自动补全,装个 IDE 插件就够了。用推土机种花,不叫能力强,叫没想清楚。


十、未来趋势:六个正在发生的变化

从这两个项目里,我看到了六条正在成形的趋势。不是预测,是已经在代码里落地了的东西。

1. 工具调用正在被"批处理化"

Codex 的 Code Mode(V8 + exec/wait)和 ZCode 的 dynamic-workflow(CreateWorkflow / AmendWorkflow / SaveWorkflow / ResumeWorkflowRun)在做同一件事:把"模型反复调用工具"变成"模型写一段流程,然后执行"。

原因很实在。假设一个任务要检查 50 个文件,传统模式是 50 轮对话,每轮的结果都进上下文,token 消耗是 O(n)。批处理模式是一次执行,token 消耗接近 O(1)。

这不是优化,是量级差异。当任务规模上去之后,这个差异会决定"能不能做"。

2. 上下文管理正在变成"经济学"

ZCode 那套预算表(20 万窗口 - 3.2 万输出预留 - 1.3 万缓冲)和 rapid refill 熔断,本质上是在做资源调度。

未来的 Agent 不会只有"压缩"这一个手段。它会像一个操作系统管理内存那样,区分热数据/冷数据、做预取、做换出、给不同来源的上下文分配不同配额。ZCode 的 microcompact / autoCompact 两级,已经是这个方向的雏形了。

3. 记忆正在"工程化"

Codex 把记忆存成 git 仓库(~/.codex/memories/.git)这件事,标志着一个转折:记忆从"模型的特性"变成了"系统的数据"。

一旦记忆是数据,它就有了完整的生命周期——产生、索引、引用、失效、回滚、迁移。memory_version.rs 和 memory_citation.rs 这两个文件的存在说明它已经在处理"版本"和"引用"了。

我猜接下来会看到:记忆的 TTL、记忆的冲突解决策略、记忆的访问权限(哪些项目共享哪些记忆)。这就是数据库领域做过的事情,会在这里重做一遍。

4. 多 Agent 从"玩具"变成"编制"

Codex 有 agent-roles(角色定义)、agent-graph-store(Agent 之间的 spawn 关系图,ThreadSpawnEdgeStatus)、以及一整套协作工具:

CollabAgentTool {
  SpawnAgent, SendInput, ResumeAgent, Wait,
  CloseAgent, SendMessage, FollowupTask,
  InterruptAgent, ListAgents
}

九个操作。这不是"能开子 Agent"那么简单了,这是一套组织管理 API——招募、派活、等待、汇报、打断、点名。

ZCode 那边也有对应的一整套:Agent、Task、SendMessage、RespondToCoordinator、submit_result、escalate。

两边都长出了几乎同构的协作原语,说明这是真实需求,不是概念炒作。

5. 权限正在从"配置"变成"语言"

Codex 的 Starlark execpolicy 是最明显的信号。当策略复杂到一定程度,"一个 JSON 配置 + 几个布尔开关"就不够用了,你需要:

  • 变量和函数(复用规则)

  • 条件判断(根据参数决定)

  • 内嵌测试(保证规则正确)

Starlark 恰好提供了这些,而且是可静态分析、无副作用的——这对安全策略来说几乎是理想特性。

我预计"权限语言"会成为一个独立的技术话题。不是"你用什么框架",而是"你的策略语言长什么样"。

6. 治理正在"可执行化"

ZCode 的 architecture-policy.yaml 是一个信号:架构约束不该是文档里的建议,而该是 CI 里的红叉。

而且它的粒度细到 layerOrder: [domain, app, adapters]——这不只是"别循环依赖",这是"依赖方向必须是这一个方向"。

当一个项目大到人类记不住所有约束的时候,唯一的办法就是让约束可执行。这件事传统软件工程做了几十年(编译器、类型系统、lint),现在轮到 Agent 项目重新做一遍。

有意思的是,Codex 和 ZCode 在这个问题上给出了不同的答案:Codex 靠强类型语言的编译器,ZCode 靠自建的检查器。但方向是一致的——把"应该"变成"必须"。


十一、写在最后

拆完这两个项目,我最大的收获不是学到了某个具体技巧,而是意识到一件事:

"AI 编程助手"这个词,已经装不下这两个东西了。

Codex 更像一个受约束的执行环境。它的核心资产是那套安全模型——沙箱、策略语言、审批档位、凭据代理。模型只是这个环境里的一个用户。

ZCode 更像一个分布式协作平台。它的核心资产是那套协议和所有权模型——租约、投递档位、attachment、trace 链。模型只是这个平台里的一个参与者。

它们都叫"AI 编程助手",是因为它们都还长着一个聊天的壳。但把壳剥掉之后,里面的东西已经完全不同了。

所以如果你问我"哪个更好"——这个问题本身就没问对。

真正该问的是:你面对的敌人是什么?

如果你面对的是"不可信的执行",Codex 那一整套墙和门禁,是现成的最优解。 如果你面对的是"不一致的状态",ZCode 那一整套协议和租约,是现成的最优解。

选架构,本质上是在选你愿意为什么付代价。

Codex 付的代价是窄。它的工具生态、它的多端体验、它的交互灵活性,都被安全模型限制住了。 ZCode 付的代价是重。84 万行 TypeScript,一大半是在处理"同一个东西在四个地方要一致"。

两边都很清楚自己在为什么付钱,这一点比技术选型本身更值得学。

最后留个问题给你:

如果明天你要给自己的项目加一个 Agent,你会先给它建墙,还是先给它立规矩?

想清楚这个,你大概就知道该去读谁的代码了。