BLOG · #Engineering

文档版本化与作用域编辑

AI 反复改写的文档需要一套让“每一版可回看、每一改可拒绝”的数据模型。本文以一个生产级 AI 写作 agent 为实证,拆解四层机制:不可变版本链(回退即分叉而非覆盖)、候选台账(编辑先入账、接受才落版本树)、确定性分节器(等长掩码与双锚点)、diff 分工(后端只保不变量,行级渲染交前端),并给出对应的测试语义与适用边界。

English version: Document versioning and scoped editing。“LLM 应用工程”系列第 7 篇。实证基座同前几篇:一个生产级 AI 写作 agent(FastAPI + React + Tauri)的实现、devlog 与测试套件。

问题:模型会改文档,谁来守住文档

真机测试里出过一个典型场景:用户在对话框发“润色”“优化参考文献”“改表 1”这类意见,有些会静默重写整篇——结果直接落为新版本,用户事后才发现改动远超预期。文档是用户在这类应用里的核心资产,而模型的每一次输出都可能触碰它。把问题从“模型行为”翻译成“数据模型”,需求收敛为四条:

  1. 每一版可回看:任何一次改写之前的状态都能找回;
  2. 每一改可拒绝:模型输出先是提议,用户接受才成为事实;
  3. 每一改可定位:“只改这一节”需要确定性地回答“这一节是哪些字节”;
  4. 审阅代价可控:用户要能看清改了什么,再做决定。

这四条没有一条靠提示词能保证。下文按层拆解我们的实现:版本链、候选台账、分节器、diff 分工。

版本链:回退是分叉,不是覆盖

存储层是一张 document_versions 表(SQLite,单用户本地应用):

CREATE TABLE IF NOT EXISTS document_versions (
    id                INTEGER PRIMARY KEY AUTOINCREMENT,
    session_id        INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
    version_no        INTEGER NOT NULL,
    content           TEXT NOT NULL,                -- LaTeX 源码
    change_summary    TEXT,
    parent_version_id INTEGER,
    ...
);

三个设计点:

每版全量快照,不存 delta。content 直接存整篇源码。代价是存储冗余,换来的是读任何一版都零重放、diff 的两端总是现成的。对几十 KB 量级的 LaTeX 文档,这个取舍毫无悬念。

version_no 每会话独立编号。add_version 在会话内取 MAX(version_no)+1:用户看到的永远是本文档的 v1/v2/v3,不是全局自增的 v847;全局 id 只用于外键与血缘。测试里有一条专门钉住它:两个会话交错落版,新会话的首版仍是 1。

**parent_version_id 记血缘,回退=分叉。**回退端点的全部逻辑:

@app.post("/api/versions/{version_id}/restore")
def restore_version(version_id: int, ...):
    """回退/分叉:以某历史版本的内容创建新的最新版本(parent 指向它)。"""
    v = db.get_version(version_id)
    ...
    ver = db.add_version(v["session_id"], v["content"],
                         f"回退自 v{v['version_no']}", v["id"])

回退不删除、不覆盖任何既有版本:它以被回退版的内容新建一版,parent 指向被回退版。对应的测试断言把语义写死:在 v2 之后回退 v1,得到的是 v3——content 等于 v1 的内容、version_no 只增不减、parent_version_id 指向 v1。“撤销回退”就是再回退一次;所有操作都是 append。

整个后端没有任何一条 UPDATE content 的路径,手动编辑也走 add_version 落新版。不可变是后面一切机制的地基:diff 才有稳定的两端,候选才有确定的 base。

候选台账:编辑先入账,接受才落版

第一版实现走的是“方案 A”:候选不落库。edit-section 只算不存,前端预览 diff 后自行调保存接口落版——零新表、不触碰版本树。它很快显出局限:刷新丢候选、无法多候选并列、服务端无审计。于是升级为“方案 B”——proposed_edits 台账:

CREATE TABLE IF NOT EXISTS proposed_edits (
    session_id      INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
    base_version_id INTEGER NOT NULL,               -- 基于哪一版做的编辑
    char_start      INTEGER NOT NULL,
    char_end        INTEGER NOT NULL,
    old_text        TEXT NOT NULL DEFAULT '',
    new_text        TEXT NOT NULL DEFAULT '',
    status          TEXT NOT NULL DEFAULT 'pending',  -- pending/accepted/rejected
    scope           TEXT NOT NULL DEFAULT 'section',  -- section/whole/bib
    ...
);

模型产出的每一笔编辑——段落级、整篇(scope='whole',区间取 [0, len])、bib——先成为一条 pending 候选;版本树只在接受时被触碰,接受复用同一个 add_version

flowchart LR A[编辑请求] --> B{作用域} B -->|section / bib| C[edit-section] B -->|whole| W[edit-whole] C --> P[proposed_edits:pending] W --> P P -->|拒绝| X[rejected:版本树不动] P -->|接受| G{base 仍是最新版?} G -->|否| E[409 候选已过期] G -->|是| S[splice 拼回] --> V[add_version 落新版]

接受路径上有两道 409:候选已处理过(accepted/rejected 不可重复处理);base 已过期base_version_id 不等于最新版)。第二道是关键:候选的字符区间是对 base 版算的,base 不再是最新版时按 base 拼回,会把 base 到最新版之间的改动静默丢掉——对抗评审曾在前端发现同源问题(旧版本上编辑并接受会覆盖后续版本),前端加了“仅最新版可改这段”的门控,这道 409 是服务端兜底。

要不要给每次编辑都套确认,我们专门做过一轮带来源的交互研究。结论先修正了风险定位:整篇重写其实已可回退(版本链在),静默默认的真正风险是作用域意外、成本、事后审阅贵——不是数据丢失。所以确认关要轻(横幅加方案卡),不做到处弹的阻塞模态;段落小改保持一键接受。业界反例也记录在案:Cursor 曾弱化 diff 审批式确认,被社区当作 regression 反馈(取自我们的调研笔记,未独立核验)。方向与 HITL 文献一致:只对不可逆动作确认、优先 undo(NN/g);拿不准时缩小行动范围、行动前消歧(Microsoft HAX G10)。

确定性分节器:等长掩码与双锚点

候选的 char_start/char_end 从哪来?答案是一个确定性分节器(约 260 行,零 LLM、零第三方依赖),把 LaTeX 源码按 \section / \subsection / \subsubsection 解析成节点树。一个节点的可编辑跨度=从它的标题命令起,到下一个层级不深于自己的标题为止——编辑子节只换子节,编辑父节连带子节。三个设计点:

屏蔽而不位移。注释与 verbatim / lstlisting / minted 等逐字环境里的 \section 是假标题;先删掉再解析会毁掉所有字符 offset。解法是把这些区域替换成等长空格

def _mask(content: str) -> str:
    """把注释与逐字环境替换成等长空格(保持所有字符 offset 不变),
    使其中的 \section 不被当成真标题——但 splice 仍用原文 offset。"""

定位在掩码文本上做,标题与内容从原文同一 offset 读取。解析与拼回共用一套坐标系,没有换算步骤,也就没有换算 bug。

**双锚点。**每个节点同时携带位置型锚(n0n1,按出现顺序,同一份内容重复解析结果一致)和内容指纹键(L1:误差分析,由层级+标题派生,同标题多节加序号后缀)。位置锚在章节增删后漂移,内容键跟着标题走、跨版本更稳,定位时优先。摘要与关键词没有 \section 可寻址,就以 \abstract{...} / \keyword{...} 命令构造“伪节”,让“改摘要”也能走作用域编辑而不塌缩成整篇重写。

**解析失败降级,不抛异常。**无分节结构就返回 editable=False,调用方回退全文修订。分节器的职责是收窄作用域,不是制造新故障点。

这一层的健壮性主要是对抗评审磨出来的,三条真问题各自留下回归测试:\section[短标题]{长标题} 的可选参数形式被漏检(该节并入前节、接受时静默丢节);注释与逐字环境内的 \section 成幽灵节点(拼回破损);以及上文提到的旧版本编辑覆盖问题。

splice、片段体检与一个反思环教训

模型返回的节片段回到主文档,三步全是代码:strip_fragment 清洗(去 Markdown 围栏;模型违规带回整篇时切出正文部分——清洗的一般方法见系列第 3 篇);check_fragment 体检(花括号配平、环境配平、无 \documentclass,软信号提示不硬拦);最后拼回是一行:

def splice(content, char_start, char_end, new_text):
    return content[:char_start] + new_text + content[char_end:]

区间外字节级不变,这条承诺直接写进测试断言。

一个教训值得单列。自主反思环里的作用域拼回用位置型锚点:若模型片段多带了一个顶层 \section,splice 后节数加一,同一轮里后续编辑项的位置锚整体漂移、改错节;而当时的回归闸门只兜“节数减少”不兜“增加”——静默错改。修复是给拼回加一条守恒律:片段必须保持被替换跨度内的顶层 \section 数,否则跳过该节。这条同样出自对抗评审,修复后成为固定测试项。

diff 的分工:后端保不变量,行级渲染交前端

版本有了、候选有了,“看清改了什么”却刻意没有做成后端服务:

职责不做什么
document_versions版本不可变、血缘可查不算 diff
proposed_editsold_text / new_text 成对留档不管展示
split_aligned骨架对齐时把整篇改动拆成逐节候选结构性改动返回 None
前端 diffLines(jsdiff)行级高亮渲染不改任何数据

split_aligned 是后端唯一“算 diff”的地方,条件收得很紧:base 与 new 的顶层 \section 同标题、同顺序、同数量,且 preamble 逐字相同,才把整篇改动拆成“逐节可独立接受”的列表——骨架对齐时各节区间互不重叠,接受某节只换该节、其余逐字不动。任何条件不满足返回 None,退回整篇 all-or-nothing。preamble 检查看似苛刻,理由很具体:改动落在节外时,逐节审阅会漏掉它。

行级 diff 交给前端 jsdiff 的 diffLines。展示粒度是 UI 关切,随设计迭代;后端只承诺可测试的不变量——版本不可变、区间外字节不变、拆分安全条件。

量化:测试怎么钉住这些语义

语义钉住它的测试 / 数据
回退=分叉restore 后 content 等于源版、version_no 只增、parent 指向被回退版
version_no 每会话独立两会话交错落版,新会话首版仍为 1
候选生命周期重复处理 409;base 过期 409
分节健壮性P0/P1 相关 29 例(含评审补 4 例),当期全量 173 passed
逐节审阅split_aligned 新增 6 例,当期全量 266 passed
越权隔离非属主读版本、restore 一律 404

这些测试全部 mock LLM、离线可跑——数据模型层恰好是 LLM 应用里可以用传统断言测死的部分。

适用边界

  1. **全量快照有天花板。**每版存整篇对几十 KB 的 LaTeX 恰当;文档到 MB 级、保存高频(如实时协同编辑),就该换 delta 存储或 CRDT——这套模型不为那个场景设计。
  2. **台账是给“人在环”的形态的。**如果产品是全自动流水线、无人逐条审阅候选,台账只是无人看的队列;那时该用的是落库前的确定性回归门(见第 11 篇)。同理,低风险小改一律套确认是 NN/g 点名的“狼来了”反模式——我们段落小改保持一键接受,确认关只放在整篇改。
  3. **分节器前提是可解析结构。**无结构文本 editable=False,版本链仍有效,但作用域编辑退化为全文修订。
  4. **split_aligned 的保守是特性不是缺陷。**骨架变了就拒绝逐节拆分:结构性改动本就不该逐节接受——接受一半的“重组”比整篇替换更危险。
  5. **版本树不是分支工作流。**血缘被完整记录,但呈现是线性的、tip 唯一;不支持并行探索多个方向再合并。需要 git 式分支语义的场景,这套模型只提供了地基。

参考

以下来源出自我们交互研究的调研笔记(devlog,带来源清单):

  • Eric Horvitz, Principles of Mixed-Initiative User Interfaces (CHI ‘99)——混合主动交互的原则性框架。
  • Microsoft HAX Guidelines——G9(支持高效撤销)、G10(拿不准时缩小行动范围/行动前消歧)、G16(交代行动后果)。
  • Nielsen Norman Group 关于确认对话框的准则——只对不可逆动作确认、优先 undo、避免“狼来了”。
  • Google PAIR, People + AI Guidebook——高掌控型产物的用户抵触全自治;解释服务于理解。
  • Cursor 社区对弱化 diff 审批的 regression 反馈——取自调研笔记,未独立核验。