# 文档版本化与作用域编辑

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

- Canonical (HTML): https://kaguc.com/blog/versioning-scoped-edit-zh/
- Date: 2026-07-29


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

## 问题：模型会改文档，谁来守住文档

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

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

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

## 版本链：回退是分叉，不是覆盖

存储层是一张 `document_versions` 表（SQLite，单用户本地应用）：

```sql
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` 记血缘，回退＝分叉。**回退端点的全部逻辑：

```python
@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` 台账：

```sql
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`：

```mermaid
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。解法是把这些区域替换成**等长空格**：

```python
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 篇](/blog/output-sanitizing-zh/)）；`check_fragment` 体检（花括号配平、环境配平、无 `\documentclass`，软信号提示不硬拦）；最后拼回是一行：

```python
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 应用里**可以**用传统断言测死的部分。

## 适用边界

1. **全量快照有天花板。**每版存整篇对几十 KB 的 LaTeX 恰当；文档到 MB 级、保存高频（如实时协同编辑），就该换 delta 存储或 CRDT——这套模型不为那个场景设计。
2. **台账是给“人在环”的形态的。**如果产品是全自动流水线、无人逐条审阅候选，台账只是无人看的队列；那时该用的是落库前的确定性回归门（见[第 11 篇](/blog/deterministic-boundary-zh/)）。同理，低风险小改一律套确认是 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 反馈——取自调研笔记，未独立核验。

