☰
AI 执行治理之三:撤销不是一个按钮
2026/10/31 1:52:16 网站建设 项目流程

前两篇讲的都是「拦住」——先把边界放到 Tool 执行之前,再按风险给每个 Tool 定策略。

但总有一部分拦不住。策略允许了,确认也通过了,写也写完了,数据已经在库里。这时候只剩一个问题:

撤销。

听起来简单:加个按钮,点一下,显示「已撤销」。真做起来,撤销大概是整条治理链上最容易造假的一环。

## 一、撤销是链条的最后一环

三篇连起来看,位置很清楚:

- 执行**前**:定边界(Prompt 不是边界,Runtime 才是)

- 执行**中**:定策略(读自动、写确认、高风险阻断)

- 执行**后**:能收回——就是这一篇

前两篇解决「不该发生的别发生」,这一篇解决「已经发生的怎么收场」。

有个问题容易跳过去:撤销一个 AI 写操作,撤到什么程度才算数?

不过在回答之前,先把我们没做到的部分摆到最前面。理由很直白——只讲优点的文章,读者没什么好回的。

## 二、先说我们做不到的部分

**异步副作用的中间态没有建模。**

这套撤销有个前提:一次工具调用同步落库。将来 AI 写操作要是转成异步——丢进队列,中间态是「处理中」——就需要一套状态机去表达「还在处理中,现在还撤不了」。这层没有。仓库里的 AI 写目前还不是异步的,所以问题没暴露出来,但代码里也没给它留位置。

**外部补偿的终态在对方手里,我们不主动对账。**

补偿请求发出去,对方一直不回话,这个状态就长期悬着(这个状态叫什么,第五节讲)。现在的做法就是诚实地悬着,界面上写清楚「结果在目标系统」,而不是假装完成。要收敛终态得做轮询或者回调——没做。

**跨组批量是逐组独立的。**

一次批量撤销,每个补偿组各自成或败,一组失败不影响别的组。隔离是好处,代价是没有「自动清扫已成功部分」这种语义。要退,得自己再撤一遍。

**批量撤销没有分页,也没有上限。**

实现是一次性把该会话或该 run 的全部副作用捞出来,逐条处理。语义简单——一次跑完,一个汇总;代价是规模不受控。一次 run 产生几百条副作用,那就是个长请求。

(顺带说个已经放弃的方案:本来想加个「每批 500 条 + 截断标记」的上限。做了一半发现问题——截断之后重跑,返回的还是同一批前 500 条,永远跑不完。整体回退了。真要做得上光标分页,那是另一件事。)

这四条加起来其实是一件事:撤销做到什么程度,不看有没有按钮,看契约写得诚不诚实。下面几节讲设计——能撤销的要能验证,不能撤销的要明说。

## 三、最危险的不是不能撤销,是「以为撤销了」

举个例子。

AI 一次调用建了个项目,顺手拆出 10 个子任务。业务上这是一个动作,数据库里是 11 行。

撤销要是只删了项目,那 10 个子任务还挂在库里——而界面上已经写着「已撤销」。用户看到这三个字就放心了,不会再看第二眼。

**这比完全不撤销更危险。**不撤销,用户知道数据还在,会给 AI 加限制,或者自己动手清理。假撤销,用户以为已经干净了,问题就沉下去了。

批量也一样。一次 run 写了 5 条,撤的时候成 3 条败 2 条。没有逐条汇总,用户根本不知道撤干净没有。

所以撤销要做的第一件事不是「怎么撤」,是先定义范围——一次业务动作到底对应哪些数据。

## 四、撤销要分档,而且要诚实

不是所有副作用都撤得了。硬要统一处理,结果就是到处都是假成功。所以先分档:

| 档位 | 语义 | 撤销动作 | 状态落点 |

|---|---|---|---|

| `none` | 不可撤销 / 目标系统没有补偿端点 | **拒绝撤销** | 不写撤销状态 |

| `local_compensate` | 本地实体,可以软删 | 软删该实体 | 目标软删 → 可经回收站恢复;状态 `revoked` |

| `governed_external` | 写到了外部系统 | 调用对方的补偿端点 | 2xx 只证明「已请求」→ 状态 `compensating`(终态在目标系统) |

有一条容易忽略:声明可撤销,不等于可撤销。

判据应该是这样——这个工具在它声明的档位下,有没有一条可验证的撤销路径。有没有入口、入口点下去会不会真的生效、生效到什么程度,这些都得能验。

最反直觉的是 `none` 这一档。不可撤销的操作,就该明确告诉你不可撤销,而不是给你一个假的撤销按钮。

发出去的邮件、短信、已经完成的支付、物理世界里的动作——这些都撤不回来。给它们一个「撤销」入口,点下去显示成功,比根本不给入口糟糕得多。所以这类操作要么在风险分级阶段就被阻断(R5),要么老老实实标成 `none`,界面上写清楚「不可撤销 / 外部无补偿」。

## 五、外部系统:只能说「已请求」,不能说「已撤销」

数据写到了别人家系统里,撤销就得跟那套系统打交道。

约定大致是这样:签一个委托身份(audience 限定到目标系统),然后调对方提供的补偿端点:

```

KeelBase → DELETE {baseUrl}{revokePath}{resultId}

Authorization: Bearer <委托 JWT>

```

对方的补偿端点返回 2xx。这意味着什么?

只意味着「补偿请求发出去了」,不意味着「对方撤了」。

所以状态不能写 `revoked`,只能写 `compensating`——终态在目标系统手里。这一点必须诚实到底:

- 接口层:2xx → `compensating`,不冒充终态

- 界面层:写清楚「结果在目标系统」,别显示成一个已经完成的勾

产品最容易在这里粉饰。把「已请求补偿」渲染成「已撤销」,因为前者看着像没做完。但用户基于这个假状态做的后续决策,全都是错的。

## 六、一次调用写多行怎么办:补偿组

回到第三节那个「项目 + 10 个任务」。

解法是让工具自己声明它写了什么:复合写工具在返回结果里带一个副作用清单,清单里第 0 条是主体对象,也就是那个项目。

同一次工具调用产生的全部副作用,归到一个补偿组。组标识用这次调用的幂等基键——好处是重试天然落到同一组,不用另外生成 id。

有了组,规则就简单了:撤销组内任意一条,就补偿整组。

组内的本地成员放进一个数据库事务——要么全撤掉,要么一个都不动。这条有实际意义,因为「撤了一半」是这个场景下最坏的中间态:项目删了、任务还在,正是第三节那个假撤销。事务让它不可能发生。

批量撤销还有个配套要求:必须按补偿组折叠。不然一个 3 成员组会被重复补偿 3 次——第一次是真撤,后面两次在撤回滚上做文章。

另外一条边界得说清楚:没声明副作用清单的工具,系统的登记范围就等于它的声明范围。工具内部真写了 10 行、只声明 1 行,系统就只认那 1 行。这个行为是诚实的,登记范围等于声明范围,但属于工具自己的契约缺陷,不是系统的。

## 七、撤销这个动作本身也要留痕

撤销不是让东西消失,是有据可查地收回了。

补偿完成之后会写一行操作审计,`action` 标成 `COMPENSATE`,进哈希链。关键是 `targetId` 记的是根业务对象的 id——那个项目,而不是十一个副作用行里的任意一个。这样你才能按业务对象反查:这个客户、这个项目,到底是怎么被撤销的,谁撤的,什么时候撤的。

归属也顺带说一下。本人只能撤自己触发的副作用,管理员能撤任意用户的,两条路都留痕。撤销权限本身也是一条边界。

## 八、可撤销 ≠ 可恢复

这两个词老被混在一起,其实是两件事。

本地撤销走的是软删——打个删除标记,从正常视图里消失,但还能从回收站恢复。

- **撤销**:把 AI 做过的事收回

- **恢复**:对撤销这件事反悔

收回去,和把收回去的再放回来,两种语义、两个入口。而且恢复态要以服务端为准,不能让前端自己推断——否则刷新一下就飘了。

## 结语

说到底,撤销不是按钮,是契约。

一份诚实的撤销契约至少得回答四个问题:这次业务动作对应哪些数据(范围)、这个工具属于哪一档(能力)、请求发出去之后状态是什么(诚实)、撤销这个动作本身有没有留痕(可举证)。

如果你也在做 Agent,有四件事可以拿自己的系统试。一次工具调用写了多行,撤销会不会只撤掉一部分。不可逆的操作,有没有一个骗人的撤销入口。外部系统补偿,状态栏里写的是「已撤销」还是「已请求」。还有撤销这个动作本身,事后能不能查出来是谁做的。

能把「撤不干净」和「撤不回来」都诚实标出来,这套系统才算真的可以被信任。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询