协作编辑中的 AI 改写如何防止误覆盖
AI 生成改写期间,协作者可能已修改原文,直接应用建议会覆盖新的决定。文章结合公开仓库中的提案机制,说明为何需要携带原始文本并校验目标内容是否变化。
AI 深度解读
AI 改写已经生成,用户也点了 Apply,为什么还不能直接写回文档?因为在等待生成和准备内容的几秒里,协作者可能已经改了原文。建议写得再流畅,也可能对应着一个过时的决定。
原文借 Breatic 公开仓库中的实现,讨论了协同 AI 编辑容易漏掉的一层:接受建议之后、真正修改文档之前,如何检查目标、权限和重复提交。对做编辑器或 AI 编程产品的人,这比继续打磨提示词更接近一个实际的工程问题。
建议生成时有效,不代表点击时还能用
原文的例子很直观:创意简报原本写着「父亲离开房间」,作者请 AI 改写;等待期间,协作者把它改成「父亲留下来帮助女儿」。此时 AI 返回的,仍然是围绕「离开」润色的句子。
协同编辑系统可以合并更新,却未必知道这条待接受的建议已经背离了最新决定。处理数据合并,与判断建议是否过期,是两件需要分别处理的事。
Breatic 的文档编辑工具因此先返回提案卡片,不立即修改共享文档。提案保存目标 Space、block ID、原始纯文本、建议内容,以及独立的 proposal ID。评论、改写、插入共用这套提案结构,但对目标的要求不同。
这里最关键的字段是原始纯文本。block ID 能回答「模型看的是哪一块」,却回答不了「这一块现在还是不是原来的内容」。
下面这段是原文用于解释改写检查的示意代码,并非可以直接调用的独立 API:
const block = findBlockById(currentDocument, proposal.blockId);
if (block === null) return { outcome: "blockGone" };
if (plainText(block) !== proposal.original) {
return { outcome: "changed" };
}
要看的是第二个判断:找到同一个块还不够,当前文字也必须与提案记录的原文一致。实际实现读取编辑器当前的 ProseMirror 文档;目标被删除就返回 blockGone,文字变化就返回 changed,用户可以基于新内容重新请求建议。

这是原文提供的示例项目截图,图中的改写尚未应用,不能把它当成写入成功的证据。
为什么点击时检查一次还不够
提案里的 Markdown 需要转换成编辑器块,准备过程还可能等待媒体配置。只要中间出现 await,协作者就有机会修改或删除目标,编辑器也可能变成只读。
所以,点击处理函数开头的一次检查,只能证明当时允许写入。
原文给出的流程,在异步准备前后各检查一次,第二次紧挨着同步写入:
const initial = inspectCurrentState();
if (initial !== null) return initial;
const prepared = await prepareNewContent(proposal.body);
const ready = inspectCurrentState();
if (ready !== null) return ready;
writePreparedBlocks(prepared);
关键是 await 后面的 inspectCurrentState()。它不只比较文字,还检查请求是否仍然可用、提案是否已经落地、当前是否允许写入、编辑器是否可编辑,以及目标的当前状态。
凡是跨过异步等待的状态,都需要重新判断是否还能作为写入依据。
目标位置同样会过期。协作者可能没改文字,只把段落移进了表格单元格。实现会再次按 ID 查找目标,而不是沿用等待前保存的位置,并按当前位置对新内容做规范化处理。文字相同,容纳它的结构也可能已经变了。
防重复和撤销,需要在文档里留下什么
只在前端维护一个 applied = true,能挡住部分重复点击,却无法在刷新后解释这条建议是否已经写入。
对于插入和改写,原文中的实现根据 proposal ID 和每个输出块的索引,派生根块 ID。之后重新渲染时,可以检查共享文档里是否存在这些痕迹。评论则把提案元数据放在线程上,并要求文档中仍然存在对应范围,才算已经落地。
这个判断跟着文档当前状态走。写入被撤销、输出消失后,提案不再算已应用;重做让输出恢复,痕迹也随之恢复。
接受提案的写入还被划为一个撤销步骤。否则,一次多块改写可能要撤销好几次,甚至连接受建议之前刚输入的文字也一起撤掉。
他们的回归测试专门卡住异步准备阶段,在暂停期间修改目标、删除目标或切换只读,再放行准备过程。测试断言分别返回 changed、blockGone 或 readOnly,保留当前文档,且不新增撤销项。另一些用例覆盖目标进出表格单元格、重复应用、撤销重做,以及通过第二个 Yjs 文档读取结果。
这些是原文转述的仓库测试断言。作者没有报告新基准测试,也没有声称重新跑过完整测试套件。
我的判断
我认同把提案和写入分开,也认同把检查放到真正提交之前。它把一个容易被归咎于「AI 不靠谱」的问题,拆成了可检查、可返回失败原因的编辑流程。
不过,纯文本比较是一道范围有限的防线。格式、属性或标记发生变化,未必能被它识别;文字没变,也不能证明模型理解正确。如果产品需要保护这些结构信息,就要评估更完整的版本或结构校验,原文没有提供这些方案的对照数据。
浏览器端最后一次检查也不是分布式锁,不能保证之后不会再收到远端更新。它保护的是当前本地文档中的写入条件,没有解决所有并发冲突。
相比直接覆盖,拒绝过期提案更容易保住协作者的新决定;相比自动把旧建议合并进新文本,它也更容易解释失败原因。但哪种方式更适合高频协作,原文没有给出用户体验或成功率数据,不能据此认定这就是所有团队的最优方案。
能拿来做什么
- 做前端 AI 编辑功能时,让建议携带目标 ID、原始内容和 proposal ID,接受时重新读取当前文档;发现目标变化后,明确返回原因,让用户基于新内容重新生成。
- 检查 Apply 流程里的 Markdown 转换、媒体准备等异步步骤,在最后一个相关
await之后重新校验权限、目标和可编辑状态,并重新定位目标。 - 为重复点击、刷新、撤销和重做设计统一的应用状态判断,同时把一次多块建议写入划为一个撤销步骤。
- 写回归测试时主动暂停异步准备,在暂停期间修改、删除或移动目标;除了检查返回值,还要验证失败时文档和撤销历史都没有被改变。