CRDT 实时协作叠上 Agent 协同编辑:Macro 的文档层究竟解决了什么 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里的一个 mapk类型、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: WALSyncerRawUpdate; 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 默认并发 6forEditor(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是引擎的稳定 IDfind是文本锚点、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.sqlCREATE 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 原生可接受/拒绝的修订并带上authorexport type DocxAgentRequest | { action: read; start?: number; count?: number } | { action: edit; operations: DocxAgentOperation[]; /** Record tracked changes; omitted to follow the documents 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 Macros 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),仅供参考