预约 Agent 如何避免并发重复订位
文章将查询空闲时段与持久化预约明确区分,要求预约系统在统一冲突规则下成功提交后,语音 Agent 才能确认。讨论覆盖并发请求、重复工具调用、响应丢失,以及状态模型、数据库约束和审计凭据。
AI 深度解读
AI 语音助手查到房源有空,向来电者确认时间,日历里也出现了预约,看起来流程已经跑通。但如果两通电话同时选择同一时段,或者写入成功后网络超时,两个人都可能听到「预约成功」。
作者讨论的就是这段容易被演示掩盖的风险:只有负责预约的系统按冲突规则持久化接受了请求,AI 才能说「已确认」。 对接日历、开发工具调用或编排 AI 工作流的程序员,都能从中拿走一套明确的接口与验收思路。
查到有空,为什么不能直接确认?
Google Calendar 的 FreeBusy 和 Microsoft Graph 的 getSchedule 都能查询忙闲,但查询不会替用户占住时段。读到空闲和写入预约,是两次操作。
问题出在两次操作之间:请求 A 查到空闲,请求 B 也查到空闲,随后两边分别写入。如果写入端没有共同遵守的冲突规则,两次查询都正确,最终预约仍然可能冲突。
用户选定时间后再查一次,可以缩短这个窗口。作者提到 Cronofy 推荐这种做法,但二次查询依然不能让读取和写入成为原子操作。两次最终检查仍可能同时通过。
而且,冲突不一定等于「同一时间出现两个预约」。有些房源允许同时接待两组客户,有些需要前后缓冲时间。一次看房还可能占用房源、经纪人、钥匙保管人等多个资源。
因此,先明确谁拥有预约的最终决定权,再定义资源、时间区间和容量。日历可能只是预约系统的镜像,CRM 负责联系人信息;它们分别写入成功,不代表整个预约已经成立。
时间也必须明确。保存确定的时间点、房源当地时区和时长,确认时复述当地日期、时间与时区。遇到「周六九点」这样的表达,应先澄清;夏令时切换还会让某些当地时间消失或重复。
防重复请求和防抢占,要分别解决
作者把两个经常混在一起的问题拆开了。
幂等性解决的是:同一次预约因为工具重复调用、网络重试或任务重跑,不能生成多条记录。并发控制解决的是:不同预约争抢同一资源时,必须按容量规则决定谁能成功。
稳定的 request_key 应绑定用户选定的具体预约意图,通过随机标识生成并保存,重试时继续使用。用户换了房源或时间,才建立新意图、生成新键。
Google Calendar 支持客户端指定 event ID,可用于避免写入成功、响应丢失后重复创建;重复 ID 对应的错误包含 409。但不同 ID 仍可能创建同一时间的事件,因此它不能代替容量控制。
如果团队自己掌握关系数据库,作者建议把约束放到提交位置。下面的示例分别限制请求键重复,以及同一房源、经纪人、开始时间上的有效预约:
CREATE TABLE viewing_booking (
booking_id uuid PRIMARY KEY,
request_key text NOT NULL UNIQUE,
property_id text NOT NULL,
agent_id text NOT NULL,
starts_at timestamptz NOT NULL,
ends_at timestamptz NOT NULL,
status text NOT NULL CHECK (status IN
('held', 'confirmed', 'cancelled', 'expired')),
provider_event_id text,
created_at timestamptz NOT NULL DEFAULT now(),
CHECK (ends_at > starts_at)
);
CREATE UNIQUE INDEX one_live_booking_per_property_agent_start
ON viewing_booking (property_id, agent_id, starts_at)
WHERE status IN ('held', 'confirmed');
-- 按房源规则选择精确时段约束或区间重叠约束,
-- 不要直接照搬这个键。
要看清索引里的 starts_at:它只限制开始时刻相同的预约,挡不住开始时间不同、实际区间重叠的预约。需要限制区间重叠时,应考虑 PostgreSQL 的范围与排斥约束,或覆盖实际区间的 Serializable 事务,并处理事务失败后的重试。允许容量为两组时,还需要并发安全的容量计数或预订模型。
唯一请求键防止重复办理,资源约束防止不同请求争抢成功。
外部日历又多一层困难:本地数据库、日历和 CRM 通常不能放进同一个数据库事务。作者建议保存持久化工作流记录,记录请求、调用与结果;外部成功而本地保存失败时,先按请求键或事件标识核对,不能直接重建。
超时以后,AI 应该承诺什么?
作者用状态机约束承诺:OFFERED 只是候选快照,SELECTED 是用户选定,COMMITTING 是正在提交,只有 CONFIRMED 才允许确认预约。
最关键的是 UNKNOWN。
超时只说明调用方没拿到结果,日历可能已经保存。此时应保留原请求键,查找对应预约并核对结果。找到就返回已有确认;能权威确认不存在时,才以同一个键重试;仍无法确定,就返回 pending_reconciliation,告知正在核查,并提供查询编号或后续联系渠道。
不能因为沉默就换键重试,也不能自动换个时间再订一次。
工具应返回明确状态,而不是「看起来可以」。作者给出的回执示例包含预约身份、时间和策略版本:
{
"request_key": "req_01J...",
"status": "confirmed",
"property_ref": "listing_7842",
"starts_at": "2026-11-07T10:30:00+01:00",
"time_zone": "Europe/Paris",
"ends_at": "2026-11-07T11:00:00+01:00",
"booking_id": "book_91f...",
"provider_event_id": "evt_7c2...",
"policy_version": "showing-policy-12",
"committed_at": "2026-11-04T09:18:11Z"
}
要注意 status:模型只能根据它组织措辞,不能从其他字段猜测成功。这些值都是示意数据。conflict 应触发刷新候选时间,rejected 应区分业务拒绝和技术失败;pending_reconciliation 则必须阻止再次发起新的预约。
原文的 Node 风格接口草图通过 scheduler.commit 提交,传入 requestKey、propertyId、agentId、startsAt、timeZone 和 expectedPolicyVersion。真正的幂等和并发保证必须由其实现提供;把查询与无保护的插入包进一个 JavaScript 函数,并不会获得这些保证。
取消和改期也要有提交结果。默认应先提交新预约,再释放旧预约;新预约结果未知时,先核对,避免两个时段都丢失。若业务选择先释放,也应明确记录这项取舍。
我的判断
这篇最有用的部分,是把对话质量、可用时间查询和事务正确性分开评估。提示词可以要求模型只调用一次,却无法协调不同电话、不同进程同时发来的请求。
不过,它提供的是设计论证,不是经过实测的可靠性报告。Retell 社区案例描述过流式超时后出现 5 次相同工具调用并发到达端点,但单个公开报告不能推导整个产品或行业的故障率。Forrester 的 2026 年从业者访谈也不是预约正确性的基准测试。
作者在 2026 年 11 月 4 日提出了 12 项合成验收用例,但明确没有运行真实代理、日历或 CRM。所给原文也没有逐项列出这 12 项,不能当成现成完整测试表。
数据库方案同样有前提:所有预约入口都必须受同一套规则约束。人工或其他渠道若能绕过预约服务直接写日历,本地约束就无法保证全局容量。短期锁、二次查询和供应商保证各有适用范围,文章没有给出对照数据,不能据此宣称某种方案在所有系统里最好。
隐私部分也应落实到数据设计:音频、转写、联系方式、预约记录分别设置保留期限,日历描述尽量只放必要信息。房源事实来自批准的数据源,涉及适配性、资格或专业判断的问题交给人工。
能拿来做什么
- 给 Node 预约接口定义固定状态,并让前端和语音回复共同遵守:只有
confirmed展示成功,pending_reconciliation展示核查状态与查询入口。 - 把
request_key保存为预约意图的一部分,让工具重发、网络重试和后台重跑复用它,同时为资源容量建立独立的提交约束。 - 在测试环境同时发送重复请求与竞争请求,并注入「外部已保存、响应丢失」故障,核对转写、工具请求、回执、日历和 CRM 是否一致。
- 使用虚构房源和联系人验证取消、改期、时区与策略变更,检查日志能否证明交易结果,同时避免复制完整通话内容。