同声传译模型 - Hy-SimulMT
Apple Silicon 上的实时同传实践
Hy-SimulMT-30B-A3B 在 Apple Silicon 上的部署、流式改造、
端到端系统构建,以及五轮故障排查的完整记录。
摘要
这个模型的官方推荐路径在本机跑不起来:vLLM 要 CUDA,llama-server 的 chat 模板缺一个关键标记。但它底层的推理能力完全可用——只要绕开官方那两层封装,直接对着它对音频的假设来喂数据。
三件事构成了全篇的主干:
一、用同一条推理内核,换掉输入形态。官方 llama-mtmd-cli 一次只能吃一个完整音频文件,每次都重载 18 GB 权重(约 8 秒),实时无从谈起。给它加一个 --stream:音频不从文件来,而是从 stdin 读裸 PCM,每凑满一轮就跑一次官方轮次逻辑。分块 schedule 一个字都不能改——首轮 1600 ms、之后每轮 800 ms,这是训练时定死的。
二、承认 TTS 是独立的延迟问题。官方 Qwen3-TTS 音质好、能克隆音色,但 MPS 上 RTF ≈ 1.3,永远追不上说话的人;macOS say 的 RTF 是 0.11,却背着每轮 0.87 秒的进程启动固定开销。实时场景里选后者,是把"能不能用"和"好不好听"分开决策。
三、真正的难点不在推理,而在音频链路。全篇最长的一章是故障排查,因为踩的坑几乎全部同构:系统在静默地丢东西,而界面不报错。独占锁没有租约、轮次编号跨会话重号、播放器死等一个永远不来的轮次、模型加载的 8 秒里音频被整段丢弃、尾部不满一块就永不结算——每一个都让程序"看起来在运行",实际上已经聋了。
模型介绍:一个为同传而生的语音模型
Hy-SimulMT 是腾讯混元于 2026 年 10 月 26 日发布的中英双向实时同传模型,Apache-2.0 开源——本文在它发布两天后开始在本机部署。这一节只交代四件事:它宣称能做到什么、这些数字用什么口径测出来、六个变体差在哪里、官方给了哪几条部署路径。后面每一个工程决策,最终都要落回这四件事。
1.1定位:语音、译文、音色一起交付
说话人的音频可以持续输入,模型边听边译,同步给出译文文字与译文语音,并支持实时音色克隆——在把一种语言换成另一种语言的同时,尽量保留原说话人的声音特点。面向的场景很明确:会议、演讲、访谈这类不能等你说完再翻译的实时交流。
官方仓库交付的不是单个权重文件,而是三件东西,理解这一点对部署很重要——因为"模型跑通了"和"服务跑通了"是两件事:
| 交付物 | 内容 | 与本文的关系 |
|---|---|---|
| 模型权重 | 30B-A3B 主干 + 音频塔,共 6 个变体(精度 × 输出形态) | 本机使用 Interleave-GGUF Q4_K_M |
| SimulInfer | 推理服务:网页、音频文件上传、实时 WebSocket,可选 TTS 与音色克隆 | 依赖 Linux + vLLM,本机不可用 |
| SimulTrain | 基于已发布 checkpoint 的继续训练工具 | 本文未涉及 |
1.2官方评测:它被放在什么位置
同传的难点不在于把语言 A 变成语言 B,而在于话还没说完就得开始翻译。多等一点,语义判断更准,但听众等待更久;早开口,则对增量理解和译文组织提出更高要求。官方所有评测都围绕这条 质量与延迟的交换曲线展开。
长音频(RealSI)。StreamLAAL 约 3 秒,是全部参评系统里最低的值;GEMBA 翻译质量评分高于所对比的开源模型,并超过五个商业 API 中的四个。"低延迟不必以明显牺牲质量为代价",是这份评测想说明的结论。
短音频。FLEURS、CoVoST2、ACL6060、RealSI-Sentence 四个公开测试集上的得分如下(分数越高越好,加粗为该列最高分,浅底行为本模型):
| 系统 | FLEURS | CoVoST2 | ACL6060 | RealSI-Sent. |
|---|---|---|---|---|
| Hy-SimulMT | 90.3 | 75.5 | 81.2 | 84.7 |
| Gemini3.5-LiveTranslate | 92.4 | 74.4 | 82.6 | 82.2 |
| Qwen3.8-LiveTranslate | 88.4 | 82.1 | 80.5 | 84.4 |
| Confucius4 R2T2 → T3PO | 89.4 | 76.8 | 76.1 | 77.7 |
| Seed-LiveInterpret2.0 | 85.8 | 67.7 | 74.8 | 80.0 |
| SimulS2ST-Omni | 78.6 | 66.6 | 73.1 | 77.4 |
| GPT-Realtime-Translate | 77.7 | 50.8 | 76.4 | 76.7 |
| XunFei-Simult-Interpretation | 73.4 | 57.0 | 57.5 | 70.9 |
| SeamlessStreaming | 72.8 | 60.9 | 49.8 | 56.9 |
读这张表要留意一件事:CoVoST2 是它唯一的明显短板——75.5 分,比 Qwen3.8 的 82.1 低了 6.6 分,也是四列里唯一没有进入前二的列。官方文案说"三个测试集高于表中其他开源模型",指的是 FLEURS、ACL6060 和 RealSI-Sentence,并不包括 CoVoST2。这类"结论覆盖到哪一列"的边界,比结论本身更值得记住。
与专业译员同场对照。官方另采集了 2026 年 9 月之后出现在互联网上的 18 段中英文音频(每段约 6 分钟,覆盖演讲、访谈、会议,横跨软件与 AI、医学、制造与能源、自然科学、农业与食品、商务管理、法律、教育、文化文旅、体育生活十个领域),并邀请六位有多年同传实战经验的译员分别处理——其中三位持 CATTI 一级口译证书、三位持二级,每个等级取三人中的最高分与模型对比。
1.3六个变体:为什么这里只能用 Interleave-GGUF
同一个 30B-A3B 主干被发布了六次,差别落在精度与输出形态两个维度上,刚好构成一张 3 × 2 的表。这两个维度里,第二个对本机的选择起决定作用。下表所有变体共享同一个前缀 Hy-SimulMT-30B-A3B,这里只写后缀:
| 精度 | 仅译文 | 原文 + 译文 | 可用推理栈 |
|---|---|---|---|
| BF16 | (基础版) | -Interleave | vLLM / SGLang-Omni ≈ 57 GiB |
| FP8 | -FP8 | -Interleave-FP8 | vLLM 需 CUDA |
| GGUF | -GGUF | -Interleave-GGUF 本文选用 | llama.cpp Q4_K_M · 18.39 GiB |
也就是说,"能在 Mac 上跑"只在第三行,"能拿到原文"只在第三列,两者的交集只有一个变体:Hy-SimulMT-30B-A3B-Interleave-GGUF,本文用的是它的 Q4_K_M 量化(18.39 GiB)。前两行在本机没有讨论价值——BF16 与 FP8 都是 CUDA 栈的游戏。
"拿到原文"不是锦上添花,而是排查能力的来源。Interleave 版每次推理会同时返回源文分支与译文分支;当某一轮输出为空时,只有源文字段能回答"是模型没听出来,还是翻译环节丢了"。本文第十章那条反复出现的关键日志——空轮:源文也为空,这段音频模型什么都没听出来——就是靠这个字段把排查方向从"翻译质量"直接拨回到"音频链路"的。用 tgt_only 版,这个判断做不出来。
反过来,精度维度是被硬件逼的:BF16 的 57 GiB 只能落在 H200 这一档机器上,Q4_K_M 的 18.39 GiB 才塞得进一台 64 GB 统一内存的笔记本——而且还要给 KV cache、macOS 本身和 TTS 留出余量(第九章会看到,内存恰是这套方案真正的天花板)。
1.4三条官方推理路径,没有一条写着 macOS
官方给了三条部署路径,另有一个训练工具。它们的能力和前提差异很大,但有一个共同点:全部假设 Linux + NVIDIA。
| 路径 | 形态与能力 | 硬前提 |
|---|---|---|
| SimulInfer + vLLM | 网页、文件上传、实时 WebSocket,含 VAD 与断句恢复策略;支持 TTS 与音色克隆 | Linux、Python 3.12+、vLLM ≥ 0.31(需支持 HYV3ForCausalLM)、NVIDIA GPU |
| llama.cpp PR #31198 | GGUF 推理;当前仅 llama-mtmd-cli 支持,llama-server 尚待适配 | 该 PR 的分支;macOS 可编译 |
| SGLang-Omni | OpenAI 兼容 HTTP + 实时 WebSocket;只返回译文文本,不含语音与克隆 | Linux + CUDA;BF16 约 57 GiB,单张 H200 |
| SimulTrain | 基于已发布 checkpoint 继续训练,需独立 Python 环境 | 训练级 GPU |
三个细节值得记住,它们决定了本文后续的形态:
- 只有 llama.cpp 这条路沾得上 Apple Silicon。vLLM 和 SGLang-Omni 都是 CUDA 栈,本机连启动的机会都没有。
- 而 llama.cpp 的支持只到"单文件推理"。PR 明确写着当前仅
llama-mtmd-cli可用,llama-server待后续适配。这意味着官方那条"起个服务、推 WebSocket"的用法,在 GGUF 上并不存在——必须自己造。 - SGLang-Omni 那条路反而提供了反证。官方注明该集成"未复现 SimulInfer 的 VAD 与恢复策略"——说明 VAD 与断句恢复并不在模型内部,而是服务层的工程。这也解释了本文为什么必须自己写一套音频链路(第八章)。
所以本机的处境可以一句话概括:能跑的路径功能不全,功能全的路径跑不了。剩下的事,就是把"能跑"的那条补到"功能全"。这是第二章的起点。
模型解剖:它到底是怎么"听"的
不理解"按轮次喂"这件事,后面所有工程决策都会做错。这个模型不是"给一段录音、还一段翻译"的批处理模型,它的推理过程被训练成了一个固定的节拍。
2.1架构与能力边界
| 维度 | 参数 | 工程含义 |
|---|---|---|
| 主干 | HYV3 MoE 30B 总参 / 3B 激活 | 每 token 只走 3B 的专家路径,所以 30B 的模型在消费级硬件上有实时可能 |
| 音频塔 | Whisper 风格 encoder | 与文本主干打包在同一个 GGUF 里,无需独立 --mmproj |
| 任务 | zh↔en 语音到语音同传 | 方向由指令语种决定,指令进上下文后不可改 |
| 上下文 | KV 96 KB/token | 16K 上下文 ≈ 1.5 GB;每 800 ms 涨一轮,约 9–11 分钟耗尽 |
| 许可 | Apache-2.0 | 可商用,本机部署无授权障碍 |
2.2固定节拍:1600 + 800
模型被训练成一个边听边译的 checkpoint 序列:先听 1600 ms 建立上下文,之后每 800 ms 生成一次译文增量。每一轮结束都必须先把当前能确定的部分译出来,再去听下一段。这解释了它为什么能做到 255 ms 的字幕延迟——它压根不等你说完。
2.3特殊 token:三个必须精确的地方
这个模型的自定义 token 不是装饰,它们直接决定输出是否可用。
| Token | 位置 | 作用与后果 |
|---|---|---|
<|hy_begin▁of▁sentence|> | 序列开头 | BOS。缺了会走不寻常的起始路径。 |
<|reasoning_mode|>reasoning_effort:no_think | BOS 之后 | 关掉思维链。同传要的是即时输出,让模型"想一想"就等于加几百毫秒延迟。 |
<|hy_User|> | 指令前、每轮音频前 | user 角色。首轮在 prefix 里给,之后每轮重发。 |
<__media__> | 指令之后 | 音频占位符,真正的 embedding 在这里被塞进序列。 |
<|hy_place▁holder▁no▁790|> | 助手回合开头 | Interleave 版必需。branch 标记,告诉模型接下来走 src→tgt 双分支。缺了源文分支会退化。 |
<|hy_place▁holder▁no▁791|> | 输出中间 | src / tgt 分隔符。解码时遇到它就从"源文"切到"译文",是两个字段的唯一分界。 |
2.4为什么"按轮次"这个约束是好事
乍看这是个限制:你不能自己决定切块粒度。但它带来两个正好被工程利用的性质。
第一,天然背压。既然每轮必须吃满 800 ms 才能进推理,那输入侧就不可能无限超前。上游只需要保证"按实时速率送",模型自己会形成节拍,永远跑不到说话人前面。
第二,延迟可预测。单轮生成实测 115–235 ms,稳定在一个很窄的区间。这意味着端到端延迟基本等于"块大小 + 生成时间",是个可以算出来的常数,而不是需要反复试的玄学。
部署路径的抉择
官方给了三条路,前两条在 macOS 上都断掉,但断的方式不同——一条是硬性不可用,一条是"能跑但结果是错的"。后者更危险。
3.1为什么必须绕开 chat 模板
llama-server 走 OpenAI 消息协议,助手回合只发 <|hy_Assistant|><think></think>,漏掉了 790 分支标记。更根本的是,模板体系里没有"每轮重放助手头"这个概念——它是一个"一次对话若干条消息"的抽象,而这个模型要的是"一次对话、几十轮、每轮都要重新宣告我是助手、我要走 src→tgt 分支"。
硬套的结果不是报错,而是源文分支塌陷:模型只能走 tgt 分支,输出退化成把译文重复拼接。第一次看到 "下一季度我们将重点提升下一季度我们将重点提升" 这种输出时,很容易误以为是量化损失或者上下文污染,实际上是模板少了一个 token。
3.2顺带解决的一个疑问:单文件 GGUF 的投影器
这批 GGUF 是单文件打包:音频塔的权重直接并在文本模型里。官方 llama-mtmd-cli 会检测到并自动 fallback(日志里那句 --mmproj not given, using the projector packaged in ...),但 llama-server 只判断 params.mmproj.path 是否非空。
于是有个取巧但有效的做法:把 --mmproj 指向模型自己。
llama-server -m models/Hy-SimulMT-30B-A3B-Interleave-Q4_K_M.gguf \
--mmproj models/Hy-SimulMT-30B-A3B-Interleave-Q4_K_M.gguf \
-c 4096 -ngl 99 --port 8899
之后 /props 里 modalities.audio 变成 true,整段音频一次跑通(14.16 s 音频 1.76 s,约 8× 实时)。这条路验证了"模型在 Metal 上确实能跑",但因为 2.1 的模板问题,它不能用来做同传——最终只是作为一条性能上界的参照系。
环境与模型获取
4.1下载通道:镜像不是备选,是唯一路
这是第一个"跟技术无关但卡住一切"的问题。HuggingFace 镜像在这台机器上完全不通——不是慢,是 HTTP 000(连接直接超时)。换成 ModelScope 直连后稳定在 10.3 MB/s。
# HF 镜像:实测 http=000,全链路超时,不要浪费时间重试 curl -I https://hf-mirror.com/... # ModelScope 直连:10.3 MB/s,18.39 GiB 约 30 分钟 curl -L -o Hy-SimulMT-30B-A3B-Interleave-Q4_K_M.gguf \ https://www.modelscope.cn/models/Tencent-Hunyuan/Hy-SimulMT-30B-A3B/resolve/master/\ Hy-SimulMT-30B-A3B-Interleave-Q4_K_M.gguf
| 文件 | 大小 | 说明 |
|---|---|---|
| Hy-SimulMT-30B-A3B-Interleave-Q4_K_M.gguf | 19,746,809,728 B 18.39 GiB | 主干 + 音频塔单文件。Q4_K_M 是实时性与质量的平衡点 |
| Qwen3-TTS-12Hz-0.6B-Base | 2.3 GiB | 可选 TTS,音色克隆用 |
4.2编译:只要一个 target
不需要编整个 llama.cpp。只要 llama-mtmd-cli,48 秒零错误。
git clone -b hyv3-speech https://github.com/lihy715/llama.cpp.Hyv3Speech.git llama.cpp-hyv3
# commit e41ca42 —— 这个 PR 至今未合入 master
cd llama.cpp-hyv3
cmake -B build -DGGML_METAL=ON -DCMAKE_BUILD_TYPE=Release
cmake --build build --config Release --target llama-mtmd-cli -j 10
mtmd_audio_stream_* 那套流式接口,还改了音频 tower 的实现细节。换分支不是选择,是前提。
4.3一个提前排除的风险
动手前担心过 GGML_OP_CONV_2D 在 Metal 上的实现约束:它要求 F32 输入输出 + F16/F32 权重,而音频塔第一层就是 conv2d。查过之后这条风险自然消解——投影器张量本来就全是 F32,条件天然满足。这类"看起来是坑其实不是"的验证,比事后 debug 便宜得多。
离线跑通:先证明它能译
在动任何代码之前,先用官方工具跑一次完整音频。这一步的价值不是"跑通了",而是拿到一条干净的基线命令,后面所有改造都以它为准。
./llama.cpp-hyv3/build/bin/llama-mtmd-cli \
-m models/Hy-SimulMT-30B-A3B-Interleave-Q4_K_M.gguf \
--audio audio/test_en.wav \
--temp 0 \
-p '<|hy_begin▁of▁sentence|><|reasoning_mode|>reasoning_effort:no_think<|hy_User|>
请完成下面的同声传译任务,将陆续收到的语音翻译成中文。请结合上下文边听边译,
输出能够确定的译文,不必等待整句结束。<__media__><|hy_Assistant|><think></think>
<|hy_place▁holder▁no▁790|>'
真实输出(14.16 s 英文音频):
Good morning, everyone.<|hy_place▁holder▁no▁791|>大家早上好。
Thank you for joining today's meeting. We are<|hy_place▁holder▁no▁791|>感谢大家参加今天的会议。
going to discuss the new<|hy_place▁holder▁no▁791|>我们将讨论
product roadmap for the next quarter. Our<|hy_place▁holder▁no▁791|>下一季度的新产品路线图。
...
run_turn_stream: 105 tokens, 9/17 turns emitted, 14.16 s audio in 4.06 s (3.49x realtime)
5.1两条基线的意义
几个值得注意的点:
- GPU 比 CPU 快 2.6 倍,而且 CPU 那条 1.33× 与 PR 作者上报的 1.40× 吻合——这是一次便宜的外部交叉验证,说明测量方法没跑偏。
- 轮数不同不代表质量不同:Metal 出 9 轮、CPU 出 14 轮,是浮点后端差异导致切轮粒度不同,译文内容一致。
- 1.33× 是"能听"的下限。纯 CPU 能跑,但余量太薄——系统稍有负载就会掉到 1× 以下,而一旦低于 1× 就意味着延迟会无限累积。
5.2两个不影响结果但会误导人的告警
| 日志 | 要不要管 | 原因 |
|---|---|---|
| special_eos_id is not in special_eog_ids | 忽略 | tokenizer 配置告警。整段与流式两种模式下输出都验证过,不影响。 |
| audio input is in experimental stage | 忽略 | llama.cpp 对多模态音频的通用提示。 |
语音合成:音质与实时的交换
同传的"传"如果只停在字幕,体验是不完整的。这里有两个 TTS 方案,结论是——实时的那个不好听,好听的那个不实时。
6.1官方方案:Qwen3-TTS-0.6B 在 MPS 上跑通
官方要求用 vLLM-Omni 部署(要 CUDA)。但 qwen-tts 这个 pip 包本身不依赖 CUDA,device_map="mps" 就能在 Apple Silicon 上跑。
uv venv tts-env --python 3.11
VIRTUAL_ENV=tts-env uv pip install qwen-tts modelscope
# torch 2.14.1 / transformers 4.57.3(严格 pin,新版会破)
./tts-env/bin/modelscope download --model Qwen/Qwen3-TTS-12Hz-0.6B-Base \
--local_dir models/Qwen3-TTS-0.6B-Base
关键突破:绕开 sox
这个包默认用 audioread / sox 读参考音频,而系统没装 sox(brew install sox 被沙箱拦)。解法不是装依赖,是走另一条分支:_normalize_audio_inputs 接受 (np.ndarray, sr) 元组,走这条就完全跳过 _load_audio_to_np。
import soundfile as sf
wav, sr = sf.read("audio/test_en.wav")
# 传元组,不传路径 → 不进 audioread / sox 那条路
prompt = tts.create_voice_clone_prompt((wav.astype("float32"), sr))
精度选择的实测结论
| 精度 | 结果 | 判断 |
|---|---|---|
| fp16 | probability tensor contains inf/nan | 不可用 logits 溢出 |
| bf16 | RTF 1.39(fp32 是 1.38) | 不提速 白折腾 |
| fp32 | RTF 1.30–1.38 稳定态 | 采用 MPS 上的实际上限 |
逐轮实测(fp32 / MPS):
[prompt] 预编码耗时 5.77s [turn 1] 1.92s -> 1.28s 音频 RTF=1.50 [turn 2] 3.29s -> 2.32s 音频 RTF=1.42 [turn 3] 5.11s -> 3.84s 音频 RTF=1.33 [turn 4] 5.62s -> 4.32s 音频 RTF=1.30 [turn 5] 2.23s -> 1.44s 音频 RTF=1.55 [total] 生成 18.17s / 音频 13.20s RTF=1.38
音色克隆的客观验证
"听起来像"不算验证。用 librosa 的 pyin 提基频(F0):参考音频中位 166.8 Hz,合成中文 157.9 Hz,偏差 5.3%,P25–P75 区间几乎重叠。这才叫克隆成功。
create_voice_clone_prompt() 把参考音频预编码一次复用,避免每轮重新编码。这一步省下的是"每句都要听一遍参考音频"的固定成本。
6.2实时方案:macOS say 的固定开销之谜
Qwen3-TTS 的 RTF 1.3 意味着合成 1 秒语音要 1.3 秒——它永远追不上说话的人。换成 say,RTF 降到 0.11,但它有个隐藏成本。
现象配音延迟稳定在 1.15 秒左右,无论这句话多长。测一个字的文本:0.868 s;测 7.5 秒的文本:0.916 s。
解释这 0.87 秒不是合成时间,是进程 fork + 语音资源加载的固定开销。合成本身只占几十毫秒(RTF 0.11)。
对策换 Eloquence 系语音只快 0.16 s,但音质差一档,不值得。真正的解法是进程内合成(AVSpeechSynthesizer),那是另一件事,不在本次范围。所以 1.15 秒就是当前方案的延迟下限,接受它。
6.3两个方案的取舍
| Qwen3-TTS-0.6B | macOS say | |
|---|---|---|
| RTF | 1.30 – 1.38 | 0.11(+ 0.87s 固定) |
| 实时可用 | 否 持续落后,越听越迟 | 是 |
| 音色克隆 | 支持 F0 偏差 5.3% | 不支持 固定音色 |
| 内存 | 约 2.3 GiB 常驻 | 无常驻 |
| 适用 | 事后听、离线配音、录音转译 | 实时同传 |
所以工程上是两套并存:./tts.sh 走离线全链路(音色克隆 + 拼接导出),./live.sh 走实时链路(say)。./live.sh --qwen 保留了切换能力,但文档里明确标注"只适合事后听"。
实时化的三个突破
从"能译一段录音"到"能跟着你说话译",中间有三个必须依次解决的问题,顺序不能变。
7.1突破一:让工具接受"音频从流里来"
官方 llama-mtmd-cli 的输入是 --audio <file>。每次调用都要重新加载 18 GB 权重(约 8 秒)——实时无从谈起。而它内部的 run_turn_stream 函数其实已经实现了完整的轮次循环,只是音频从文件里读。
所以改造的思路不是重写,是换掉数据源:新增 --stream,音频从 stdin 读裸 PCM16LE 单声道 16 kHz,其余逻辑一行不动。
+ static bool g_stream_stdin = false;
+ static int g_stream_first = 1600; // 首轮音频毫秒数
+ static int g_stream_step = 800; // 之后每轮毫秒数
+
+ static int run_turn_stream_stdin(mtmd_cli_context & ctx, ...) {
+ // 与 run_turn_stream 完全相同的轮次逻辑,只把 fname 换成 stdin
+ }
规模:tools/mtmd/mtmd-cli.cpp | 260 ++++++++++++++,净增 260 行,没有改动任何既有函数。
核心循环
while (!g_is_interrupted) {
// 本轮要吃的样本数:首轮 1600ms,之后 800ms
const size_t want = (size_t) rate * (first ? first_ms : step_ms) / 1000;
// 阻塞读,凑不满不进推理 —— 这就是节拍器
while (pcm.size() < want && !eof) {
int16_t buf[4096];
size_t nmax = std::min<size_t>(want - pcm.size(), 2048);
size_t got = fread(buf, sizeof(int16_t), nmax, stdin);
if (got == 0) { eof = true; break; }
for (size_t i = 0; i < got; i++)
pcm.push_back((float) buf[i] / 32768.0f);
}
// 每段音频是独立 user message,第二论起要补角色 token
if (turn > 0) decode_tokens({ t_user });
// 音频 → embedding → 塞进 KV
mtmd_encode_chunk(ctx.ctx_vision.get(), c);
// ... add_embd 后 llama_process(DECODE)
// 重放助手头 + 分支标记,然后逐 token 生成
decode_tokens(post_toks);
// 遇到 791 切到 tgt 字段,遇到 EOG 结束本轮
}
fread 上。
输出协议:JSON Lines
{"type":"ready"}
{"type":"turn","i":0,"ms":1600,"src":"Good morning, everyone.","tgt":"大家早上好。","n_gen":11,"gen_ms":149.0,"pos":312}
{"type":"turn","i":1,"ms":800,"src":"Thank you for joining today's meeting.","tgt":"感谢大家参加今天的会议。","n_gen":14,"gen_ms":187.3,"pos":389}
{"type":"done","turns":17}
字段里 pos 特别有用——它是当前上下文位置,进程靠它判断什么时候该换一个干净会话。gen_ms 则直接给出单轮生成耗时,是延迟分解的原始数据。
7.2突破二:一个必须拒绝的诱惑
改造过程中最大的诱惑是"自己决定块大小"。既然要边听边译,那切 3.2 秒不是更省算力、上下文也更连贯吗?
不行。这个 schedule 是训练时定死的。自己切 3.2 秒的实测结果是译文重复、轮次错位——模型在训练中见到的永远是"1600 之后每 800 一小步",换粒度就等于让它做一件没学过的事。
这条约束一直延伸到服务端:Python 侧的切块粒度也必须是 800 ms,因为 C++ 首轮吃 1600(= 2 块),只有 1600 是 800 的整数倍,块边界才天然对齐。对齐之后才敢做整块丢弃——丢半块会让后面所有音频错位一格。
7.3突破三:让它常驻
前两个突破解决"能不能流式",第三个解决"能不能一直流式"。做法是把 llama-mtmd-cli 作为子进程常驻,stdin 接音频、stdout 读 JSON、stderr 归日志,外面套一层 Python 服务管生命周期。
这带来一组必须处理的新问题,全部落在服务端:进程崩溃怎么恢复、上下文满了怎么换会话、两个浏览器同时开怎么仲裁、TTS 跟不上怎么不堵住字幕。这些是第 8 章的内容。
系统架构
整条链路有四个进程/线程组,边界很清楚:浏览器负责采集与播放,Python 负责编排与账本,C++ 负责推理,say 负责发声。
8.1线程模型
| 线程 | 职责 | 关键约束 |
|---|---|---|
| writer | 每个客户端一条,串行推送出去的帧 | 队列满则丢弃,绝不阻塞生产者 |
| _reader | 读模型 stdout,解析 JSON 行 | 此处绝不能写 stdin,会与模型互锁 |
| _drain | 把 stage 里的音频切块写进 stdin | 独立线程。写满管道会阻塞,不能跑在 reader 里 |
| tts_worker ×2 | 调用 say 合成单轮 | 并发数 2 是上限,再多会各自加载语音资源互拖 |
| tts_emitter | 按轮次 id 顺序广播音频 | 等一轮最多 3 秒,超时跳过而不是死等 |
| watchdog | 1.5 秒采样,判链路是否还活着 | 6 秒无增长才告警 |
_drain 向 stdin 写入,管道写满就会阻塞;如果它跑在读 stdout 的线程里,模型输出没人消费,两边就互相卡死。正确做法是加载完成后由独立线程补送,reader 线程只负责读。日志里那句「补送加载期间积压的 2206ms 音频」就是这条路径。
8.2关键参数与它们的理由
| 参数 | 值 | 为什么是这个值 |
|---|---|---|
| CTX | 16384 | 16K ≈ 1.5 GB KV。本机 swap 长期 15 GB+,开 32K 会让模型页被换出,推理直接失去实时性。省内存比拉长会话值。 |
| BLOCK_MS | 800 | 与 C++ 的 1600(=2×800)严格对齐,才敢整块丢弃。 |
| MAX_BACKLOG_MS | 10000 | 允许落后 10 秒。定得比"还能听"宽得多——宁可暴露延迟,不能偷吞内容。 |
| PCM_LEASE | 4.0 s | 独占锁必须有租约。客户端断开时 drop_client 不一定被调用,没租约就永久锁死。 |
| N_PREDICT | 160 | 单轮生成上限。800 ms 音频实测远用不完。 |
| SILENT_RMS | 1e-3 | 低于此值算静音。连续 4 秒触发界面告警。 |
| TTS_WORKERS | 2 | 从 3 降下来。并发 say 各加载一份语音资源,内存紧张时互相拖死。 |
8.3独占锁:一个必须存在的粗暴机制
两路 PCM 写进同一个 stdin 会互相插帧——字节数对得上,但样本交错,模型听到的是两段音频按位混合的噪声,结果就是"什么都听不出来"。这个 bug 是在"Chrome 假麦克风还在推流、同时用 /api/simulate 灌测试音频"的场景下暴露的。
解法是第一个开口的客户端独占,但它必须带租约:
now = time.time()
if self.pcm_owner is not None and self.pcm_owner is not owner:
if now - self.pcm_lease < self.PCM_LEASE: # 4 秒内
self.rejected_bytes += len(data) # 记账,不静默
return
log("音源独占已过期,交由新音源接管")
self.pcm_owner = owner
self.pcm_lease = now
注意 rejected_bytes 这个字段——被拒绝也要记账。否则调试时会看到"收到的字节数怎么比送进模型的少",误判成丢包。
性能实测
延迟不是一个数字,是一条分解链。把每一段拆开,才知道优化该往哪打。
say 的进程启动固定成本,与文本长度无关。这就是当前方案的下限。9.1全量数据
| 指标 | 数值 | 备注 |
|---|---|---|
| 模型加载(冷启) | 约 8 s | 页缓存热后 2 s |
| 单轮生成(800 ms 音频) | 115 – 235 ms | 非常稳定,可直接当常数用 |
| 说话 → 字幕 | 平均 255 ms | 比专业同传好一个数量级 |
| 说话 → 出声 | 平均 1150 ms | 其中 870 ms 是 say 固定开销 |
| 连续压测(66 s 音频) | 82 轮 | 积压恒定 0–800 ms,丢弃 0 |
| 浏览器全链路(96 s) | 87 行字幕 / 86 段配音 | 无中断、无告警 |
| KV 缓存 | 96 KB/token | 16K 上下文约 1.5 GB |
| 整段批处理(14.16 s) | 3.49× 实时 | Metal;纯 CPU 1.33× |
9.2内存是这个方案的真正天花板
所有性能问题里,最反复出现的一个不是算法,是swap。
事件连跑 5–6 个批处理任务(每个都加载一次 18 GB GGUF)后,swap 用到 17.4 GB / 18.4 GB,TTS 的 RTF 从 1.38 掉到 6.97,翻译加载从 1.9 s 涨到 15 s。
性质不是代码 bug。模型权重页被换出到磁盘,每次推理都要从 SSD 读回来。
处置等系统回收后自然恢复。但这件事促使把 CTX 从 32768 降到 16384,省下 1.6 GB KV——在内存紧张的机器上,"少花 1.6 GB"比"多撑十分钟"值钱得多。
延伸这个现象还解释了另一类"说不清"的故障:say 在 swap 已满时启动会被无限拉长,3 个 worker 同时卡住,emitter 按序死等 → 所有音频广播中断,但字幕还在出。看起来是"半死不活",其实是内存。
故障排查实录
这一章是全篇最长的,因为真正的成本都在这里。五轮排查,每一种故障都长得像"程序没反应",但根因分布在完全不同的层——从 C++ 的分块边界,到浏览器的 AudioContext 拓扑,再到 macOS 的 TCC 权限。
10.1第一类:静默失败的模式
把五轮里所有 bug 摆在一起看,会发现它们共享同一个形状:
| 共同特征 | 后果 | 为什么难查 |
|---|---|---|
| 进程活着、计时在走、界面无报错 | 用户以为"卡了" | 没有任何异常可抓 |
| 字节数一直在涨 | 账本看起来完全对平 | 全零样本也能让账本完美对平 |
| 日志被节流 | i % 15 == 0 让 50 轮空轮只打 4 行 | 抽样掩盖了连续性 |
| 错误提示被样式隐藏 | 运行时给状态条加 hide | "连接断开"四个字压根没显示过 |
所以第一轮修复的重点不是某个具体 bug,而是让失败显形:心跳、租约、账本、落盘、告警。后面几轮的定位速度能快起来,全靠这一轮铺的可观测性。
10.2五轮排查
现场模型进程存活,pos 冻结在 757 不再变化,TTS 有最后一条成功日志,没有"进程结束"。
根因四个独立缺陷叠加,任一都足以致命:
- TTS emitter 死等 30 秒。swap 满时
say启动被无限拉长,3 个 worker 同时卡死;emitter 按序等第 67 轮,waited > 30才跳过,期间所有 audio 广播全断。复现日志:TTS 第 67 轮超时,跳过。 - 轮次编号跨会话重号。
turn_seq在start()里归零,但tts_order/tts_done没清 → 新会话的 0/1/2 与上一会话残留同号,播放器等一个永远不来的轮次。 - 独占锁无租约。连接断了但
drop_client没被调用,锁永久指向死客户端 → 之后所有音频被静默return。 - 尾部不结算。不足 800 ms 的尾巴永远躺在
fread缓冲里等下一块。
修复emitter 等待 30 s→3 s;say 超时 60 s→8 s;worker 3→2;turn_seq 改为全局唯一;独占锁加 4 s 租约;静音 1.2 s 后补零结算;前端加心跳 + 退避重连。
验证66 s 音频 → 82 轮,积压恒定 0–800 ms、丢弃 0、TTS 超时 0;尾部那句「把推理延迟降下来」正常结算(修复前停在 [78])。
质疑是输入语音处理有问题,还是模型本身?(这个问题问得对,我之前一直没分清。)
先排除模型原生 16 kHz 与原生 48 kHz(say --data-format=LEI16@48000 合成,自带齿音高频)分别灌入,模型都完整译出,一句不落。结论:模型和字节链路都没问题,是链路里有五个点会把音频"删掉"。
- 前端
sendPCM先清空缓冲再判断能否发送 → WS 短暂不 ready 就永久丢 100 ms。 - 模型加载那 8 秒
push_pcm直接 return → 切方向/重置期间说的话整段消失,且不计入任何统计。 - 尾部 < block/3(266 ms)不结算 → 说一停顿,最后半句原地等。
- 积压 > 2400 ms 就整块丢弃 → 为追平实时而偷吞内容。
- 多窗口抢占静默丢弃 → 后开的页面完全没反应且无提示。
修复发不出去就攒着(上限 12 s);加载期音频攒进 stage、ready 后由独立线程补送;尾部门限降到 50 ms;丢弃阈值提到 10 s 且必打日志;新增 busy 消息提示"另一个窗口正在占用麦克风"。
重大收获同口径音频账本(见 11.1)。11 秒 48k 音频实测 recv 39186 / model 38400 / done 38400 / stage 786 / discard 0,差额 786 ms 与 stage_ms 精确吻合。
先解释提示那句话不是程序坏了,是程序在汇报事实:页面每秒上报一次能量,连续 4 秒低于 -60 dBFS 就判定"没有声音进来"。
客观数据落盘的 1005 秒原始采样里,每秒 RMS 最高只有 50,正常说话是 300–3000;969 秒的 RMS < 10。当前会话 180 秒 / 224 轮全部空轮。
对照同一会话里灌入已知音频,模型立刻完整译出 → 模型完好,问题在"送进去的是什么"。
改动输入设备下拉(本机有 tranScreen Audio 虚拟声卡和 iPhone 连续互通麦克风,默认很容易落错);实时 dBFS 数字;软件增益兜底(包络归一到 0.22,上限 150×,门限 8e-5 防放大底噪);"测一下麦克风"按钮——录 4 秒、报电平、原样回放。
教训「没有声音」这句话无法行动,必须给数字。「原样回放」是唯一能让用户自己判定"到底收没收到"的手段。
关键新信息"开头正常"这一条推翻了上一轮"全程静音"的判断——信号是好的,是运行中失效。
落盘分析最后 90 秒:非零采样占 76.4%(不是断流,设备在采),但 RMS 只有 2–5,峰值 1386。也就是麦克风还开着,但采到的是近乎寂静的底噪。
时间线咬合
14:50:32 [76] '你好,今天你在干啥?' → 识别正常 ✅ 14:50:33 触发配音 TTS(1.24s 音频) 14:51:07 之后 197 秒全部空轮,level 卡在 0.0001
根因采集和播放共用一个 AudioContext,而播放链路必须接 destination(硬件输出)。macOS 上内置麦克风与扬声器挂在同一个 AudioUnit——一旦有声音开始播放,AudioUnit 从"纯输入"切到"双向",采集流在切换中被拆掉,Chrome 补不回来。配音一响,麦克风就哑了。
修复采集独立成自己的上下文,终点改为内存里的 MediaStreamDestination,彻底不碰硬件输出;去掉原来那条"0 增益 Gain → destination"(就是它让采集上下文也占了一路硬件输出);轨道 mute/ended 立刻重建;连续 25 秒数字静音且距上次重建超 30 秒则自动重连一次。
为什么之前测不出来假麦克风不绑物理输出设备,永远不触发这个切换。用 --use-file-for-fake-audio-capture 跑通的 96 秒 / 87 行字幕,恰好绕过了故障路径。这是本轮最重要的方法论教训。
尝试用 puppeteer 拉起 Chrome,四种接法(硬件输出 / 内存 sink × APM 开 / 关)、三个通道逐项拆分,全部拿到精确的 0.000000。同时在扬声器放 1 kHz 测试音也收不到。
解释macOS 把麦克风权限(TCC)判给启动 Chrome 的那个进程。子进程拿到的是静音流,而不是报错——所以既不能当"设备坏了"的证据,也不能当"代码对了"的验证。
结论真实麦克风这条路径只能由用户亲自点。自动化能验证的只有两件事:代码在 fake device 下是否正确,以及服务端逻辑是否自洽。
附带发现/live.html 是 404,正确路径是根路径 /。前几次探针全打在一个 404 JSON 页上——虽然不影响 getUserMedia,但干扰了判断。
10.3其余工程坑(速查)
| 坑 | 现象 | 解 |
|---|---|---|
| numba 缓存清理被安全钩子杀进程 | 服务启动即消失,无 traceback | 设 NUMBA_CACHE_DIR=/tmp/...,必须在 import 前 |
| 预热是陷阱 | 持推理锁跑不受控推理,卡住后拖死全部任务(实测 300 s) | 去掉预热,让首个任务自然承担 kernel 编译开销 |
macOS 没有 timeout 命令 | 用错得到假的 exit 0,误判脚本正常 | 用 Python 侧超时或 gtimeout |
| mtmd-cli 译文走 stdout、日志走 stderr | 解析器等 stdout 里的日志,0 轮命中 | 只解析 stdout 的 JSON 行 |
0 % 12 == 0 恒真 | 秒推「已 0 秒没收到声音」 | 守卫必须加 stall_sec >= 6 |
html,body{height:100%} | 破坏 sticky 顶栏(flex 容器限高) | 改 html{height:100%} + body{min-height:100%} |
| 回放历史字幕自动滚到底 | 首屏被顶栏遮住 | autoScroll 加 if (!S.running) return |
| 运行中控件放在滚动区 | 一滚就找不到"结束"按钮 | 红点/计时/音量条全部移到顶栏 |
/api/simulate 用 sleep(0.1) 累加 | 节奏漂移,延迟测量从 255 ms 虚高到 500 ms | 按绝对时钟对齐 |
| Chrome headless 起 remote debugging | 必须加特定 flag | --no-sandbox --user-data-dir=<独立目录> |
方法论:从"猜"到"量"
这一章不是技术细节,是这次真正学到的东西。前两轮我判断错了两次,原因都不是不懂技术,而是把"看起来一致"当成了"证明了"。
11.1音频账本:一条能定位丢在哪的尺子
核心思路:把音频在链路上的每一步都换算成毫秒,用同一个口径记账。这样任何一处丢失都会变成两个数字之间的差,而不是靠听感猜。
discard_ms 自己丢的、rejected_ms 被别人占锁拒收的),任何一处丢失都会在差值里显形。账本能证明"没丢",不能证明"有内容"。要证明后者,必须看电平(RMS / dBFS)或落盘回放。
11.2落盘取证:把"猜"变成"看"
一旦把收到的原始 PCM 落到 live-cache/recv.pcm,很多争论就消失了。可以直接算每秒 RMS、画包络、截取片段重放:
落盘总时长 1005.0s 文件 30.2 MB
全文件最高峰值 1386 最高每秒 RMS 50
RMS 分布: <10: 969 秒 10–50: 31 秒 50–200: 5 秒 >200: 0 秒
↑ 正常说话应该在 300–3000
这组数字一次性回答了三个问题:设备在采吗(在,非零占 76%)、有声音吗(几乎没有)、是代码问题吗(不是,采到的东西本身就没能量)。
11.3三条被实践否定的"验证方式"
| 看起来合理的验证 | 为什么无效 | 正确做法 |
|---|---|---|
| 用假麦克风跑通全链路 | 假设备不绑物理输出,永远不触发 AudioUnit 的模式切换。96 秒 / 87 行字幕全部通过,真实设备一秒就哑。 | 假设备只用于验证代码逻辑;真实设备的问题必须真实设备验。 |
| 把 16k 上采样到 48k 做重采样对照 | 上采样不会造出 8 kHz 以上的能量,降回去必然无损——实验设计本身没有信息量。 | 要造真实条件,必须用原生 48k 合成/录音(自带齿音高频)。 |
| 用 puppeteer 测真实麦克风 | macOS 把 TCC 判给父进程,子进程拿到静音流而不是报错。 | 让用户自己点「测一下麦克风」;自动化只测 fake device 路径。 |
11.4排查期间的自伤要如实说明
排查过程中我重启了三次服务,每次模型重载 8 秒。如果用户正好在说话,那几句必然丢——这也在他感知的"尾部没了"里。这类由排查动作本身引入的噪声,必须主动告知,否则会把用户推向错误结论。
边界与去向
12.1当前方案的硬边界
| 限制 | 原因 | 能否绕过 |
|---|---|---|
| 只能单向 | 方向由指令语种决定,指令在首轮就进了上下文 | 换方向要重启模型(约 8 s)。可以预启两个进程并存,代价是内存翻倍 |
| 约 9–11 分钟换会话 | 16K 上下文,每 800 ms 涨一轮 | 开更大上下文,但内存紧张的机器上会失去实时性 |
| 同一时刻只接受一个音源 | 两路 PCM 写同一 stdin 会互相插帧 | 不解。这是物理约束,只能仲裁 |
| AI 配音不实时 | Qwen3-TTS RTF 1.3;say 有 870 ms 固定开销 | 进程内合成(AVSpeechSynthesizer)可以消掉 870 ms,但要写 ObjC/Swift 桥 |
| 外放会自激 | 模型听到自己的译文,当成新输入再译一遍 | 戴耳机,或启用 AEC(但 AEC 又会压近端语音,是个权衡) |
12.2如果继续做,优先级排序
- 进程内 TTS。870 ms 是当前配音延迟的 76%,用
AVSpeechSynthesizer可以直接砍掉。这比换模型、调参数都划算。 - 双向常驻。预启 zh→en 与 en→zh 两个进程,按方向切换路由。内存不够就在切换时懒加载。
- 音频链路的形式化。把账本从"事后 curl"变成界面上的常显数字,让用户自己就能判断丢在哪一环。
- 向上游反馈 mmproj 与模板的修复。PR #31198 若合入 master,并且
llama-server的模板支持"每轮重放助手头",这套自研服务就可以简化掉一大半。这是最该做的事——把临时方案变成上游能力。
12.3这个实践最后证明了什么
30B 的 MoE 语音模型在消费级笔记本上做实时同传,不是"理论上可行",是现在就能用——255 ms 的字幕延迟已经优于人类同传。
真正的门槛不在模型。模型很配合:它只要按训练时的节拍被喂数据,就会稳定地吐出高质量译文。门槛在那条把自己和它连起来的链路:浏览器的音频拓扑、操作系统的权限与时钟域、进程间的管道与锁、以及内存这张永远不够大的桌子。
这次最大的教训是:当系统"看起来在运行"时,最该怀疑的不是逻辑,而是有没有东西在静默地消失。而对抗静默的唯一手段,是让每一步都能被量出来。
复现清单
13.1文件
| 文件 | 行数 | 作用 |
|---|---|---|
| live.py | 949 | 同传服务:进程管理、WebSocket、TTS、HTTP、账本、看门狗。零第三方依赖 |
| web/live.html | 1036 | 界面。单文件、零外部依赖,采集/播放/告警全在里面 |
| mtmd-cli.cpp | 1192 | 官方文件 + --stream 改造(+260 行) |
| live.sh / run.sh / tts.sh / ui.sh | — | 实时 / 离线字幕 / 离线配音 / 批量 UI 四个入口 |
| pipeline.py · server.py | — | 离线全链路、批量 Web UI(端口 8765) |
| LIVE.md | 189 | 运行手册与判据速查 |
13.2三步启动
# 1. 实时同传(推荐:先戴耳机) ./live.sh # → http://127.0.0.1:8770 # 2. 没麦克风也能验证链路:把 wav 当麦克风灌进去 curl -X POST --data-binary @audio/test_zh.wav \ "http://127.0.0.1:8770/api/simulate?realtime=1" # 3. 怀疑漏句时,看账本 curl -s http://127.0.0.1:8770/api/status | python3 -m json.tool
13.3故障速查
| 界面提示 | 含义 | 是否自愈 |
|---|---|---|
已 N 秒没收到声音 | 服务端确认字节数不再增长,链路真断了 | 前端随后判死重连 |
连接中断,正在重连… | 心跳 9 秒无回执(TCP 半开) | 是,退避重试最多 30 次 |
麦克风听不到声音(-83 dB) | 设备在采,但能量接近 0 | 否,要查设备/权限/电平 |
另一个窗口正在占用麦克风 | 多页面同时打开,只认第一个开口的 | 是,4 秒租约后自动接管 |
| 计时在走、无提示、无新字幕 | 模型在推理,或音频不足一块 | 正常,等尾部结算(1.2 s) |
Cmd + Shift + R)。服务端已发 no-store,但普通刷新仍可能命中旧脚本——这一点在排查中实际误导过判断。