语音里的预约排期

9 分钟读完

V26
实战手册 · 语音与实时智能体

语音里的预约排期:缺陷在写入,不在听。

一个负责订预约的语音智能体,还没听错任何东西,就已经把同一个时段重复预约出去了——因为从报出一个时段到听见「可以」之间,有十到十五秒,而日历会在这段时间里挪动。排期是一个穿着语音识别外衣的并发问题:开口之前先锁住一个时段,用带前置条件的写入去确认,并在用到之前就把那句救场词写好。

步骤 1量一量「报价」与「接受」之间的那段空隙。

每一个排期回合都是同一个形状:智能体读取可约时段,报出一个或几个选项,来电者做决定,智能体写入。这四步里有三步要花掉真实的时间,而在这段时间里没有任何东西替你把库存占住。

把它数出来。「我这边有十一号周二下午两点一刻、十二号周三上午九点半,或者周四下午四点」——大约二十四个词,按自然的每分钟 150 词算,差不多 9.6 秒。再加上来电者的斟酌时间:在三个时间之间挑选是两到五秒,如果他要翻自己的日历就更久;再加 话轮结束检测 的 500–800 毫秒,以及写入的一个往返。从「读取可约时段」到「写入被接受」,现实中的窗口是 12 到 18 秒,而智能体一开口,这段时间就不在你的控制之内了。

现在把量压上去。一条每小时报出 40 次时段的线路,在任意一个 15 秒窗口里,期望有 40 × 15 / 3600 = 0.17 次他人的在途报价——大约六分之一。这听起来还能忍,直到你注意到它的分布:每条线路上的每个智能体,都会先报最早的那些时段,因为来电者要的就是这个,也因为每份提示词都这么要求模型。重叠的报价并不是撒在整张日历上的,它们挤在同一个或同两个时段上——所以那个六分之一的碰撞率,接近的是重复预约率本身,而不会被稀释掉。

这个失败不会出现在 ASR 评测、插话打断测试或延迟预算里,所以团队才会把它发上线。它出现的形态是客服工单里的「智能体把人订进了一个已经被占的时段」,然后被错误归档成「幻觉」。

步骤 2开口之前先预留——而且只预留一个时段。

解法是在话说出口之前就把时段从货架上拿下来,而不是等来电者同意之后。一个带短生存期的临时预留,把一场竞态变成了一次租约:持有租约的那一方可以确认,其他所有人会立刻被告知实情,而不是在十秒之后。

直觉会让你把即将报出的每个选项都锁住。请抵抗这个直觉,因为「锁定」就是你从流通中抽走的库存,而在规模之下,锁定本身会制造短缺。以一条每小时报价 400 次、生存期 20 秒的繁忙线路为例:

  • 每通电话锁三个选项——400 × 3 × 20 / 3600 = 任意时刻被锁住 6.7 个时段。若当天剩余的时段池只有八个,那么剩余容量的三分之二对所有人都是不可见的,包括你自己的网页预约页面。
  • 每通电话锁一个选项——400 × 1 × 20 / 3600 = 2.2 个时段。在真正要紧的那个时段上拿到同样的保护,成本只有三分之一。

所以,锁住你推荐的那一个——第一个选项,也就是大多数来电者会接受的那一个——其余的则不加锁地报出去,当作你稍后还得重新核对的候选。这种不对称是诚实的,而且便宜:被推荐的时段受保护,备选项承担的是它们本来就有的风险。

有三个属性让「锁定」是安全的、而不是一个新的泄漏点:生存期在 20–30 秒 这个量级(一句话加一个回合,而不是一通电话);在挂机、话题转移、以及确认了另一个时段时释放;以及一个按通话计的上限,让一通冗长跑题的电话无法累积锁定。请把未释放的锁定当成一次资源泄漏来对待,并给它一块看板——这就是 持久状态与可恢复性 的意思。

步骤 3用带前置条件的写入去确认,绝不盲写。

锁定只是收窄了窗口,并没有关上它。锁定会过期,前台的人类可以直接把你压过去,另一条线路上的第二个智能体也可能持有第一个系统从未得知的锁定。所以必须让写入本身成为那个会安全失败的东西——也就是说,它要带上一个前置条件,描述智能体当时所谈论的那个世界。

HTTP 的机制是标准且被广泛实现的:连实体标签一起读出资源,然后把 If-Match 设成那个标签再写。如果资源变了,服务器返回 412 Precondition Failed,你的写入不会落地。Google Calendar 的 v3 API 在每一个事件资源上都带有 etag,而多数预约后端也都暴露了 etag 或一个可以同样使用的版本列。请再配上一个幂等键,这样超时后的重试就不可能产生第二笔预约——经典的重复预约 bug 不是两个来电者,而是一个来电者和一次被重试的请求。

# Offer -> hold -> speak -> confirm. The precondition is the whole design.
hold = calendar.hold(slot=best, ttl_s=25, call_id=call.id)
if not hold.granted:
    best = next_candidate()            # never speak an unheld recommendation

speak(f"I can do {best.spoken}. Does that work?")
answer = listen()

try:
    booking = calendar.confirm(
        hold_id=hold.id,
        if_match=hold.etag,              # 412 if the world moved
        idempotency_key=f"{call.id}:{best.id}",
    )
except PreconditionFailed:             # expired or taken: STEP 4
    recover(call, reason="slot_gone")
else:
    speak(f"Booked: {booking.spoken}. You'll get a text.")

请注意 if not hold.granted 这一支避免了什么。没有它,智能体就会报出一个它并不拥有的时段——而这是整场交互里成本最高的一句话:来电者此刻已经相信了一件不成立的事,之后任何更正听起来都像无能,而不像尽责。

步骤 4锁定在话说一半时过期——把那句话提前备好。

确认时拿到 412,不是一个记下来再悄悄重试的错误。它是一次对话事件,而智能体大约只有一个回合来处理它,否则来电者就会判定这个系统坏了。请提前写好那句救场词并测试它,因为一个在工具失败下临场发挥的模型,要么长篇道歉,要么——糟糕得多——订了别的东西,还把它描述成来电者要的那个。

行之有效的形状是:点明变化、给出最近的备选、只问一个问题。「那个刚被订走了——我下一个能排的是周二下午三点。要给您订上吗?」三个属性要紧。它是一句话,所以来电者不会丢掉线索。它只含一个新时间,而不是一份新清单。而且它不解释并发模型,因为没有哪个来电者想听。

有两种失败模式,要在系统提示词里明确禁止,并在评测中验证:

  • 静默替换。订一个相邻时段,然后报告成功。这是会招来投诉的那一种;在医疗与法律场景下还会造成真实伤害。它是一次由工具结果触发的 目标漂移 失败。
  • 无界重试。一边循环确认、一边让来电者在沉默中等着。请把同一回合内的自动重试限制为一次,然后就开口说话。这里的截止期纪律与 超时与截止期预算 相同,只是这里的预算单位是「一个人类能容忍多久的空气音」——大约两秒。

如果来电者根本无法被服务,交接应当是一次把尝试过的时段一起带过去的 热转接,这样人类不必从头再谈一遍。

步骤 5在报出任何东西之前,先问那个区分点。

STEP 1 的那道算术还有第二层含意:报价越长,窗口越宽。念三个选项要付出大约十秒的暴露,念一个只要三秒。所以收窄竞态最便宜的办法,是先问一个收窄范围的问题,然后只报一个已锁住的时段。

那个区分点几乎总是「上下午」或「哪一天」,因为这正是来电者真正有约束的维度:「上午还是下午?」然后「周三上午九点半我能排——可以吗?」在这里要紧的每一个维度上,两个短回合都胜过一个长回合——暴露窗口、记忆留存,以及那种让来电者接受「最后听到的那个」的近因偏倚。一般情形在 语音里的长列表;排期是它最常见的实例,也是清单的代价从「来电者困惑」变成「订错」的那个实例。

由此直接得出两条规则:

  • 绝不要念序号。「周二请按 1 或说 1」会引诱来电者在想起某一项时说「1」,而他们想起的那一项,通常是你最后说的那一个。
  • 按来电者会复述的方式说时间。「周三九点半」是可以被复核的;「2026-11-11 的 09:30」不是。把字符逐个说清的纪律在 语音里的字母与数字。

步骤 6把看起来像一个的三种失败分开埋点。

「智能体订错了」是三个不同的缺陷,有三种不同的修法,而一个合计的「预约准确率」会把你究竟得了哪一种藏起来。请把三者分别发出:

  • 锁定过期率——在确认之前就超时的锁定,占已授予锁定的比例。上升意味着你的报价太长,或生存期太短。这个指标会告诉你 STEP 5 没有被执行。
  • 前置条件失败率——确认时的 412,占已尝试确认的比例。上升意味着真实的争用:并发超出了时段池能支撑的量,或者有某个通道在不加锁的情况下写入(前台、网页表单、第二个智能体)。
  • 不一致率——「被订下的时间」与「转写记录里智能体说出的时间」不同的那些预约。这是唯一能抓到静默替换的指标,而它要求把预约记录与转写记录做连接,而不是相信智能体自己对「成功」的报告。

能抓住上面三个指标漏掉的一切的端到端检查,是对账:每通电话之后,重新读一遍日历,把上面的东西与智能体说过的话做比对。不一致就是缺陷,无论是哪个组件造成的——这与 用录制轨迹做回放测试 里「检查效果,而不是意图」的原则相同,也正是 评估语音智能体 必须包含一条写入侧断言、而不能只有转写评分的原因。

今天就做这件事,在任何提示词工作之前:取一周的预约,把每一笔与它的通话转写连接起来,数出「说出口的时间」与「订下的时间」不一致的行数。那个数字就是你真实的排期准确率。如果它不是零,解法是一个锁定加一个 If-Match——不是更好的模型,不是更长的提示词,也不是更多示例。