qinghuan
← 回到知识探索

AI 直觉 / 可下载小项目

助手撤销刚才的修改,为什么不能直接盖回旧稿?

记录每个字段改前和改后的值与修订号,撤销时先核对全部条件,保留后来发生的无关修改,并把冲突交代清楚。

展开本文目录
  1. 下载完整练习包
  2. 先动手看看
  3. 先找出这次操作留下的变化
  4. 别人改营业时间,不妨碍撤销我的标题
  5. 同一处被改过,就先停下来
  6. 值又变回来了,也不等于没人碰过
  7. 三处一起撤销,先检查完再动手
  8. 运行四个场景,核对实际保留下来的资料
  9. 账本要能独立回看,不能跟着当前值一起变
  10. 能撤回文字,不等于能撤回所有后果
  11. 记住这三点
  12. 别把例子用过头
  13. 自己试一小步
  14. 查原始资料
开始前,会这些就够了

助手把标题改好后,另一位编辑更新了营业时间。你说“撤销刚才的修改”,如果助手直接恢复整份旧稿,标题确实回来了,新的营业时间却也没了。撤销需要知道自己改过哪些地方,以及这些地方后来又发生了什么。

这次做一个只处理平面文字字段的离线撤销器。它记录一次修改的前后状态,撤销前检查每个字段的修订号,任何冲突都会让整批撤销停止。程序操作内存中的虚构资料,不调用模型或远程服务;这里的可逆性来自明确的数据约定。

把例子带到自己的电脑上

下载完整练习包

需要 Python 3.10 或更新版本,无第三方依赖;制作时实测 Python 3.14.0。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。

下载 ZIP · 5 个文件
包里有什么
  • conditional_undo.py:完整示例
  • README.md:环境、范围与独立练习
  • observed.stdout.txt:制作时的实际输出
  • verification.json:源码与产物验证记录
  • SHA256SUMS.txt:文件内容摘要

在这个文件夹打开 PowerShell,逐行运行

python --version
python -B conditional_undo.py

解压到独立练习文件夹,在含源文件的目录打开终端运行。程序会在当前目录写入教学报告;先预测结果,再查看当次输出。单进程、顺序执行的字段日志与版本条件;不提供分布式事务、崩溃恢复或真实服务回滚。

找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。

现在撤销,会不会覆盖后来发生的修改?

助手先把 title 改为 Tea final,删除 phone,再增加 note。每个字段的修订号从 0 开始,每次写入加 1,删除后也保留编号。接着选择后来发生了什么。

尚未执行撤销。三个待撤销字段都符合日志中的条件。

执行前预览:内容和修订号都要对得上
字段日志要求的内容本次检查前的内容修订号(要求 → 实际)通过吗
title"Tea final""Tea final"1 → 1通过
phone字段不存在字段不存在1 → 1通过
note"upstairs""upstairs"1 → 1通过

本次检查前

{
  "title": "Tea final",
  "hours": "10:00-19:00",
  "note": "upstairs"
}

当前资料(未撤销)

{
  "title": "Tea final",
  "hours": "10:00-19:00",
  "note": "upstairs"
}

重点试“改走又改回”:文字虽然一样,修订号已从 1 变成 3。再试成功撤销后重复使用旧日志,检查结果为什么又不同。

这四个案例对应正文的单线程、平面字符串字段实验,所有修改都经过同一计数入口。面板只在当前页面计算,没有写入文件、商户或服务;真实并发需要在服务端原子检查并提交,不能靠页面预览保证。

先找出这次操作留下的变化

主例一次修改三个字段:改标题、删电话、加便条。每项保存 before 和 after,里面包含字段是否存在、文字值与修订号。撤销时,改标题需要写回旧标题,删电话需要重新放回旧号码,加便条则需要删除新添的字段,三个逆动作并不相同。

整份旧稿可以帮助解释当时情况,却不能直接充当撤销命令。Azure 的补偿事务说明指出恢复旧状态可能覆盖并发工作,逆向处理要结合业务。本例把范围缩到三个文字字段,便于完整检查,不把它当成通用分布式补偿框架。

别人改营业时间,不妨碍撤销我的标题

unrelated_edit 里,后来的编辑只把 hours 改为 10:00-19:00。撤销器仅检查自己的账本里记录的 title、phone、note,这三处仍符合写入后的状态,于是整批回退;hours 保持新值。最后的文档既不是整份原稿,也不是助手修改后的那份,而是两个人的操作按规则合成的结果。

这里允许独立字段分别变更,是本题的数据约定。若标题和营业时间共同决定某个业务规则,就不能只检查各自的值;应把关联条件一起纳入检查范围。给每个字段加编号,不会自动知道哪些字段在业务上相互依赖。

读报告时可以先划掉账本没有提到的字段,再对剩下的三项逐一比较。营业时间的新值会出现在撤销前和撤销后的资料里,却不会出现在 reverted 清单。这个差别正是验收重点:结果不是“与原稿完全相等”,而是“我这次的变化被撤回,同时合法的后来变化还在”。

同一处被改过,就先停下来

same_field_conflict 让另一位编辑再次改标题。当前标题与原账本的 after 不同,撤销器报告冲突,并保留现状。它不会猜谁的改动更重要,也不会把新标题强行换回最早的标题。报告同时保存预期状态与当前状态,供后续人工或业务规则决定下一步。

RFC 6902 的 test 操作提供了“先检验目标值,再继续操作”的规范例子。本例借用这一思路,但账本不是 JSON Patch 文档,也没有实现 JSON Pointer、数组路径或该规范全部操作;实际传给工具之前仍要按工具支持的格式转换。

值又变回来了,也不等于没人碰过

changed_away_and_back 先把标题改成另一个值,随后改回 Tea final。只比较文字,会误以为当前状态仍是助手刚写完的状态。本例每次写入都递增对应字段的 revision;账本期待 1,当前已经是 3,所以仍然拒绝旧撤销。这个实验把“现在看起来一样”和“期间没有新写入”分开。

删除字段后也保留修订号,称为删除记录。电话被别人重新创建时,旧删除操作不能借“字段曾经消失”来覆盖新号码。即使把相同文字再写一次,本题也算一次新写入。修订号不倒退,而且所有写入都必须经过 Store;绕过它直接改字典会破坏这个前提。

这些编号只在一个字段的生命周期内比较,不是全系统时间,也不能用于给不同字段排序。标题修订号三并不意味着它比电话修订号二晚一秒。初始字段从零开始,新增字段首次写入后变为一;撤销后继续递增,因为撤销也是一次新写入,不能把历史计数器一起拨回过去。

三处一起撤销,先检查完再动手

undo 的第一轮只检查,不修改;发现任意冲突就返回。全部通过后,才按账本逆序恢复。冲突案例里,虽然便条和电话仍符合要求,也都不回退。这是本题选择的整批策略,避免把一次三处修改撤掉一半后,用户误以为已全部恢复。

这种先检查再执行只在本脚本的单线程内存环境成立。真实数据库或远程接口里,检查与写入之间可能插入别人的操作,需要服务端事务或条件写入保证原子性。JSON Patch 在 HTTP PATCH 中也有整体成功要求,但这不能让普通 Python 两段循环自动获得跨进程原子性。

本地入口还把值限定为文字或删除标记,并在编辑前检查整批参数。若最后一项是整数,前面合法的标题也不会先写进去。这里故意不接收任意外部账本:撤销只消费本程序 apply 产生的记录。若未来允许读入外部文件,还要验证账本结构、归属与字段关系,不能把它当作可信命令直接执行。

运行四个场景,核对实际保留下来的资料

保存完整代码为 conditional_undo.py,在独立目录运行。脚本生成 conditional-undo-report.json,包含原稿、修改账本、撤销前后资料、冲突详情和第二次撤销状态。样本全为虚构,不连接数据库,也不执行实际编辑操作。

第一组成功恢复三处,同时保留新的营业时间;后三组分别因同字段新值、值改走再改回、已删除字段重建而冲突。断言检查冲突时整份内容未变化,也检查非法编辑批次在任何字段写入前被拒绝。重复使用旧账本会冲突,不会被描述成第二次撤销成功。

展开收起代码 · 小实验python · 117 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

小实验 · python
"""Flat string fields with per-field revision checks; single-process simulation."""
from copy import deepcopy
from dataclasses import dataclass, field
import json
from pathlib import Path


@dataclass
class Store:
    values: dict[str, str]
    revisions: dict[str, int] = field(default_factory=dict)

    def __post_init__(self):
        self.values = deepcopy(self.values)
        self.revisions = {key: 0 for key in self.values}

    def state(self, key):
        return {"present": key in self.values, "value": self.values.get(key),
                "revision": self.revisions.get(key, 0)}

    def put(self, key, value):
        if not isinstance(key, str) or not key:
            raise ValueError("nonempty_string_key_required")
        if value is not None and not isinstance(value, str):
            raise ValueError("string_or_delete_required")
        if value is None:
            self.values.pop(key, None)
        else:
            self.values[key] = value
        # A tombstone revision remains even after deletion.
        self.revisions[key] = self.revisions.get(key, 0) + 1


def apply(store, edits):
    # Validate the complete request before mutating this local store.
    if any(not isinstance(k, str) or not k or
           (v is not None and not isinstance(v, str)) for k, v in edits.items()):
        raise ValueError("invalid_edit")
    journal = []
    for key, value in edits.items():
        before = store.state(key)
        store.put(key, value)
        journal.append({"field": key, "before": before, "after": store.state(key)})
    return journal


def undo(store, journal):
    # All guards checked first: one conflict means no fields are reverted.
    conflicts = []
    for entry in journal:
        current = store.state(entry["field"])
        if current != entry["after"]:
            conflicts.append({"field": entry["field"], "expected": entry["after"],
                              "current": current})
    if conflicts:
        return {"status": "conflict", "reverted": [], "conflicts": conflicts}
    for entry in reversed(journal):
        old = entry["before"]
        store.put(entry["field"], old["value"] if old["present"] else None)
    return {"status": "reverted", "reverted": [e["field"] for e in journal], "conflicts": []}


def scenario(name, later_edits):
    store = Store({"title": "Tea draft", "phone": "010-1000", "hours": "09:00-18:00"})
    original = deepcopy(store.values)
    journal = apply(store, {"title": "Tea final", "phone": None, "note": "upstairs"})
    for edits in later_edits:
        apply(store, edits)
    before_undo = deepcopy(store.values)
    result = undo(store, journal)
    after_undo = deepcopy(store.values)
    repeat = undo(store, journal)
    assert repeat["status"] == "conflict"  # stale journal is never silently reused
    assert store.values == after_undo
    if result["status"] == "conflict":
        assert before_undo == after_undo
    return {"case": name, "original": original, "journal": journal,
            "before_undo": before_undo, "result": result, "after_undo": after_undo,
            "repeat_status": repeat["status"]}


def main():
    cases = [
        scenario("unrelated_edit", [{"hours": "10:00-19:00"}]),
        scenario("same_field_conflict", [{"title": "Someone else's title"}]),
        scenario("changed_away_and_back", [{"title": "Other title"}, {"title": "Tea final"}]),
        scenario("deleted_field_recreated", [{"phone": "010-3000"}]),
    ]
    safe, conflict, aba, recreated = cases
    assert safe["result"]["status"] == "reverted"
    assert safe["after_undo"] == {"title": "Tea draft", "phone": "010-1000", "hours": "10:00-19:00"}
    assert conflict["result"]["status"] == aba["result"]["status"] == recreated["result"]["status"] == "conflict"
    assert aba["before_undo"]["title"] == "Tea final"
    assert aba["result"]["conflicts"][0]["current"]["revision"] == 3
    assert aba["result"]["conflicts"][0]["expected"]["revision"] == 1
    assert conflict["after_undo"]["note"] == "upstairs" and "phone" not in conflict["after_undo"]
    assert recreated["after_undo"]["phone"] == "010-3000"
    probe = Store({"title": "untouched"})
    try:
        apply(probe, {"title": "would change", "bad": 123})
    except ValueError:
        pass
    else:
        raise AssertionError("invalid batch accepted")
    assert probe.values == {"title": "untouched"} and probe.revisions == {"title": 0}
    report = {"scope": "single-thread, flat string fields; all writes pass through Store",
              "policy": "all-or-nothing guards per undo batch; revisions never roll back",
              "cases": cases, "assertions": "passed"}
    Path("conditional-undo-report.json").write_text(
        json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
    for row in cases:
        print(f"{row['case']}: {row['result']['status']}; repeat={row['repeat_status']}")
    print("PASS: unrelated edit preserved, conflicts atomic, ABA detected, deletion tracked")


if __name__ == "__main__":
    main()

怎样运行:Python 3.10 或更新版本,在独立练习目录执行:python conditional_undo.py。仅标准库。 2026-10-15 已用 Python 3.14.0 执行这份代码并通过自带检查;下方是当次输出。单进程、顺序执行的字段日志与版本条件;不提供分布式事务、崩溃恢复或真实服务回滚。

运行后对照结果
unrelated_edit: reverted; repeat=conflict
same_field_conflict: conflict; repeat=conflict
changed_away_and_back: conflict; repeat=conflict
deleted_field_recreated: conflict; repeat=conflict
PASS: unrelated edit preserved, conflicts atomic, ABA detected, deletion tracked

账本要能独立回看,不能跟着当前值一起变

程序用 deepcopy保存展示快照,字段本身则限定为不可变的文字值。这样 later_edits 修改 Store 时,报告里的 original 和 before_undo 不会跟着变化。若将来支持嵌套对象,不能只放宽类型检查;要同时决定路径、容器删除和副本的规则。

第二次撤销故意返回冲突,因为第一次撤销本身也产生了新的修订号。若产品希望提示“此前已撤销”,还需要独立保存操作记录并核对其结果。这个扩展不在本题实现内,不能只因当前文字碰巧等于原稿,就推断某次撤销已经成功执行。

能撤回文字,不等于能撤回所有后果

2026-10-15 已在独立临时目录运行四组场景,全部内置断言通过;完整文章代码与执行文件一致。交付时除了 PASS,还应打开报告,逐项确认保留的营业时间、冲突字段以及没有被部分回退的电话和便条。

范围只含一次进程内、所有写入都受控的平面文字资料。已经发出的通知、对方已读取的内容或真实付款,不能靠恢复字段消除后果;这些动作需要各自业务补偿。本例也没有持久化、崩溃恢复、权限或多进程竞争验证,不能据此宣称一个真实 Agent 已具备可靠撤销能力。

记住这三点

  • 撤销应依据本次修改账本恢复受影响字段,保留其他人的无关修改。
  • 同时检查字段值和单调修订号,能在受控写入路径中识别值改回原样的情形。
  • 整批检查与整批回退需要明确原子性边界,离线单线程代码不能替代服务端保障。

别把例子用过头

  • 把整份旧快照直接盖回去,顺手撤掉别人的工作。
  • 只比较文字相同就认定没有后续写入,或删除字段时把修订号一同丢掉。
  • 发现一项冲突后仍回退其他字段,最终却报告整批撤销成功。

自己试一小步

给虚构资料增加两项互相关联的字段后,你会怎样设计一份不会覆盖后来工作的撤销规则?

  1. 另选业务场景,自行定义一组必须共同成立的字段条件,先说明允许哪些字段独立改变。
  2. 构造至少五个撤销案例,涵盖新增、删除、无关变化、同字段变化与值改回原样;运行前写预期。
  3. 设计一种冲突后的报告格式,让用户看清本次改过什么、现在是什么,以及哪些地方完全未动。
  4. 说明若迁移到数据库,检查和写入应在哪个边界内完成;不使用模拟结果冒充并发验证。

做到这些,就算完成

  • 账本能否区分新增、修改和删除,历史记录是否保持独立?
  • 每次写入是否更新修订号,所有修改路径是否遵守同一约定?
  • 一项冲突时整批是否保持原样,是否清楚声明真实原子性尚未验证?

查原始资料

想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。

  • Azure Architecture Center:补偿事务 (新标签页)↗Microsoft · 英文

    核读 Context and problem、Solution 和 Problems and considerations;支持逆操作需考虑并发变化、业务规则及补偿可能失败的边界。

    2026-10-15 已实际打开公开原始正文并核读;无需登录。本例仅为内存字段撤销,没有实现 Saga 或真实分布式事务。

  • RFC 6902:test 与错误处理 (新标签页)↗IETF / RFC Editor · 英文

    核读第 4.6、5 节;支持先验证目标值以及失败时不能宣称整个 Patch 成功的思路。

    2026-10-15 已实际打开公开原始正文并核读;无需登录。账本是本地格式,不是完整 JSON Patch 实现。

  • Python 3.10:深复制 (新标签页)↗Python Software Foundation · 英文

    核读赋值绑定及深浅复制说明;支持保存不随当前对象变化的展示快照。

    2026-10-15 已实际打开公开原始正文并核读;无需登录。未将内存副本说成持久化审计或恢复系统。