MiniCPM-o 视频双工 API 协议
本文档定义视频双工(Video Full-Duplex)模式的 WebSocket 协议。 音频双工模式见 audio-duplex-protocol.md。
一句话总结
一根 WebSocket,客户端每秒发 1 秒音频 + 1 帧视频,服务端实时回音频和文字。
模式约束
| 项目 | 值 |
|---|---|
| 端点 | wss://host/v1/realtime?mode=video |
| 帧格式 | JSON 文本帧 |
| 上行音频 | 16 kHz,单声道,float32 PCM,base64 编码 |
| 下行音频 | 24 kHz,单声道,float32 PCM,base64 编码 |
| 上行视频 | JPEG,base64 编码,建议每个 append 携带 |
| 会话总时长上限 | 300 秒(5 分钟),含所有等待和空闲时间 |
| 有效对话时间 | 约 90 秒(总时长包含排队、初始化、用户沉默等,实际模型活跃推理时间约 90 秒) |
| 上下文窗口 | 8192 tokens,不可配置。满时服务端主动关闭会话 |
生命周期
┌─────────┐ ┌─────────┐ ┌─────────┐
│ 连接 │ ───→ │ 对话 │ ───→ │ 结束 │
│ Setup │ │ Stream │ │ Close │
└─────────┘ └─────────┘ └─────────┘
Setup
连接 URL:wss://host/v1/realtime?mode=video
session_id 由服务端自动生成(格式 rt_{timestamp_ms}),通过 session.created 返回。
Client ──WSS──→ Server
← session.queued (可选:有排队时才出现)
← session.queue_update (可选:排队位置变化时出现,0~N 次)
← session.queue_done (必达:Worker 分配完成)
Client → session.update "我要中文助手,用这个音色"
Server → session.created "好的,准备就绪,session_id=xxx"
重要:客户端必须等到收到
session.queue_done后才能发送session.update。session.queue_done是必达事件——即使无需排队(Worker 空闲),服务端也会立即发送它。
Stream
两条独立的数据流同时工作,互不阻塞:
上行流(Client → Server): 下行流(Server → Client):
每秒发一个包,包含: 模型随时推送:
- 1 秒的 16kHz 音频 - 回复的 24kHz 音频片段
- 1 帧 JPEG 视频截图(建议) - 回复的文字
- "我在听"状态信号
Close
三种关闭原因:
| reason | 触发方 | 含义 |
|---|---|---|
user_stop |
客户端 | 用户主动结束 |
timeout |
服务端 | 会话总时长达到 300 秒 |
context_full |
服务端 | 上下文窗口 8192 tokens 已满 |
事件总览
客户端 → 服务端(3 种)
| 事件 | 什么时候发 | 发什么 |
|---|---|---|
session.update |
开始时发一次 | 系统提示词、参考音色 |
input_audio_buffer.append |
每秒发一次 | 1 秒音频 + 1 帧视频 |
session.close |
想结束时发一次 | 关闭原因 |
服务端 → 客户端(3 种核心 + 辅助)
| 事件 | 什么意思 | 带什么数据 |
|---|---|---|
session.created |
配置完成 | session_id |
response.output_audio.delta |
模型在说话 | 音频 + 文字 + end_of_turn |
response.listen |
模型在听 | KV cache 等监控数据 |
辅助事件:session.queued、session.queue_update、session.queue_done、session.closed、error
消息格式
session.update(客户端 → 服务端)
{
"type": "session.update",
"session": {
"instructions": "你是一个友好的中文助手",
"max_slice_nums": 1,
"ref_audio": "<base64 WAV, 16kHz>",
"tts_ref_audio": "<base64 WAV, 16kHz>"
}
}
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
instructions |
string | 是 | 系统提示词 |
max_slice_nums |
int | 否 | 视频最大切片数(1=快速 64tok/帧,4=精细 192tok/帧),默认 1 |
ref_audio |
string | 否 | LLM 参考音频(base64 WAV, 16kHz),用于语义风格克隆 |
tts_ref_audio |
string | 否 | TTS 参考音频(base64 WAV, 16kHz),用于声学特征克隆。未提供时 fallback 到 ref_audio |
input_audio_buffer.append(客户端 → 服务端)
{
"type": "input_audio_buffer.append",
"audio": "<base64, 16000 samples = 1s, float32 PCM>",
"video_frames": ["<base64 JPEG>"],
"force_listen": false,
"max_slice_nums": 1
}
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
audio |
string | 是 | 16 kHz 单声道 float32 PCM,1 秒 = 16000 samples = 64000 bytes,base64 编码。最小 4000 samples (250ms) |
video_frames |
string[] | 否 | JPEG 帧列表(通常 1 帧),base64 编码。视频双工模式下建议每个 append 携带,不携带时为未定义行为 |
force_listen |
bool | 否 | 强制模型进入 listen 状态(打断模型说话),默认 false |
max_slice_nums |
int | 否 | 覆盖本次 chunk 的视频切片数(1~9) |
session.close(客户端 → 服务端)
{
"type": "session.close",
"reason": "user_stop"
}
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
reason |
string | 否 | 关闭原因,建议填 "user_stop" |
session.created(服务端 → 客户端)
{
"type": "session.created",
"session_id": "rt_1714200000000",
"prompt_length": 256
}
response.output_audio.delta(服务端 → 客户端)
{
"type": "response.output_audio.delta",
"text": "今天天气真好",
"audio": "<base64, 24000 samples = 1s, float32 PCM>",
"end_of_turn": false,
"kv_cache_length": 1024
}
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
text |
string | 是 | 本次生成的文字片段 |
audio |
string | 是 | 24 kHz 单声道 float32 PCM,base64 编码 |
end_of_turn |
bool | 是 | 本轮生成是否结束(turn EOS)。true 时表示模型说完了这句话,即将切回 listen |
kv_cache_length |
int | 是 | 当前 KV 缓存已使用 token 数(上限 8192) |
关于文字与音频的对齐
由于模型架构特性,文字生成领先于音频合成。同一个 output_audio.delta 中的 text 和 audio 并非严格同步——文字内容通常领先音频几百毫秒。
示例(两个连续的 delta):
// delta 1
{ "text": "今天天气真好", "audio": "<音频: '看来今天天气'>" }
// delta 2
{ "text": ",比较适合散步", "audio": "<音频: '真好,比较适合'>" }
客户端应以音频播放进度为准呈现体验,文字可作为提前预览或字幕使用。
关于输出音频长度
- 非首个、非末尾的 delta:音频长度固定为 1 秒(24000 samples)
- 首个 delta:音频可能短于 1 秒
- 末尾 delta(
end_of_turn=true):音频可能短于 1 秒
response.listen(服务端 → 客户端)
{
"type": "response.listen",
"kv_cache_length": 1024
}
模型当前在听。客户端收到后应停止播放队列中的音频(如果有残留)。
session.closed(服务端 → 客户端)
{
"type": "session.closed",
"reason": "timeout"
}
| reason | 含义 |
|---|---|
stopped |
用户主动 session.close 后的确认 |
timeout |
会话总时长达到上限(视频模式 300 秒) |
context_full |
上下文窗口 8192 tokens 已满 |
server_shutdown |
服务端正在关闭 |
error |
因不可恢复错误终止 |
完整时序
时间 ──────────────────────────────────────────────────────────→
┌─────────────────────────────────┐
│ Phase 1: 连接 & 排队 │
└─────────────────────────────────┘
Client: WSS Connect ─────→
← Server: session.queued (你排在第 3 位,约等 45 秒)
← Server: session.queue_update (第 2 位,约 20 秒)
← Server: session.queue_done (轮到你了!)
⚠️ 排队阶段客户端不应发送任何消息,只被动接收排队事件。
⚠️ 如果 Worker 立即可用,排队阶段会被跳过。
┌─────────────────────────────────┐
│ Phase 2: 会话初始化 │
└─────────────────────────────────┘
Client: session.update ─┐ (system prompt、ref audio)
Server: session.created ←┘ (模型就绪,返回 session_id)
┌─────────────────────────────────┐
│ Phase 3: 全双工对话 │
└─────────────────────────────────┘
Client: append(audio₁ + frame₁) ──→
Client: append(audio₂ + frame₂) ──→
Client: append(audio₃ + frame₃) ──→ ← Server: listen (模型在听)
Client: append(audio₄ + frame₄) ──→
Client: append(audio₅ + frame₅) ──→ ← Server: listen (还在听)
Client: append(audio₆ + frame₆) ──→ ← Server: output_audio.delta("你好", audio, end_of_turn=false)
Client: append(audio₇ + frame₇) ──→ ← Server: output_audio.delta(",", audio, end_of_turn=false)
Client: append(audio₈ + frame₈) ──→ ← Server: output_audio.delta("可以帮你?", audio, end_of_turn=true)
Client: append(audio₉ + frame₉) ──→ ← Server: listen (说完了,又在听了)
...
┌─────────────────────────────────┐
│ Phase 4: 关闭 │
└─────────────────────────────────┘
任一条件触发:
- 用户发 session.close
- 总时长 ≥ 300s → session.closed {reason: "timeout"}
- KV cache ≥ 8192 → session.closed {reason: "context_full"}
Client: session.close ──→
← Server: session.closed {reason: "stopped"}
注意:客户端始终在发 append(含音频+视频),不管服务端是在听还是在说。这就是"全双工"。
不纳入协议的功能
| 功能 | 为什么不需要协议事件 |
|---|---|
| 暂停/恢复 | 客户端停止发 append 即等效暂停,模型会持续 listen 等待 |
| 取消生成 | 全双工模式下用 force_listen 字段打断,不需要独立的 cancel 事件 |
| 回复结束标记 | end_of_turn=true 已标记,不需要额外的 response.done 事件 |
| 上下文窗口大小配置 | 固定 8192 tokens,不可调 |
状态机
connect
│
▼
┌──── QUEUED ─────┐
│ │ 等待 Worker 分配
│ │ 客户端不可发送任何消息
│ │ 可能收到: session.queued / session.queue_update
└────────┬─────────┘
│ 收到 session.queue_done
▼
┌─── CONNECTED ───┐
│ │ 只允许发: session.update
│ │ 其他一律 → error (invalid_event)
└────────┬─────────┘
│ 收到 session.created
▼
┌──── ACTIVE ─────┐
│ │ 允许发: append (建议含 video_frames) / close
│ │ append 中可携带 force_listen=true
└────────┬─────────┘
│ close / timeout / context_full / 异常
▼
CLOSED
错误码
客户端错误
| code | 含义 | WS 关闭 |
|---|---|---|
not_ready |
会话未建立就发数据 | 否 |
unknown_event |
不认识的事件 type | 否 |
missing_field |
必填字段缺失 | 否 |
invalid_payload |
字段值非法(base64/JPEG 解码失败) | 否 |
服务端错误
| code | 含义 | WS 关闭 |
|---|---|---|
service_unavailable |
服务未就绪 | 是 (1013) |
queue_full |
排队已满 | 是 (1013) |
worker_busy |
没有空闲 Worker | 是 (1013) |
worker_connect_failed |
Worker 连接失败 | 是 (1013) |
inference_error |
推理出错 | 否(可恢复) |
错误消息格式
{
"type": "error",
"error": {
"code": "missing_field",
"message": "audio field is required",
"type": "client_error"
}
}
静默丢弃
客户端发送 chunk 过快时,服务端丢弃旧 chunk,不返回 error。保证总是处理最新的 chunk。
非法 JSON
WebSocket 帧无法 JSON.parse → 直接关闭连接,close code = 1003。