一个把世界收窄,一个把世界织密。 一个相信编译器,一个相信契约。 这不是谁抄谁的故事,是两种世界观在同一个赛道上的正面相撞。
一、先讲个反常识的事
假设你招了两个人,都叫"AI 编程助手",都宣称能帮你改代码、跑测试、提交 PR。
第一个人,随身带了一整套保安系统。他每次想执行一条命令,先要过一道用 Starlark 写的策略引擎;想读写文件,先要过内核级的沙箱(macOS 上叫 Seatbelt,Linux 上是 landlock + seccomp);甚至连"修改文件"这个动作,他都不肯用标准的 diff 格式,自己发明了一套补丁语法。
第二个人,随身带了一整套行政体系。他关心的是:同一个任务,在桌面客户端、浏览器、手机远控、SSH 远程这四个地方,看到的进度必须完全一致;如果你在手机上点了"取消",桌面上的那个任务要立刻知道自己失去了所有权。为此他给每一次任务运行发了一张"租约",上面写着所有者的宿主机 ID、客户端 ID、设备名、运行 ID 和追踪 ID。
第一个人是 OpenAI 的 Codex。第二个人是 z.ai(智谱)的 ZCode。
这两个项目我在本地完整拆了一遍。拆完之后最大的感受是:大家嘴上说的都是"AI 编程助手",但骨子里在解的根本不是同一道题。
Codex 在解的是——如何让一个不可信的模型,在一个危险的真实系统里安全地干活。 所以它建墙、设关卡、写规则、做沙箱。它的敌人是"意外"。
ZCode 在解的是——如何让一个 Agent,在四种形态的客户端和三种远程环境下保持同一个自我。 所以它做协议、发租约、定契约、传 traceId。它的敌人是"分裂"。
把这两个敌人认清楚,后面所有代码细节就都顺了。
先上一组硬数据,感受一下量级:
| Codex | ZCode | |
|---|---|---|
| 主语言 | Rust(edition 2024) | TypeScript(Node 24) |
| 代码文件 | 4,707 个 .rs | 3,896 个 .ts / .tsx |
| 代码行数 | 约 1,877,593 行 | 约 843,335 行 |
| 模块划分 | 114 个 crate 目录 | 14 个共享包 + 16 个 CLI 内部包 |
| 构建 | Cargo + Bazel 9.0.0 双轨 | pnpm workspace + turbo |
| 工具链 | Rust 1.95.0 | Node 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 轮毫无意义。真正该管的是资源,不是轮数。
小结
| Codex | ZCode | |
|---|---|---|
| 主循环形态 | 队列驱动(Submission/Op/Event) | 无限循环 + 显式状态机 |
| 并发策略 | FuturesOrdered 并发执行按序返回 | ToolScheduler 拓扑排序分组并行 |
| 中断 | 100ms 优雅期再 abort | turnAbortSignal 协作式取消 |
| 终止条件 | 资源与审批 | 资源与审批 + 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(重):只在接近阈值时触发。它要调用模型生成摘要,把前面一大段历史替换成一段总结。
为什么分两级?因为重压缩是有代价的:
-
要花一次模型调用(钱 + 时间)
-
摘要是有损的,会丢信息
-
摘要完可能立刻又被填满(就是前面说的 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,你会先给它建墙,还是先给它立规矩?
想清楚这个,你大概就知道该去读谁的代码了。