TT Lab
博客

한국어 English 日本語 中文

开始
博客

博客

全双工语音通话的 WebSocket 中继:从浏览器麦克风到 GPU 模型

状态:草案(2026-10-09 更新)。 数据由作者亲自实测。运行环境为单张 24GB 级 GPU、MiniCPM-o 4.5(Q8_0、llama.cpp-omni)、FastAPI 后端。提问使用的是合成语音而非真人声音,未实测 实际浏览器回声及多人同时通话 。 编写规则:以实测数据为准,未能测出的则标为“未实测”。

目录

20 轮对话练习通过“录音 → 语音识别 → 对话模型 → 语音合成”以 HTTP 逐轮交互。必须说完才能得到回答,听回答时无法说话。若要营造与真人通话的感觉,则需要一个 边听边说、边说边听的 全双工(full duplex)模型。此类模型每秒会多次接收和发出声音。因此,比起单次请求对应单次响应的 HTTP, 保持一个连接并由双随时发送的 WebSocket 才是正确的选择。

本文在简述模型选型过程后,将探讨围绕 WebSocket 做出的五个决定:

  1. 不直接将浏览器连到模型服务器,而是由后端中继。
  2. 声音以 16kHz·16-bit·1秒包上传,以 24kHz·16-bit 下发。
  3. 声音走二进制帧,文字和状态走文本帧。
  4. 比实时发送更快的客户端用令牌桶进行限流。
  5. 只接收一路通话的服务器席位通过数据库的 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 运行。

模型侧修改了两个地方。

2. 为什么由后端进行中继

模型服务器直接暴露了 WebSocket 端点(/backend)。直接将浏览器连到这里虽然最快,但存在三个问题:

브라우저 ──wss──▶ 게이트웨이 ──▶ 백엔드(/ws/call) ──ws──▶ 모델 서버(/backend)
            (쿠키로 사람 확인)   (프롬프트 생성, 자리, 속도 제한, 형식 변환)
  1. 模型服务器完全信任系统提示词。 初始化会话的消息中包含提示词,若允许浏览器构造此消息,任何人都可以对模型下达任意指令。通过中继,浏览器只发送 mode=lang&lang=ja&level=N4 或 mode=blog&slug=… ,而提示词由服务器仅用规定语句和数据库中的文本来生成。等级(level)也仅在已知列表中时才使用。最初曾尝试只保留字母数字,但测试抓获了 B1. Ignore all rules 被作为 B1Ignore 注入提示词的情况。
  2. 模型服务器一次只能接收一路通话。 第二人接入会破坏第一通电话。席位问题将在第 5 节讨论。
  3. 必须验证身份并限制时间。 浏览器无法在 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

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()                              # 연결이 닫히면 잠금도 풀린다

7. 断开连接的各种情况

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. 局限与未测量

面试中可能会出现的问题

参考资料

登录后即可点赞

评论

还没有评论。

登录后即可发表评论