云计算百科
云计算领域专业知识百科平台

全双工语音Agent实战:WebRTC、语义VAD、打断与工具调用

把录音转成文字、调用聊天模型、再把答案转成语音,可以很快做出“会说话”的演示;但它离自然对话仍有明显距离。用户说到一半停顿一下,系统可能立刻抢答;用户想插话,扬声器还在播放上一段;模型调用工具时,前端不知道是在查询还是已经执行;网络抖动后,用户听到的内容和服务端记住的内容甚至不一致。

全双工语音Agent的难点不是多接两个音频接口,而是同时维护四条时间线:用户正在说什么、模型正在生成什么、扬声器实际播放到了哪里、业务工具执行到了哪一步。WebRTC解决实时媒体传输,VAD判断说话边界,打断机制同步“说出”和“听到”的差异,工具层则把自然语言意图变成受权限控制的业务动作。任何一层只做“能跑”,对话都可能在真实环境里失控。

本文用浏览器加业务后端的架构拆解完整链路。示例以通用事件状态机为主,并结合当前Realtime接口说明WebRTC会话和语义VAD的落点;即便换成其他实时语音服务,状态、权限和验证方法仍然适用。

1. “全双工”不等于双方永远同时说

通信意义上的全双工表示上行和下行音频可以同时传输。对话意义上的全双工更复杂:系统需要在自己发声时继续监听用户,识别用户是在应和、环境噪声还是明确打断,并在合适时停止输出。若只是同时打开麦克风和扬声器,却没有回声消除和打断状态,系统很可能把自己的声音再次当作用户输入。

自然语音Agent应支持至少四种行为:用户完整说完后正常回应;用户短暂停顿时耐心等待;用户说“等等”或提出新问题时立即停止;工具正在执行时可以解释状态,但不能把“停止播报”错误等同于“撤销已经提交的转账”。因此媒体中断、模型响应取消和业务操作取消必须是三个独立动作。

业务工具

业务后端

实时语音会话

浏览器/WebRTC

用户

业务工具

业务后端

实时语音会话

浏览器/WebRTC

用户

#mermaid-svg-F3jsDAjSoCaH5ieX{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-F3jsDAjSoCaH5ieX .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-F3jsDAjSoCaH5ieX .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-F3jsDAjSoCaH5ieX .error-icon{fill:#552222;}#mermaid-svg-F3jsDAjSoCaH5ieX .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-F3jsDAjSoCaH5ieX .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-F3jsDAjSoCaH5ieX .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-F3jsDAjSoCaH5ieX .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-F3jsDAjSoCaH5ieX .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-F3jsDAjSoCaH5ieX .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-F3jsDAjSoCaH5ieX .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-F3jsDAjSoCaH5ieX .marker{fill:#333333;stroke:#333333;}#mermaid-svg-F3jsDAjSoCaH5ieX .marker.cross{stroke:#333333;}#mermaid-svg-F3jsDAjSoCaH5ieX svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-F3jsDAjSoCaH5ieX p{margin:0;}#mermaid-svg-F3jsDAjSoCaH5ieX .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-F3jsDAjSoCaH5ieX text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-F3jsDAjSoCaH5ieX .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-F3jsDAjSoCaH5ieX .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-F3jsDAjSoCaH5ieX .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-F3jsDAjSoCaH5ieX .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-F3jsDAjSoCaH5ieX #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-F3jsDAjSoCaH5ieX .sequenceNumber{fill:white;}#mermaid-svg-F3jsDAjSoCaH5ieX #sequencenumber{fill:#333;}#mermaid-svg-F3jsDAjSoCaH5ieX #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-F3jsDAjSoCaH5ieX .messageText{fill:#333;stroke:none;}#mermaid-svg-F3jsDAjSoCaH5ieX .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-F3jsDAjSoCaH5ieX .labelText,#mermaid-svg-F3jsDAjSoCaH5ieX .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-F3jsDAjSoCaH5ieX .loopText,#mermaid-svg-F3jsDAjSoCaH5ieX .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-F3jsDAjSoCaH5ieX .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-F3jsDAjSoCaH5ieX .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-F3jsDAjSoCaH5ieX .noteText,#mermaid-svg-F3jsDAjSoCaH5ieX .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-F3jsDAjSoCaH5ieX .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-F3jsDAjSoCaH5ieX .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-F3jsDAjSoCaH5ieX .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-F3jsDAjSoCaH5ieX .actorPopupMenu{position:absolute;}#mermaid-svg-F3jsDAjSoCaH5ieX .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-F3jsDAjSoCaH5ieX .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-F3jsDAjSoCaH5ieX .actor-man circle,#mermaid-svg-F3jsDAjSoCaH5ieX line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-F3jsDAjSoCaH5ieX :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

麦克风音频

SRTP音频流

流式语音回答

工具调用事件

call_id + 工具参数

鉴权、校验、审批、幂等

执行允许的操作

结构化结果

function_call_output

插话

立即停止本地播放

取消响应/截断未播放内容

基于已听内容继续会话

2. 为什么浏览器优先考虑WebRTC

WebRTC的价值不只是“能传音频”。浏览器原生提供麦克风采集、编解码协商、抖动缓冲、拥塞控制、回声消除、加密媒体以及网络路径协商。RTCPeerConnection承载音频轨道,数据通道可以传会话配置、工具事件和错误。相比在WebSocket中自行切PCM、Base64编码、排序和播放,WebRTC更适合不稳定网络下的浏览器实时媒体。

建立连接通常经历:浏览器申请麦克风权限;创建PeerConnection并加入本地音轨;通过SDP offer描述本地能力;服务返回SDP answer;ICE寻找可用网络路径;DTLS协商密钥后由SRTP承载媒体。浏览器与托管实时服务连接时,对方可能封装了ICE和媒体服务器细节,但应用仍应监听connectionState、iceConnectionState和音轨事件,不能把“拿到answer”当成会话永久可用。

WebSocket更适合服务器到服务器、电话网关或开发者必须亲自处理音频帧的场景。它不是错误选择,只是客户端要负责更多事情:输入缓冲提交、输出音频队列、播放时钟、重排、取消和截断。无论选哪种传输,业务状态机都不应依赖“某个socket现在是开的”来推断用户听到了什么。

3. 正确的鉴权边界:长期密钥绝不能进浏览器

浏览器代码、网络请求和打包产物都可被用户查看,因此不能嵌入服务端API Key。当前Realtime WebRTC流程提供两种常见路径:业务后端用长期密钥创建短期客户端凭证,浏览器再以该凭证建立会话;或者浏览器把SDP交给业务后端,由后端代为调用统一WebRTC接口。两种方式都把长期密钥留在可信服务器。

后端在签发会话前应验证自己的登录态、租户、配额和允许的模型配置。短期凭证也不是“公开无害”:它应只返回给当前用户,限制会话能力并避免写入日志。客户端传来的voice、instructions和tools不能原样上送,服务端必须使用白名单覆盖敏感配置。

下面用FastAPI和httpx展示一个最小签发端点。真实地址和请求字段应以服务商当前GA文档为准;模型名称由部署环境设置,代码不把密钥写入仓库。这个端点只负责短期凭证,不执行工具。

from __future__ import annotations

import os

import httpx
from fastapi import Depends, FastAPI, HTTPException
from pydantic import BaseModel

app = FastAPI()
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "")
REALTIME_MODEL = os.environ.get("REALTIME_MODEL", "")

class User(BaseModel):
user_id: str
tenant_id: str

def current_user() –> User:
# 示例占位:生产环境必须从已验证的Cookie或Bearer Token解析身份。
return User(user_id="demo-user", tenant_id="demo-tenant")

@app.post("/api/realtime/token")
async def create_realtime_token(user: User = Depends(current_user)) –> dict:
if not OPENAI_API_KEY or not REALTIME_MODEL:
raise HTTPException(status_code=503, detail="实时语音服务尚未配置")
session = {
"session": {
"type": "realtime",
"model": REALTIME_MODEL,
"audio": {
"input": {
"turn_detection": {
"type": "semantic_vad",
"create_response": True,
"interrupt_response": True,
}
}
},
"instructions": "回答简洁;执行任何有副作用的工具前必须等待服务端审批。",
}
}
headers = {
"Authorization": f"Bearer {OPENAI_API_KEY}",
"Content-Type": "application/json",
"OpenAI-Safety-Identifier": user.user_id,
}
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(
"https://api.openai.com/v1/realtime/client_secrets",
headers=headers,
json=session,
)
if response.status_code >= 400:
raise HTTPException(status_code=502, detail="创建实时会话失败")
payload = response.json()
return {"value": payload["value"], "expires_at": payload.get("expires_at")}

不要把上游错误正文直接返回浏览器,其中可能含请求细节。签发端点还需要速率限制、审计和会话并发限制。稳定的安全标识符应是隐私保护后的内部ID,不能把邮箱或手机号直接发送给上游。

4. 从SDP到可说话会话

浏览器端的最短主流程是:获取短期凭证;创建RTCPeerConnection;把远端音轨绑定到audio元素;用getUserMedia取得本地音轨;创建数据通道;生成offer并设置本地描述;将SDP发送给Realtime calls接口;最后设置远端answer。工具事件和会话事件通过数据通道收发,音频本身走媒体轨道。

麦克风权限必须由用户动作触发,并在UI中清楚显示录音状态。结束会话时,应停止每个本地track、关闭data channel和peer connection、撤销计时器并清理音频元素;只关闭页面组件而不停止track,会让浏览器继续显示麦克风占用。

生产实现要处理状态变化。disconnected可能只是短暂网络抖动,failed通常需要重新建链;恢复连接时不能无条件重放上一条工具结果。每个控制事件带唯一event_id,每个工具调用带call_id,服务端使用幂等表防止重连造成重复执行。

5. Server VAD与语义VAD的区别

VAD的第一层问题是“有没有人在说话”。传统Server VAD通常依据音量、频谱和连续静音时间判定开始、结束,常见参数包括能量阈值、前置保留时间和结束静音长度。它速度快、行为可解释,但不知道“我想订明天……不,后天的票”中的停顿是否表示说完。

语义VAD还会根据已识别内容估计一句话是否完整。陈述句明确收束时可以更快结束,尾音犹豫或语义未完成时继续等待。它减少机械抢话,但不是读心术:口音、领域术语、网络丢包和背景谈话仍会误导边界。产品应提供“按住说话”或手动发送作为兜底,官方Realtime文档也把关闭VAD后的Push-to-talk作为有效交互模式。

调参不能只看主观“自然”。至少测四个量:用户说完到首个有效音频的等待、被系统提前截断的比例、系统无意义等待的比例、用户打断后输出停止的时间。不同场景目标不同:导航口令偏向快速,心理咨询偏向耐心,嘈杂车间可能更适合按钮控制。

下面的状态机展示如何把声学VAD事件与语义完成概率组合起来。它并不替代真正的VAD模型,而是负责产品层的“何时提交这一轮”。连续静音达到最小值后,只有语义置信度较高才快速提交;否则等待到硬超时。所有阈值都放进配置,以便用真实录音校准。

from __future__ import annotations

from dataclasses import dataclass
from enum import Enum, auto

class TurnState(Enum):
IDLE = auto()
SPEAKING = auto()
MAYBE_DONE = auto()
COMMITTED = auto()

@dataclass(frozen=True)
class VadConfig:
minimum_silence_ms: int = 300
hard_timeout_ms: int = 1400
semantic_done_threshold: float = 0.72

@dataclass
class TurnDetector:
config: VadConfig
state: TurnState = TurnState.IDLE
silence_ms: int = 0

def speech_started(self) –> None:
self.state = TurnState.SPEAKING
self.silence_ms = 0

def speech_frame(self) –> None:
if self.state is not TurnState.COMMITTED:
self.state = TurnState.SPEAKING
self.silence_ms = 0

def silence_frame(self, duration_ms: int, semantic_done: float) –> bool:
if self.state not in (TurnState.SPEAKING, TurnState.MAYBE_DONE):
return False
self.silence_ms += max(0, duration_ms)
self.state = TurnState.MAYBE_DONE
quick_done = (
self.silence_ms >= self.config.minimum_silence_ms
and semantic_done >= self.config.semantic_done_threshold
)
timed_out = self.silence_ms >= self.config.hard_timeout_ms
if quick_done or timed_out:
self.state = TurnState.COMMITTED
return True
return False

def reset(self) –> None:
self.state = TurnState.IDLE
self.silence_ms = 0

def _vad_check() –> None:
detector = TurnDetector(VadConfig())
detector.speech_started()
assert detector.silence_frame(200, semantic_done=0.95) is False
assert detector.silence_frame(120, semantic_done=0.95) is True
detector.reset()
detector.speech_started()
assert detector.silence_frame(1000, semantic_done=0.2) is False
assert detector.silence_frame(400, semantic_done=0.2) is True

if __name__ == "__main__":
_vad_check()

硬超时保证系统不会永远等待,但过短会切断慢速说话者。配置应按设备、语言和环境分层,而不是按用户的敏感属性推断。麦克风前置降噪有时会吞掉轻声首字,VAD需要保留一小段前置音频,否则检测到“开始说话”时开头已经丢失。

6. Barge-in:打断的是三种不同状态

用户在Agent说话时开口,客户端首先应立即停止本地播放,这一步不能等待模型确认。其次取消仍在生成的响应,避免无用音频继续到达。最后必须让会话历史只保留用户实际听到的部分,否则模型下一轮会以为自己已经说完未播放的句子。

在当前Realtime的WebRTC和SIP连接中,服务端掌握输出缓冲并可在VAD打断时自动截断未播放音频;使用WebSocket自行播放时,客户端必须记录最后一个assistant item和实际播放毫秒数,发送conversation.item.truncate。截断会删除未听部分及对应文本,以保持会话状态一致。具体事件名应以当前接口参考为准。

第三种状态是业务动作。若Agent刚说“我来提交工单”,用户立刻说“算了”,需要查看工具是否尚未执行、正在执行还是已经提交。媒体取消不自动回滚业务事务。可取消查询可以传播取消令牌;不可逆操作必须在执行前二次确认,并在完成后明确告知结果。不要让模型根据语气自行判断撤销成功。

下面的Python类维护“已生成、已播放、工具状态”三条账本,并产生通用截断事件。它展示的是协议无关的核心不变量:heard_ms不能大于已生成时长,同一工具调用只能从允许的状态迁移。

from __future__ import annotations

from dataclasses import dataclass, field
from enum import Enum

class ToolState(str, Enum):
PROPOSED = "proposed"
APPROVED = "approved"
RUNNING = "running"
SUCCEEDED = "succeeded"
FAILED = "failed"
CANCELLED = "cancelled"

@dataclass
class PlaybackLedger:
item_id: str | None = None
generated_ms: int = 0
heard_ms: int = 0
tools: dict[str, ToolState] = field(default_factory=dict)

def mark_generated(self, item_id: str, duration_ms: int) –> None:
if self.item_id not in (None, item_id):
raise ValueError("上一条音频尚未结算")
self.item_id = item_id
self.generated_ms = max(self.generated_ms, duration_ms)

def mark_played(self, duration_ms: int) –> None:
self.heard_ms = min(max(0, duration_ms), self.generated_ms)

def interrupt_event(self) –> dict[str, object] | None:
if self.item_id is None:
return None
return {
"type": "conversation.item.truncate",
"item_id": self.item_id,
"content_index": 0,
"audio_end_ms": self.heard_ms,
}

def transition_tool(self, call_id: str, target: ToolState) –> None:
current = self.tools.get(call_id, ToolState.PROPOSED)
allowed = {
ToolState.PROPOSED: {ToolState.APPROVED, ToolState.CANCELLED},
ToolState.APPROVED: {ToolState.RUNNING, ToolState.CANCELLED},
ToolState.RUNNING: {ToolState.SUCCEEDED, ToolState.FAILED},
}
if target not in allowed.get(current, set()):
raise ValueError(f"非法工具状态迁移: {current} -> {target}")
self.tools[call_id] = target

def _ledger_check() –> None:
ledger = PlaybackLedger()
ledger.mark_generated("assistant-1", 2400)
ledger.mark_played(900)
event = ledger.interrupt_event()
assert event and event["audio_end_ms"] == 900
ledger.transition_tool("call-1", ToolState.APPROVED)
ledger.transition_tool("call-1", ToolState.RUNNING)
ledger.transition_tool("call-1", ToolState.SUCCEEDED)

if __name__ == "__main__":
_ledger_check()

7. 工具调用必须经过服务端授权

语音模型可以提出函数调用,但不能因为“听起来像用户同意”就直接操作订单、门锁、付款或工单。工具定义只描述模型可提出什么;真正执行者必须在服务端重新验证用户身份、租户、参数、资源权限、风险等级和幂等键。来自浏览器的call_id和参数同样是不可信输入。

工具分为只读、可逆写入和高风险不可逆写入。只读查询可以自动执行,但仍需字段级权限和速率限制;创建草稿、添加提醒等可逆动作可以先执行后允许撤销;转账、删除、发布和控制实体设备应要求界面或明确语音二次确认,确认内容需包含对象、数量和后果。确认必须绑定具体call_id和参数哈希,不能复用一句脱离上下文的“好的”。

工具结果要结构化、限长,并区分可展示内容与模型可见内容。数据库异常堆栈、内部主键和访问令牌不能回传模型。耗时工具应返回“已受理”状态并通过事件更新,而不是让实时会话一直沉默。模型可以播报等待提示,但不要每秒重复生成无意义语音。

若用户在工具运行时插话,系统可以停止Agent的播报,却不能声称任务已取消,除非业务服务确认取消。幂等键应由tenant_id + user_id + call_id + 参数哈希构成并持久化;重连、重试或模型重复发出相同调用时,返回已存在的结果,而不是再次执行。

8. 回声、噪声和设备差异

浏览器通常支持回声消除、噪声抑制和自动增益,但不同设备实现差异很大。笔记本扬声器音量过高、蓝牙耳机切换模式、车载环境或多人会议都会改变输入特性。不能只在开发者的安静房间和同一副耳机上验收。

测试集应包含近讲麦克风、远场扬声器、键盘声、电视背景、人声重叠、弱网、蓝牙切换和系统权限被撤销。记录原始音频需要明确同意和保留策略;很多情况下只存时间戳、VAD事件、转写和统计量就足够排障。若确需保存音频,应加密、限制访问并提供删除机制。

全双工中最棘手的是Agent自身声音泄漏到麦克风。可以结合浏览器AEC、播放参考信号和产品策略降低误触发。发生持续自激时,与其让Agent和自己对话,不如自动降级为按住说话模式,并提示用户使用耳机。

9. 延迟要拆开测,不能只报一个平均值

用户感知延迟由多个阶段组成:麦克风采集与上行、VAD等待、模型首输出、首音频到达、播放缓冲、工具往返。一个总耗时无法说明该优化哪里。每轮应记录speech_started、speech_stopped、input_committed、response_created、首个音频包、本地首次播放和播放结束时间。

P50只能描述普通情况,语音体验常被弱网下的尾延迟破坏,因此还要看P95和失败分布。打断单独统计:从用户开口到扬声器静音、到上游取消确认、到会话截断确认分别多久。工具调用则区分模型提出、后端接收、审批完成、业务提交和结果播报。

降低VAD静音阈值可能缩短响应,却增加抢话;加大播放缓冲能减少卡顿,却让打断时多播几百毫秒。优化必须联合看延迟、截断率和打断体验,不能追求一个数字。对查询工具,可先播一句很短的状态提示;对无需语言模型的固定确认音,可以由客户端本地播放,避免额外生成调用。

10. 会话历史与长对话

实时会话有上下文上限,长对话需要截断或摘要。自动删除最旧消息虽然能继续运行,却可能删掉用户约束、确认状态或工具结果。应用应把长期事实、当前任务状态和闲聊历史分开:长期事实存入受权限控制的业务状态;任务状态由确定性状态机维护;自然语言历史只保留近期必要内容。

摘要不是审计记录。涉及金额、时间、地址和确认的字段必须保留结构化原值,不能让模型摘要后成为唯一依据。打断后的未播放文本不应进入摘要,否则系统会记住用户从未听到的信息。工具结果使用引用ID关联业务系统,后续需要时重新查询当前状态。

新会话重连时,不要把旧的短期凭证和PeerConnection复用。创建新连接后恢复必要结构化状态,并明确哪些正在运行的工具任务仍然有效。UI应区分“语音连接已恢复”和“业务任务已恢复”,避免用户因听见欢迎语就误以为操作没有中断。

11. 安全、隐私与滥用边界

语音是高敏感数据。开始采集前要有可见提示,离开页面或结束会话后立即停止track。日志默认不记录原始凭证和完整音频。儿童、医疗、金融和工作场所录音还涉及更严格的同意与保留要求,不能只依赖一份通用隐私声明。

语音提示注入同样存在:背景电视、电话另一端或播放的录音都可能向Agent发号施令。声纹不是可靠的单因素授权,检测到“像某个人”也不能替代登录和二次确认。高风险工具应要求应用界面的确定性确认,或使用与当前会话绑定的挑战信息。

模型输出语音也需要内容策略。实时流生成后已开始播放,事后审核太晚;可在高风险领域关闭自动响应,保留VAD但将create_response设为false,在转写完成、策略检查或RAG验证后再手动触发响应。代价是增加延迟,却换来控制。系统应根据风险选择,而不是所有场景都追求最低首音频延迟。

12. 验收与故障排查

第一组验收是轮次:完整陈述、句中停顿、反复自我修正、极短回答和长时间沉默。第二组是打断:开头、中间、结尾插话,检查本地立即静音、上游取消以及历史截断是否一致。第三组是工具:参数缺失、越权资源、重复call_id、用户取消、工具超时和完成后断线。第四组是网络:丢包、抖动、切网和重连。

系统总抢话时,先看VAD的speech_stopped是否过早,再看语义完成概率和音频是否丢帧;不要先改模型提示词。打断后模型重复未听内容,检查实际播放时钟与截断事件。工具重复执行,检查幂等存储而非让模型“不要重复”。浏览器没有声音,按麦克风权限、本地track、PeerConnection状态、远端track、音频自动播放策略和输出设备顺序排查。

每次故障应能关联同一session_id下的媒体状态、控制事件和工具审计,但不要为方便排障收集超出需要的个人信息。对外展示友好错误,对内保存错误类别和上游请求ID;不要在语音里朗读堆栈或密钥配置。

12.1 前端状态机不能只靠一个“连接中”布尔值

语音页面至少同时维护媒体连接、用户轮次、模型响应和工具任务四组状态。PeerConnection已经connected,不代表麦克风track仍是live;模型响应已经done,不代表音频播放队列已经清空;UI显示“已取消”,也不代表业务工具确认了取消。把这些状态压成一个布尔值,会在重连和打断时产生大量竞态。

媒体状态可以是idle / requesting_permission / connecting / connected / reconnecting / closed / failed;轮次状态可以是listening / user_speaking / waiting / agent_speaking。状态迁移由已确认的事件驱动,不从按钮文案反推。每次新连接分配递增generation,旧连接迟到的事件携带旧generation,前端直接丢弃,避免断开的PeerConnection把新会话改回失败状态。

播放也需要队列所有权。收到新assistant item时建立播放记录,音频真正送入输出设备后更新heard_ms。用户打断时先原子地冻结该记录,再清队列和发送截断;如果先清对象再计算播放位置,服务端只能得到零或错误时间。耳机设备切换会重建音频输出,切换期间应暂停“已听时长”计数。

12.2 工具确认要让用户知道自己确认了什么

一句“确认吗”没有足够上下文。确认界面或播报应包含动作、目标、关键参数和可逆性,例如“将为订单A取消明天下午的预约,取消后需重新排队”。用户确认后,服务端计算当前参数哈希与提案时是否一致;模型若在两者之间修改了日期或对象,旧确认失效。

纯语音确认适合低风险动作,但要避免背景人声代答。高风险动作使用屏幕按钮、设备认证或独立二次因素。无屏电话场景可让用户复述随机挑战或关键参数,但仍不能把声纹识别当作唯一身份。确认超时后工具回到待提案,而不是保持无限期可执行。

工具进度语言要来自后端状态,不由模型猜测。只有业务服务返回running,Agent才能说“正在处理”;只有拿到成功结果,才能说“已完成”。超时应说“尚未确认结果”,并提供查询入口,不能把HTTP超时翻译成“操作失败”或“操作成功”。

12.3 降级路径决定系统能否在坏网络下工作

实时音频连接失败时,页面可以降级到按住说话、上传短录音或文字输入。降级前清理旧媒体和未提交输入,避免同一句话从WebRTC和上传接口各发送一次。业务工具与会话状态放在后端,因此更换交互通道不应丢失已确认任务。

语义VAD不可用时,可退回Server VAD;噪声过高时,可退回Push-to-talk;音频输出失败时,把文本回答显示在可访问的区域。降级不是偷偷降低质量,UI要明确当前模式,让用户知道为什么需要点击发送或为什么暂时只有文字。

无障碍也属于基本能力。所有语音输出应提供实时或最终文本,按钮可用键盘操作,录音状态不能只靠颜色表示。用户应能调整音量、停止朗读、关闭自动播放和删除会话。对听力或语言障碍用户,文本通道不是备用功能,而是等价入口。

12.4 用可重放事件验证竞态

实时问题往往无法只靠最终截图复现。测试环境可保存脱敏后的控制事件序列和相对时间,不保存原始音频;回放时注入speech_started、音频delta、工具调用、断线和重连,验证状态机最终一致。对同一序列随机改变无依赖事件的到达顺序,可以暴露“先收到done还是先收到最后音频包”这类竞态。

至少构造三个关键序列:用户在模型首音频前就打断;工具结果返回时连接刚好重建;旧响应的音频包在新轮次开始后迟到。正确系统应根据response和connection generation丢弃旧数据,不能仅凭事件类型接收。每个修复过的线上竞态,都加入这一小组回放用例。

12.5 监控指标要能区分网络、模型和产品决策

会话成功率应拆成取麦权限失败、凭证签发失败、SDP交换失败、ICE失败、上游关闭和应用主动结束。否则“连接失败”一个指标无法指导修复。轮次指标拆成VAD提前结束、硬超时提交、手动提交和用户放弃;打断指标区分本地静音成功、上游取消成功和截断成功。

隐私允许时,可以保存音频时长和能量统计,但默认不保存内容。日志中的transcript也可能含敏感信息,应设置短保留期、字段脱敏和租户访问控制。排障面板只显示必要片段,并记录谁查看过。技术上能记录整段对话,不代表业务上应该记录。

告警应关注趋势和尾部:某浏览器版本的ICE失败率上升、某设备类型的VAD硬超时增加、工具幂等命中突然飙升、截断确认缺失。模型首音频变慢与网络播放卡顿是不同问题,只有分阶段时间戳才能定位。每次发布语音配置也要带版本号,指标按配置版本切分,避免多组阈值混在一起。

12.6 真实环境评测需要固定脚本也需要自由对话

固定脚本便于版本比较,例如要求测试者读包含自然停顿、自我修正和相似实体的一组句子,并在指定词处打断Agent。自由对话则能发现脚本没有覆盖的节奏。两者都要保留,不应只让内部员工按照实现逻辑说话。

测试者不知道当前使用哪组VAD参数,主观评分才不容易受预期影响。除“自然不自然”外,让他们标记具体事件:被抢话、等待过久、打断无效、重复提问、错误执行工具。事件标签能映射到日志,比一个总体验分更适合排障。

上线灰度从低风险只读工具开始。先证明连接、轮次和打断可靠,再逐步开放写操作;高风险工具即使语音体验成熟,也继续保留确定性确认。语音流畅度不能成为放松授权的理由。

13. 总结

全双工语音Agent是一套实时分布式系统,不是把STT、LLM和TTS顺序串起来。WebRTC负责可靠传输媒体,语义VAD改善轮次边界,Barge-in要求同步本地播放和服务端历史,工具调用则必须经过服务端鉴权、审批与幂等控制。

最重要的实现原则有三条:用户一开口就停止本地播放,但不要把停止播报等同于撤销业务;服务端只记住用户真正听到的音频;模型只能提出工具调用,不能绕过确定性权限。把这三条落实,再用真实设备、弱网和对抗音频测试,语音Agent才会从“能开口”走向“可以放心交互”。

参考资料

  • OpenAI Realtime API入门
  • OpenAI Realtime WebRTC连接指南
  • OpenAI Realtime会话与打断处理
  • OpenAI Realtime VAD指南
  • OpenAI Realtime工具与MCP
  • OpenAI Realtime客户端事件参考
  • W3C WebRTC 1.0规范
  • IETF RFC 8825:WebRTC概览
  • IETF RFC 8829:JavaScript Session Establishment Protocol
  • MDN:RTCPeerConnection
  • OpenAI Agents SDK:构建Voice Agents
  • OpenAI Realtime Server-side Controls
  • 赞(0)
    未经允许不得转载:网硕互联帮助中心 » 全双工语音Agent实战:WebRTC、语义VAD、打断与工具调用
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!