状态:草案(2026-10-09 更新)。 数据由作者亲自实测。运行环境为单张 24GB 级 GPU、MiniCPM-o 4.5(Q8_0、llama.cpp-omni)、FastAPI 后端。提问使用的是合成语音而非真人声音,未实测 实际浏览器回声及多人同时通话 。 编写规则:以实测数据为准,未能测出的则标为“未实测”。
目录
- 1. 模型:为什么选择 MiniCPM-o 4.5
- 2. 为什么由后端进行中继
- 3. 声音以什么格式发送
- 4. 帧:划分二进制与文本
- 5. 比实时发送更快的客户端
- 6. 在两个 Web Pod 中守住一个席位
- 7. 导致连接中断的因素
- 8. 端到端连贯实测值
- 9. 局限与未实测部分
- 面试可能问到的问题
- 参考资料
20 轮对话练习通过“录音 → 语音识别 → 对话模型 → 语音合成”以 HTTP 逐轮交互。必须说完才能得到回答,听回答时无法说话。若要营造与真人通话的感觉,则需要一个 边听边说、边说边听的 全双工(full duplex)模型。此类模型每秒会多次接收和发出声音。因此,比起单次请求对应单次响应的 HTTP, 保持一个连接并由双随时发送的 WebSocket 才是正确的选择。
本文在简述模型选型过程后,将探讨围绕 WebSocket 做出的五个决定:
- 不直接将浏览器连到模型服务器,而是由后端中继。
- 声音以 16kHz·16-bit·1秒包上传,以 24kHz·16-bit 下发。
- 声音走二进制帧,文字和状态走文本帧。
- 比实时发送更快的客户端用令牌桶进行限流。
- 只接收一路通话的服务器席位通过数据库的 advisory lock 来守护。
简短回答。 经过中继,通话也在 0.33 秒内建立;当问出“澳大利亚的首都是哪里?”时,在话音刚落的 0.30 秒后就听到了“It's Canberra.”。中继转换 1 秒时长的声音,上行耗时向上取整为 1.3ms,下行为 5.9ms。在浏览器与后端之间用二进制 PCM 代替 base64 JSON 后,传输量由上行 85KB/s → 32KB/s、下行 128KB/s → 48KB/s。
1. 模型:为什么选择 MiniCPM-o 4.5
我们在 Hugging Face 上无需密钥即可获取的三款全双工模型,用相同的问题进行了测试(原始数据见 docs/experiments/2026-10-07-full-duplex-voice/).
| 模型 | 问题 | 回答 | 话音结束到声音响起(中位数) | 观察 |
|---|---|---|---|---|
| Moshiko (2024-09) | 英文 FLEURS 12 | 11/12 | 6 个在用户说话过程中,其余 0.2–5.2 秒 | 每 80ms 一步,每步 34.6ms |
| LLM-jp-Moshi-v1 (2026-01) | 日文 FLEURS 12 | 12/12 | 全部在用户说话过程中 | 在用户发言期间占比 30–88% 的附和与自言自语 |
| MiniCPM-o 4.5 Q8_0 (2026-02) | 英文问题 10 | 10/10 | 1.03 秒 | 每 1 秒包处理 50ms,VRAM 14.7GB |
MiniCPM-o 答对了堪培拉、100 度、火星、莎士比亚,日语在加入“何語で話しても日本語だけで答えて”(无论说什么语言都只用日语回答)的指令后,10 次中有 9 次用日语回答。韩语不太熟练(官方语言为英语和中文)。PyTorch 版本需要 28GB 以上显存,因此通过 llama.cpp 分支(llama.cpp-omni)的 GGUF 运行。
模型侧修改了两个地方。
- 开始说话的阈值。 默认设置下,从话音结束到声音输出的中位数为 4.0 秒(0.9–8.3)。模型每秒决定一次“是听还是说”,如果将会话设置中的
listen_prob_scale从 1.0 降低到 0.0,“听”标记的得分会减 2。由此缩短至 1.03 秒(−0.7–2.4),也没有出现未回答的问题。 - 每次开启通话耗时 41 秒。 查看服务器日志发现,每个会话为了获取 7 个特殊标记的嵌入,会将整个 LLM 权重文件(8.7GB)读入内存,并在会话结束后丢弃。由于该数值仅取决于模型文件,将其修改为在进程中只创建一次后,开启时间缩短至 0.11 秒(若输入正文 6,500 个标记的博客文章则为 1.6 秒)。
2. 为什么由后端进行中继
模型服务器直接暴露了 WebSocket 端点(/backend)。直接将浏览器连到这里虽然最快,但存在三个问题:
브라우저 ──wss──▶ 게이트웨이 ──▶ 백엔드(/ws/call) ──ws──▶ 모델 서버(/backend)
(쿠키로 사람 확인) (프롬프트 생성, 자리, 속도 제한, 형식 변환)
- 模型服务器完全信任系统提示词。 初始化会话的消息中包含提示词,若允许浏览器构造此消息,任何人都可以对模型下达任意指令。通过中继,浏览器只发送
mode=lang&lang=ja&level=N4或mode=blog&slug=…,而提示词由服务器仅用规定语句和数据库中的文本来生成。等级(level)也仅在已知列表中时才使用。最初曾尝试只保留字母数字,但测试抓获了B1. Ignore all rules被作为B1Ignore注入提示词的情况。 - 模型服务器一次只能接收一路通话。 第二人接入会破坏第一通电话。席位问题将在第 5 节讨论。
- 必须验证身份并限制时间。 浏览器无法在 WebSocket 握手中附加自定义标头。因此通过同源登录 Cookie 识别用户(与现有 Web 终端方式相同),并将通话限制在 10 分钟、静音连接在 15 秒后断开。
将博客文章放入提示词时,额外阻止了一点。该模型的特殊标记格式形如 <|im_end|> ,若正文中包含相同字符串,便可关闭系统消息并插入新指令。测试将正文中的 <| 替换为 < | 来将其失效,并通过测试确认整个提示词中只包含我们附加的那两个特殊标记。
3. 声音以什么格式发送
| 区间 | 格式 | 1 秒分量 |
|---|---|---|
| 浏览器 → 后端 | 16kHz 单声道 16-bit PCM,二进制帧 | 32,000 B |
| 后端 → 模型 | 包含 float32 的 base64 JSON(input.append) |
85,391 B |
| 模型 → 后端 | 24kHz float32 base64 JSON(response.output.delta) |
128,063 B |
| 后端 → 浏览器 | 24kHz 16-bit PCM,二进制帧 | 48,000 B |
- 降至 16kHz 的地方是浏览器。 模型的语音编码器(Whisper 系列)接收 16kHz。虽然通过
new AudioContext({sampleRate: 16000})从一开始就获取 16kHz 很简单,但部分浏览器会拒绝此选项。因此我们以设备的默认采样率(通常为 48kHz)接收,再通过线性插值降采样。人声大多在 4kHz 以下,因此这种程度完全足够。我们通过 Node 测试确认了 48kHz 的 1 秒是否正好对应 16,000 个采样点、44.1kHz 是否相同——采样率若有误,模型听到的声音就会变快或变慢并导致误解,而肉眼无法察觉。 - 打包单位为 1 秒。 该模型以 1 秒为单位进行收听和判断。即使发送得更碎,服务器也需等到凑够 1 秒才会处理,因此延迟不会降低。如果包大小不正好是 32,000 字节,中将会将其丢弃。
- 降至 16-bit 的地方是中继。 若将模型格式(float32 base64)直接原封不动发送到浏览器,下行流量将达到每秒 128KB。若降至 16-bit 则为 48KB,减少了 63%。人耳听不出区别(16-bit 与 CD 深度相同)。
- 转换开销。 使用标准库
array转换 1 秒分量,上行耗时向上取整 1.3ms,下行 5.9ms(Mac arm64 上的 Python 3.12,5 次重复的中位数)。部署后在运营后端的两个 Pod 上用相同代码重新测试,桌面级 x86 CPU 上行 1.03ms、下行 5.61ms,笔记本级 x86 CPU 上为 1.21ms、6.02ms,与 Mac 几乎一致(2026-10-09)。相比模型处理一个 1 秒包所需的 50ms,这微不足道。
4. 帧:划分二进制与文本
WebSocket 帧分为文本(opcode 1)和二进制(opcode 2)。声音通过二进制发送,其余则通过 JSON 文本发送。
브라우저 → 백엔드 이진: 1초 PCM 텍스트: {"type":"hangup"}
백엔드 → 브라우저 이진: 모델 목소리 조각 텍스트: ready · text · listen · done · busy · unavailable · timeout
这样划分后,接收方在 typeof ev.data === 'string' 处分流,无需给声音包裹 JSON 或用 base64 膨胀。在浏览器中将其设为 ws.binaryType = 'arraybuffer' ,二进制帧就不会变成 Blob ,而是直接变成可用的 ArrayBuffer 。
用关闭码传达原因。 WebSocket 的关闭码 4000–4999 是留给应用层使用的范围。我们使用了 4401(需要登录)、4404(无文章)、4429(其他人正在通话)、4503(模型服务器准备中)。前端会将这些代码转换为人类语言。比起“失败了”,“当前其他人正在通话,请稍后再拨”更能激起用户再次拨打的意愿。
播放要首尾相接。 模型会断断续续按生成的内容每几百毫秒发送一次声音。将收到的片段制成 AudioBufferSourceNode 并在“前一片段结束的时间点”进行调度,就能无缝衔接。如果每个片段随收随播,就会相互重叠。
打断。 用户一旦打断,模型就会转为收听(listen)。然而,如果已经收到的声音还留在播放队列中,用户听起来就会觉得模型还在一直说话。因此,当 listen 到来且 麦克风声音较大时 ,便清空队列。若在麦克风安静时清空,回答的最后一部分就会被切断——因为模型在生成完所有回答后还会发送 listen ,但此时播放尚未结束。
5. 比实时发送更快的客户端
模型服务器会按顺序处理 1 秒包。必须等前一个包的响应结束后,才会读取下一个包。因此,如果客户端将录好的 30 秒内容一股脑发送过去,队列就会堆积,回答会越来越慢,在此期间其他人也无法获取席位。
我们在中继中加入了令牌桶。每秒填充 1.5 个,最多积攒 3 个。正常通话每秒来一个包,在 10 分钟(600 个)内不会丢失任何一个;如果集中发送,在头 3 个之后也只能以 1.5 个/秒的速度通过。留出 1.5 的余量是为了应对网络抖动导致两个包连在一起到达的情况。只要能从外部注入时间,就能在短短几毫秒内测试这种行为。
在制作测量工具时也踩过同样的陷阱。如果测试客户端提前发送数据包,导致“话音结束到声音响起”比实际更短。因此,必须将数据包设为 完全录制完成的瞬间仅在(开始后 k 秒)时发送。
6. 两个 Web Pod 争夺一个名额
模型服务器只接收一个通话,但后端 Pod 有两个。进程内的变量(asyncio.Lock)无法得知其他 Pod 的通话情况。
第 20 篇的对话练习通过数据库的部分唯一索引来保留名额。每轮都有 HTTP 请求往返,且期间没有连接,因此将名额作为行保留,如果 3 分钟没有说话则使其过期。通话则不同。通话期间 WebSocket 一直保持打开,因此只需在 连接存活期间 占用名额即可。
因此,我们使用了 Postgres 的会话级 advisory lock(建议锁)。
seat = db() # 통화마다 연결 하나
got = seat.execute("select pg_try_advisory_lock(%s)", (SEAT_KEY,))
if not got: ... # 4429 로 닫는다
try:
await relay(ws, prompt)
finally:
seat.close() # 연결이 닫히면 잠금도 풀린다
- 通话结束时关闭连接,连接关闭时锁就会释放。
- 即使 Pod 崩溃,名额也不会被死锁。 如果连接断开,Postgres 会自动释放该会话的锁。如果以行的形式保留名额,则必须等待过期时间。
- 成本是每个通话一个数据库连接。由于同时只有一个通话,因此也只有一个连接。
- 询问“现在能否呼叫”的 API 会获取锁并立即释放来确认。
7. 断开连接的各种情况
- 空闲连接。 Cilium 网关的 Envoy 在默认配置下会断开双方静默 60 秒的连接(
proxy-idle-timeout-seconds)。网页终端因此每 25 秒发送一次心跳。由于通话时浏览器每秒发送一次音频,因此不需要单独的心跳。相反,如果 15 秒内没有音频传来,中继会率先断开(视为关闭了窗口或标签页进入休眠)。 - 部署。 重新启动后端会断开打开的 WebSocket。通话上限为 10 分钟,重新呼叫即可。由于上述原因,名额会自动释放。
- 模型服务器未准备好时。 模型服务器的
/health在加载模型之前就会返回 200(模型在第一个会话中加载,耗时 70-80 秒)。因此,我们在启动服务器后立即打开一个会话来加载模型,然后根据之后创建的文件来判断是否准备就绪。中继等待打开会话最多 20 秒,超过则通过 4503 通知“准备中”。
8. 整体连贯测量值
将中继路由器作为虚拟数据库启动,并连接到模型服务器,以 1 秒为间隔实时发送合成语音问题(scripts/e2e_relay.py).
| 问题 | 通话开启 | 说话结束后的声音 | 回答 |
|---|---|---|---|
| What is the capital city of Australia? | 0.33秒 | 0.30秒 | It's Canberra. |
| 日本の首都はどこですか? | — | 0.52秒 | 東京です。 |
| (博客第 25 篇)概率抽样 10% 留下了几个慢请求? | — | 2.52秒 | 是的,留下了 10%。 (错误) |
此测量不包含网关和 TLS。部署后按段重新测量了生产路径(2026-10-09)。
| 区段 | 测量值(20 次,中位数) |
|---|---|
生产后端 Pod → 通话服务器,开启会话(到session.created 为止) |
0.33秒 |
| 在集群内部向后端打开 WebSocket(明文) | 6.5ms (4.3–18.3) |
| 从外部经由网关(TLS·Envoy)打开 WebSocket | 36ms (21–72) |
网关和 TLS 增加的开启时间约为 30ms。开启通话的大部分时间是模型服务器准备会话的 0.33 秒。没有使用生产路径测量整个已登录通话(直到说话结束后的声音)的数据——未在生产环境中创建测量账号(未测量)。因此,WebSocket 打开部分是在未登录的情况下打开并由服务器通过 4401 关闭的流程来测量的。
此时,一件事也浮出水面。如果用 Python websockets 客户端测量,“直到关闭结束”在生产路径中的中位数为 1,045ms ,而在集群内部为 6.9ms。通过 aiohttp 单独测量关闭帧到达的时间,生产路径中也是 36ms。服务器的关闭信号很快到达,慢的是该客户端在收到关闭帧后等待 TCP 连接断开的时间。这意味着如果将屏幕上显示“连接已断开”的时机绑定到 TCP 终止,经由代理的路径可能会延迟约 1 秒。
9. 局限与未测量
- 仅使用合成语音进行了提问。人声、方言、噪音为 未测量 。
- 浏览器的回声消除(
echoCancellation)是否会抹除 Web Audio 播放的声音因浏览器和设备而异,且未进行测量。页面上建议使用耳机。 - 由于服务器是 1:1 的,设计上并发通话只有一个。要接收多个通话,需要增加 GPU 或支持批处理的服务器(未测量).
- )。同系列的 Realtime-Venus(基于 MiniCPM-o 4.5,BF16 约 20GB)以及 NemotronLabs VoiceChat-11B 的 GGUF(仅英语)为 未测量 。
ScriptProcessorNode即将被废弃。迁移到AudioWorklet可以减少主线程忙碌时的卡顿,但未进行测量。
面试中可能会出现的问题
- 为什么不用 SSE 而是 WebSocket? SSE 是从服务器到客户端的单向通信。全双工通话每秒上传音频并同时接收声音。如果将上传通过单独的 HTTP 请求发送,则会产生每秒一个请求及其连接成本。
- 为什么不使用 WebRTC? WebRTC 具备 UDP、抖动缓冲区和回声消除,更适合通话。但它需要 TURN/STUN 服务器和媒体服务器(例如 LiveKit)。由于该模型以 1 秒为单位进行判断,数十毫秒的延迟差异对体验影响不大,而且复用已经运营的 WebSocket 路径(网页终端)更为简单。如果用户增加或考虑移动网络线路,迁移到 WebRTC 是正确的。
- 为什么避免使用 base64? base64 会使体积膨胀到 4/3 倍。float32 是 16 位的两倍。两者结合,相同的声音会变成 2.7 倍(48KB → 128KB/s)。
- 为什么不用 Redis 锁代替 advisory lock? 本网站已经在使用 Postgres,并且需要连接断开时自动释放锁的特性。Redis 锁必须设置过期时间,如果通话时间超过该时间则需要进行续期。
参考资料
- RFC 6455 The WebSocket Protocol — 帧 opcode(第 5.2 节)、关闭代码范围(第 7.4.2 节)
- PostgreSQL 文档,Advisory Locks(第 13.3.5 节),
pg_try_advisory_lock - MiniCPM-o 4.5 GGUF: huggingface.co/openbmb/MiniCPM-o-4_5-gguf, llama.cpp-omni: github.com/tc-mb/llama.cpp-omni
- MDN, Web Audio API —
AudioBufferSourceNode.start(when),BaseAudioContext.createBuffer - 第 20 篇语言学习实时语音对话、各部件实测,第 19 篇实时语音对话应用专用 NVIDIA 开源