并发 AI 代理如何在生产账户中发生冲突
并发 AI 代理可能在生产环境中互相覆盖变更。划定所有权边界,拒绝过时写入,谨慎使用租约,并审计每次操作。

在同一个生产账户上运行两个自主任务,并不会因为两个任务描述不同就变得安全。它们共享一个可变系统,而每个计划都可能在下一次 API 调用前变得过时。如果两个代理都能修改同一资源,就需要由服务强制执行的所有权边界。
通常的故障没有灾难性宕机那么显眼。一个代理向群组添加成员,另一个代理却根据较早读取的结果替换群组的完整成员列表。两个请求都返回成功。后一个请求移除了前一个请求添加的成员。每次运行都遵守了自己的指令,但你的 API 接受了一个无效的操作顺序。
把代理运行视为一个不受信任的并发客户端。它拥有真实凭据,但时序并不可靠。为它划定狭窄的所有权范围,让写入请求必须基于它读取的版本,并记录足够的上下文,以便日后解释某次变更为什么被接受或拒绝。人工审查依然有价值,但它不能替代能够检测过时状态的接收方。
将任务边界与写入边界分开
任务边界说明要求代理完成什么。写入边界说明它可以修改哪些可变对象。这是两件不同的事。团队把它们混为一谈时,就容易受到损害。
“更新预发布环境的部署”听起来范围很小,但它仍可能涉及共享镜像标签、发布指针、流量规则、DNS 记录、数据库迁移台账和通知频道。代理可以遵守任务措辞,同时与拥有其中某个对象的发布任务发生冲突。
用接收服务能够检查的方式定义所有权。好的边界应列出持久的资源标识符,而不是宽泛的工作类别:
- 一个环境和一条部署记录
- 一个租户或客户账户
- 一个拉取请求及其分支
- 一张事故工单,以及其变更集中列出的资源
- 一个维护窗口,以及明确列出的目标
避免使用“后端工作”或“生产环境清理”这样的边界。它们只是供人理解的标签,无法告诉 API 哪次写入应该失败。
一条有用的所有权记录应包含运行 ID、资源 ID、允许的操作和过期时间。把它保存在拥有资源的服务附近。如果部署控制器拥有发布指针,就应该由它验证谁可以推进指针。电子表格、聊天消息或提示词中的指令,都无法阻止一条在创建者下班后才到达的请求。
授予写入权限前,先绘制一张小型冲突图
对每个自动化任务列出它读取、写入、删除的资源,以及它使用的共享默认值。然后标记所有可能写入同一标识符的任务对,也标记一个任务的输入可能被另一个任务改变的情况。这不是官僚流程,而是能暴露基于角色的权限所掩盖的冲突。
例如,一个代理轮换服务令牌,另一个代理更新集成配置。它们可能从未调用同一个端点。配置写入者可能先读取当前令牌引用,然后在轮换操作改变该引用后,发布完整配置文档。冲突存在于文档版本中,而不是某条完全相同的命令中。
如果你无法描述一次运行的写入集合,就不要授予它广泛的生产写入权限。让它准备一份提案,或者先将它限制在某个资源命名空间内,直到你能够明确描述这条边界。
成功响应仍可能抹掉正确变更
当客户端发送完整表示时,最后写入获胜实际上是一项数据丢失策略。在演示中它看似无害,因为每个客户端都会立即读取并写入。代理却可能花几分钟查看日志、生成计划、请求批准,并在请求超时后重试。
考虑一个包含 notification-policy 资源的服务。它向代理 A 返回以下表示:
{
"id": "prod-alerts",
"version": 41,
"destinations": ["[email protected]"],
"severity": "high"
}
代理 A 计划添加一个备用通知目标。审查期间,代理 B 将 severity 从 high 改为 critical,并成功写入版本 42。随后,代理 A 根据版本 41 发送完整替换请求:
{
"destinations": ["[email protected]", "[email protected]"],
"severity": "high"
}
如果端点接受该请求,它就会悄悄撤销 B 的变更。两个代理都不需要存在程序错误。问题在于 API 允许旧观察结果覆盖更新后的事实。
部分更新可以缩小影响范围,但不能消除问题。添加通知目标的补丁仍可能违反新配额、更新后的路由策略,或者与读取后发生的删除操作冲突。服务必须根据当前状态判断该补丁是否仍然有效。
所以,“我们只允许代理使用 PATCH”不能算并发设计。它只是让写入内容更小。你仍然需要一个条件,把写入与代理观察到的状态关联起来。
让每个改变状态的请求都带有条件
对代理写入来说,乐观并发控制通常是第一道合适的防线。客户端读取版本、ETag、生成号或修订令牌,然后在预期更新中带回该值。只有当前值仍然匹配时,服务才接受写入。
RFC 9110 为这类请求定义了 If-Match。服务器会在应用方法前评估这个条件。如果实体标签不再匹配,服务器就以 412 Precondition Failed 拒绝该方法。这不是 API 带来的不便,而是服务器拒绝假装过时的计划仍然正确。
条件式 HTTP 更新可以这样写:
GET /v1/notification-policies/prod-alerts
HTTP/1.1 200 OK
ETag: "41"
Content-Type: application/json
{"destinations":["[email protected]"],"severity":"high"}
代理将该 ETag 带入写入请求:
PATCH /v1/notification-policies/prod-alerts
If-Match: "41"
Content-Type: application/json
Idempotency-Key: run-7f3c-add-backup
{"destinations":["[email protected]","[email protected]"]}
如果另一个写入者生成了 ETag "42",就返回清晰的拒绝结果:
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 子句中检查版本,并将受影响行数为零视为冲突:
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 错误页面,会把代理推向猜测。
测试你预期会发生的冲突
不要等生产流量来证明检查机制有效。构建一个测试,让一次运行在读取和写入之间暂停,允许第二次运行修改同一资源,然后释放第一次运行。验证四个结果:
- 第一次写入失败,且资源没有改变。
- 响应指出版本过时,而不是返回通用服务器错误。
- 代理不会自动重新发送旧请求体。
- 你的日志能够将两次尝试关联到各自的运行身份和批准记录。
再使用超时和重试运行同一测试,以证明幂等行为是独立的。这是两条不同的故障路径,也需要不同的预期结果。
同时审计尝试过的操作和最终状态
代理发生冲突后,生产账户需要两类记录:命令路径和权威资源历史。网关日志说明谁通过哪个获批会话请求了操作。服务日志说明状态是否发生变化、哪个版本获胜,以及请求为什么失败。
不要满足于只写着“PATCH 成功”的活动记录。记录资源标识符、方法、请求关联 ID、客户端发送的前置条件、幂等键或其安全引用、响应状态以及最终 ETag。如果服务保存字段级历史,就在那里记录发生变化的字段,而不是试图从代理对话记录中推断。
Sallyport 会从加密的哈希链审计日志生成 Sessions 和 Activity 日志。如果你使用它来记录代理操作,在调查存在争议的序列时运行以下检查:
sp audit verify
该命令会在密文上离线验证链条,不需要保管库密钥。它可以确认本地日志是否保持完整,但在断言事件经过之前,还应将其与目标服务的请求日志进行对照。
这里还要考虑保留期限和访问规则。代理对话记录可能包含错误的推理或复制来的运维细节,而请求日志应是一份精简的事实记录。保留重建权限和状态转换所需的证据,同时限制能够浏览这些记录的人。
将并行化放在相互独立的资源集合中
你不需要为每次自主运行建立一个全局队列。你需要一条规则,让相互独立的工作继续进行,同时明确共享变更。可以按租户、环境、代码仓库分支、服务,或其他能够由服务验证的资源命名空间进行划分。
一种实用的生产设计是由协调器为每次运行分配写入集合,并只为该集合授予凭据或网关访问权限。协调器不负责判断每次变更是否明智,而是防止两个工作进程意外获得重叠权限。接收服务仍然必须强制检查版本和不变量,因为协调器会故障,分配会漂移,人们也可能绕过正常路径启动紧急工作。
当一个操作涉及多个资源时,如果这些服务实际上无法一起执行事务,就不要急于把它称为一个原子变更。记录预期状态,按顺序写入,让后续步骤验证之前的步骤,并在执行前定义补偿方案。补偿操作同样需要检查当前状态。回滚到旧快照可能会抹掉原始运行之后发生的合法变更。
第一次生产测试应该刻意保持无聊:选择一个共享配置资源,让两个代理运行从同一版本开始,并让它们提出相互不兼容的编辑。如果服务接受了两次写入,就先修复该端点,再给任一运行更大的权限范围。发生冲突后,自主运行就没那么有趣了,这正是应该先在受控测试中强制制造冲突的原因。
常见问题
什么算是并发 AI 代理?
当多个代理的权限时间窗口重叠,并且它们都能发起会影响同一真实状态的写入时,就属于并发。不同的聊天线程、不同的机器和不同的凭据都不会改变这一点。如果一次运行能够基于另一次运行较早观察到的状态采取行动,就应该将它们视为并发运行。
如果两个代理处理不同的代码仓库,它们也会发生冲突吗?
可以。生产账户通常包含共享默认值、配额、IAM 绑定、DNS 名称、账单设置和部署指针,这些内容会让看似无关的任务发生交集。按资源划分所有权,比假设不同项目就意味着影响范围彼此隔离更安全。
人工批准足以防止代理冲突吗?
不够。批准只能证明某人在某个时间允许了一个请求,不能证明另一个写入者改变状态后,这个请求仍然合理。接收请求的服务必须通过版本、前置条件、租约或等效控制来拒绝过时写入。
什么时候应该使用幂等键,什么时候应该检查版本?
当风险是重试、超时或重复投递导致同一请求被多次执行时,使用幂等键。当风险是一个看似有效的更新建立在旧表示之上时,使用版本前置条件。成熟的写入 API 通常需要两者。
分布式锁能解决并发代理写入吗?
只有在每个写入者都遵守分布式锁,并且锁的过期、所有权和故障行为都定义清楚时,分布式锁才有帮助。它无法修复接受过时更新的服务端点。应先采用服务端前置条件;如果确实需要,再为长时间运行的独占任务增加短租约。
每个 AI 代理都应该拥有自己的生产凭据吗?
即使多个代理最终代表同一个团队行动,也应为每次自主运行提供独立身份和权限集。共享管理员凭据会抹去归因信息,也会让撤销权限变得一刀切。代理身份应同时出现在操作网关和目标服务的日志中。
紧急生产变更应该怎么处理?
紧急运行仍然需要相同的服务端冲突保护,因为紧迫性不会让过时状态变得正确。为操作员提供有文档记录的紧急授权路径,限制其范围和有效期,并在事后加强审查。不要因为代理曾经需要快速操作,就建立永久绕过机制。
代理操作的审计轨迹应该记录什么?
写入日志必须记录目标资源、操作者身份、请求标识符、先前版本、请求的状态转换、结果以及服务返回的版本。只写着“代理更新了生产环境”的记录过于模糊,无法调查冲突。重试时也要保持关联 ID 不变。
自主代理可以安全地并行部署吗?
只有当每项变更针对互不重叠的资源,并且服务能够验证这一边界时,自动代理才能安全地并行部署。例如,彼此独立的拉取请求可以并行运行,但两个修改同一环境发布指针的任务应当串行执行。并行工作很有价值,但让多个任务同时拥有一个可变对象的写入权限通常是不负责任的。
如何验证 Sallyport 审计日志?
sp audit verify 会在不需要保管库密钥的情况下,检查 Sallyport 加密哈希链审计日志的完整性。它可以显示本地操作记录是否遭到篡改,但不能替代目标服务自己的请求日志和资源日志。调查存在争议的写入时,应对照这两类记录。