从 WebSocket 到 RTC:我们怎么用火山引擎给 AI 穿搭顾问装上眼睛
Site Owner
发布于 2026-03-15
# 从 WebSocket 到 RTC:我们怎么用火山引擎给 AI 穿搭顾问装上"眼睛" > 一个二手奢侈品电商的"数字试衣镜"落地记录。涉及火山引擎 RTC、豆包多模态大模型、实时视频流与 AI Vision 的集成实战。
从 WebSocket 到 RTC:我们怎么用火山引擎给 AI 穿搭顾问装上"眼睛"
一个二手奢侈品电商的"数字试衣镜"落地记录。涉及火山引擎 RTC、豆包多模态大模型、实时视频流与 AI Vision 的集成实战。
为什么要做视频对话
我们的产品 Airtist 是一个二手奢侈品平台,里面有个 AI 穿搭顾问叫 Airy。最早 Airy 只能语音聊天——用户描述自己穿了什么,Airy 靠想象力给建议。你能想象这有多别扭。
"我穿了一件……嗯……米色的……有点像风衣但不是风衣的东西。"
这种对话效率极低。我们需要让 Airy 直接看到用户的穿搭,像照镜子一样给出实时反馈。所以有了"数字试衣镜"这个方向:用户打开前置摄像头,Airy 通过视觉理解分析画面,语音告诉你"这条围巾和外套的色调有点打架"。
听起来简单,做起来踩了不少坑。
架构演进:WebSocket 方案的痛
先说说我们之前的语音方案长什么样:
前端 AudioWorklet 录音 → PCM 编码 → WebSocket → 后端代理 → 豆包 WebSocket API
↓
前端 AudioContext 播放 ← Base64 解码 ← WebSocket ← 后端转发 ← 豆包返回音频
后端充当了一个音频代理,所有 PCM 数据都要经过它中转。这个方案能跑,但问题一堆:
- 延迟大约 500ms,语音对话有明显的"等一下"感
- 前端要自己处理 PCM 编解码、字节对齐、Base64 转换
- 移动端兼容性是噩梦——我们前后修了 9 轮,AudioWorklet 在 iOS Safari 上的表现让人怀疑人生
- 最致命的:这个架构根本没法加视频
所以当产品说"我们要加摄像头"的时候,我们没有在 WebSocket 方案上硬塞,而是直接切到了火山引擎 RTC。
两个方案的核心差异:
| 维度 | WebSocket 语音方案 | RTC 视频方案 |
|---|---|---|
| 通信协议 | WebSocket(自建代理) | 火山引擎 RTC SDK |
| 音频处理 | 前端手动 PCM 编解码 | SDK 内置,不用管 |
| 视频能力 | 没有 | 原生支持 |
| 后端角色 | 音频代理(重) | Token + AI 启停(轻) |
| 端到端延迟 | ~500ms | ~200ms |
| 移动端兼容 | 9 轮修复 | SDK 已处理 |
200ms 的延迟差距在语音对话里感知很明显。500ms 你会觉得 AI 在"想",200ms 你会觉得它在"听"。
整体架构
切到 RTC 之后,架构变得清爽很多。后端从"音频搬运工"变成了"门卫"——只管发 Token 和通知云端启停 AI。
sequenceDiagram
participant U as 用户(H5)
participant F as 前端 RTC SDK
participant B as 后端 Spring Boot
participant V as 火山引擎 RTC 云端
participant AI as AI Agent(豆包)
U->>F: 点击视频模式
F->>F: 请求摄像头/麦克风权限
F->>B: POST /rtc/token
B-->>F: { appId, token, roomId, userId }
F->>V: joinRoom(token)
F->>F: 开启本地音视频采集 & 发布流
F->>B: POST /rtc/start-ai
B->>V: StartVoiceChat OpenAPI(HMAC-SHA256 签名)
V->>AI: 创建 AI Agent,加入房间
AI-->>F: AI 加入房间,订阅用户音视频流
loop 实时对话
U->>F: 说话 / 展示穿搭
F->>V: 音视频流
V->>AI: 转发流
AI->>AI: ASR → LLM(含 Vision 分析)→ TTS
AI->>V: 语音回复流
V->>F: 转发给用户
F->>U: 播放 AI 语音
end
U->>F: 点击挂断
F->>V: leaveRoom + destroyEngine
F->>B: POST /rtc/stop-ai
B->>V: StopVoiceChat
三层职责分得很干净:
- 前端:管引擎生命周期、管 UI 渲染、管设备切换
- 后端:管 Token 签发、管 AI Agent 启停
- 云端:管音视频路由、管 AI 推理全链路(ASR + LLM + TTS + Vision)
前端:useVideoChat 的 10 步生命周期
前端的核心逻辑收在一个 useVideoChat Hook 里。通话建立分 10 步,每一步失败都要能回滚,不然就会出现"摄像头亮着但没声音"这种诡异状态。
先看状态机:
type VideoConnectionStatus =
| 'idle' // 啥也没干
| 'connecting' // 正在连
| 'connected' // RTC 通了
| 'waiting_ai' // 等 AI 进房间
| 'ai_joined' // AI 来了,可以聊
| 'error'; // 炸了
10 步流程:
1. VERTC.enableDevices() // 弹权限弹窗
2. VERTC.enumerateDevices() // 拿到设备列表
3. POST /api/v1/rtc/token // 找后端要 Token
4. VERTC.createEngine(appId) // 创建引擎实例
5. 注册事件监听 // onUserJoined / onError 等
6. engine.joinRoom(token, roomId) // 进房间
7. startAudioCapture + startVideoCapture // 开麦克风和摄像头
8. setLocalVideoPlayer(DOM_ID) // 把本地画面渲染到全屏 DOM
9. publishStream(AUDIO_AND_VIDEO) // 发布流
10. POST /api/v1/rtc/start-ai // 通知后端启动 AI
步骤 1-2 是设备准备,3 是鉴权,4-9 是 RTC 标准流程,10 是业务逻辑。
挂断的时候反过来清理:
// 清理顺序很重要,别乱
stopVideoCapture()
stopAudioCapture()
unpublishStream(AUDIO_AND_VIDEO)
leaveRoom()
VERTC.destroyEngine(engine)
漏掉任何一步都可能导致摄像头指示灯不灭,或者下次进房间报"引擎已存在"的错。
后端:AI Agent 的启动与 OpenAPI 签名
后端就三个接口,逻辑不复杂,但 AI Agent 的配置细节比较多。
启动 AI 时,后端向火山引擎发一个 StartVoiceChat 请求,请求体里塞了四大块配置:
ASR(语音识别)
{
"Provider": "volcano",
"ProviderParams": { "Mode": "bigmodel", "StreamMode": 0 },
"VADConfig": { "SilenceTime": 600 },
"InterruptConfig": {}
}
用的是火山引擎的大模型 ASR,不是传统的流式 ASR。VAD 静默检测设了 600ms——用户停顿超过 0.6 秒就认为说完了。InterruptConfig 留空表示允许打断,用户随时可以插嘴。
LLM(大模型推理 + 视觉理解)
{
"Mode": "ArkV3",
"ModelName": "doubao-seed-1-8-251228",
"TopP": 0.3,
"SystemMessages": ["你是Airy,Airtist的专属二手奢侈品穿搭顾问..."],
"HistoryLength": 10,
"VisionConfig": {
"Enable": true,
"SnapshotConfig": {},
"StorageConfig": { "TosConfig": {} }
}
}
这里最关键的是 VisionConfig.Enable: true。打开之后,AI Agent 会定时从用户的视频流里截帧,送进多模态模型做视觉理解。用户不需要手动拍照,AI 自己会"看"。
TopP 设成 0.3 是为了让穿搭建议更确定、更专业,不要太发散。HistoryLength: 10 保留最近 10 轮对话上下文。
TTS(语音合成)
{
"Provider": "volcano_bidirection",
"ProviderParams": {
"Credential": { "ResourceId": "seed-tts-1.0" },
"VolcanoTTSParameters": {
"req_params": {
"speaker": "ICL_zh_male_qingshuangshaonian_tob",
"audio_params": { "speech_rate": 0, "loudness_rate": 0 }
}
}
}
}
双向流式 TTS,AI 边想边说,不用等整句生成完。音色选了"清爽少年",和穿搭顾问的人设比较搭。
Agent 配置
{
"TargetUserId": ["12345"],
"UserId": "AiryBot_12345_1707456000000",
"WelcomeMessage": "我是你的Airy-AI助手,有什么需要我为您效劳的吗?"
}
TargetUserId 指定 AI 和谁互动,UserId 是 AI 在 RTC 房间里的身份标识,动态拼接避免冲突。
OpenAPI 签名
调火山引擎 OpenAPI 需要 HMAC-SHA256 签名,流程和 AWS SigV4 类似:
请求体 SHA256 → 拼 CanonicalRequest → 拼 StringToSign → 派生签名密钥 → 算最终签名
派生密钥的链路:SecretKey → HMAC(日期) → HMAC(区域) → HMAC(服务) → HMAC("request")。每一层用上一层的输出做下一层的 key。第一次写容易搞混顺序,调试的时候建议把每一步的中间值打出来对比官方示例。
踩坑记录
1. 移动端自动播放被拦截
浏览器安全策略要求音视频播放必须由用户手势触发。RTC SDK 的 onAutoplayFailed 事件会在这种情况下触发。
我们的处理方式:弹一个全屏半透明遮罩,中间放一个"点击开始对话"按钮。用户点击后调用 engine.play(),同时隐藏遮罩。不优雅,但管用。
2. 前后摄像头切换
enumerateDevices() 拿到的 videoinput 设备列表,在不同手机上顺序不一样。有的手机前置在前,有的后置在前。我们用了一个循环切换的策略:维护一个 currentCameraIndex,每次切换就 (index + 1) % devices.length,不去猜哪个是前置哪个是后置。
3. AI UserId 前缀不匹配
这个坑比较隐蔽。后端创建 AI Agent 时 UserId 前缀是 AiryBot_,但前端 onUserJoined 里检测 AI 用户用的是 ai_ 前缀。结果 AI 进了房间,前端不认识它,状态一直卡在 waiting_ai。
修复很简单——统一前缀就行。但排查花了不少时间,因为 RTC 连接本身是正常的,音频也能听到,只是 UI 状态不对。
4. 引擎清理顺序
destroyEngine 之前必须先 leaveRoom,leaveRoom 之前必须先 unpublishStream。顺序反了不会报错,但会导致下次创建引擎时出现资源占用问题。在 iOS 上尤其明显——摄像头指示灯不灭。
UI 设计思路
简单说一下界面,因为视频对话的 UI 和普通聊天差别很大。
整个页面就是一个全屏的摄像头画面,做了镜像翻转(transform: scaleX(-1)),模拟照镜子的感觉。右上角一个 80x80 的圆形悬浮窗,用来显示 AI 的头像或远端视频。底部是毛玻璃控制栏,五个按钮:切换摄像头、截图、挂断、摄像头开关、麦克风开关。
进入页面时先显示一个 1.5 秒的引导动画——四个角有呼吸闪烁的对焦框,中间写"准备中"。这个延迟不是装饰,是给设备权限请求和 RTC 连接留时间。动画结束时通话通常已经建立好了,用户感知不到等待。
截图功能是从 <video> 元素画到 Canvas 再导出 JPEG。截完在左下角显示一个缩略图预览,3 秒后自动消失。目前只存在前端本地,后续会传到 OSS 关联到用户衣橱。
还没做完的事
说几个已知的缺口:
- 实时字幕还是硬编码的占位文本,需要接 RTC 的字幕回调拿到 ASR 转写和 AI 回复文本
- 没有断线重连——网络抖动直接断了,用户得手动重新进入
- 通话计时和积分扣费逻辑还没接
- AI 头像是静态的,后续想换成虚拟形象的实时视频流
其中字幕和断线重连优先级最高。字幕对用户体验影响大,尤其在嘈杂环境下;断线重连则是基本的健壮性要求。
回头看
从 WebSocket 语音方案切到 RTC 视频方案,代码量反而少了。前端不用再和 AudioWorklet、PCM 编码搏斗,后端从音频代理瘦身成了三个简单接口。复杂度转移到了火山引擎的云端——ASR、LLM、TTS、Vision 全在那边跑,我们只管对接。
这个取舍值不值得?对我们这个体量的团队来说,值得。把精力花在产品体验上(引导动画、截图功能、UI 细节),比花在音视频底层协议上回报更高。
当然代价是对火山引擎的依赖变深了。RTC SDK 的行为、OpenAPI 的签名规则、AI Agent 的配置参数——这些都是黑盒或半黑盒。出了问题排查链路会更长。
这是一个工程判断,不是技术信仰。选适合当前阶段的方案,别选"正确"的方案。