很多 Voice Agent Demo 会把“用户一开口,机器人停了”当成打断成功。这个判断太粗了。它把至少三件不同的事压成了一个结果:系统有没有把用户的短促杂音误当成打断;取消指令发出后,音频到底多久才停止;新一轮回答是否仍把没播放完的话当成用户已经听过。

我不建议只记一个 barge_in_success = true。它无法回答是 VAD 慢了、TTS 队列没清、网络还在送旧音频,还是上下文把“已生成”误写成“已播放”。这四类问题的修复完全不同。

下面使用一个无第三方依赖的合成事件夹具,专门定义指标和状态边界。脚本可以验证计时口径与回归路径,但不能代表某个云服务、模型或电话线路的真实延迟。

目录

一次打断至少要拆成四个时间点

停播延迟从哪里开始计时

误触发不能只靠“用户说话了”判断

上下文恢复:只承认用户真正听到的内容

可运行的测试夹具

中文客服里还要额外看什么

一次打断至少要拆成四个时间点

先看一条合成 trace。它只描述事件顺序,不是线上日志:

t0=12_000  VAD 发现疑似用户语音
t1=12_080  满足最短有效语音时长,接受打断
t2=12_095  向播放队列发送 cancel(turn_id=41)
t3=12_180  客户端确认旧 turn 的音频已停止

t0 -> t1 是判定延迟,受 VAD 窗口、最短语音时长和噪声门限影响;t1 -> t2 是服务端调度延迟;用户真正会感到的“机器人还在抢话”,主要落在 t2 -> t3。如果把 t0 -> t3 全叫做停播延迟,调播放队列时就会误改 VAD 参数。

"TTS 播放队列" "会话状态机" "VAD / 端点检测" "用户音频" "TTS 播放队列" "会话状态机" "VAD / 端点检测" "用户音频" "疑似语音 t0" "接受打断 t1" "cancel(turn_id) t2" "已停止播放 t3" "丢弃未播放文本,创建新轮次"

因此我会至少记录四类字段:vad_onset_msinterrupt_accepted_mscancel_sent_msplayback_stopped_ms。没有后两个字段,只能证明检测到了声音,不能证明用户真的抢回了话轮。

停播延迟从哪里开始计时

本文的夹具把停播延迟定义为:

stop_latency_ms = playback_stopped_ms - cancel_sent_ms

不是从 VAD 第一个高分帧开始。原因很简单:VAD 的职责是判断要不要打断,播放队列的职责才是停下来。两者可以各自优化,指标也应该能各自归因。

对 RTP 或 WebRTC 音频链路,还要把接收端“旧音频仍在播放”和网络包丢失分开。RTP 序列号可以用于发现包丢失及恢复发送端包序;测量时应保留发送、接收、播放三种事件,而不是拿单一客户端回调替代整条链路。RFC 3550 给出了 RTP 序列号与丢包统计的协议依据。

真正要看的不是一个漂亮平均数,而是按打断样本计算:

指标定义它暴露的问题
stop_latency_mst3 - t2播放取消、缓冲和客户端确认是否拖尾
decision_latency_mst1 - t0VAD 窗口和判定门限是否过慢
cancel_delivery_gap_mst2 - t1状态机、队列或 RPC 调度是否阻塞
stale_audio_after_cancel取消后仍播放的旧 turn 帧数是否存在未按 turn_id 丢弃的音频

脚本示例会对不满足 cancel_sent_ms <= playback_stopped_ms 的 trace 直接报错。比起拿负数或缺失值去算平均,这个失败更值得先处理。

误触发不能只靠“用户说话了”判断

VAD 不是“理解用户正在打断”的模块,它只处理短音频块并给出语音概率或相关特征。以 WebRTC 当前的 VoiceActivityDetector 为例,接口按 chunk 处理音频,并暴露 chunk 级语音概率和 RMS;这说明它是打断判定的一项输入,而不是完整的会话决策器。WebRTC VAD 源码

中文客服特别容易把这些声音误收为打断:客服 TTS 尾音被回声带回、用户的“嗯”只是确认、环境里有导航提示音、电话线路把双讲压成断续波形。一个保守的最小规则是:VAD 触发后,不要立即取消;先满足最短有效语音时长,或结合 ASR partial 与业务状态再接受。

夹具里的 is_false_trigger() 将“检测到 VAD、但没有接受打断”的事件单独记为误触发候选。生产上还应继续区分:回声、噪声、用户确认音、用户真实插话。不要把所有未接受的 VAD 事件都当成模型错误。

上下文恢复:只承认用户真正听到的内容

打断后的第二个坑不在音频,而在文本状态。假设模型已经生成:

“退款会在三个工作日内原路退回;如果银行卡已注销,请先提交新的收款账户……”

用户在“原路退回”后插话。新一轮上下文不能把后半句也写成“客服已经告知”。那部分只存在于服务端生成缓冲或 TTS 队列里,用户没有听到。

所以会话状态至少要分开保存:

{
  "turn_id": 41,
  "generated_text": "模型已生成的完整文本",
  "played_text": "客户端已经播出的文本",
  "cancelled": true,
  "next_turn_context": "只基于 played_text 和用户新输入重建"
}

played_text 的同步通常不是逐字精确的,它可能按分句、音频片段或播放器进度估计。这个误差必须保留,不要假装自己知道用户精确听到哪个字。对于金额、地址、日期和权益说明,保守做法是新一轮重新确认关键字段,必要时给人工入口。

可运行的测试夹具

本地的 barge_in_metrics.py 刻意没有接 VAD、TTS 或网络。它做的是更基础的事:让团队先统一“接受打断”“发送取消”“确认停播”“可进入下一轮上下文”分别是什么意思。

核心函数如下:

def stop_latency_ms(trace: BargeInTrace) -> int:
    if trace.cancel_sent_ms is None or trace.playback_stopped_ms is None:
        raise ValueError("accepted interrupt requires cancel and stopped timestamps")
    if trace.playback_stopped_ms < trace.cancel_sent_ms:
        raise ValueError("playback_stopped_ms cannot precede cancel_sent_ms")
    return trace.playback_stopped_ms - trace.cancel_sent_ms


def recover_context(trace: BargeInTrace) -> tuple[str, ...]:
    if not trace.interrupt_accepted:
        return trace.played_tokens
    return trace.played_tokens

第二个函数看起来有点无聊,正是故意的:无论取消是否发生,恢复上下文都不能偷偷混入 generated_tokens。复杂系统最容易在这儿用“生成过”覆盖“播放过”。

运行方式:

python3 examples/barge_in_metrics.py
python3 -m unittest discover -s tests -v

测试覆盖四条边界:正确用 t2 -> t3 计算停播延迟;短促 VAD 不接受为打断;上下文只返回已播放 token;旧 turn 的迟到音频不能覆盖新 turn。

中文客服里还要额外看什么

中文客服不是只有“能否立刻闭嘴”。一句用户插话往往带着任务状态:

  • “等一下”可能只是要求暂停解释,不等于取消整个流程;
  • “不是这个订单”要求重判对象,不能继续沿用上一轮 RAG 引用;
  • “金额不对”要冻结自动确认,并保留人工接管;
  • “你再说一遍”则需要回放已播内容或用更短句复述,不能从未播放段落接着念。

这些分支属于会话状态与业务编排,不属于 VAD 准确率。真正上线前,测试样本要把电话窄带、回声、噪声、方言、用户确认音、播报数字和地址逐项加入,并分别统计误触发、漏触发、停播尾音和上下文错误恢复。夹具只能保证计时口径不乱,不能替代真实电话样本。


本文说明:文中的时间点、trace、阈值和 Python 代码均为机制演示与测试夹具,不代表任一 Voice Agent、云 TTS、VAD 模型或电话线路的真实性能。生产阈值应结合音频链路、业务风险、真实样本和人工服务流程单独校准。

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐