市面上 Code Agent 很多,但能让 AI 自己定时干活、能组队协作、能操控浏览器和桌面的,Zode 是第一个。这篇文章聊聊它背后的 Rust 实现。
上个月我把 Zode 开源了,一个用 Rust 写的终端 AI 编码引擎。跟朋友聊的时候,被问到最多的问题是:"Claude Code、Cursor、Copilot 那么多,为什么还要自己写一个?"
答案藏在 Zode 三个"别的 Agent 做不到"的能力里:
- 会自己上班 ------ 定时触发、循环监控,人不在线活照跑
- 能组队 ------ 多个 AI Agent 流水线协作,设计→实现→审查全自动
- 真会"点" ------ 操控 Chrome、操控桌面应用、生成 UI 设计稿
这三个能力背后是三个核心子系统:调度引擎 、Agent 团队 、浏览器桥接。这篇文章把它们的技术实现掰开来看。


一、整体架构
Zode 是一个 Cargo workspace,三层结构:
scss
zode (CLI 入口)
└── zode-tui (ratatui 终端 UI)
└── zode-core (引擎核心)
└── vendor/agent (AI Agent 运行时)
数据流:用户输入 → Engine 构建 QueryLoop → 工具链(Sandbox → Gate → Search)→ LLM 调用 → 事件流 → TUI 渲染。
关键设计原则:工具装饰链。每个工具注册时经过多层包装:
Sandbox层:限制文件/网络访问PermissionGate层:拦截敏感操作,弹窗请求用户审批ToolSearch层:注册到工具搜索索引
Rust 的 trait 系统让这个装饰链实现得非常干净------每一步都是一个 trait object,零额外开销。
二、调度引擎:让 AI 学会「到点上班」
这是 Zode 最独特的功能------其他 Agent 都是你打开它才开始干活,Zode 能自己定时触发。
2.1 使用姿势
bash
# 每天早上 9 点拉 PR CI 状态
/schedule every "Mon 09:00" "检查昨天所有 PR 的 CI,失败的总结原因发 Slack"
# 每小时监控生产日志
/schedule every 1h "检查生产日志有没有新 ERROR,有就发给我"
# 每 5 分钟监控 PR CI,失败了自动修
/loop 5m "监控这个 PR,CI 失败就自动修复"
2.2 跨进程去重:毫秒级 CAS
最棘手的问题:如果用户开了两个 Zode 进程,同一个定时任务不能执行两次。
解决方案是 Epoch Millisecond CAS(比较并交换):
rust
// 核心逻辑(简化版)
fn try_mark_fired(&self, job: &ScheduleJob, expected_ms: u64) -> Result<(), StoreError> {
// 持有 schedules.lock(fs4 排他锁)
let store = self.locked_store.read();
// 原子 CAS:只有 last_fired_ms 仍为旧值时才能写入新值
if job.last_fired_ms == expected_ms {
job.last_fired_ms = fire_ms;
self.write_atomic();
Ok(())
} else {
Err(StoreError::FiredByOther)
}
}
关键技巧:所有时间计算都在 Epoch Millisecond 域进行(不是本地时间),确保夏令时切换时跨进程计算结果一致。
rust
// DST-safe 的 interval slot 计算
pub fn latest_interval_epoch_slot(anchor_epoch_ms: u64, interval_ms: u64, now_epoch_ms: u64) -> u64 {
if now_epoch_ms <= anchor_epoch_ms { return anchor_epoch_ms; }
// 在 epoch 域做整除,不受本地时区影响
anchor_epoch_ms + ((now_epoch_ms - anchor_epoch_ms) / interval_ms) * interval_ms
}
2.3 死进程守护:FS4 锁 + 孤儿恢复
每个 schedule job 在启动时获取一个独立的 fs4 文件锁:
rust
pub struct ScheduleAttemptLease {
lock_file: std::fs::File,
token: String, // 唯一执行 token
job_lock_path: PathBuf,
}
impl Drop for ScheduleAttemptLease {
fn drop(&mut self) {
// 进程崩溃时 OS 自动释放锁,无需心跳
fs4::FileExt::unlock(&self.lock_file).ok();
}
}
启动时的「孤儿恢复」逻辑:
rust
fn recover_orphan_attempts(store: &ScheduleStore) -> Vec<ScheduleJob> {
store.jobs.iter()
.filter_map(|job| {
let lease = job.attempt_lease();
match lease.try_lock() {
Ok(_) => {
// 锁空闲 + token 匹配 = 上一个进程崩溃了
// 但不确定远程副作用是否已发生 → 停用任务,等待人工审查
job.disable("orphan_attempt_recovered");
Some(job)
}
Err(_) => None, // 锁被持有 = 另一个进程正在执行
}
})
.collect()
}
设计哲学:宁愿漏掉一次定时触发,也绝不重复执行一个可能已产生副作用的操作。安全性优先于可用性。
三、Agent 团队:AI 的流水线协作
3.1 Team 架构
每个 tab 有一个 TeamManager(Arc<TeamManager>),管理当前团队的 roster:
scss
TeamManager
├── Board (共享看板)
│ ├── board.json (CAS 修订计数器 + HMAC-SHA256)
│ └── Claims (文件路径租约,TTL 自动过期)
├── InternalSession (进程内 Agent)
│ └── 共享 PermissionGate/Hooks/FileCache
└── ExternalSession (子进程 CLI)
└── 支持 --resume 恢复上下文
3.2 三阶段 send 协议
team_send 到执行的过程是一个严格的三阶段状态机:
rust
impl TeamManager {
pub async fn send(&self, to: &str, message: &str, claims: &[PathBuf]) -> Result<TeamSendResult> {
// 阶段 1:Busy-check + Generation 递增
let guard = self.reset_busy(to).await?;
let gen = guard.generation();
// 阶段 2:原子 Claim(board lock 内完成)
self.board.claim_paths(to, claims, TTL_DURATION).await?;
// 阶段 3:Dispatch + 后台 TTL 续期
let handle = self.backend.send(message).await;
tokio::spawn(renew_claims_loop(board, to, gen));
// SendCleanup RAII guard:无论成功/取消/panic,都会释放 claims
let _cleanup = SendCleanup::new(self.board.clone(), to, gen);
handle.await
}
}
「代际守卫」------防止过期回调污染状态:
rust
fn reset_idle(&self, name: &str, completed_generation: u64) {
let mut member = self.members.get_mut(name)?;
// 只有当 send 完成时的 generation 与当前一致时才重置
// 否则说明已经有一个新的 send 开始了
if member.generation == completed_generation {
member.status = MemberStatus::Idle;
}
}
这个单调递增的 generation 计数器保证了:即使一个旧的 send 回调在网络延迟后返回,也不会覆盖新 send 的状态。简单但可靠。
3.3 三种协作模式
| 模式 | 描述 | 适用场景 |
|---|---|---|
| 流水线 | 设计→实现→审查→迭代 | 有明确前置依赖 |
| 辩论 | 多模型独立作答→互审→Leader 裁决 | 需要多角度思考 |
| 蜂群 | 分片文件→并行开工→Board 汇总 | 独立任务 |
四、工具装饰链:Rust 的优雅抽象
4.1 装饰器模式实践
工具注册的核心流程:
rust
fn build_tools(&self) -> Vec<Arc<dyn Tool>> {
let mut tools: Vec<Arc<dyn Tool>> = vec![
Arc::new(ReadTool), Arc::new(WriteTool), Arc::new(BashTool), /* ... */
];
// 第一层:Sandbox
tools = tools.into_iter()
.map(|t| self.maybe_wrap_sandbox(t))
.collect();
// 第二层:Permission Gate(Mutating 工具)
tools = tools.into_iter()
.map(|t| self.maybe_wrap_gate(t))
.collect();
// 第三层:ToolSearch(注册到最后)
tools.push(Arc::new(ToolSearch::new(tools.clone())));
tools
}
4.2 GateView:展示与执行分离
PermissionGatedTool 最精妙的设计是 GateView trait:
rust
pub trait GateView: Send + Sync {
fn view(&self, input: &serde_json::Value) -> serde_json::Value;
}
对于浏览器工具,BrowserGateView 的实现:
rust
impl GateView for BrowserGateView {
fn view(&self, input: &Value) -> Value {
let mut enriched = input.clone();
// 注入运行时上下文------这些信息模型不应"伪造"
enriched["_target"] = json!(self.target); // "managed" | "bridge"
enriched["_page_url"] = json!(self.current_url); // 当前页面 URL
enriched
}
}
审批卡片上显示:目标是 managed Chrome 还是用户的 bridge Chrome;当前页面 URL------这些都是 agent 无法伪造的,由 session 状态注入。而 tool 内部收到的仍是原始 input,不受污染。
4.3 并发安全:双重检查锁
同一个 tool 在多个并发调用中应该只提示一次:
rust
impl Tool for PermissionGatedTool {
async fn call(&self, input: Value) -> ToolResult {
// 第一次检查(快速路径,无锁)
if self.always_allow.load(Ordering::Acquire) {
return self.inner.call(input).await;
}
// 第二次检查(持有锁,防止并发重复提示)
let _lock = self.approval_lock.lock().await;
if self.always_allow.load(Ordering::Acquire) {
return self.inner.call(input).await;
}
// 弹出审批
let approved = self.gate.request_approval(&self.view(input)).await?;
if approved.is_always() {
self.always_allow.store(true, Ordering::Release);
}
self.inner.call(input).await
}
}
AtomicBool + tokio::sync::Mutex 的组合:绝大多数情况走快速路径(一次原子读),只在第一次审批时短暂持锁。
五、写在最后
Zode 目前还在快速迭代中,很多功能(浏览器操控、桌面自动化、Agent 团队)都在每天变得更加稳定。这个项目的核心哲学是:
AI 不应该是你手里的工具,而应该是你的工程团队。
如果你对 Rust、AI Agent、终端应用感兴趣,欢迎来玩:
- 🦀 GitHub: github.com/ZSeven-W/zo...
- 🚀 快速体验:
zode -p "帮我写一个 WebSocket 聊天服务"
如果你也在做类似的 AI Agent 系统,或者对调度引擎、多 Agent 协作有想法,欢迎在评论区交流 👇
标签:Rust、AI Agent、开源、系统设计、异步编程