English version: Document versioning and scoped editing。“LLM 应用工程”系列第 7 篇。实证基座同前几篇:一个生产级 AI 写作 agent(FastAPI + React + Tauri)的实现、devlog 与测试套件。
问题:模型会改文档,谁来守住文档
真机测试里出过一个典型场景:用户在对话框发“润色”“优化参考文献”“改表 1”这类意见,有些会静默重写整篇——结果直接落为新版本,用户事后才发现改动远超预期。文档是用户在这类应用里的核心资产,而模型的每一次输出都可能触碰它。把问题从“模型行为”翻译成“数据模型”,需求收敛为四条:
- 每一版可回看:任何一次改写之前的状态都能找回;
- 每一改可拒绝:模型输出先是提议,用户接受才成为事实;
- 每一改可定位:“只改这一节”需要确定性地回答“这一节是哪些字节”;
- 审阅代价可控:用户要能看清改了什么,再做决定。
这四条没有一条靠提示词能保证。下文按层拆解我们的实现:版本链、候选台账、分节器、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:
接受路径上有两道 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。
**双锚点。**每个节点同时携带位置型锚(n0、n1,按出现顺序,同一份内容重复解析结果一致)和内容指纹键(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_edits | old_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 应用里可以用传统断言测死的部分。
适用边界
- **全量快照有天花板。**每版存整篇对几十 KB 的 LaTeX 恰当;文档到 MB 级、保存高频(如实时协同编辑),就该换 delta 存储或 CRDT——这套模型不为那个场景设计。
- **台账是给“人在环”的形态的。**如果产品是全自动流水线、无人逐条审阅候选,台账只是无人看的队列;那时该用的是落库前的确定性回归门(见第 11 篇)。同理,低风险小改一律套确认是 NN/g 点名的“狼来了”反模式——我们段落小改保持一键接受,确认关只放在整篇改。
- **分节器前提是可解析结构。**无结构文本
editable=False,版本链仍有效,但作用域编辑退化为全文修订。 - **
split_aligned的保守是特性不是缺陷。**骨架变了就拒绝逐节拆分:结构性改动本就不该逐节接受——接受一半的“重组”比整篇替换更危险。 - **版本树不是分支工作流。**血缘被完整记录,但呈现是线性的、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 反馈——取自调研笔记,未独立核验。