返回列表
AI
35 分钟

从 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 之前必须先 leaveRoomleaveRoom 之前必须先 unpublishStream。顺序反了不会报错,但会导致下次创建引擎时出现资源占用问题。在 iOS 上尤其明显——摄像头指示灯不灭。


UI 设计思路

简单说一下界面,因为视频对话的 UI 和普通聊天差别很大。

整个页面就是一个全屏的摄像头画面,做了镜像翻转(transform: scaleX(-1)),模拟照镜子的感觉。右上角一个 80x80 的圆形悬浮窗,用来显示 AI 的头像或远端视频。底部是毛玻璃控制栏,五个按钮:切换摄像头、截图、挂断、摄像头开关、麦克风开关。

进入页面时先显示一个 1.5 秒的引导动画——四个角有呼吸闪烁的对焦框,中间写"准备中"。这个延迟不是装饰,是给设备权限请求和 RTC 连接留时间。动画结束时通话通常已经建立好了,用户感知不到等待。

截图功能是从 <video> 元素画到 Canvas 再导出 JPEG。截完在左下角显示一个缩略图预览,3 秒后自动消失。目前只存在前端本地,后续会传到 OSS 关联到用户衣橱。


还没做完的事

说几个已知的缺口:

  1. 实时字幕还是硬编码的占位文本,需要接 RTC 的字幕回调拿到 ASR 转写和 AI 回复文本
  2. 没有断线重连——网络抖动直接断了,用户得手动重新进入
  3. 通话计时和积分扣费逻辑还没接
  4. AI 头像是静态的,后续想换成虚拟形象的实时视频流

其中字幕和断线重连优先级最高。字幕对用户体验影响大,尤其在嘈杂环境下;断线重连则是基本的健壮性要求。


回头看

从 WebSocket 语音方案切到 RTC 视频方案,代码量反而少了。前端不用再和 AudioWorklet、PCM 编码搏斗,后端从音频代理瘦身成了三个简单接口。复杂度转移到了火山引擎的云端——ASR、LLM、TTS、Vision 全在那边跑,我们只管对接。

这个取舍值不值得?对我们这个体量的团队来说,值得。把精力花在产品体验上(引导动画、截图功能、UI 细节),比花在音视频底层协议上回报更高。

当然代价是对火山引擎的依赖变深了。RTC SDK 的行为、OpenAPI 的签名规则、AI Agent 的配置参数——这些都是黑盒或半黑盒。出了问题排查链路会更长。

这是一个工程判断,不是技术信仰。选适合当前阶段的方案,别选"正确"的方案。

#项目实战#AI 协同