Agent 写入有日志,也未必能撤销
作者审视自己的 MCP 文章编辑工具后发现,保存修改前后状态并要求确认,不等于具备撤销能力。案例中的工具会覆盖已发布文章,而目标平台没有版本历史,暴露了写入保护与恢复机制之间的差距。
AI 深度解读
给 AI Agent 的写入工具加上确认按钮,再把修改前后的内容记进日志,能不能算支持撤销?作者检查了自己用于修改 DEV.to 文章的 MCP server,发现答案是否定的:它能拦住未经确认的修改,也能查出改过什么,却无法安全地恢复已经写错的内容。
这篇文章的价值,在于把「有记录」和「能恢复」之间缺失的工程环节拆了出来。只要你的 AI 工具会修改线上内容,这个区别就值得认真检查。
确认过的修改,为什么仍然需要撤销
作者的 MCP server 提供了 update_article 工具,可以直接修改已经发布的 DEV.to 文章。按作者描述,DEV.to 没有文章版本历史,一次错误的 PUT 会覆盖原来的标题或正文。
因此,工具在写入之前会先读取当前文章。只要文章已经发布,而且本次修改涉及 title 或 body_markdown,调用方就必须显式传入 confirm=True。没有确认时,工具返回 applied: False,同时提供修改差异 diff 和内容指纹 fingerprint,让调用方先看清准备改什么。
这个设计解决了一个实际问题:Agent 不能直接覆盖线上正文,必须先经过确认。
但确认只能证明「当时同意执行」,不能证明「执行结果一定正确」。需求可能理解错,预览可能漏看,确认之后也可能才发现内容不合适。作者之前一直把「DEV.to 没有版本历史」当作设置确认门槛的理由,却没有继续追问:已经确认、已经写入的错误,准备怎么收拾?
事前确认负责控制写入,事后撤销负责恢复状态,两者不能互相替代。
日志保存了旧内容,却没有定义怎么恢复
作者确实记录了每次成功修改的前后状态。下面这段代码把发生变化的字段写入本地 JSONL 文件,解决的是追查修改的问题:
def _log_article_update(article_id, before, fields_changed, after):
os.makedirs(os.path.dirname(_ARTICLE_UPDATE_LOG), exist_ok=True)
entry = {"article_id": article_id, "fields_changed": sorted(fields_changed), "url": after.get("url")}
for field in fields_changed:
entry[f"{field}_before"] = before.get(field)
entry[f"{field}_after"] = after.get(field)
with open(_ARTICLE_UPDATE_LOG, "a") as f:
f.write(json.dumps(entry) + "\n")
关键在 entry[f"{field}_before"] 和 entry[f"{field}_after"]:它们保存了恢复可能需要的数据,但没有把恢复变成一个可调用、带条件检查的操作。
直觉上,撤销似乎很简单:找到这篇文章最后一条日志,取出 fields_changed 对应的旧值,再调用 update_article(article_id, title=..., body_markdown=..., confirm=True) 写回去。
问题出在「再写回去」之前。
日志之后,文章可能已经被另一次工具调用改过,也可能有人在 DEV.to 网站手动编辑,或者同一套流水线并发运行了第二次。旧日志描述的是某次修改前后的状态,无法证明当前文章仍然停留在那次修改之后。
例如,下午 2 点改了标题,下午 3 点又改了正文。此时撤销 2 点的修改,如果直接拿整份旧内容覆盖,就可能顺手抹掉 3 点的正文编辑。即使只恢复标题,也得说明:允许保留后续正文修改的依据是什么?
作者把缺口归为三个部分:生成可执行的补偿写入、检查历史状态是否仍然适用、规定后续修改发生后的撤销语义。所谓补偿写入,就是通过一次新的写入抵消之前的修改,并不会让已经发生的操作从历史中消失。
撤销要核对的,是原操作留下的状态
有意思的是,作者的工具已经具备一部分保护能力,只是用于正常写入,没有接到撤销流程上。
调用方可以把预览时取得的指纹,通过 expected_fingerprint 传回去。如果线上内容已经变化,工具就拒绝继续写入,避免拿旧预览确认新内容。
下面的函数根据标题和正文生成指纹,用来判断内容是否发生变化:
def _article_fingerprint(before):
basis = "\x1f".join(f"{f}={before.get(f)!r}" for f in ("title", "body_markdown"))
return hashlib.sha256(basis.encode()).hexdigest()[:16]
要看清第一行只纳入了 title 和 body_markdown,最后一行则把 SHA-256 的十六进制结果截取为前 16 个字符。它检查的是这两个字段组成的状态,并不代表文章所有属性。
正常写入时,比较基准是调用方预览过的内容;撤销时,比较基准应当是被撤销操作完成后留下的内容。
这也是为什么「撤销前重新读取一次,再用刚读到的指纹写回旧值」仍然不够。它只能确认这次读取之后有没有变化,不能确认历史操作之后有没有发生过其他编辑。历史记录必须与状态校验衔接起来,才能回答旧内容现在还能不能恢复。
遇到后续修改,工具还需要明确选择:直接拒绝撤销,还是允许只恢复部分字段。原文没有实现这套规则,也没有交付可用的 undo_last_update。作者明确表示,不会在刚想到方案的当天,就把未经充分测试的撤销函数放进修改公开博客的工具里。
我的判断
我赞同作者对安全边界的重新划分。日志回答「发生了什么」,撤销还要回答「现在执行什么,才能恢复且不覆盖后来发生的工作」。把这两个问题分开,能让工具的能力描述更准确。
不过,这是一篇代码自查和设计反思,没有完整实现,也没有对照测试。作者认为现有指纹机制已经解决了真正撤销中大部分困难,这仍属于经验判断,不能据此认定接上线就足够可靠。
从展示的代码看,还有一个需要继续验证的边界:状态检查与实际写入之间,如果又发生了修改,工具如何处理?原文没有说明 DEV.to API 是否提供条件写入能力,也没有展示这段间隔如何受到保护。指纹可以帮助发现过期状态,但仅凭文中的片段,无法确认并发覆盖已经得到完整解决。
与直接恢复旧快照相比,补偿写入能明确表达要恢复哪些字段,却需要额外定义冲突策略;与平台自带版本历史相比,本地日志的方案还要自行承担恢复入口和校验逻辑。作者案例中的前提,正是目标平台没有可直接使用的版本历史。换到已有版本管理的系统,应先检查现成恢复能力是否满足需要。
能拿来做什么
- 给 AI 编程工具或 MCP 工具梳理一次写入能力,分别标出是否需要确认、是否保存旧值、是否提供恢复入口、是否检查后续修改,避免用「有日志」笼统代表安全。
- 如果 Node 服务会调用外部 API 修改内容,可以先让修改记录包含明确的资源标识、变更字段和前后值,再设计这些数据如何转换为补偿请求,并关联原操作完成后的状态校验依据。
- 第一版撤销可以采用保守规则:发现相关内容已经变化就拒绝自动恢复,并展示冲突供人工处理。等部分恢复的边界定义清楚,再支持跨越后续编辑撤销。
- 为撤销验证补上「修改后又被编辑」的场景,覆盖人工修改、第二次工具调用和并发运行。只验证旧值成功写回,无法证明后来产生的内容不会被覆盖。