LiveKit工程师复盘:语音对话不应固定等待,通过语义判断而非计时器来决定用户是否说完话,将VAD响应从1600ms降至543ms。
人类对话不会在每次停顿后等上 900 毫秒。
Stivers 等人(PNAS, 2009)测量了十种语言的轮次转换。众数是 0–200 毫秒。Levinson 后来指出了一个尴尬的含义:语言产出规划一个词大约需要 600 毫秒,所以人们在其他说话者还在讲的时候就已经在编码回复了。一个语音 Agent 在 Deepgram 报告 speech_final 之后还要固定等待 0.9 秒,这不是在谨慎——它只是明显不像人。
六周前的我们就是这样。每个停顿后固定消抖。实验室里感觉很有礼貌,在真实电话里却死气沉沉。"你叫什么名字?"埋在沉默里。"我的号码是……"被截断了。同一个计时器,两种截然不同的失败模式。沉默计时器分不清"说完了"和"在思考"。LiveKit 的开放 eot-bench 给出了数据:VAD 阈值大约需要 1600 毫秒的死音才能达到语义检测器在约 543 毫秒就能达到的打断率。
我们扔掉了那个计时器。
新循环只有一个决策:对方把话轮交过来了吗?
Pipeline 还是那个无聊的、人人都有的一样:
PSTN → SignalWire Stream → Deepgram (mulaw) → LLM → Cartesia → PSTN
有趣的部分发生在停顿处。
Deepgram 的 speech_final 只是话轮结束的一个候选。随后一个廉价分类器从话语加上最近几轮对话来估算 P( caller finished )。额外等待时间是该概率的递减函数:
P ≥ 0.75 → 立即提交(干脆的问题 / 完整的请求)
P ≤ 0.25 → 最多等 1.1 秒(拖长的"然后……"、"我的号码是")
中间值 → 线性 ramp 1.1s → 0s
分类器迟到或缺失 → 0.55s 回退
明显的情况永远不会打到模型。拖长的连词(and、but、because、the、to……)在几微秒内返回 P=0.05。问号返回 0.95。一两个词的回应("okay"、"yeah")倾向于不完整,因为在电话上它们通常意味着还有下文。模糊的文本送入一个只允许输出一个浮点数的 4-token gpt-4o-mini 调用。
分类器在每个 finalize 的 STT 段上后台运行,不是在端点运行。等到 Deepgram 触发 speech_final 时,一个新的 P 值通常已经被缓存了。决策预算是等待 in-flight 分数的 45 毫秒;如果它错过了,HTTP 调用会被 shield 这样结果温暖下一次停顿而不是被丢弃。这就是"分类器在热路径上"和"分类器邻接热路径上"的区别。
推测生成隐藏了剩余的等待
在我们决策的同时,我们已经启动 LLM → 流式 TTS 进入一个 asyncio 队列。提交一个完整的话轮意味着刷新已经存在的音频。Caller 几乎在他们说完的同时就听到了第一个 Cartesia 分块。如果他们在窗口中途恢复,我们取消任务、丢弃队列,并用更早的片段重新种子话轮检测器,这样下一个端点把旧词和新词拼接起来。LLM 永远不会把半成品当作已完成的话轮。
这个拼接比听起来更重要。没有它你会得到:
Human: "My account number is"(停顿,我们差点回答) Human: "44821" LLM 只听到"44821"
有了 reseed 它听到"My account number is 44821"。同样的音频。不同的产品。
打断是一个 VAD 事件,不是 transcript 事件
播放循环过去在检查标志时才会注意到打断。如果 TTS 饥饿了,await audio_queue.get() 停驻,Agent 就会在人类说话时继续说上另外 200–400 毫秒。这就是人们描述的"它闭嘴不了"。
来自 Deepgram 的 SpeechStarted 在任何词之前基于语音能量触发。我们现在:
在 VAD handler 中立即设置打断标志 刷新 provider 音频缓冲区,而不是在播放循环中(约 50ms) 即使 TTS 队列为空也至少每 50ms 轮询一次标志 忽略前 250ms / 3 个分块的播放的打断,因为 Agent 自己的第一个音节的模拟线路回声看起来完全像是 caller 开始说话
只有实际听到的词才会被提交。部分 Agent 回复会与 [interrupted] 一起存储,这样下一个 LLM 话轮就知道人类已经听到了什么。上下文匹配现实是一个延迟特性。一个以为它完成了某句话而 caller 从未听到的模型会重复说话。
Transcript 写入、语音持久化、非实时事件的 webhook 扇出——都是 asyncio.create_task。热路径是:接收 mulaw,也许取消,也许刷新,也许开始生成。
"Agent-first"不是一个落地页形容词
如果电话另一端的东西是一个人必须点击的仪表盘,那延迟工作就毫无用处。AgentLine 的客户是 agent runtime——Hermes、OpenClaw、Claude Code、Codex、通过 MCP 的 Claude.ai——它们需要一个真实的 PSTN 号码。
这强制了一个不同的产品表面。
注册是通过明文 REST 的邮件 OTP。MCP 本身需要一个 Bearer key 来连接,所以你不能把注册放在 MCP 后面。POST /v1/auth/otp 然后 POST /v1/auth/verify 创建账户并返回一个 key。同一个邮件总是映射到同一个账户,所以签名的 agent 和后来用那个收件箱登录的人类不是两个用户。没有 agent 会卡住的"创建账户"表单。
Key 以 al_live_ 开头,不是 sk_live_。这不是品牌。Stripe 的 secret-key 模式在每个 agent runtime 的重写列表里。我们最先发布了 sk_live_。Hermes/OpenClaw/Warp 吃掉了 key,agent 只看到 <REDACTED:STRIPE_SECRET>。账户存在。工具输出是空的。我们重命名了前缀。
实时话轮不经过人类收件箱。一个单文件 relay(agentline_relay.py install --agent-id agt_xxx)检测本地 runtime,安装一个用户服务(systemd / launchd / Task Scheduler),并保持一个 WebSocket。每个电话呼叫映射到一个持久 runtime 会话——Claude --resume、Codex thread id、Hermes X-Hermes-Session-Key、OpenClaw --session-key。不受信的 SMS 和生命周期事件落在 agent 稍后消费的本地收件箱。实时话语调用 runtime 现在。
webhook/WS payload 带有一个 push_token。Agent 用 X-Push-Token 把口语答案 POST 回去。热路径上没有 API key,因为我们看到的最常见的 401 是"runtime 把 key 放在 env 里但模型拒绝把它放在 header 里"。它推送的文本是原话说出的。如果它写了一条 Slack 消息而不是,caller 听到"让我看看……"直到话轮过期。这就是为什么事件会强调 ACTION_REQUIRED: LIVE CALLER WAITING。
托管语音 LLM 在本地处理社交话轮("嘿,你好吗")。事实 / 工具话轮发出 [CHECKING],播放一个拖延短语,然后等待 agent 的推送。如果 webhook 已知已死,我们停止为剩下的通话发出 call.utterance 并托管回答。死的 agent 不能持有 PSTN 流。
入站回答 XML 是另一条神圣路径。publish_event() 插入邮箱行并调度 webhook 投递作为后台任务。我们过去在返回 LaML 前 await webhook。慢或缺失的端点意味着 Stream 从未启动,duration=0,failed / no-answer。一个不能延迟电话响铃的 webhook 是一个 agent-first 约束,不是可靠性的可选项。
Claude.ai 通过 MCP + OAuth 2.1(PKCE、动态客户端注册)与我们对话。桌面使用 mcp-remote + Bearer key。任何方式用同样的工具:配置一个号码、开始一个通话、推送上下文、挂断。
这实际上听起来怎么样
Caller: "你能帮我看看 John 是否回我邮件了吗?" Agent(托管,0 额外等待——这是一个完整的问题): "嗯,让我帮你查一下……" [CHECKING] Relay 命中本地 runtime,runtime 读取邮件,POSTs: "好的——John 二十分钟前回复了,他周四 2 点可以。" Cartesia 说出那句话。Caller 从未看到仪表盘。
Caller: "我的订单号是……" 分类器: P≈0.05,我们等待。他们继续说"……44821。" 拼接后的话轮一次性送到 LLM。
Caller 在两个词的时候与 Agent 重叠说话。Provider 缓冲在 SpeechStarted 时清除。Transcript 存储实际听到的内容。下一轮不会重复。
分类器仍然是一个微小的聊天补全,不是本地音频话轮检测器。我们保留了相同的 decide_wait 接口,这样我们可以换入 LiveKit 的 ONNX 话轮检测器(约 50ms CPU)、Pipecat SmartTurn 或 VAP 模型(Ekstedt & Skantze / MaAI)而不触动 pipeline。纯文本 P(finished) 听不到拖长的"soooo……"意味着"还在想"。声学融合是下一个明显的切入。
我们也不声称 PSTN 上端到端 sub-200ms。运营商抖动、Deepgram 端点检测(约 300ms)、Cartesia TTFA 和 PSTN 本身是一个下限。我们移除的是自找的 900ms、等播放循环的打断、以及假设人类操作电话的产品形态。
如果你有一个已经在干活的 agent,它可以有一个电话号码。如果你一直在和沉默计时器搏斗,偷走那个概率等待函数——它只有四十行,它就是全部诀窍。