# 并发 AI 代理如何在生产账户中发生冲突

在同一个生产账户上运行两个自主任务，并不会因为两个任务描述不同就变得安全。它们共享一个可变系统，而每个计划都可能在下一次 API 调用前变得过时。如果两个代理都能修改同一资源，就需要由服务强制执行的所有权边界。

通常的故障没有灾难性宕机那么显眼。一个代理向群组添加成员，另一个代理却根据较早读取的结果替换群组的完整成员列表。两个请求都返回成功。后一个请求移除了前一个请求添加的成员。每次运行都遵守了自己的指令，但你的 API 接受了一个无效的操作顺序。

把代理运行视为一个不受信任的并发客户端。它拥有真实凭据，但时序并不可靠。为它划定狭窄的所有权范围，让写入请求必须基于它读取的版本，并记录足够的上下文，以便日后解释某次变更为什么被接受或拒绝。人工审查依然有价值，但它不能替代能够检测过时状态的接收方。

## 将任务边界与写入边界分开

任务边界说明要求代理完成什么。写入边界说明它可以修改哪些可变对象。这是两件不同的事。团队把它们混为一谈时，就容易受到损害。

“更新预发布环境的部署”听起来范围很小，但它仍可能涉及共享镜像标签、发布指针、流量规则、DNS 记录、数据库迁移台账和通知频道。代理可以遵守任务措辞，同时与拥有其中某个对象的发布任务发生冲突。

用接收服务能够检查的方式定义所有权。好的边界应列出持久的资源标识符，而不是宽泛的工作类别：

- 一个环境和一条部署记录
- 一个租户或客户账户
- 一个拉取请求及其分支
- 一张事故工单，以及其变更集中列出的资源
- 一个维护窗口，以及明确列出的目标

避免使用“后端工作”或“生产环境清理”这样的边界。它们只是供人理解的标签，无法告诉 API 哪次写入应该失败。

一条有用的所有权记录应包含运行 ID、资源 ID、允许的操作和过期时间。把它保存在拥有资源的服务附近。如果部署控制器拥有发布指针，就应该由它验证谁可以推进指针。电子表格、聊天消息或提示词中的指令，都无法阻止一条在创建者下班后才到达的请求。

### 授予写入权限前，先绘制一张小型冲突图

对每个自动化任务列出它读取、写入、删除的资源，以及它使用的共享默认值。然后标记所有可能写入同一标识符的任务对，也标记一个任务的输入可能被另一个任务改变的情况。这不是官僚流程，而是能暴露基于角色的权限所掩盖的冲突。

例如，一个代理轮换服务令牌，另一个代理更新集成配置。它们可能从未调用同一个端点。配置写入者可能先读取当前令牌引用，然后在轮换操作改变该引用后，发布完整配置文档。冲突存在于文档版本中，而不是某条完全相同的命令中。

如果你无法描述一次运行的写入集合，就不要授予它广泛的生产写入权限。让它准备一份提案，或者先将它限制在某个资源命名空间内，直到你能够明确描述这条边界。

## 成功响应仍可能抹掉正确变更

当客户端发送完整表示时，最后写入获胜实际上是一项数据丢失策略。在演示中它看似无害，因为每个客户端都会立即读取并写入。代理却可能花几分钟查看日志、生成计划、请求批准，并在请求超时后重试。

考虑一个包含 `notification-policy` 资源的服务。它向代理 A 返回以下表示：

```json
{
  "id": "prod-alerts",
  "version": 41,
  "destinations": ["oncall@example.test"],
  "severity": "high"
}
```

代理 A 计划添加一个备用通知目标。审查期间，代理 B 将 `severity` 从 `high` 改为 `critical`，并成功写入版本 42。随后，代理 A 根据版本 41 发送完整替换请求：

```json
{
  "destinations": ["oncall@example.test", "backup@example.test"],
  "severity": "high"
}
```

如果端点接受该请求，它就会悄悄撤销 B 的变更。两个代理都不需要存在程序错误。问题在于 API 允许旧观察结果覆盖更新后的事实。

部分更新可以缩小影响范围，但不能消除问题。添加通知目标的补丁仍可能违反新配额、更新后的路由策略，或者与读取后发生的删除操作冲突。服务必须根据当前状态判断该补丁是否仍然有效。

所以，“我们只允许代理使用 PATCH”不能算并发设计。它只是让写入内容更小。你仍然需要一个条件，把写入与代理观察到的状态关联起来。

## 让每个改变状态的请求都带有条件

对代理写入来说，乐观并发控制通常是第一道合适的防线。客户端读取版本、ETag、生成号或修订令牌，然后在预期更新中带回该值。只有当前值仍然匹配时，服务才接受写入。

RFC 9110 为这类请求定义了 `If-Match`。服务器会在应用方法前评估这个条件。如果实体标签不再匹配，服务器就以 `412 Precondition Failed` 拒绝该方法。这不是 API 带来的不便，而是服务器拒绝假装过时的计划仍然正确。

条件式 HTTP 更新可以这样写：

```http
GET /v1/notification-policies/prod-alerts

HTTP/1.1 200 OK
ETag: "41"
Content-Type: application/json

{"destinations":["oncall@example.test"],"severity":"high"}
```

代理将该 ETag 带入写入请求：

```http
PATCH /v1/notification-policies/prod-alerts
If-Match: "41"
Content-Type: application/json
Idempotency-Key: run-7f3c-add-backup

{"destinations":["oncall@example.test","backup@example.test"]}
```

如果另一个写入者生成了 ETag `"42"`，就返回清晰的拒绝结果：

```http
HTTP/1.1 412 Precondition Failed
Content-Type: application/json

{
  "error": "stale_version",
  "resource": "notification-policy/prod-alerts",
  "expected_etag": "41",
  "current_etag": "42",
  "retryable": false
}
```

如果代理可以不做判断就重新发送相同请求体，不要把这个错误标记为可重试。重试必须从重新读取开始，并作出新的决定。代理可能发现目标结果已经存在，最新策略已经使该变更无效，或者需要由人来选择两个相互竞争的结果。

对于数据库，应在变更语句本身使用等效条件。典型更新会在 `WHERE` 子句中检查版本，并将受影响行数为零视为冲突：

```sql
UPDATE notification_policy
SET destinations = :destinations,
    version = version + 1
WHERE id = :id
  AND version = :observed_version;
```

不要先读取版本，再在第二个操作中发起无条件更新。检查和状态变更必须在存储该状态的权威服务内同时完成。

## 幂等性可以阻止重复，但不能解决分歧

团队常常在端点上添加幂等键，然后宣布并发写入问题已经解决。幂等键能防止同一个逻辑请求重复产生效果，却不能告诉服务两个不同请求是否兼容。

网络超时能清楚说明两者的区别。代理发送创建部署的请求，却丢失了响应。使用同一个幂等键重试时，应返回原始结果，而不是创建第二个部署。这是抑制重复。

再看两个代理，它们分别为同一个生产环境选择了不同的候选版本。它们发送不同的请求体和不同的幂等键。两个请求都可以完全幂等，但其中一个仍然应该因为发布指针已经改变而失败。

重要的写入端点应同时使用两种控制：

- 幂等键将重试和重复投递绑定到一次已完成的操作。
- 版本前置条件会拒绝基于过时资源状态作出决定的写入。
- 服务端不变量会检查即使当前写入也必须遵守的规则，例如活动凭据数量上限。

将幂等键与请求指纹和已完成响应一起保存。如果调用方使用同一个键提交不同请求体，应拒绝该请求。对不同操作返回第一次响应，会制造调试混乱，也可能掩盖客户端错误。

严格处理过期时间。服务应保留幂等键足够长的时间，以覆盖实际重试行为，但幂等存储不是永久的命令历史。历史应保存在审计日志中。

## 只为不能重叠的工作使用租约

有些操作持续时间很长，仅靠乐观检查会让用户体验变差。数据库迁移、破坏性对账任务或切换操作都可能涉及许多相互依赖的写入。在这些情况下，可以为一次运行提供资源的短期租约。

租约必须包含所有者、过期时间和围栏值。围栏值很重要，因为租约过期的工作进程可能在另一个进程获得新租约后醒来并继续运行。每次受保护的写入都必须携带租约的单调递增令牌，服务必须拒绝比最近接受的令牌更旧的令牌。

没有围栏机制时，锁服务可以告诉代理 A 它的租约已过期，却无法阻止 A 的延迟请求抵达数据库。目标服务必须拒绝这条请求。这正是团队声称拥有分布式锁时经常遗漏的部分。

让租约范围小、有效期短。不要为一次完整的自主调查锁定“生产环境”。锁定 `migration/customer-1842` 或 `release/prod-eu`，并且只在运行仍持续推进时续租。如果代理进程、它所在的电脑或网络消失，租约应该能够安全过期。

对于支持版本检查的普通配置编辑，不要使用租约。长时间锁定会把日常变更变成排队任务，最终人们会学会绕过锁。收到 `412` 后重新读取并制定计划，比因过时的锁持有者引发故障更便宜。

## 代理身份必须穿过网关保留下来

共享生产令牌会让服务端看到所有运行都使用同一个名称。发生冲突后，你只能看到该令牌执行了操作，却无法知道哪个进程制定了变更、哪个批准覆盖了它，或应该停止哪次运行。这会让清理过程变慢，也会让撤销权限变得过于宽泛。

为每个代理进程提供独立的会话身份，然后将稳定的关联标识符传递给每个目标请求。目标服务应记录身份、运行 ID、请求 ID、目标资源、观察到的版本、结果以及它自己生成的版本。不要把这些信息埋在提交消息的文字说明中。

Sallyport 会在代理执行操作时，将 API 和 SSH 凭据留在代理进程之外的加密保管库中，这有助于在代理的规划上下文和秘密本身之间保持边界。它的会话级授权可以在新启动的代理进程开始运行之前识别该进程。这种授权用于控制谁可以行动，但不能替代目标服务端的前置条件。

不要让代理在任意请求头中自行选择有效身份。应由网关或目标服务根据经过身份验证的会话绑定身份。否则，运行结束后代理可以声称自己是部署协调器，你的日志就只剩下表演。

SSH 也遵循相同原则，尽管其传输协议不同。为不同工作类别使用独立主体或受限账户。让远程命令日志包含运行标识符，并避免使用一个可以编辑所有应用目录的共享 shell 账户。

## 批准时间不等于事务时间

某人可以批准代理的请求，但十秒后另一个写入者改变状态，这次批准的写入仍然可能变得错误。这是并发系统中的正常情况。批准说明审查时刻的权限和意图，却不会冻结资源。

危险的设计是让人批准“更新生产配置”这样宽泛的一句话，然后任由代理在之后的某个时间执行一系列读写。更安全的设计会展示目标和预期效果，再由服务在写入到达时强制检查版本或租约。

如果批准后前置条件失效，不要自动将这次批准用于变化后的计划。代理应具体报告冲突：哪个资源发生了变化、它观察到哪个版本、哪个字段发生了变化（如果服务能够确定），以及它提出的结果是否仍然需要。随后，人可以批准新操作，或者代理重新读取后执行安全的无操作。

对于每次使用都存在实质风险的操作，例如删除生产凭据或改变对外可见的路由规则，适合逐次调用批准。对于一批普通的、范围狭窄且带条件的写入，按运行批准通常更有用，因为操作员可以检查运行身份和范围，同时不会形成条件反射式的点击习惯。

不要把一堆批准误认为控制。如果操作员看不到资源 ID、操作和当前冲突结果，他们批准的只是一个句子，而真正的工作在别处由服务完成。

## 为冲突响应定义负责人

被拒绝的过时写入是一次成功的安全结果，但前提是运行知道接下来该做什么。“遇到错误就重试”是错误的默认策略，它会把分歧变成自动竞赛。

允许自主执行前，先为每条写入路径分类。类别决定由谁解决冲突：

| 变更类型 | 版本冲突时 | 负责人 |
| --- | --- | --- |
| 添加名称唯一且相互独立的资源 | 重新读取，如果名称仍未使用则重试 | 代理 |
| 根据当前源数据更新计算字段 | 重新读取、重新计算，然后重试 | 代理 |
| 推进共享发布指针 | 停止并展示两个候选版本 | 发布负责人 |
| 修改访问成员或权限 | 停止并请求审查 | 账户所有者 |
| 删除或替换共享配置文档 | 除非有明确租约覆盖，否则停止 | 指定操作员 |

重点不是让代理变得畏缩，而是区分重新计算和判断。代理可以安全地根据当前输入重试生成报告，但不应仅仅因为看到 `412`，就自行选择两个已批准的生产版本、两项访问决策或两种不同的回滚方案。

让冲突响应可供机器读取。包含资源身份、当前版本、冲突类别，以及端点是否允许重新自动尝试。一个含糊的 `409` 和 HTML 错误页面，会把代理推向猜测。

### 测试你预期会发生的冲突

不要等生产流量来证明检查机制有效。构建一个测试，让一次运行在读取和写入之间暂停，允许第二次运行修改同一资源，然后释放第一次运行。验证四个结果：

1. 第一次写入失败，且资源没有改变。
2. 响应指出版本过时，而不是返回通用服务器错误。
3. 代理不会自动重新发送旧请求体。
4. 你的日志能够将两次尝试关联到各自的运行身份和批准记录。

再使用超时和重试运行同一测试，以证明幂等行为是独立的。这是两条不同的故障路径，也需要不同的预期结果。

## 同时审计尝试过的操作和最终状态

代理发生冲突后，生产账户需要两类记录：命令路径和权威资源历史。网关日志说明谁通过哪个获批会话请求了操作。服务日志说明状态是否发生变化、哪个版本获胜，以及请求为什么失败。

不要满足于只写着“PATCH 成功”的活动记录。记录资源标识符、方法、请求关联 ID、客户端发送的前置条件、幂等键或其安全引用、响应状态以及最终 ETag。如果服务保存字段级历史，就在那里记录发生变化的字段，而不是试图从代理对话记录中推断。

Sallyport 会从加密的哈希链审计日志生成 Sessions 和 Activity 日志。如果你使用它来记录代理操作，在调查存在争议的序列时运行以下检查：

```sh
sp audit verify
```

该命令会在密文上离线验证链条，不需要保管库密钥。它可以确认本地日志是否保持完整，但在断言事件经过之前，还应将其与目标服务的请求日志进行对照。

这里还要考虑保留期限和访问规则。代理对话记录可能包含错误的推理或复制来的运维细节，而请求日志应是一份精简的事实记录。保留重建权限和状态转换所需的证据，同时限制能够浏览这些记录的人。

## 将并行化放在相互独立的资源集合中

你不需要为每次自主运行建立一个全局队列。你需要一条规则，让相互独立的工作继续进行，同时明确共享变更。可以按租户、环境、代码仓库分支、服务，或其他能够由服务验证的资源命名空间进行划分。

一种实用的生产设计是由协调器为每次运行分配写入集合，并只为该集合授予凭据或网关访问权限。协调器不负责判断每次变更是否明智，而是防止两个工作进程意外获得重叠权限。接收服务仍然必须强制检查版本和不变量，因为协调器会故障，分配会漂移，人们也可能绕过正常路径启动紧急工作。

当一个操作涉及多个资源时，如果这些服务实际上无法一起执行事务，就不要急于把它称为一个原子变更。记录预期状态，按顺序写入，让后续步骤验证之前的步骤，并在执行前定义补偿方案。补偿操作同样需要检查当前状态。回滚到旧快照可能会抹掉原始运行之后发生的合法变更。

第一次生产测试应该刻意保持无聊：选择一个共享配置资源，让两个代理运行从同一版本开始，并让它们提出相互不兼容的编辑。如果服务接受了两次写入，就先修复该端点，再给任一运行更大的权限范围。发生冲突后，自主运行就没那么有趣了，这正是应该先在受控测试中强制制造冲突的原因。
