AiVedio:实时 AI 视频与 WebRTC
从实时数字人、WebRTC 媒体拓扑、信令设计、RTCPeerConnection、ICE/STUN/TURN、短期 Session Token、会话状态机、AI 实时媒体流水线、重连恢复、质量指标、录制、容量成本和计费一致性出发,设计低延迟 AI 视频会话系统。
第十三章:实时 AI 视频与 WebRTC
本章主题:实时数字人、实时口播和实时生成视频流。
本章的核心不是“浏览器怎样播放一个视频”,而是:怎样把一个低延迟、双向、长连接、按时长计费、可能跨越 NAT 和企业防火墙的 AI 会话,做成可鉴权、可恢复、可观测、可审计的生产系统。
WebRTC 提供实时音视频和数据传输能力,但它不是完整的业务会话系统。信令协议、身份认证、配额、计费、供应商适配、会话恢复、录制和审计,仍然需要由应用层设计。W3C 的 RTCPeerConnection 负责管理实时连接;WebRTC 的信令通道则由应用自行选择,常见实现是 HTTPS 或 WebSocket。(W3C WebRTC)
13.1 先定义什么叫“实时 AI 视频”
“AI 视频”至少有四种不同形态,不能都用同一套架构。
| 形态 | 典型交互 | 核心指标 | 推荐技术 |
|---|---|---|---|
| 文件生成 | 提交提示词,数十秒或数分钟后得到 MP4 | 任务成功率、总耗时、成本 | HTTP、MQ、对象存储、CDN |
| 流式预览 | 模型尚未完成时持续输出可播放内容 | 首帧时间、缓冲稳定性 | WebRTC、分片 MP4、低延迟 HLS |
| 实时数字人 | 用户说话,数字人立即理解、回答并口型同步 | 对话轮次延迟、打断延迟、音画同步 | WebRTC |
| 大规模直播 | 主播或数字人向大量观众单向输出 | 并发、CDN 命中率、播放稳定性 | WebRTC/WHIP 推流,HLS/CMAF 分发 |
WebRTC 协议体系中的实时媒体,强调媒体生成和播放在时间上紧密相邻,适合交互式通信,而不是“先完整生成文件,再下载播放”。(RFC 8825)
实时数字人应至少定义以下指标:
T_session_create = 点击开始到平台创建业务会话
T_ice_connect = 开始协商到 ICE/DTLS 连接建立
T_first_audio = 会话开始到收到第一段可播放音频
T_first_video = 会话开始到解码第一帧视频
T_turn = 用户结束说话到 AI 开始回答
T_barge_in = 用户打断到 AI 停止当前回答
T_recover = 网络切换或短暂断网后的恢复时间
其中,对话轮次延迟可拆成:
T_turn
= T_uplink
+ T_vad_endpoint
+ T_asr
+ T_llm_first_token
+ T_tts_first_chunk
+ T_avatar_first_frame
+ T_downlink
+ T_jitter_buffer
这意味着优化实时 AI 体验时,重点不是等待整段文本、整段语音或整段视频生成完成,而是让 ASR、LLM、TTS 和数字人渲染都能够分块、流水线化并尽快产出第一块结果。
高级表达:
文件生成关注总完成时间,实时 AI 关注首包、首音频、首帧、轮次延迟和打断延迟。
13.2 WebRTC 只是媒体能力,不是完整业务系统
一个生产级实时 AI 系统应明确分离三个平面。
控制面
负责:
- 用户鉴权和租户隔离。
- 创建、续期和结束业务会话。
- 供应商选择和并发配额。
- 短期凭证签发。
- SDP、ICE Candidate 等信令交换。
- 用量、计费和审计。
- 重连、恢复和会话终止。
媒体面
负责:
- 麦克风或摄像头采集。
- 音频、视频编码。
- RTP/SRTP 媒体传输。
- DTLS 密钥协商。
- ICE 连通性检查。
- 抖动缓冲、丢包恢复和拥塞控制。
- AI 生成音视频的实时下行。
观测面
负责:
- 浏览器
getStats()指标。 - 供应商媒体节点指标。
- TURN 中继比例和流量。
- 首帧、卡顿、丢包、抖动和 RTT。
- 会话并发、时长、费用和异常终止。
推荐架构:
控制面
Browser ── HTTPS/WSS ──► Realtime Session Service
│ ├── Auth / Tenant / Quota
│ ├── Provider Adapter
│ ├── Ephemeral Token Broker
│ ├── Session State / Billing
│ └── Audit / Reconciliation
│ │
│ ▼
│ Provider Control API
│
└════════════ WebRTC Media Plane ════════════► Provider Media Node
ICE + DTLS + SRTP
│
└── 必要时经过 TURN
关键原则:
普通 Go API 服务负责控制面,不应无条件承担实时音视频转发。
如果所有 RTP 包都先进入普通业务 API,再转发到供应商,会产生以下问题:
- Go API 的连接数、带宽和内存被媒体流量占满。
- 每多一跳都会增加延迟、抖动和故障点。
- 业务发布、扩缩容和 GC 抖动会直接影响媒体质量。
- 需要自行处理 RTP、RTCP、NACK、PLI、拥塞控制和关键帧请求。
- 录制、转码和协议转换职责与普通业务接口混杂。
只有在必须执行服务端录制、合流、实时审核、多人转发、协议转换或媒体水印时,才应引入专用 SFU、媒体网关或基于专业 WebRTC 协议栈构建的媒体服务。
13.3 三种媒体拓扑
方案一:浏览器直连供应商媒体节点
Browser ───── WebRTC ───── Provider Media Node
│ │
└── HTTPS/WSS ─ Backend ───────┘
优点:
- 媒体路径最短。
- 平台带宽成本最低。
- 后端不需要承载 RTP 转发。
- 适合一对一实时数字人。
缺点:
- 平台对媒体内容的实时控制较弱。
- 录制和审核依赖供应商能力。
- 供应商协议差异会进入客户端或适配层。
- 故障排查需要同时关联浏览器和供应商指标。
方案二:浏览器连接平台媒体网关,平台再连接供应商
Browser ── WebRTC ── Platform Media Gateway ── WebRTC/RTP ── Provider
优点:
- 可以统一录制、审核、水印和协议转换。
- 可隐藏供应商媒体拓扑。
- 可统一做多供应商切换和会话迁移。
- 可支持多人房间、旁路录制和观众转发。
缺点:
- 增加一跳延迟。
- 平台承担高额带宽、CPU 和运维成本。
- 需要专业媒体工程能力。
- 转码会显著增加成本和延迟。
方案三:交互用户走 WebRTC,大规模观众走 HLS
┌── WebRTC ── Interactive User
AI / Host ── WebRTC ── SFU / Media Gateway
└── Transcode ── HLS/CMAF ── CDN ── Audience
这是实时互动和大规模分发结合时最常见的边界:
- 少量互动席位使用 WebRTC。
- 大量只观看用户使用 HLS/CMAF。
- 互动席位的低延迟不被 CDN 分片链路拖慢。
- 观众侧不为每人维护高成本的 WebRTC 会话。
13.4 端到端会话创建流程
推荐的完整时序如下:
Browser
│
│ 1. POST /v1/realtime/sessions
│ Idempotency-Key: client-request-id
▼
Realtime Session Service
│ 2. 鉴权、租户限流、并发配额、余额或套餐检查
│ 3. 创建本地 session,状态 CREATING
│ 4. 申请带过期时间的并发 lease
▼
Provider Adapter
│ 5. 使用平台长期密钥调用供应商
│ 6. 携带 provider_idempotency_key
▼
Provider Control API
│ 7. 创建供应商实时会话
│ 8. 返回 provider_session_id、短期凭证、SDP 或 endpoint
▼
Realtime Session Service
│ 9. 保存 provider_session_id、过期时间和协商版本
│ 10. 状态切换为 NEGOTIATING
│ 11. 返回平台 session_id、一次性 signaling ticket、ICE 配置
▼
Browser
│ 12. 创建 RTCPeerConnection
│ 13. 添加本地音频/视频 Track 或 recvonly Transceiver
│ 14. 交换 Offer/Answer
│ 15. Trickle ICE Candidate
│ 16. ICE + DTLS + SRTP 建链
▼
Provider Media Node
│ 17. 接收用户音频,返回 AI 音频和视频
│ 18. 上报 connected、usage、ended 等事件
▼
Realtime Session Service
19. 持续计量、续租、监控和最终对账
不要把数据库事务跨越供应商调用
错误做法:
BEGIN
INSERT realtime_session
调用供应商,等待数秒
UPDATE realtime_session
COMMIT
这会长时间占用数据库连接和行锁,并把第三方延迟传递给数据库。
正确做法:
事务 A:插入 CREATING 会话并提交
↓
申请并发 lease
↓
事务外调用供应商
↓
事务 B:CAS 更新 CREATING → NEGOTIATING
如果供应商已经创建会话,但平台在保存结果前崩溃,应由 Reconciler 使用 provider_idempotency_key 或业务元数据查询供应商,而不是直接再次创建新会话。
这与异步视频生成中的原则相同:
第三方调用超时不等于第三方未执行;重试前必须先判断是否处于“对方已成功、本地结果未知”的歧义状态。
13.5 信令设计
WebRTC 没有规定应用必须使用哪一种信令协议。RTCPeerConnection 负责媒体协商状态机,但 SDP、ICE Candidate 和业务事件怎样在双方之间交换,由应用决定。JSEP 描述了 JavaScript 如何通过 createOffer、setLocalDescription、setRemoteDescription 等接口控制协商。(RFC 9429)
常见选择
| 信令需求 | 推荐方式 |
|---|---|
| 创建会话 | HTTPS |
| 初始 Offer/Answer | HTTPS 或 WebSocket |
| Trickle ICE Candidate | WebSocket,或独立 HTTPS POST |
| 供应商状态事件 | WebSocket、Webhook |
| 单向只读状态通知 | SSE |
| 终止会话 | HTTPS DELETE,附带幂等键 |
SSE 是单向协议,不适合独立承担双向 Offer/Answer 和 Candidate 交换。可以用 SSE 接收状态事件,但浏览器向服务器发送 Candidate 仍需 HTTPS 或其他上行通道。
信令消息必须可去重、可排序、可识别协商代次
{
"message_id": "01J...",
"session_id": "rt_01J...",
"connection_epoch": 3,
"negotiation_revision": 7,
"sequence": 18,
"type": "ice_candidate",
"payload": {
"candidate": "candidate:...",
"sdpMid": "0",
"sdpMLineIndex": 0
}
}
字段含义:
message_id:消息去重。connection_epoch:区分旧连接与恢复后的新连接。negotiation_revision:区分不同 Offer/Answer 轮次。sequence:检测丢失、乱序和重放。session_id:只能在当前租户和用户权限范围内访问。
Trickle ICE 允许边收集 Candidate 边进行连通性检查,从而缩短建链时间。应用层信令应确保传给 ICE 实现的 Candidate 不重复且顺序正确。(RFC 8838)
尽量固定 Offerer 角色
如果业务允许,规定始终由浏览器或始终由供应商发起 Offer,可以显著减少双方同时发起协商造成的 glare 问题。
需要动态增删 Track、切换设备或重新协商时,应使用:
- 协商锁。
negotiation_revision。- stale answer 拒绝。
- perfect negotiation 模式或等价状态机。
不要随意修改 SDP 文本
优先使用标准 API:
addTrack()。addTransceiver()。RTCRtpSender.setParameters()。RTCRtpTransceiver.setCodecPreferences(),使用前检测兼容性。
只有供应商存在明确互操作要求时才做 SDP munging,并且必须有浏览器兼容测试。原始 SDP 和 ICE Candidate 可能包含网络地址、编解码能力和证书指纹,不应无脱敏地写入普通日志。
13.6 RTCPeerConnection 生命周期
浏览器端至少需要理解四组状态。
| 状态 | 作用 |
|---|---|
signalingState | Offer/Answer 协商状态 |
iceGatheringState | 本地 ICE Candidate 收集状态 |
iceConnectionState | ICE 连通性检查状态 |
connectionState | ICE 与 DTLS 综合连接状态 |
常见业务状态映射:
NEW
↓ 创建 RTCPeerConnection
NEGOTIATING
↓ Offer/Answer 已完成
CONNECTING
↓ ICE + DTLS 成功
CONNECTED
↓ 收到首个可解码媒体帧
PLAYING
├── 短暂网络波动 → DEGRADED / RECONNECTING
├── ICE restart 成功 → PLAYING
└── 无法恢复 → FAILED / ENDED
注意:
connectionState === "connected"不等于用户已经看到数字人。- 产品层“可用”通常还要求收到远端 Track,并解码出第一帧或播放出第一段音频。
disconnected可能只是短暂网络抖动,不应立即销毁会话。failed通常需要 ICE restart 或重建RTCPeerConnection。- 页面刷新后,原
RTCPeerConnection无法直接复活,只能重建媒体连接并恢复业务上下文。
浏览器端骨架代码
以下代码展示核心结构,具体信令消息需按供应商适配。
type SessionBootstrap = {
sessionId: string;
signalingUrl: string;
signalingTicket: string; // 一次性、短期凭证
iceServers: RTCIceServer[];
remoteOffer?: RTCSessionDescriptionInit;
};
class RealtimeAvatarClient {
private pc?: RTCPeerConnection;
private ws?: WebSocket;
private sessionId?: string;
private connectionEpoch = 1;
async start(localStream: MediaStream, remoteVideo: HTMLVideoElement) {
const resp = await fetch("/v1/realtime/sessions", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Idempotency-Key": crypto.randomUUID(),
},
body: JSON.stringify({
mode: "interactive_avatar",
publishAudio: true,
publishVideo: false,
subscribeAudio: true,
subscribeVideo: true,
}),
});
if (!resp.ok) {
throw new Error(`create session failed: ${resp.status}`);
}
const bootstrap = (await resp.json()) as SessionBootstrap;
this.sessionId = bootstrap.sessionId;
this.pc = new RTCPeerConnection({
iceServers: bootstrap.iceServers,
bundlePolicy: "max-bundle",
});
for (const track of localStream.getAudioTracks()) {
this.pc.addTrack(track, localStream);
}
// addTrack 创建的音频 transceiver 可同时接收远端音频;
// 本例额外声明一个只接收的 AI 视频 transceiver。
this.pc.addTransceiver("video", { direction: "recvonly" });
this.pc.ontrack = (event) => {
const [stream] = event.streams;
if (stream) {
remoteVideo.srcObject = stream;
} else {
const current = remoteVideo.srcObject as MediaStream | null;
const merged = current ?? new MediaStream();
merged.addTrack(event.track);
remoteVideo.srcObject = merged;
}
};
this.pc.onicecandidate = (event) => {
if (!event.candidate || !this.ws || this.ws.readyState !== WebSocket.OPEN) {
return;
}
this.sendSignal("ice_candidate", event.candidate.toJSON());
};
this.pc.onconnectionstatechange = () => {
const state = this.pc?.connectionState;
if (state === "failed") {
void this.restartIce();
}
};
// 浏览器 WebSocket 不能随意设置 Authorization Header,
// 因此这里只允许使用一次性、极短期 ticket,不能放长期 Access Token。
const url = new URL(bootstrap.signalingUrl);
url.searchParams.set("ticket", bootstrap.signalingTicket);
this.ws = new WebSocket(url);
await new Promise<void>((resolve, reject) => {
if (!this.ws) return reject(new Error("missing websocket"));
this.ws.onopen = () => resolve();
this.ws.onerror = () => reject(new Error("signaling websocket failed"));
});
this.ws.onmessage = async (event) => {
const msg = JSON.parse(event.data);
if (!this.pc || msg.connection_epoch !== this.connectionEpoch) return;
switch (msg.type) {
case "answer":
await this.pc.setRemoteDescription(msg.payload);
break;
case "offer": {
await this.pc.setRemoteDescription(msg.payload);
const answer = await this.pc.createAnswer();
await this.pc.setLocalDescription(answer);
this.sendSignal("answer", this.pc.localDescription);
break;
}
case "ice_candidate":
await this.pc.addIceCandidate(msg.payload);
break;
case "session_ended":
await this.close("remote_ended");
break;
}
};
if (bootstrap.remoteOffer) {
await this.pc.setRemoteDescription(bootstrap.remoteOffer);
const answer = await this.pc.createAnswer();
await this.pc.setLocalDescription(answer);
this.sendSignal("answer", this.pc.localDescription);
} else {
const offer = await this.pc.createOffer();
await this.pc.setLocalDescription(offer);
this.sendSignal("offer", this.pc.localDescription);
}
}
private sendSignal(type: string, payload: unknown) {
this.ws?.send(JSON.stringify({
type,
payload,
session_id: this.sessionId,
connection_epoch: this.connectionEpoch,
message_id: crypto.randomUUID(),
}));
}
private async restartIce() {
if (!this.pc) return;
this.pc.restartIce();
const offer = await this.pc.createOffer();
await this.pc.setLocalDescription(offer);
this.sendSignal("offer", this.pc.localDescription);
}
async close(reason: string) {
this.ws?.close();
this.pc?.close();
if (this.sessionId) {
await fetch(`/v1/realtime/sessions/${this.sessionId}`, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
"Idempotency-Key": crypto.randomUUID(),
},
body: JSON.stringify({ reason }),
keepalive: true,
}).catch(() => undefined);
}
}
}
生产代码还应补充:
- 自动播放限制处理。
- 页面隐藏和设备切换处理。
- 信令重连和事件补拉。
- Candidate 发送队列。
- 协商互斥。
AbortController。- 超时和取消。
- 指标采集。
- Track ended、mute、unmute 处理。
13.7 ICE、STUN 和 TURN
ICE 用于在 NAT、防火墙和多网卡环境中寻找可用媒体路径。它会收集候选地址、形成 Candidate Pair、执行连通性检查,并选择最终路径。ICE 使用 STUN 和 TURN 作为组成部分。(RFC 8445)
三类常见 Candidate
| Candidate 类型 | 含义 |
|---|---|
host | 本机网卡地址 |
srflx | 通过 STUN 发现的 NAT 映射公网地址 |
relay | TURN 服务器分配的中继地址 |
STUN 不是媒体中继
STUN 的主要作用是帮助端点发现 NAT 映射地址、检查连通性和维持 NAT Binding。STUN 本身不是完整的 NAT 穿透方案,也不会替用户持续转发媒体。(RFC 8489)
TURN 是兜底中继
当双方无法建立直接路径时,TURN 服务器作为中继转发数据。TURN 是可靠可用性的关键组成部分,但会产生明显的带宽和跨地域成本。(RFC 8656)
Browser ──► TURN Relay ──► Provider Media Node
生产级 TURN 要点
-
多地域部署
TURN 应尽量接近用户,并根据用户、供应商媒体节点和网络质量选择区域。
-
支持多种传输兜底
优先使用 UDP;对于限制 UDP 的企业网络,应提供 TCP/TLS 等兼容路径。部分企业环境只允许常见 TLS 端口,需要单独验证。
-
短期凭证
不要把静态 TURN 用户名和密码写入前端代码。凭证应与用户、租户、会话和过期时间绑定。
-
真实中继健康检查
只探测 TURN 端口能否建立连接是不够的。应测试:
- Allocate 是否成功。
- Permission/ChannelBind 是否成功。
- 实际双向数据是否可转发。
- 不同运营商和地域是否可达。
-
带宽和滥用控制
TURN 是公开网络中的高带宽服务,应限制:
- 单租户并发。
- 单会话带宽。
- 凭证有效期。
- 可访问 Peer 范围。
- 异常流量和扫描行为。
-
监控 Relay Ratio
turn_relay_ratio = relay candidate 成为 selected pair 的会话数 / 成功连接会话数Relay Ratio 突然升高,可能代表:
- STUN 故障。
- 某区域 UDP 被阻断。
- 浏览器或供应商 ICE 配置变化。
- 企业网络用户占比上升。
-
不要默认强制 relay
iceTransportPolicy: "relay"会强制所有流量经过 TURN,只适合:- 隐藏端点 IP 的特殊隐私场景。
- TURN 演练和故障测试。
- 已知无法直连的受限网络。
普通场景应允许 ICE 优先选择更直接的路径。
13.8 短期 Session Token 设计
前端永远不应获得供应商长期 API Key。建议把凭证分成四层:
| 凭证 | 作用 | 推荐特征 |
|---|---|---|
| 平台 Access Token | 调用平台业务 API | 用户级,常规登录生命周期 |
| Signaling Ticket | 建立信令连接 | 一次性、极短期、绑定 session |
| Provider Ephemeral Token | 连接供应商媒体或信令节点 | 最小权限、短期、不可跨 session |
| TURN Credential | 使用 TURN 中继 | 短期、绑定租户和过期时间 |
短期 token 可以是 JWT,也可以是服务端保存状态的 opaque token。无论哪一种,都应至少包含或绑定:
subject / user_id
tenant_id
session_id
audience
scope
region
provider
connection_epoch
issued_at
not_before
expires_at
jti / nonce
max_session_duration
推荐 scope:
signal:connect
signal:offer
signal:candidate
media:publish:audio
media:publish:video
media:subscribe:audio
media:subscribe:video
session:terminate
安全原则:
- 创建会话的用户只能连接自己的
session_id。 - Ticket 首次使用后立即失效。
- Ticket 不写入普通访问日志。
- 不在
localStorage长期保存媒体凭证。 - Token 过期不代表已有媒体连接一定自动断开,因此服务端仍需强制
max_session_duration。 - Provider token 原文不进入数据库;如需审计,只保存 token 哈希、
jti和过期时间。 - 供应商长期密钥只存在于后端密钥管理系统中。
WebRTC 要求安全传输,并围绕不可信网站、信令方和网络攻击者建立安全模型;应用层仍必须正确处理身份、权限、用户授权和凭证泄露。(RFC 8827)(RFC 8826)
13.9 会话状态机和数据模型
会话状态机
CREATING
├── 创建供应商会话成功 → NEGOTIATING
├── 结果未知 → RECONCILING
└── 明确失败 → FAILED
NEGOTIATING
├── 开始连通性检查 → CONNECTING
├── 凭证过期 → EXPIRED
└── 用户取消 → TERMINATING
CONNECTING
├── 媒体建链成功 → CONNECTED
├── 短暂故障 → RECONNECTING
└── 超时 → FAILED
CONNECTED
├── 网络切换 → RECONNECTING
├── 用户结束 → TERMINATING
├── 达到时长上限 → TERMINATING
└── 供应商终止 → ENDED / FAILED
RECONNECTING
├── ICE restart 成功 → CONNECTED
├── 重建媒体会话成功 → CONNECTED
└── 恢复超时 → FAILED
TERMINATING
└── 资源释放和结算完成 → ENDED
PostgreSQL 表
CREATE TABLE realtime_sessions (
id uuid PRIMARY KEY,
tenant_id uuid NOT NULL,
user_id uuid NOT NULL,
project_id uuid,
client_request_id text NOT NULL,
mode text NOT NULL,
provider text,
provider_session_id text,
region text,
status text NOT NULL,
status_version bigint NOT NULL DEFAULT 0,
connection_epoch bigint NOT NULL DEFAULT 1,
negotiation_revision bigint NOT NULL DEFAULT 0,
max_duration_seconds integer NOT NULL,
signaling_expires_at timestamptz,
session_expires_at timestamptz,
created_at timestamptz NOT NULL,
connected_at timestamptz,
first_audio_at timestamptz,
first_video_at timestamptz,
ended_at timestamptz,
last_heartbeat_at timestamptz,
selected_candidate_type text,
selected_protocol text,
close_reason text,
error_code text,
error_message text,
UNIQUE (tenant_id, client_request_id),
UNIQUE (provider, provider_session_id)
);
建议另建:
realtime_session_events
realtime_usage_samples
realtime_provider_usage
realtime_recordings
realtime_connection_attempts
为什么需要 status_version 和 connection_epoch
status_version 用于状态 CAS:
UPDATE realtime_sessions
SET status = 'CONNECTED',
status_version = status_version + 1,
connected_at = COALESCE(connected_at, now())
WHERE id = $1
AND status IN ('CONNECTING', 'RECONNECTING')
AND status_version = $2;
connection_epoch 用于 fencing:
- 页面刷新后建立新连接,epoch 加一。
- 服务端只接受最新 epoch 的信令和心跳。
- 旧标签页或旧网络连接即使恢复,也不能重新夺回会话。
- 旧连接收到关闭指令后应主动释放。
13.10 AI 实时媒体流水线
典型的实时数字人链路:
Browser Microphone
│
├── AEC / Noise Suppression / AGC
├── Opus Encode
└── WebRTC Uplink
│
▼
Provider Media Edge
│
├── VAD
├── Streaming ASR
├── LLM Streaming Inference
├── Streaming TTS
├── Avatar / Lip-sync Renderer
├── Audio/Video Timestamp Alignment
└── WebRTC Downlink
│
▼
Browser Jitter Buffer / Decode / Render
音频通常比视频更重要
实时口播场景中,应优先保证:
- 用户音频上行连续。
- AI 音频下行可理解。
- 口型和音频同步。
- 最后才是视频分辨率。
当带宽不足时,可以:
- 先降低视频码率。
- 再降低帧率或分辨率。
- 保持音频码率和连续性。
- 必要时临时冻结画面,但不要让语音断断续续。
Barge-in:用户打断 AI
打断不是简单的“停止前端播放”,而是一条跨组件控制链:
Client/Server VAD detects speech_started
│
├── 浏览器立即衰减或静音当前 AI 音频
├── 发送 cancel_response(response_id)
├── LLM 停止当前 generation
├── TTS 清空未发送音频
├── Avatar renderer 丢弃未播放帧
└── 新一轮输入开始
必须为每次回答分配 response_id 或 generation_id。否则旧回答的迟到事件可能污染新一轮会话。
需要注意:
- 本地立即静音可以降低用户感知的打断延迟。
- 服务端仍必须取消上游生成,否则会继续产生供应商费用。
- 取消事件和媒体包存在竞态,客户端应按当前 generation 过滤控制事件。
- 不应把“前端已静音”误认为“供应商已停止计费”。
DataChannel 的使用边界
DataChannel 适合传输低延迟、与媒体强相关的控制信息,例如:
speech_started。speech_stopped。response_started。response_cancelled。- 实时字幕片段。
- 表情、手势或页面控制事件。
但以下数据不应只存在于 DataChannel:
- 最终计费事实。
- 会话终态。
- 余额扣减。
- 合规审计记录。
- 必须重放的业务命令。
原则仍然是:
DataChannel 负责低延迟交互,PostgreSQL 负责业务事实。
13.11 编解码与互操作
WebRTC 端点需要协商双方共同支持的媒体能力。音频基线通常优先选择 Opus;视频需要根据浏览器、硬件、供应商和授权约束选择共同支持的 H.264、VP8 或其他编码。WebRTC 的音频和视频互操作要求分别由相关 RFC 规定。(RFC 7874)(RFC 7742)
工程上应建立供应商实时能力矩阵:
| 能力 | 示例字段 |
|---|---|
| 上行媒体 | audio、video、screen |
| 下行媒体 | generated audio、generated video |
| 音频编码 | Opus、G.711 等 |
| 视频编码 | H.264、VP8、AV1 等 |
| 分辨率 | 360p、720p、1080p |
| 帧率 | 15、24、30、60 fps |
| 声道 | mono、stereo |
| SDP 模式 | browser-offer、provider-offer |
| ICE | full trickle、half trickle、non-trickle |
| TURN | vendor TURN、platform TURN、both |
| 中途改轨 | add/remove/replace track |
| 录制 | server-side、client-side、none |
| 打断 | response cancel、audio flush |
| 用量事件 | 秒、音频分钟、视频分钟、token、帧 |
原则:
- 尽量选择双方原生支持的编码,避免转码。
- 不假设所有浏览器和设备都支持同一硬件编码器。
- 不只看“浏览器能否解码”,还要看供应商是否接受该 profile、level、packetization mode 和帧率。
- 编解码协商失败应暴露明确错误,不应只显示“连接失败”。
- 变更编码优先使用标准 API,不依赖脆弱的字符串替换。
13.12 重连与会话恢复
必须区分四种情况。
情况一:信令断开,但媒体仍在传输
WebSocket disconnected
RTCPeerConnection connected
处理:
- 不要立即关闭媒体连接。
- 重连信令通道。
- 使用
last_event_sequence补拉缺失事件。 - 重新验证当前
connection_epoch。 - 恢复心跳和控制命令。
情况二:ICE 暂时 disconnected
处理:
- 进入短暂观察窗口。
- 继续采集
getStats()。 - 如果很快恢复,则不做重协商。
- 超过阈值后执行 ICE restart。
情况三:ICE failed 或网络发生切换
常见于:
- Wi-Fi 切换到蜂窝网络。
- VPN 开启或关闭。
- NAT 映射失效。
- 企业网络策略变化。
处理:
pc.restartIce()
→ createOffer({ iceRestart: true })
→ 新一轮 Offer/Answer
→ 新 ICE ufrag/password
→ 重新选择 Candidate Pair
情况四:供应商媒体会话不可恢复
如果供应商不支持在原 session 上 ICE restart,则需要:
- 创建新的 provider session。
- 平台
connection_epoch + 1。 - 返回新的短期 token 和 ICE 配置。
- 客户端重建
RTCPeerConnection。 - 恢复对话上下文,但不假设原媒体包可续传。
- 关闭旧 provider session。
Resume API
POST /v1/realtime/sessions/{session_id}/resume
Content-Type: application/json
Idempotency-Key: ...
{
"last_event_sequence": 183,
"current_connection_epoch": 2,
"reason": "network_changed"
}
响应可明确指示:
{
"action": "ice_restart",
"connection_epoch": 2,
"signaling_ticket": "one-time-ticket"
}
或:
{
"action": "recreate_peer_connection",
"connection_epoch": 3,
"ice_servers": [],
"provider_ephemeral_token": "..."
}
高级原则:
恢复的是业务会话上下文,不是强行复活一个已经失效的浏览器 RTCPeerConnection 对象。
13.13 WebRTC 质量指标
W3C WebRTC Stats API 定义了从 RTCPeerConnection.getStats() 获取连接、RTP、编解码和 Candidate Pair 指标的方式。(W3C WebRTC Stats)
浏览器侧重点指标
连接
connectionState。iceConnectionState。- selected candidate pair。
- Candidate 类型:host、srflx、relay。
- 传输协议:UDP、TCP。
currentRoundTripTime。
入站音频
packetsReceived。packetsLost。jitter。bytesReceived。audioLevel,浏览器支持时使用。- concealed samples 等音频隐藏指标,浏览器支持时使用。
入站视频
framesReceived。framesDecoded。framesDropped。framesPerSecond。freezeCount。totalFreezesDuration。keyFramesDecoded。bytesReceived。
出站媒体
bytesSent。packetsSent。retransmittedPacketsSent。framesEncoded。qualityLimitationReason,浏览器支持时使用。
派生指标
bitrate_bps
= 8 × (bytes_now - bytes_prev)
/ (time_now - time_prev)
loss_rate
= ΔpacketsLost
/ (ΔpacketsLost + ΔpacketsReceived)
frame_drop_rate
= ΔframesDropped
/ (ΔframesDecoded + ΔframesDropped)
采集时应注意:
- 使用差值,不直接拿累计值当速率。
- 处理计数器重置和 Track 切换。
- 不要每秒把完整 Stats Report 全量上传。
- 可每 2~5 秒本地采样,每 15~30 秒聚合上报。
- 不上传不必要的本地 IP、远端 IP 和完整 Candidate 文本。
- 按
session_id + connection_epoch + track_identifier聚合。
平台核心指标
realtime_session_create_success_rate
realtime_session_create_latency_p95
webrtc_ice_connect_success_rate
webrtc_ice_connect_latency_p95
webrtc_first_audio_latency_p95
webrtc_first_video_latency_p95
webrtc_turn_relay_ratio
webrtc_reconnect_rate
webrtc_reconnect_success_rate
webrtc_unexpected_disconnect_rate
webrtc_one_way_audio_rate
webrtc_video_freeze_ratio
provider_session_error_rate
provider_session_concurrency
provider_realtime_cost_per_minute
示例 SLO
以下只是项目初始值,必须按真实网络基线校准:
| 指标 | 示例目标 |
|---|---|
| 会话创建成功率 | ≥ 99.9% |
| ICE 建链成功率 | ≥ 99.5% |
| 首音频 P95 | ≤ 2 秒 |
| 首视频 P95 | ≤ 3 秒 |
| 短暂断网恢复成功率 | ≥ 95% |
| 非用户主动异常终止率 | ≤ 1% |
SLO 需要按地域、浏览器、网络、供应商和是否经过 TURN 分桶,否则全局平均值会掩盖局部故障。
13.14 录制设计
方案一:浏览器 MediaRecorder
优点:
- 实现简单。
- 不需要服务端承载媒体。
- 适合用户主动保存本地片段。
缺点:
- 页面关闭、崩溃或系统回收后录制可能丢失。
- 不同浏览器支持的容器和编码不同。
- 很难作为计费、审计或合规的唯一事实源。
- 长时间录制会受到内存和上传稳定性影响。
MediaRecorder 可以按 timeslice 周期产生数据块,但过大的块可能导致浏览器缓冲过多并造成卡顿或内存压力。(W3C MediaStream Recording)
方案二:供应商服务端录制
优点:
- 接入快。
- 不依赖浏览器生命周期。
- 通常能录到供应商实际输出。
缺点:
- 数据位置和保留策略受供应商限制。
- 供应商切换后录制格式不一致。
- 可能无法获得用户输入、AI 输出和控制事件的独立轨道。
方案三:平台媒体网关旁路录制
优点:
- 可获得稳定、统一的录制格式。
- 支持单独保存用户音频、AI 音频、AI 视频和字幕事件。
- 便于后续审核、编辑和对账。
缺点:
- 增加平台带宽、存储和媒体运维成本。
- 需要处理丢包、时间戳、分段、轨道同步和录制恢复。
推荐录制数据模型:
recording_id
session_id
recording_mode composite / separate_tracks
audio_input_asset_id
ai_audio_asset_id
ai_video_asset_id
caption_asset_id
event_log_asset_id
started_at
ended_at
status
retention_policy
consent_version
推荐流程:
Session CONNECTED
↓
Start Recorder
↓
持续生成短分段
↓
上传对象存储
↓
更新 Recording Manifest
↓
Session ENDED
↓
Finalize / Remux / Validate
↓
生成平台 Asset
不要等整个长会话结束后才一次性上传一个巨大文件。应使用分段、校验和、断点恢复和最终 manifest。
合规要点
- 录制前取得明确授权。
- UI 持续展示录制状态。
- 保存同意版本和时间。
- 定义用途、保留期限和删除机制。
- 按租户和角色控制访问。
- 对下载、播放和删除进行审计。
- 明确数据驻留地域。
- 供应商是否使用输入输出进行训练必须可配置并可证明。
- 不把未经授权的原始录音用于模型训练。
13.15 WebRTC 与 HLS 的使用边界
HLS 通过 HTTP 播放列表和媒体分段进行流媒体分发,天然适合 CDN 缓存和大规模单向播放。(RFC 8216)
| 维度 | WebRTC | HLS/CMAF |
|---|---|---|
| 交互方向 | 双向 | 主要单向 |
| 延迟 | 低,适合实时互动 | 较高,取决于分段和缓冲 |
| NAT 穿透 | ICE/STUN/TURN | 普通 HTTP/HTTPS |
| 大规模分发 | 每会话成本较高 | CDN 扩展能力强 |
| 弱网策略 | 实时拥塞控制、及时性优先 | 缓冲和 HTTP 重传优先 |
| 浏览器端控制 | Track、DataChannel、Stats | 播放列表和媒体分段 |
| 典型场景 | 视频通话、数字人、云游戏、实时协作 | 点播、直播观看、大规模分发 |
选择原则:
需要用户说话、打断、实时控制
→ WebRTC
只需要观看,允许若干秒缓冲
→ HLS/CMAF
少量主播互动,大量观众观看
→ WebRTC ingest + HLS distribution
WHIP 已标准化为基于 HTTP 的 WebRTC 推流协议,适合把 WebRTC 媒体送入流媒体服务或 CDN。它解决的是 WebRTC ingest,不等于完整的实时 AI 会话协议,业务鉴权、计费、AI 控制和会话恢复仍需应用层实现。(RFC 9725)
13.16 Go 后端接口设计
Provider Adapter
type RealtimeProvider interface {
CreateSession(ctx context.Context, req CreateProviderSessionRequest) (ProviderSession, error)
GetSessionByIdempotencyKey(ctx context.Context, key string) (ProviderSession, error)
ExchangeSDP(ctx context.Context, providerSessionID string, desc SessionDescription) (SessionDescription, error)
AddICECandidate(ctx context.Context, providerSessionID string, candidate ICECandidate) error
RestartSession(ctx context.Context, providerSessionID string) (ProviderSession, error)
CloseSession(ctx context.Context, providerSessionID string, reason string) error
GetUsage(ctx context.Context, providerSessionID string) (ProviderUsage, error)
}
type ProviderSession struct {
ProviderSessionID string
Region string
SignalingEndpoint string
EphemeralToken string
ExpiresAt time.Time
RemoteOffer *SessionDescription
Capabilities RealtimeCapabilities
}
创建会话伪代码
func (s *Service) CreateRealtimeSession(
ctx context.Context,
actor Actor,
idempotencyKey string,
req CreateSessionRequest,
) (*CreateSessionResponse, error) {
// 1. 参数、权限和租户策略检查。
if err := s.policy.ValidateCreate(ctx, actor, req); err != nil {
return nil, err
}
// 2. 先通过唯一约束创建本地事实,不跨外部调用持有事务。
session, created, err := s.repo.CreateOrGetCreating(
ctx, actor, idempotencyKey, req,
)
if err != nil {
return nil, err
}
if !created && session.Status != StatusCreating {
return s.buildExistingResponse(ctx, session)
}
// 3. 申请带 TTL 的并发 lease;lease 不是事实源。
lease, err := s.capacity.Acquire(ctx, session.ID, actor.TenantID, req.Region)
if err != nil {
_ = s.repo.MarkFailed(ctx, session.ID, "CAPACITY_EXHAUSTED")
return nil, err
}
providerKey := "rt-session:" + session.ID.String()
providerSession, err := s.provider.CreateSession(ctx, CreateProviderSessionRequest{
IdempotencyKey: providerKey,
Mode: req.Mode,
Region: req.Region,
MaxDuration: req.MaxDuration,
})
if err != nil {
if IsAmbiguousTimeout(err) {
_ = s.repo.MarkReconciling(ctx, session.ID, err.Error())
// HTTP 层可映射为 202 Accepted,并把同一个 session_id 返回给客户端。
return &CreateSessionResponse{
SessionID: session.ID,
Status: "RECONCILING",
}, nil
}
_ = s.capacity.Release(ctx, lease.ID)
_ = s.repo.MarkFailed(ctx, session.ID, NormalizeProviderError(err))
return nil, err
}
// 4. CAS 保存供应商结果。若此处失败,由 Reconciler 处理孤儿会话。
updated, err := s.repo.BindProviderSession(ctx, BindProviderSessionParams{
SessionID: session.ID,
ExpectedStatus: StatusCreating,
Provider: s.providerName,
ProviderSessionID: providerSession.ProviderSessionID,
Region: providerSession.Region,
ExpiresAt: providerSession.ExpiresAt,
LeaseID: lease.ID,
})
if err != nil {
return nil, err
}
// 5. 签发一次性平台 signaling ticket,不返回长期供应商密钥。
ticket, err := s.tickets.Issue(ctx, TicketClaims{
UserID: actor.UserID,
TenantID: actor.TenantID,
SessionID: updated.ID,
ConnectionEpoch: updated.ConnectionEpoch,
Scopes: []string{"signal:connect", "signal:offer", "signal:candidate"},
ExpiresAt: time.Now().Add(90 * time.Second),
OneTime: true,
})
if err != nil {
return nil, err
}
return &CreateSessionResponse{
SessionID: updated.ID,
SignalingTicket: ticket,
ICEServers: s.turn.IssueConfig(actor.TenantID, updated.ID),
RemoteOffer: providerSession.RemoteOffer,
}, nil
}
需要补偿的崩溃窗口
| 崩溃位置 | 风险 | 补偿方式 |
|---|---|---|
| 创建本地 session 后、调用供应商前 | CREATING 卡住 | 超时扫描后失败或重试 |
| 供应商成功后、本地绑定前 | 供应商孤儿会话、持续计费 | 按 idempotency key 查询并绑定或关闭 |
| 本地绑定后、响应浏览器前 | 浏览器重试创建 | 唯一键返回原 session |
| 结束供应商会话成功后、本地更新前 | 本地仍显示 CONNECTED | Provider 事件和 Reconciler 修正 |
| 本地标记 ENDED 后、供应商关闭失败 | 供应商继续计费 | Close retry + usage 对账告警 |
13.17 并发、容量和成本
实时 AI 系统的主要容量单位不是 HTTP QPS,而是:
- 当前在途会话数。
- 当前供应商并发槽位。
- TURN 中继会话数。
- 每秒媒体带宽。
- 媒体网关 CPU 和编码器实例数。
- 录制写入带宽。
- 每分钟供应商费用。
根据 Little 定律:
平均并发会话数 C
≈ 会话到达率 λ × 平均会话时长 E[D]
TURN 有效媒体负载可粗略估算为:
B_turn_payload
≈ C × relay_ratio × (bitrate_uplink + bitrate_downlink)
如果按 TURN 网卡总收发字节估算,因为每个被中继字节会进入一次并离开一次:
B_turn_nic_total
≈ 2 × B_turn_payload
实际云厂商计费还要区分入口、出口、跨区和公网流量,不能只按一个总带宽数字估算。
录制存储量:
storage_per_day
≈ sessions_per_day
× average_duration
× recorded_total_bitrate
× retention_replication_factor
容量保护顺序:
- 租户并发上限。
- 用户并发上限。
- 供应商并发配额。
- 地域媒体节点容量。
- TURN 带宽上限。
- 录制和转码容量。
- 成本预算上限。
高并发时不应无限创建实时 session。应明确返回:
429 Too Many Requests:用户或租户配额不足。503 Service Unavailable:全局或供应商容量不足。Retry-After:可重试时间建议。- 可选排队票据:仅在产品允许等待时使用。
实时会话的排队时间通常不能像离线视频生成那样很长。超过可接受等待时间,应快速失败并提示用户,而不是让短期 token 在队列里过期。
13.18 计费与用量一致性
供应商可能按以下任一方式计费:
- Session 创建次数。
- 已连接分钟数。
- 音频输入分钟数。
- 音频输出分钟数。
- 视频输出分钟数。
- 生成帧数。
- ASR、LLM、TTS 使用量。
- TURN 或媒体带宽。
因此不要只保存一个 duration_seconds。应保存原始维度:
provider_session_created_at
provider_connected_at
provider_ended_at
input_audio_ms
input_video_ms
output_audio_ms
output_video_ms
rendered_frames
asr_seconds
llm_input_tokens
llm_output_tokens
tts_characters
turn_ingress_bytes
turn_egress_bytes
recording_bytes
关键原则:
- 浏览器心跳只能证明“客户端还在”,不能作为最终计费事实。
- 浏览器上报时长不可信。
- Provider usage 事件可能重复、乱序或迟到。
- 用量事件必须带
provider_event_id或可构造唯一键。 - 会话结束后仍需执行最终 Usage API 对账。
- 预估费用和最终费用分开保存。
- 关闭请求失败时不能立即认为费用停止。
推荐:
实时展示:本地估算 usage
账单结算:供应商最终 usage + 平台规则
差异处理:reconciliation adjustment
13.19 故障矩阵
| 故障 | 发现方式 | 即时处理 | 长期修复 |
|---|---|---|---|
| 供应商创建成功但本地超时 | CREATING 超时、供应商存在同 idempotency key | 进入 RECONCILING,不直接重建 | 查询并绑定孤儿 session |
| 信令断开但媒体正常 | WebSocket closed、PC connected | 重连信令,不关闭媒体 | 事件序列补拉、ticket 轮换 |
| ICE 长时间 checking | 建链超时 | 尝试 TURN 或更换区域 | 检查 STUN/TURN 和网络策略 |
| ICE disconnected | iceConnectionState | 短暂观察 | 超时后 ICE restart |
| ICE failed | connectionState=failed | ICE restart 或重建 PC | 分析 Candidate、网络和供应商 |
| TURN 服务可连但无法中继 | Allocate 成功、无实际数据 | 切换 TURN 节点 | 加入端到端 relay 探测 |
| 只有声音没有画面 | 音频 bytes 增长、framesDecoded 为 0 | 请求关键帧或重协商 | 编解码和 Track 映射测试 |
| 画面有但无声音 | 视频正常、音频包为 0 | 检查 autoplay、Track mute | 浏览器策略和音频路由兼容 |
| 首帧一直未到 | connected 但无 frame | 超时失败或降级为音频 | 供应商渲染首帧 SLO |
| 用户打断但供应商继续生成 | 本地已静音、usage 继续上涨 | 重发 cancel,关闭旧 generation | generation fencing 和对账 |
| 页面刷新后旧连接恢复 | 同 session 多个 epoch | 只保留最高 epoch | fencing token |
| Provider close 失败 | 本地 ENDED、供应商仍 active | 后台重试关闭 | 最终 usage 对账与告警 |
| Recorder 失败 | 录制分段停止增长 | 会话继续,标记 recording degraded | 独立 recorder HA 和补偿 |
| Token 在协商前过期 | 401/403、session 未连接 | 重新签发一次性 ticket | 缩短建链路径、避免排队过长 |
13.20 测试策略
单元测试
- 状态机合法转换。
status_versionCAS。connection_epochfencing。- Signaling message 去重和顺序。
- Ticket scope、过期和一次性消费。
- Provider error 标准化。
- 用量事件幂等。
集成测试
- 真浏览器 Offer/Answer。
- Trickle ICE。
- TURN-only 模式。
- Provider 作为 Offerer 和 Browser 作为 Offerer。
- Track 增删和设备切换。
- ICE restart。
- 页面刷新后的 session resume。
网络故障测试
应使用网络仿真覆盖:
- 延迟增加。
- 抖动。
- 丢包。
- 带宽收窄。
- 包重排。
- UDP 被阻断。
- IPv4/IPv6 切换。
- Wi-Fi 切换蜂窝网络。
- VPN 开关。
- 企业代理环境。
长稳测试
- 连续数小时会话。
- 高频创建和销毁 PeerConnection。
- 内存、句柄和 goroutine 泄漏。
- TURN allocation 泄漏。
- Recorder 分段积压。
- 信令断开重连风暴。
安全测试
- 重放一次性 ticket。
- 跨租户访问 session。
- 修改
session_id、epoch 和 sequence。 - 伪造供应商 webhook。
- 恶意 SDP 和 Candidate。
- Token 出现在 URL、日志、错误上报和前端存储。
- TURN 凭证盗用和带宽滥用。
- 未授权录制。
13.21 常见错误设计
错误一:用 WebSocket 直接传所有音视频帧
问题:
- 需要自行实现编解码、抖动缓冲、时钟同步和拥塞控制。
- TCP 队头阻塞可能放大实时延迟。
- NAT 穿透和弱网适配能力差。
- 浏览器硬件媒体能力难以直接利用。
WebSocket 适合信令和业务事件,不是 WebRTC 媒体面的通用替代品。
错误二:只配置 STUN,不配置 TURN
在开发网络中可能正常,上线后会在对称 NAT、企业防火墙和 UDP 受限网络中出现大量无法连接。
错误三:把供应商长期密钥发给浏览器
长期密钥一旦泄露,攻击者可以绕过平台配额、计费和审计直接创建会话。
错误四:connected 就开始计费或宣布成功
需要区分:
业务 session 已创建
信令已建立
ICE 已连接
DTLS 已完成
远端 Track 已到达
首音频已播放
首视频已解码
不同供应商的计费起点也可能不同。
错误五:disconnected 立即销毁会话
移动网络切换和短暂抖动很常见。应设置恢复窗口并支持 ICE restart。
错误六:只依赖浏览器发送 close
页面崩溃、断网和进程被杀时,浏览器无法可靠发送关闭请求。必须有:
- Provider 事件。
- Session lease。
- 最大会话时长。
- 后台 Reaper。
- 最终 usage 对账。
错误七:把 Redis 心跳当成会话和费用事实
Redis 可用于 lease、在线状态和快速并发控制,但最终会话状态、使用量和费用必须落 PostgreSQL,并与供应商对账。
错误八:无条件把所有会话录制到普通 API 服务器
录制属于媒体数据面,应交给专用 Recorder 或媒体网关,而不是让业务 API 接收并缓存长时间媒体数据。
13.22 面试高频追问
1. WebRTC 的信令是不是 WebSocket?
不是。WebRTC 不规定信令协议。WebSocket、HTTPS、SIP 或自定义协议都可以。应用通过信令交换 SDP、ICE Candidate 和业务事件。
2. STUN 和 TURN 有什么区别?
STUN 帮助端点发现 NAT 映射并检查连通性;TURN 在无法直连时真正中继媒体。只部署 STUN 无法覆盖所有网络。
3. 为什么媒体不直接经过 Go API?
普通 Go API 更适合鉴权、会话、计费和信令。媒体转发需要 RTP/RTCP、抖动缓冲、拥塞控制和高带宽稳定性,应直连供应商或经过专业媒体节点。
4. 页面刷新后怎样恢复?
恢复业务 session_id、对话上下文和未结算状态,但创建新的 RTCPeerConnection。通过递增 connection_epoch 使旧连接失效。
5. 网络从 Wi-Fi 切到 5G 怎样处理?
先观察 ICE 是否自动恢复;失败后执行 ICE restart。如果供应商不支持原 session 重启,则创建新 provider session,并恢复业务上下文。
6. 怎样判断会话真正可用?
不能只看 connectionState=connected。还应确认远端 Track 到达、首音频可播放、首视频解码,并结合业务首包事件。
7. 怎样隐藏供应商密钥?
长期密钥只保存在后端。后端为每个业务 session 签发一次性 signaling ticket、短期 provider token 和 TURN credential,并限制 scope、TTL 和 session 绑定。
8. WebRTC 和 HLS 怎样选?
双向实时交互使用 WebRTC;大量只观看用户使用 HLS/CMAF。常见组合是 WebRTC 推流或互动,HLS 通过 CDN 扩大观众规模。
9. 怎样保证关闭会话后不继续扣费?
关闭调用要幂等并重试;不能只相信客户端 close。还要使用 Provider 事件、后台 Reaper 和最终 Usage API 对账,发现孤儿会话立即关闭并生成调整流水。
10. 怎样做录制?
浏览器 MediaRecorder 适合轻量本地录制,但不适合作为合规事实源。生产审计通常使用供应商录制或平台媒体网关旁路录制,并分段上传对象存储。
13.23 两分钟面试口述答案
实时 AI 视频和普通异步视频生成是两套不同的数据面。普通生成任务通过 MQ 和对象存储交付文件,而实时数字人需要双向、低延迟媒体,因此使用 WebRTC。我们的 Go 后端只负责控制面,包括鉴权、租户并发、供应商路由、创建业务 session、签发短期 token、交换信令、计费和故障恢复;媒体尽量由浏览器通过 ICE、DTLS 和 SRTP 直连供应商媒体节点,无法直连时由 TURN 中继,不让普通 Go API 无条件转发 RTP。
会话创建使用幂等键,本地先创建 CREATING 记录,再在事务外调用供应商。如果供应商已创建但本地超时,会进入 RECONCILING,通过供应商幂等键查询,避免重复会话和重复计费。浏览器侧维护 RTCPeerConnection,并区分 signalingState、iceConnectionState 和 connectionState;连接成功还不等于业务可用,我们会继续测量首音频和首视频。网络切换时优先 ICE restart,无法恢复再重建 PeerConnection,同时递增 connection_epoch,防止旧连接复活。
质量侧通过 getStats 采集 RTT、jitter、packet loss、bitrate、framesDecoded、freezeCount 和 selected candidate type,并按地域、浏览器、供应商和是否经过 TURN 分桶。录制、用量和合规由服务端负责,客户端心跳和 DataChannel 只做体验与控制,不作为最终计费事实。大规模只观看场景不会全部走 WebRTC,而是采用 WebRTC ingest 加 HLS/CDN 分发。
13.24 本章应记住的 12 句话
- WebRTC 解决实时媒体,不自动解决业务会话、鉴权、计费和恢复。
- 信令协议由应用定义,WebSocket 只是常见选择,不是 WebRTC 的固定组成。
- 普通 Go API 管控制面,媒体尽量直连供应商或专业媒体节点。
- ICE 负责选路,STUN 帮助发现地址,TURN 在无法直连时中继。
- 没有 TURN 的 WebRTC 上线方案通常是不完整的。
- 供应商长期密钥不能进入浏览器,只能签发短期、最小权限凭证。
connected不等于用户已看到首帧,必须单独测量首音频和首视频。- 网络恢复要区分信令重连、ICE restart 和重建 PeerConnection。
- 使用 connection epoch 防止旧页面、旧网络连接和迟到消息复活。
- 实时质量必须用 RTT、jitter、loss、freeze 和 TURN ratio 观测。
- 客户端心跳不是计费事实,最终用量必须与供应商对账。
- WebRTC 负责互动,HLS/CDN 负责大规模观看。