
一个 AI 请求写数据要等人点确认。这件事里有两个「过期」而它们不是一回事- **对话里还要不要继续等下去** —— 等不到就让对话往下走- **这一行还能不能被裁决** —— 不能就是不能点了也不该执行。我们曾经用同一个超时回答这两个问题。结果是**列表上显示「等你处理」点下去必然失败。**这篇文章讲两件事**把两个问题拆成两个窗口每个窗口只有一个函数算边界以及裁决只能有一个仲裁点。**## 一、它们为什么会被当成一个问题因为两者都长成「这个确认多久之后过期」而人一看到同一个时间值就会想用一个字段存它。但它们**回答的是两个不同的问句**而且**消费者也不同**| 问句 | 消费者 | 答错的后果 ||---|---|---|| 对话**还要不要再等** | 前端那一次等待 | 多等一下或者早一点让对话继续 || 这一行**还能不能被裁决** | 数据层与所有裁决入口 | **点了也不执行**或者**早该失效却还能点** |第一问答错的代价是**体验**。第二问答错的代价是**一致性** —— 而两者共用一个值时**第二个问题的答案会被第一个问题的需要牵着走**。## 二、现场两处各算一遍靠一句注释维持这是我们真实遇到过的一次。离线窗口的边界当时**在两处各算了一遍**读取路径在**内存里**比较创建时间维护清扫在 **SQL 里**比较。两处之所以被认为一致靠的是**代码里的一句注释**大意是「这里的判据与清扫那边一致」。**注释不是判据。** 两处的边界只要有一天不一致就会出现这样一种行 列表显示「等你处理」而清扫任务已经把它判成超时。⇒ **用户点下去必然失败。** 而且这个失败**不是偶发的****是列表自己制造出来的** —— 它把一个已经死掉的行呈现成一个待办。**这类失败最难查的地方在于它看起来像「偶发点击失败」而根因在「两个地方对同一个时刻的定义不同」。**## 三、补法一拆成两个窗口每个窗口只有一个函数算边界补法有两半。**第一半承认它们是两个窗口。**- **对话内等待**是一个短窗口我们的默认是 60 秒—— 到期只**结束这次等待**让对话继续往下走**不改数据**- **离线待办**是一个长窗口默认 24 小时—— 行在这个窗口内保持「待裁决」**到期才转成超时**。**第二半每个窗口的边界全仓只有一个函数算。**读取路径和清扫任务**调同一个函数**拿到同一个时刻。于是「列表显示可裁决」与「已被判死」这两件事在结构上**不可能同时成立**。这一步的关键不是「算得对」是**「只有一处算」**。**只要有两处你就只剩一句注释在保它。**## 四、补法二裁决只能有一个仲裁点拆成两个窗口之后**裁决入口就有两条**对话里点和之后在待办里点。两条可能并发。所以第二件事是**让它们抢同一个东西**。我们的做法是**数据库的条件更新** —— 更新条件里带上「当前状态必须是待裁决」。于是- **更新影响到 1 行** ⇒ 这次裁决成立**由它执行**- **影响到 0 行** ⇒ 已经有人裁决过了**这次绝不执行**。**为什么不能先读再判断** 因为「读」和「写」之间有一个窗口两条裁决都能读到「还是待裁决」。**这就是把判断放在了动作之前、却没有把仲裁放在动作之前。** 条件更新是原子的**谁先改到那一行谁就拥有这次裁决。****两条入口、一个仲裁点** —— 这是本节的全部内容。## 五、现场二一次「不知道」被当成了「同意」还有一处是我们自己的。裁决成功要落库。**如果这次落库抛错了呢** 旧的做法是**吞掉异常照样把内存里的等待放行**。后果是这样一条链1. 数据库抖了一下落库失败2. 但内存里的等待被放行了**工具被执行了**3. 而那一行**仍然是待裁决**4. 下一次裁决对话里或者待办里看到「还在等」**再执行一次**。**一次「我们不知道决策有没有落库」被当成了一次「同意」。**补法很直白**拿不到「决策已经落库」的证据就不放行。** 宁可这次失败、让用户重试也不要把「不确定」翻译成「执行」。**⚠ 这一条要说得再准一点**外部工具**没有幂等键**时第二次执行就是**一次真的重放**目标那边会真的收到两次。**所以在这种链路上fail-closed 不是保守是必需。**## 六、一条这条设计划不到的边界**仲裁点保证的是「至多一次裁决」。**它**不保证**「工具只被执行一次」 —— 如果同一个请求本身被**重发**模型重试、进程重启那是**另一个问题**得在另一层解决。**这两件事要分开**一个是「同一次决策不能被裁决两次」一个是「同一次调用不能被发两次」。**把它们混成一句「我们保证只执行一次」就是过度声称。**这一条我们写在结果消息与文档里而不是留给读者去猜。## 七、一个你可以自己做的检验四问1. **你的「过期」在回答几个问题** 如果一个超时同时决定了「界面还等不等」和「这行还能不能用」那里就是一个共用的值。2. **同一个时间边界有几处实现** 两处就是一处注释的距离。3. **并发裁决靠什么仲裁** 内存里的判断不算仲裁**数据库的条件更新或任何原子的比较并交换才算**。4. **裁决失败时的默认是什么** 如果「落库失败」的下一步是「照样执行」那它默认是放行。## 结语回到开头**一个超时回答不了两个问题。**我们做的是**拆成两个窗口**60 秒的对话内等待 / 24 小时的离线待办、**每个窗口的边界只有一个函数算**、**所有裁决抢同一个条件更新**、**拿不到「已落库」就不放行**。**下面这套取自开源项目 KeelBase 的实现** —— 一个夹在 AI Agent 与企业业务系统之间的运行时在 Tool 真正执行前重新检查身份、授权、范围、风险与确认状态。仓库境内访问建议用 Gitee 镜像- GitHubhttps://github.com/rain6fish/KeelBase- Giteehttps://gitee.com/rain6fish/KeelBase照实交代两处。一是上面那条边界**我们保证的是至多一次裁决不是至多一次执行**。二是**这套东西的前提是「裁决能落库」** —— 如果数据库不可用我们选择**拒绝**而不是放行对某些场景这可能意味着确认功能整体不可用而**那是有意的****确认的意义就是「没有被确认过就不能执行」那么在无法确认任何事的时候正确的结果是不能执行。**