Zed 深度实战:Rust + GPU 渲染 + CRDT 协作,手搓一个 RGA 内核看懂 AI 原生编辑器
关键词:Zed、Rust、GPUI、CRDT、RGA、Tree-sitter、AI 编辑器、协作内核、性能优化
一、背景:当"编辑器"变成 AI 时代的驾驶舱
如果你每天和代码打交道超过 8 小时,就应该意识到一个事实:编辑器不是工具,是驾驶舱。你的思维流速,被这个驾驶舱的渲染延迟、内存占用、协作摩擦牢牢卡着脖子。
过去十年,VS Code 几乎赢了所有人。它基于 Electron——用 HTML/CSS/JS 把整个 Chromium 浏览器套在桌面上。这套方案让扩展生态爆炸式繁荣,却也埋下原罪:每个按键都可能触发一次跨进程的 DOM diff,每次滚动都在和浏览器引擎抢 CPU,GC 一来整屏卡顿。Atom 的创始人 Nathan Sobo 当年亲手写了 Electron(前身 Atom Shell),也最早看穿了它的天花板。于是他带着原班人马另起炉灶,用 Rust 从零写了一个叫 Zed 的编辑器,2024 年 1 月开源,2025 年 10 月补齐 Windows,2026 年 4 月发布 1.0。
Zed 的核心赌注只有一句话:把编辑器当成一个需要 120fps、极低延迟、还能让人和 AI 同时改同一份文档的实时系统来设计。这背后是三块硬骨头:
- GPUI:一套自研的、用 GPU 加速的 immediate-mode UI 框架,彻底抛弃 DOM。
- CRDT:用无冲突复制数据类型做多人实时协作的数学底座。
- Tree-sitter:增量语法树,让"理解代码"的成本从 O(全文) 降到 O(变更)。
这篇文章不打算给你一份"Zed 使用入门"。我们要做的是把它拆开,看到骨架,然后亲手用 Rust 写一个能跑的 RGA 协作内核——因为真正的理解,永远来自"我也能写出来"。
二、核心概念:三个被低估的工程决策
2.1 GPUI:为什么是 GPU 加速的 immediate-mode UI
先厘清两个词。Retained-mode(保留模式) 是你熟悉的 DOM 模型:你维护一棵树,改状态时框架去 diff 新旧树,再批量更新。问题在"维护"和"diff"这两步本身有成本,而且成本随 UI 复杂度线性增长。
Immediate-mode(即时模式) 反过来:每一帧你都重新"声明"一遍 UI,框架把这一帧的所有绘制命令收集起来,丢给 GPU 批量执行。没有长生命周期的节点树要维护,没有 diff。对编辑器这种"满屏都是字形、每帧都在变"的场景,即时模式 + GPU 批处理几乎是降维打击——成千上万个 glyph 被合并成少数几次 draw call,GPU 干这个比 CPU 算 DOM 快几个数量级。
Zed 把这个思路推到底:连文本编辑区、滚动条、弹层都是 GPUI 里用 Rust 直接画的,没有 WebView 兜底。这就是为什么它冷启动近乎瞬时、滚动如丝、内存只占 VS Code 的一个零头。GPUI 本身以 Apache 2 协议单独开源,意味着你甚至可以用它写自己的桌面应用。
工程启示:当你做的工具"每秒要重画几千个元素"时,别再用保留模式的思维。即时模式 + 场景图(scene graph)+ GPU 批处理,是这类高频重绘 UI 的更优解。
2.2 CRDT:协作的数学底座,以及为什么是 RGA
多人同时改同一份文档,冲突怎么解?两条路线:
- OT(Operational Transformation,操作变换):中央服务器把每个人发的操作"变换"一下再广播,典型代表是 Google Docs。优点是通信省,缺点是必须有一个可信的中心来串行化操作,离线编辑和 P2P 很难做。
- CRDT(Conflict-free Replicated Data Type,无冲突复制数据类型):每个副本独立应用任意顺序的操作,最终必然收敛到同一状态,不需要中心裁决。代价是数据里要带额外元数据(操作 ID、墓碑标记),体积更大。
Zed 的协作选了 CRDT。但不是随便一个 CRDT 都行——文本是序列(sequence),不能用计数器或集合那类经典 CRDT。文本协作正确的原语是 RGA(Replicated Growable Array,可复制可增长数组)。
RGA 的核心思想极简却精妙:
- 文档不是
String,而是一个带唯一 ID 的字符节点链表。 - 每个字符插入时,拿到一个全局唯一且可比较的操作 ID(典型实现是
逻辑时钟 Lamport + 站点编号 Site)。 - 每个字符还记录它的 origin:插入位置之前的那个字符的 ID。
- 文档的最终顺序,由这些 ID 和 origin 推导出一个确定的全序——无论操作以什么顺序到达,同一份操作集合永远排出同一个序列。
- 删除不物理移除字符,而是打一个 tombstone(墓碑) 标记。这样并发的"在它后面插入"依然能找到正确的 anchor。
这一点是很多"CRDT 科普"一笔带过、却最该亲手验证的地方。下面我们直接写一个。
2.3 Tree-sitter:把"理解代码"变成增量操作
传统语法高亮/补全靠正则表达式或每次全量重解析。Tree-sitter 是 GitHub(前 Atom 团队)做的增量解析器生成器:它维护一棵增量语法树,你改了第 200 行的一个字符,它只重新解析受影响的局部子树,其余照用。这让"实时语义高亮、跳转定义、结构选择"在超大文件上也几乎是零成本。Zed 把 Tree-sitter 作为语言理解的统一底座,再接上 LSP(Language Server Protocol)拿类型信息和补全,接上 DAP(Debug Adapter Protocol)做调试——三者各司其职,全是标准协议,不绑架语言。
三、架构分析:Zed 的分层与 Rust 的角色
把 Zed 横切一刀,能看到清晰的分层:
┌─────────────────────────────────────────────┐
│ AI 层 Agent / Assistant:把光标上下文 │
│ 喂给模型,结果回流成编辑操作 │
├─────────────────────────────────────────────┤
│ 协作层 WebSocket + CRDT 操作广播/合并 │
├─────────────────────────────────────────────┤
│ 语言服务层 Tree-sitter(解析)+ LSP(智能) │
│ + DAP(调试) │
├─────────────────────────────────────────────┤
│ 模型层 Rope/Piece-table 文本缓冲 + RGA │
│ CRDT(单一事实来源) │
├─────────────────────────────────────────────┤
│ 渲染层 GPUI:即时模式 + GPU 批处理 │
└─────────────────────────────────────────────┘
为什么非得是 Rust? 三个理由:
- 没有 GC 暂停。编辑器最怕的就是"正滚着呢,GC 来了,卡 200ms"。Rust 的 ownership 在编译期管理内存,没有运行时垃圾回收的不可预测停顿。
- 并发安全靠类型而非靠运气。渲染线程在读缓冲、编辑线程在写缓冲、语言服务线程在解析——Rust 的
Send/Sync和借用检查器把这些共享状态的数据竞争挡在编译期。 - 零成本抽象。Tree-sitter 是 C 写的,Rust 的 FFI 调用零开销;GPUI 的矢量运算能在编译期展开成极致高效的机器码。
注意一个关键设计:CRDT 缓冲是整份文档的单一事实来源(single source of truth)。无论是本地按键、AI Agent 的修改、还是协作同伴的光标,最终都化作"对 RGA 的一个操作"。人和 AI 第一次坐在了同一份数据结构的两端。
四、代码实战:亲手写一个能跑的 RGA 协作内核
下面这段 Rust 不到 80 行,却实现了 RGA 的精髓:本地插入、本地删除(墓碑)、远端操作幂等合并、确定性线性化。它不依赖任何第三方 crate,复制即可编译运行。
use std::collections::HashMap;
// 操作 ID = (逻辑时钟 Lamport, 站点编号 Site),全局唯一且可全序比较
type SiteId = u32;
type Lamport = u64;
type OpId = (Lamport, SiteId);
#[derive(Clone)]
struct RgaNode {
id: OpId,
origin: Option<OpId>, // 插入点之前那个字符的 ID(None = 文档开头)
ch: char,
deleted: bool, // 墓碑标记:删除只置位,不物理移除
}
struct Rga {
site: SiteId,
clock: Lamport,
nodes: HashMap<OpId, RgaNode>,
}
impl Rga {
fn new(site: SiteId) -> Self {
Rga { site, clock: 0, nodes: HashMap::new() }
}
// 把整张图按 RGA 规则拍平成有序的 ID 序列(含墓碑)
fn linearize(&self) -> Vec<OpId> {
// 1. 按 origin 建立「父亲 -> 孩子们」的索引
let mut children: HashMap<Option<OpId>, Vec<OpId>> = HashMap::new();
for n in self.nodes.values() {
children.entry(n.origin).or_default().push(n.id);
}
// 2. 同一父亲下的孩子按 ID 降序:ID 越大(越新)越靠近父亲、排在最前
for v in children.values_mut() {
v.sort_by(|a, b| b.cmp(a)); // 降序
}
// 3. 从根(origin = None)深度优先遍历,即得全局确定的线性顺序
let mut out = Vec::new();
Self::dfs(None, &children, &mut out);
out
}
fn dfs(origin: Option<OpId>, children: &HashMap<Option<OpId>, Vec<OpId>>, out: &mut Vec<OpId>) {
if let Some(kids) = children.get(&origin) {
for id in kids {
out.push(*id);
Self::dfs(Some(*id), children, out);
}
}
}
// 当前可见字符(跳过墓碑)对应的 ID 序列
fn visible_ids(&self) -> Vec<OpId> {
self.linearize()
.into_iter()
.filter(|id| !self.nodes[id].deleted)
.collect()
}
// 本地插入:在可见位置 index 之前插入字符 c
fn local_insert(&mut self, index: usize, c: char) -> OpId {
self.clock += 1;
let id = (self.clock, self.site);
// 锚定到 visible[index-1],让新字符排在它后面;index==0 则锚定文档头
let origin = if index == 0 {
None
} else {
Some(self.visible_ids()[index - 1])
};
self.nodes.insert(id, RgaNode { id, origin, ch: c, deleted: false });
id
}
// 本地删除:给可见位置 index 的字符打墓碑
fn local_delete(&mut self, index: usize) {
let id = self.visible_ids()[index];
if let Some(n) = self.nodes.get_mut(&id) {
n.deleted = true;
}
}
// 应用远端操作:幂等。重复收到同一 ID 不会改变结果
fn apply_remote(&mut self, node: RgaNode) {
self.clock = self.clock.max(node.id.0);
self.nodes.entry(node.id).or_insert(node);
}
fn to_string(&self) -> String {
self.visible_ids().iter().map(|id| self.nodes[id].ch).collect()
}
}
fn main() {
// —— 场景一:两个副本各自离线编辑,再互相合并 ——
let mut a = Rga::new(1);
let mut b = Rga::new(2);
for c in "Hello".chars() {
a.local_insert(a.to_string().len(), c);
}
for c in "World".chars() {
b.local_insert(b.to_string().len(), c);
}
// 交换全部操作(apply_remote 幂等,重复应用无害)
let ops_a: Vec<RgaNode> = a.nodes.values().cloned().collect();
let ops_b: Vec<RgaNode> = b.nodes.values().cloned().collect();
for n in ops_a { b.apply_remote(n); }
for n in ops_b { a.apply_remote(n); }
// 无论合并顺序如何,必然收敛到同一文档
assert_eq!(a.to_string(), b.to_string());
println!("[场景一] 合并后收敛为: {}", a.to_string());
// —— 场景二:同一基线上的并发插入(同一位置) ——
let mut x = Rga::new(1);
for c in "Hello".chars() {
x.local_insert(x.to_string().len(), c);
}
let mut y = Rga::new(2);
for n in x.nodes.values().cloned() {
y.apply_remote(n); // y 先同步到 "Hello"
}
assert_eq!(y.to_string(), "Hello");
// x 在 H 后面插入 X,y 在 H 后面插入 Y(并发,互不知情)
let _xid = x.local_insert(1, 'X'); // 位置 1 = H 之后
let _yid = y.local_insert(1, 'Y');
// 互相交换全部操作
let ox: Vec<RgaNode> = x.nodes.values().cloned().collect();
let oy: Vec<RgaNode> = y.nodes.values().cloned().collect();
for n in ox { y.apply_remote(n); }
for n in oy { x.apply_remote(n); }
assert_eq!(x.to_string(), y.to_string());
println!("[场景二] 并发插入后收敛为: {}", x.to_string());
}
跑起来你会看到:场景一两个副本各自打成 "Hello"/"World" 后合并,结果两边完全一致;场景二两个人在 "Hello" 的同一个位置并发插了 X 和 Y,合并后依然是同一个字符串。这个"无论操作以什么顺序到达都收敛"的性质,正是 CRDT 的全部价值,也是 Zed 协作敢做离线编辑、敢做 P2P 的底气。
注意:我们这里用
Lamport + Site做 ID,同一父亲下按 ID 降序排列。生产级的 Yjs/Automerge 用的是更精妙的 YATA 算法(在并发同位置时做出更符合直觉的"谁先看到谁"的偏向),但收敛性保证和这里的 RGA 完全一致。理解了这 80 行,你就拿到了理解它们的钥匙。
4.1 让内核"联机":一个最小协作中继
光有内核还不够,得有网络把操作广播出去。下面是用 Rust 标准库写的最简中继(无第三方依赖),它把任意副本发来的字节原样广播给同房间的其他副本——这就是 CRDT 最舒服的地方:中继不需要理解操作语义,只管转发。
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
// 所有在线副本的连接,由互斥锁保护
let peers = Arc::new(Mutex::new(Vec::<TcpStream>::new()));
let listener = TcpListener::bind("127.0.0.1:9001").unwrap();
println!("CRDT relay listening on :9001");
for stream in listener.incoming() {
let stream = match stream {
Ok(s) => s,
Err(_) => continue,
};
// 保存一份克隆用于广播(TcpStream 可克隆,底层共用同一 socket)
peers.lock().unwrap().push(stream.try_clone().unwrap());
let peers = Arc::clone(&peers);
thread::spawn(move || relay(stream, peers));
}
}
// 每个副本一个线程:读上游字节,原样广播给所有同伴
fn relay(mut stream: TcpStream, peers: Arc<Mutex<Vec<TcpStream>>>) {
let mut buf = [0u8; 4096];
loop {
let n = match stream.read(&mut buf) {
Ok(0) | Err(_) => break, // 断开
Ok(n) => n,
};
let msg = buf[..n].to_vec();
let mut guard = peers.lock().unwrap();
for s in guard.iter_mut() {
// 忽略单点写入失败,继续广播给其他副本
let _ = s.write_all(&msg);
}
}
}
真实生产里你当然要加 WebSocket 升级、房间隔离、鉴权、心跳和背压——但**"中继是无状态、不理解语义的转发器"这一条不变**。这恰好解释了为什么 CRDT 架构天然适合边缘和 P2P:中心越"笨",就越不容易成为瓶颈和单点。
4.2 工程化:把 Zed 接上你自己的 AI
Zed 的 AI 能力是"原生"的——不是装个插件,而是编辑器内核直接持有一个上下文引擎:它知道你光标在哪、当前文件是什么、相关符号有哪些,把这些作为上下文喂给模型,模型返回的结果再作为编辑操作回流进 RGA 缓冲。配置(示意,以官方文档为准)长这样:
{
"vim_mode": true,
"theme": "One Dark",
"ui": { "fonts": { "size": 14.0 } },
"telemetry": { "metrics": false, "diagnostics": false },
"git": { "auto_fetch": true },
"agent": {
"enabled": true,
"default_model": { "provider": "anthropic", "model": "claude-3-7-sonnet" }
}
}
关键是 agent 这一层:你可以用 Cmd+Shift+A 唤起 Agent 模式,让它跨文件改代码;也可以接本地模型(通过兼容 OpenAI 协议的本地服务)做到"代码不出机器"。人和 AI 编辑的是同一份 RGA 缓冲——这正是"AI 原生"和"套个 Copilot 插件"的本质区别。
五、性能优化:Zed 为什么快,以及我们能偷师什么
5.1 GPU 批处理与字形图集
GPUI 把所有文本和图形绘制命令收集成场景图,再批量提交 GPU。字形($\approx$ glyph)会先烘进一张字形图集(glyph atlas)——常用字的位图缓存起来,重复绘制直接采样纹理,不再重新光栅化。结果就是:滚动十万行文件,每帧的绘制成本被压到常数级。
5.2 文本缓冲用 Rope / Piece Table,而非 String
你绝不会在编辑器内核里用 String 做主缓冲——每次中间插入都是 O(n) 的全量拷贝。Zed 用的是 Rope/Piece-table 这类数据结构:把文本切成段,编辑只在段边界上改指针,插入删除是 O(log n)。配合 RGA 的节点链表,编辑操作本身几乎零拷贝。
// 直觉示意:Piece Table 不移动数据,只记录"哪段来自哪儿"
struct Piece { source: Source, start: usize, len: usize }
enum Source { Original, AddBuffer }
struct PieceTable { original: String, add: String, pieces: Vec<Piece> }
// 插入 "XYZ" 到位置 p:把原 piece 一分为二,中间塞一个指向 add 的 piece
5.3 多线程解析,主线程永不被阻塞
Tree-sitter 的增量解析被丢到后台线程,主线程只负责渲染。Rust 的 Send/Sync 让"缓冲被多线程共享"这件事在编译期就证明安全,而不是靠"我测试过没崩"。
5.4 没有 GC,就没有不可预测的卡顿
这一点怎么强调都不过分。任何带 tracing GC 的编辑器,在输入高峰都可能出现"每隔几秒顿一下"。Rust 的编译期内存管理把这种不确定性彻底抹掉。延迟敏感型应用(编辑器、交易系统、游戏、音频处理)选 Rust,首要理由往往不是性能上限,而是延迟下限。
六、总结与展望:我们能从 Zed 偷走什么
Zed 给我们的最大礼物,不是"又一个快编辑器",而是一套可迁移的工程范式:
- 高频重绘的 UI,用即时模式 + GPU 批处理,而不是保留模式 + DOM diff。
- 任何需要多人/多端共享的可变状态,先想 CRDT——协作、草稿、配置同步、离线优先,CRDT 能让中心"变笨",让系统更健壮。本文那 80 行 RGA 就是你入门的脚手架。
- 延迟敏感系统,认真考虑无 GC 语言。Rust 不是银弹,但它在"内存安全"和"零成本抽象"之间找到了罕见的平衡点。
- AI 不该是外挂,而该是内核的一等公民。人和 Agent 编辑同一份结构化缓冲,才是"AI 原生"的真正含义。
当然,Zed 也不是没有代价:GPUI 生态远不如 Electron 成熟,扩展数量、平台细节打磨都还在追赶;对重度依赖 VS Code 插件生态的团队,迁移成本真实存在。但方向上,它押注的是对的——当 AI 开始和人同屏写代码,编辑器的延迟和协作能力,直接决定了你的思考速度。
最后留个作业:把本文的 RGA 内核扩成支持"按 ID 定位插入点"的完整 API,再接上 4.1 的中继,你就拥有了一个最小可运行的协作编辑器原型。那一刻你会真正明白:那些看起来高不可攀的"实时协作",底层不过是一些优雅得令人发指的数据结构。
参考资料:Zed 官方博客与开源仓库(zed-industries/zed)、GPUI 框架文档、CRDT 原始论文与 Yjs/Automerge 实现、Tree-sitter 官方文档。本文 RGA 代码为教学自研实现,收敛性等价于生产级序列 CRDT。