跳到主要内容

双写竞态:当你的智能体与用户同时编辑同一个日历事件时

· 阅读需 14 分钟
Tian Pan
Software Engineer

智能体自信地报告:“我已将会议改至周四下午 3 点。”用户却盯着原本周二上午 10 点的时段发呆,因为在智能体制订计划到提交更改的这段时间内,用户自己编辑了该事件。“最后写入者胜”(Last-write-wins)策略让自动化的操作覆盖了人类的修改,而用户对助手的信任也因这一次事故而崩塌。这就是双写竞争(dual-writer race),也是智能体工具链从未专门设计应对的 bug 类别。

大多数智能体平台都无意中继承了这一问题。工具层将 update_event 视为一个简单的函数调用:获取 ID,获取新字段,返回成功。底层的提供商 API 十多年来一直提供乐观并发原语(optimistic concurrency primitives)——ETags、版本令牌(version tokens)、If-Match 前提条件——但几乎没有人将它们贯通。模型无法知道它一分钟前所推理的世界已不再是现状,因为由于它所获得的抽象层静默地丢弃了这些信息。

只要智能体触及共享状态,这种模式就会随处可见:日历、CRM 记录、Jira 工单、共享文档、项目追踪器、工单队列。用户在智能体读取后的几秒钟内打开了一条记录,双方都进行了编辑,智能体最后写入,人类的更改随之消失。智能体报告成功。控制面板显示成功。用户却不认可,而且 Prompt 模板中没有任何道歉信息能修补这种损害。

为什么工具层丢弃了版本令牌

让我们看看生产环境下一个典型的“重新安排会议”工具是如何运作的。智能体调用 get_event(event_id),SDK 返回一个包含 summarystartendattendeeslocation 的已解析对象。智能体根据这些字段进行推理,选择一个新时间,然后调用 update_event(event_id, {start, end})。封装层对提供商执行 PATCH 请求。完成。

注意缺失了什么。提供商的响应中携带了一个 etag 字段——Google Calendar 在每个事件资源上都公开了一个,Microsoft Graph 在每个事件上公开了 @odata.etag。这些令牌标识了 智能体当时看到的版本。SDK 要么在反序列化过程中将其丢弃,要么保留了它但工具封装层从未读取,抑或是读取了但在 PATCH 请求中从未设置 If-Match。当调用离开你的网关时,请求的内容实际上是“无论该事件处于什么状态,都将其设置为这些字段”。“最后写入者胜”并不是智能体选择的默认行为,而是工具层内置的默认行为。

协议层面的修复是机械性的。Google Calendar 接受 If-Match: <etag>,并在资源自读取以来发生变化时返回 412 Precondition Failed。Microsoft Graph 要求对多种资源类型的 PATCH/DELETE 请求使用 If-Match,并在 ETag 过时时返回 409 Conflict——Planner 严格执行此规则,最近一篇关于将 AI 智能体连接到 Microsoft Planner 的实践指南明确指出:“永远在更新前立即获取,绝不要缓存 ETag。”Jira 的状态转换 API 出于同样的原因将其并发转换响应从 400 切换为 409 Conflict:409 是标准的信号,表示“你的写入在竞争中失败了”。

版本令牌是提供商免费提供给你的契约。工具层的任务就是确保它在端到端之间保持完整。

三层修复:带版本读取、带前提写入、将冲突视为结果

一个双写安全的工具需要三者协同工作,跳过其中任何一个都会在无意中重新引入竞争风险。

带版本的读取(Read-with-version)。 每一个读取工具都应返回对象及其版本令牌,并且智能体的工况内存应保持它们的配对。如果你现在的工具 schema 返回的是 {event: {...}},请将其改为返回 {event: {...}, version: "abcd1234"},并将 version 字段标注为“对模型不透明但在写入时必需”。智能体不需要理解这个令牌,它只需要将其传回即可。

带前提条件的写入(Write-with-precondition)。 每一个写入工具都应接受一个版本令牌,并将其作为 If-Match 转发给提供商(或提供商对应的机制——Microsoft Graph SDK 中的 IfMatch 参数、Google API 中的 If-Match 请求头、Jira 问题中的 version 负载字段、或者你在 Salesforce 上通过之前的 SELECT FOR UPDATE 存储的 _token)。如果智能体在调用写入时没有带版本号,工具应拒绝执行——“智能体忘记传递令牌”这一故障模式应该是显式的报错,而不是静默失败。

将冲突视为一种结果(Conflict-as-outcome)。 当前提条件校验失败时,工具不应进行重试。它应向规划器返回一个结构化的结果:“冲突:事件在你的读取和写入之间已被修改;新版本为 X。”这个结果必须是模型可以推理的一等结果,而不是隐藏的重试逻辑(即重新读取、重新应用并再次写入)。隐藏的重试是最糟糕的设计选择,因为它将“人类的更改很重要”变成了“人类的更改悄无声息地消失”——这正是版本令牌最初想要防止的失败情况。

第三层之所以重要,是因为如果只告诉智能体“写入失败,请重试”,它会很自然地重新读取,重新应用同样的意图,并再次覆盖人类的修改。智能体必须知道 世界已经发生了变化,这样规划器才能决定最初的意图是否仍然合理。一个用户刚刚删除了底层事件的会议改期请求,不再是改期操作,而是一个全新的决策。

当你无法简单重试时,冲突解决是什么样的

假设智能体(agent)计划将一个 30 分钟的周会从周二上午 10 点移至周四下午 3 点。在读取和写入之间,用户接受了来自他人邀请的同一日程的不同时间,现在该日程变成了周二上午 11 点。这时触发了 412 错误。智能体该怎么办?

存在三种可靠的路径,而选择哪一条取决于工具与用户之间的契约。

第一种是基于最新状态重新规划。冲突结果包含了该日程的新版本。规划器重新读取日程,看到用户的更改,并询问模型在新的初始状态下,原始意图(移至周四下午 3 点)是否仍然适用。通常情况下是适用的——用户更改了时间,但移至周四的目标没变。有时则不适用——用户已经亲自移动了会议,智能体现在的工作是什么都不做。

第二种是向用户呈现冲突。工具返回给规划器,规划器生成一条消息:“我正准备把你周二上午 10 点的周会移到周四下午 3 点,但我发现你刚刚把它改到了周二上午 11 点——我应该继续移动它,还是保留在你设置的位置?”当智能体重新规划的置信度较低,或者用户界面有空间进行澄清交互时,这是正确的路径。这条路径能建立信任,因为承认冲突证明了智能体察觉到了它。

第三种是中止并解释。智能体报告称由于底层记录已更改,无法完成操作,然后停止。这适用于一次性写入流程(如发送邀请、将工单移过单向门槛),在这些流程中重新规划毫无意义,而呈现冲突会带来比价值更多的困惑。

起作用的是工具层的静默重试策略,即捕获 412/409 错误并悄悄重新运行。这种模式继承自幂等 Web 请求,在那类请求中,调用期间世界并未发生变化——但在双写(dual-writer)场景下,前提条件失效是因为世界发生了变化,带着陈旧的意图重新运行是错误(bug),而不是修复。

另一半:在尝试写入之前获知状态已更改

乐观并发在写入时捕获冲突。对于另一种失败模式来说,这太晚了:智能体在多次工具调用之间进行了 30 秒的推理,等到它提交时,用户已经完成了它准备做的事情。PATCH 请求成功了——版本令牌仍然匹配,因为用户移动的是一个不同的日程——但智能体的整个计划是建立在一个已不存在的世界之上的。

这里的防御手段是变更订阅(change-feed subscription)。Google Calendar 提供推送通知,当受监控的日历发生变化时,API 会通过带有 X-Goog-Resource-State 请求头的 Webhook 进行通知。Microsoft Graph 提供增量查询,仅返回自同步令牌以来发生的变化。智能体的会话在多轮任务开始时订阅它所接触的资源;当智能体已读取的记录触发通知时,运行时环境会使工作内存中的该记录失效,并向规划器呈现“内容在你操作期间已更改”。

在实践中,这更多是一个会话状态问题,而非 API 问题。智能体的工作内存需要一个“实时读取集(live read set)”——包含它加载的每个资源的 ID 和版本——以及一个在其中任何资源失效时触发的钩子。目前的智能体框架大多没有这种概念;它们将每次工具调用视为独立的,并假设世界在调用之间是静止的。添加实时读取集是一项乏味的底层工作,但它能让智能体在计划过时时发出响亮的告警,而不是自信地执行错误计划。

同一思路的一个较弱但成本更低的方案是在任何依赖超过 30 秒推理的写入之前进行“预读取检查”。如果规划器即将提交,且读取发生在很久以前,则先进行另一次读取,与已加载的内容进行对比,如果差异较大,则退出并重新规划。这可以在不需要 Webhook 的情况下捕获推理缓慢的情况。

评估(Eval)必须注入冲突,而不是等待冲突

这类错误之所以能一直留到生产环境,是因为评估(eval)从未产生过它。测试固件环境在构建上就是单写者的:评估工具创建测试日程,智能体读取它,智能体写入它,断言检查最终状态。在运行过程中没有人类编辑日程,因为评估中没有人类。智能体通过了测试。在生产环境中的表现也是一样的,直到有一天,一个真实用户在错误的时刻触碰了一条真实的记录。

对抗性并发必须成为一类评估场景。测试工具至少需要三种测试形式:

  1. 工具调用中途编辑。 在智能体读取和写入之间,测试工具修改资源。通过条件:智能体要么正确地重新规划,要么呈现冲突——绝不能在不承认更改的情况下报告成功。
  2. 陈旧计划应对新状态。 在智能体最后一次读取之后但在提交之前,测试工具以某种方式修改资源,使得计划的操作变得错误。通过条件:智能体通过变更订阅或提交前预读取检测到更改。
  3. 已完成的操作。 测试工具执行智能体正准备执行的操作(用户已经移动了会议;用户已经关闭了工单)。通过条件:智能体识别出当前状态并什么都不做,而不是重复操作。

最近关于多用户与 AI 智能体协作文档编辑的学术研究在文档场景中提出了同样形式的问题:一旦你假设存在多个写者,评估集就必须对其他写者进行建模,否则你交付的系统将假设它是唯一的。

为什么 CRDT 对这类 Bug 而言是分散注意力

当涉及到并发(concurrency)时,人们往往会谈论 CRDT 和操作转换(OT)。它们是解决“字符级实时协同编辑”——如 Google Docs 或 Figma 这类问题——的正确工具。在这些场景下,两名作者同时在一个文档中输入,系统必须以亚秒级的粒度合并意图。Figma 曾投入六个月的研发时间将 OT 迁移到 CRDT,以获得共享画布状态的收敛性保证。

几乎没有任何 Agent 双写场景是这样的。Agent 编辑的是结构化记录——事件、Issue、联系人、交易——其时间跨度通常在秒到分钟级别,且所对接的 API 通常已经暴露了 ETag 和版本令牌(version tokens)。在这种情况下,采用 CRDT 是在对一个协议已经解决的问题进行过度设计。修复的方法是将现有的原语(primitives)贯通到工具层,而不是向一个已经拥有完善中心化节点的系统引入分布式数据结构。请将 CRDT 类的方案留给 Agent 和用户真的在同时编辑同一个段落的时刻。

导致这一问题发布的组织性失败

这类 Bug 普遍存在的原因是组织架构层面的,而非技术层面的。构建 Agent 工具层的团队只追求“调用成功”。负责日历供应商集成的团队将 SDK 视为黑盒。负责评估(evals)的团队只测试理想路径(happy path)。处理事故的团队与上述任何团队在描述“Agent 的写入在技术上是成功的,但在语义上是错误的,因为它覆盖了人类的操作”时,都缺乏通用的词汇。

将“双写安全”(dual-writer-safe)列入平台团队的路线图才是正解。这需要三个交付物:一个要求在读取时提供版本令牌并在写入时接受它们的工具 Schema 标准;一个在规划器(planner)中将“冲突”视为一种结果的约定,以便模型能够对 412/409 状态码进行推理;以及一个内置了对抗性并发测试的评估框架。这些都不是科研难题。它们是在 REST API 设计领域已经解决了 15 年的基础设施问题,正等待着有人将它们引入 Agent 边界。

那些能赢得持久信任的 Agent,并不是推理能力最强的,而是那些在外部世界发生变化时,其工具调用能明确且准确地报错,并且在用户交互界面承认冲突而不是掩盖冲突的 Agent。“我正准备移动你的会议,但我发现你已经改好了”是一句让助理显得鲜活的话。而“我已经重新安排了会议”——实际上日历根本没变——则是让 Agent 显得彻底坏掉的话。

References:Let's stay in touch and Follow me for more thoughts and updates