CRDT 实时协作叠上 Agent 协同编辑:Macro 的文档层究竟解决了什么
【免费下载链接】macroMacro is a unified workspace for teams: email, chat, docs, tasks, agents, calls, and CRM — @-linked together with shared AI memory.项目地址: https://gitcode.com/GitHub_Trending/macro3/macro
当"AI 帮你改文档"从演示走向日常,真正的难点并不在模型能不能写出漂亮句子,而在一个更朴素的工程问题:当 Agent 和三个人同时改同一份文档时,谁的操作会被覆盖,谁的编辑看起来像谁做的,断网之后又该相信谁。本文基于 Macro 仓库的真实实现,拆开它的文档层:Loro CRDT 的本地优先同步链路、Agent 作为"保留号段 peer"的接入方式,以及 blame 表如何把"某个节点最后是谁改的"变成可查询的事实。
文档不是一段 Markdown,而是一棵可寻址的树
Macro 官方在工程博文里交代过一段失败经验:最初让 LLM 整块替换顶层 Markdown 块,结果"要么改得比你要的多得多,要么完全没抓住重点",最终被下架回滚。原因很直白——整块替换是一个不可再分的操作,它把"改一个词"和"重写一整节"变成了同一粒度的 CRDT 操作,冲突消解自然变得粗糙。
第二代实现换了个方向:给 Agent 一个覆盖文档树的 XMLlens,让它对薄薄一层库发出细小的变更。落到代码上,Word 文档的协作格式(format version 2)被组织成三个容器:
// packages/collaboration/src/docx/word-agent.ts export const WORD_CONTAINERS = { meta: 'docxMeta', parts: 'wordParts', blocks: 'wordBlocks', } as const; export const WORD_FORMAT_VERSION = 2;每个块是wordBlocks里的一个 map(k类型、p父节点、o位置键、x属性 XML),而每个段落的正文是富文本,其 mark 即格式(r:w:b→<w:b/>)。这意味着"斜体""加粗""插入批注"都不是字符串替换,而是树结构与富文本 mark 上的独立操作。
块之间的顺序没有用数组下标,而是用分数索引:
// Keys are base-62 fractions in (0, 1) compared as plain strings. // Concurrent writers can mint the same key; readers break ties by block id.这段注释把 CRDT 协作中最容易被忽略的一类冲突说清楚了:顺序键相同不是错误,而是一个需要在读取侧确定性收敛的状态。两个人(或两个 Agent)同时往同一位置插入块,可能生成相同的 base-62 key,靠 block id 做最终排序,结果在所有副本上一致——而不是靠"谁先到服务器"。
离线与状态同步:先记账,再对账
同步引擎的实现集中在 engine.ts。它的启动路径不是"连上服务器拉全量文档",而是同时打开两条独立的通道:
export type SyncSources = { wal: WALSyncer<RawUpdate>; live: LiveSyncSource; };本地每次产生 Loro update,先落 WAL,再考虑网络:
private async handleLocalUpdates(update: LoroRawUpdate) { if (this.readonly()) return; this.log('debug', 'engine: local update, appending to WAL'); void this.syncs.wal.append(update); this.chatter?.post({ type: 'update', data: update }); }WAL 是一个 IndexedDB 上的追加日志(wal.ts),每条记录带delivered与createdAt,未投递条目超过WAL_TTL_MS(一周)会被丢弃。引擎每 5 秒做一次shallow-snapshot落盘,并且先保存成功、再裁剪已投递的 WAL 条目:
await this.snapshotStore.save(snapshot); // now safe to drop WAL entries it captures. we prune only // after the save succeeds so that we can always recover fully. await this.syncs.wal.pruneDelivered();这个顺序不是洁癖。反过来的话,快照写失败而 WAL 已裁剪,用户离线期间的编辑就永久丢失了。
恢复阶段最能体现"本地优先"的代价。首次同步可能来自三个互不信任的种子:乐观状态、IndexedDB 缓存、S3 快照。源头是 first-wins 的,因此引擎必须做一次反熵(anti-entropy)对账——拿本地版本向量向服务端要"我持有的版本之后的一切":
const since = this.loroManager.doc.version(); const promise = this.requestAndHandleUpdatesSince(since, 1, generation);请求失败会退避重试 3 次、间隔 2 秒;reconnect事件会触发一次可重入的补拉(rerunIfInFlight),避免"重连被并入一个即将耗尽重试的请求"。而收到远端更新但缺少因果前驱时,处理方式值得单独拎出来:
const pendingOnly = importResult.error.every( (e) => e.code === LoroManagerError.ImportPending ); if (pendingOnly) { // Loro retains causally-ahead updates. Pull the missing operations // rather than resetting the document. this.log('debug', 'engine: remote update pending on missing ops'); void this.convergeFromServer(); return 'pending'; }缺依赖不等于数据损坏。直接把文档 reset 到服务端快照,会丢掉本地尚未上传的编辑;正确做法是留着这条"因果超前"的更新,把缺口补回来。只有真正无法导入的更新才触发 reset。这条分支是离线编辑能否成立的分水岭。
顺带一提,浏览器内还有一条BroadcastChannelChatter,把本地更新"闲聊"给同一文档的其他标签页,避免多开窗口靠服务器转一圈。
Agent 就是一个 peer,只不过工号是预留的
Macro 没有为 Agent 造一套独立的写入 API。AI 编辑 worker 直接以 Loro peer 的身份加入文档:
// packages/collaboration/src/collab/ai-peer.ts export const AI_PEER_COUNT = 1000; export const AI_PEER_BASE = 999_999_999_999_999_000n; export const isAiPeer = (peer: bigint): boolean => peer >= AI_PEER_BASE && peer < AI_PEER_BASE + BigInt(AI_PEER_COUNT);服务端有一份逐字对应的实现:
// services/sync-service/src/ai_peer.rs pub const AI_PEER_COUNT: u64 = 1000; pub const AI_PEER_BASE: u64 = 999_999_999_999_999_000; pub fn is_ai_peer(peer: u64) -> bool { (AI_PEER_BASE..AI_PEER_BASE + AI_PEER_COUNT).contains(&peer) }选 15 个 9 打头的号段是个聪明的偷懒:人类的 peer id 在 u64 全域随机,撞号概率接近于零,于是服务端仅凭 peer id 就能判断这是不是 AI,无需维护 peer→user 的映射表。两侧注释都写着 "Keep in sync",因为这是靠约定维持的兼容性——改一边不改另一边,AI 就会被当成人类计入在线协作者。
接入之后,Agent 的写入与人类完全同构。AI_EDITING.md 描述得很直接:
We do this by having a Cloudflare worker join the document as a real Loro peer, make its edits through the same CRDT system everyone else uses, and stream them out keystroke by keystroke.
流水线是:模型写 JS → QuickJS 沙箱 →DocumentEditor→DocumentOp[]→ 动画层切成DocumentOpStep[](光标移动、停顿、编辑)→ 回放为真实的 Lexical 编辑 → 经 mirror 落到 Loro → 同步给所有人。"切碎成击键"不只是为了好看:粒度越细,与人类并发输入的文本级合并就越自然,读者的光标也不会被整块内容瞬移打断。
一个容易被忽略的细节是 peer 池。peer-pool.ts 默认并发 6,forEditor(editor)会把所有 writer 统一定名成同一个人设:
/** * A pool whose every writer is `editor` — the agent or persona that asked for * the edit — so readers see its name on each cursor, not a pooled one. */ static forEditor(editor: string, opts?: ...): PeerPool于是"一个 AI 用多个光标同时改"和"六个 AI 各改各的",在协议层是同一套机制,差别只在于身份是否复用。
冲突消解:靠 CRDT,也靠操作设计
CRDT 保证收敛,但不保证语义正确。Macro 在两层做了工作。
第一层是引擎自带的:并发改不同段落天然合并;同段落文本按 Loro 的富文本规则插删;块顺序由分数索引 + block id 兜底。runDocxAgentRequest的注释把这个契约写得很清楚:
/** * Join the document as a Loro peer, answer one read or apply one edit, and * push the edit to everyone. The edit is made on the state merged from the * server a moment before and lands as ordinary CRDT operations, so open * editors patch it in and concurrent edits to other paragraphs merge. */第二层是操作设计。Agent 拿到的不是"重写这段 Markdown",而是按 ID 定位的语义操作:
export type DocxAgentOperation = | { type: 'replaceText'; paragraph: string; find: string; replace: string; occurrence?: number } | { type: 'setText'; paragraph: string; text: string } | ({ type: 'formatText'; paragraph: string; find?: string; occurrence?: number } & RunFormat) | { type: 'insertParagraph'; after?: string; before?: string; text: string; style?: string } | { type: 'delete'; id: string } | { type: 'setStyle'; paragraph: string; style: string } | { type: 'addComment'; paragraph: string; find?: string; occurrence?: number; text: string };注意paragraph: string是引擎的稳定 ID,find是文本锚点、occurrence消歧。这套设计的用意是:让模型输出"意图",而不是输出"全量状态"。模型不擅长生成精确的 JSON Patch(官方博文原话是 "LLMs are terrible at JSON patches"),但完全能胜任"把第 3 段里的第二次 'deadline' 换成 '10 月 30 日'"。
同时有两道硬闸门控制爆炸半径:单次编辑最多 50 个操作(MAX_DOCX_OPERATIONS),单次读取最多 60,000 字符(READ_BUDGET),超出则要求 Agent 续读。这两个常量看起来琐碎,实际上是把"Agent 一次失误"限制在可审阅的范围内——而不是让它有机会重写整篇文档。
责任归属:blame 表与"在场"是两件事
CRDT 的版本向量能告诉你"文档收敛了",但回答不了产品最常被问的那句话:这段是谁改的?
Macro 的答案是一张单独的 blame 表(0002_add_blame.sql):
CREATE TABLE blame ( document_id TEXT NOT NULL, node_id TEXT NOT NULL, peer_id TEXT NOT NULL, timestamp_ms INTEGER NOT NULL, PRIMARY KEY (document_id, node_id) );写入点在 WebSocket 协议处理里。每收到一个PeerUpdate,服务端先做权限判定,再解析可选的编辑归因,然后取出该连接的 peer id,对 Loro 导入后实际被触及的节点逐个记录:
if !imported.touched_nodes.is_empty() && let Some(peer_id) = peer_id { dss.push_blame_events( imported.touched_nodes.into_iter() .map(|node_id| BlameEvent { document_id, node_id, peer_id, timestamp_ms }) .collect(), ); }三个工程决策藏在细节里:
- 只记 touched_nodes,不记整篇文档。一次击键式编辑只影响它真正碰到的节点,blame 因此是增量而非快照。
- 先缓冲、再批量落库。事件进
pending_blame,由 alarm tick 触发一次 D1batch()写入,避免每个 keystroke 打一次数据库。 - 主键是
(document_id, node_id),即"最后编辑者"语义(last-write-wins),而非完整历史。完整历史交给 Loro oplog 与快照,blame 只服务"当前这一行归谁"。
读取侧则通过peer_user_map把 peer id 解析成 user_id。而 AI peer 落在保留号段,因此服务端可以主动区分"人类在线"与"AI 在线":
// services/sync-service/src/durable_object/peer_ids.rs if !include_ai && self.actor.is_some() { return Vec::new(); } self.peer_ids.iter().copied() .filter(|peer| include_ai || !is_ai_peer(*peer)) .collect()另一条归属链路是文档内部的修订记录。当 Agent 以trackChanges: true编辑时,改动会被编码成 Word 原生可接受/拒绝的修订,并带上author:
export type DocxAgentRequest = | { action: 'read'; start?: number; count?: number } | { action: 'edit'; operations: DocxAgentOperation[]; /** Record tracked changes; omitted to follow the document's setting. */ trackChanges?: boolean; /** The name tracked changes and comments are attributed to. */ author?: string; };revisions.ts 里,文本修订被存成 span 的wrap属性([[open, close], ...]),段落标记修订走w:pPr/w:rPr,格式修订走r:w:rPrChange——这些是 Word 本身就能识别、用户能逐条接受或拒绝的结构,不是 Macro 自造的影子记录。
需要强调一个边界:blame 与 awareness 是两套东西。awareness.ts 基于EphemeralStore,默认 10 秒超时;它承载的是光标、选区、颜色、哪个 AI 正在打字。选区里可能含 WASM 指针,因此必须经LoroCursor.encode()才能过线。换句话说,在场是易失的,归属是持久的——把这两者混在一个通道里,就会出现"AI 断线后 blame 全空"或"离线编辑无法归因"的荒谬结果。
纯文本可移植性与结构化能力,究竟怎么取舍
大纲里最后这个问题,Macro 的答案不是二选一,而是同一份 CRDT 数据、多个投影。
对 Agent 暴露的是 XML lens:结构明确、带稳定 ID、可按段落定位,适合生成小幅变更。对用户暴露的是块编辑器:有样式、大纲、评论、修订。对 Word 互操作暴露的是 DOCX 包——readDocxState/writeDocxChanges/diffDocxStates负责在包结构与 CRDT state 之间做双向映射。
代价也很真实,代码里并不掩饰。旧格式文档缺少修订与批注能力,此时 Agent 会被明确拒绝,并被要求先让用户在 Macro 里打开一次完成升级:
throw new DocxAgentError( "This Word document is still in Macro's previous editor format, which cannot " + "record tracked changes or Word comments. Ask the user to open it once in Macro " + "(that upgrades it), then try again; or pass trackChanges false to edit it directly " + "without comments." );另一个诚实的取舍在同步层:某些 AI 编辑会话是不广播的。引擎里那段"零字节不是合法 Loro payload"的注释,说的正是这类会话使用的 noop live source:
// Nothing to converge. Zero bytes are not a valid Loro payload (real // sources encode "no new ops" as a non-empty update), so importing them // would throw and trigger a reset — the noop live source used by // non-propagating AI edit sessions answers with exactly this.换句话说,"Agent 像人一样协作"是默认路径,但不是唯一路径。当一次 AI 操作需要"看到结果再决定下一步"、却又不能把中间态推给所有协作者时,系统允许它走一条静默通道,最后只提交终态。
这套设计真正解决的是什么
把几条线合起来看,Macro 文档层解决的并不是"让 AI 能写文档",而是三个更基础的问题:
一是让 Agent 的写入具备人类写入的全部性质。同一个 Loro peer 模型、同一条 WebSocket 协议、同一套 WAL 与快照恢复。Agent 不需要特权 API,也就不会绕过离线、重连、冲突合并这些真实路径——它必须和人类一起承受网络。
二是把"冲突"从覆盖问题降级为合并问题。细粒度操作 + 富文本 CRDT + 分数索引排序,让并发编辑在结构层就能共存;ImportPending分支保证因果缺口靠补拉而不是重置来修复。这决定了离线编辑到底能不能用。
三是把责任做成可查询的数据,而不是日志里的字符串。保留 peer 号段让 AI 身份在协议层可判定,blame表让"这个节点最后是谁改的"变成一次 JOIN,修订记录让 AI 的改动可以被逐条拒绝。三者服务的是同一个目标:当 Agent 成为文档的共同作者,审计能力必须和写作能力同时到位。
反过来看,这套架构的约束同样明显:blame 只保留最后编辑者,节点级的粒度意味着"这段被整体搬走"会留下一条并不直观的归属;AI peer 号段依赖 TS 与 Rust 两侧手工同步;旧格式文档在升级前无法享受修订能力。这些都是工程上被明确记录、而非被隐藏的边界——对一个仍在早期验证阶段的协作系统来说,这种诚实本身比功能清单更有信息量。
【免费下载链接】macroMacro is a unified workspace for teams: email, chat, docs, tasks, agents, calls, and CRM — @-linked together with shared AI memory.项目地址: https://gitcode.com/GitHub_Trending/macro3/macro
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考