mindsurf-omni(sft_merge)
139M 参数的中文语音对话模型。听语音、说语音,中间不经过文本——音频和文字在模型内部是同一条序列上的 token。
权重文件 sft_merge_768.pth,
sha256 d67c744019f786e851247fea712953f62573161b08318f704a872bee89a82753。
这张卡怎么读
只写量过的数字,每条都带测量误差。判定只有三种:改善、劣化、无法区分。 「无法区分」不是「一样好」,是这批数据分不出谁好谁坏——想下结论得加样本量。
有些指标标着「仅报告」,意思是它这次不参与通过或失败的判定,只印出来看。 原因是它分辨不了我们关心的那点差别,比如单看一边的错字率,这批数据能分辨的最小差别是 0.0529,而我们关心的是 0.0500,比它的分辨能力还小,那它说什么都不作数。
所有数字取自代码仓库的
configs/release/headline_numbers.json,
那是唯一真源,每条带 source 指向产出它的原始读数文件。
它是怎么来的
不是重训出来的。父模型 sft_graft_frozen 上做过两次目标不同的 DPO,一次冲质量、
一次冲长度,两次都只动了负责思考的那半(Thinker),所以可以直接把两次的权重差加起来:
sft_merge = sft_graft_frozen + Δ(质量 DPO) + Δ(长度 DPO)
相加之前先量了这两个改动会不会互相抵消。在 Thinker 的 91 个张量上,两个 Δ 的方向 余弦是 +0.1329(这一遍数出来是 9478 万个数,比 Thinker 的 8986 万多 491.52 万—— 词嵌入和输出头共享同一份权重,按张量遍历时被算了两遍)——接近垂直,也就是各改各的、不冲突,所以相加之后两个效果都保住了。 (对照:合并后的总位移是 1.7545,两个 Δ 各自是 0.9617 和 1.3499;如果完全垂直, 勾股定理算出来应该是 1.6575,实测比它略大一点,正是那点正相关。)
负责发声的那半(Talker 加音频投影层,104 个张量)与父模型逐位相同,一个比特都没动。
只跑了一个配置(两个 Δ 都按原样相加),没有扫参数。
结构
| 参数量 | 来源 | |
|---|---|---|
| Thinker(负责思考) | 89,864,448 | 我们自己的中文基座 |
| Talker(负责发声) | 47,050,754 | 上游 MiniMind-O 发布权重 |
| 把两半接起来的部分 | 2,168,320 | 不属于任何一半 |
| 合计 | 139,083,522 |
Talker 只通过 talker.embed_proj 读 Thinker 的隐状态。接口窄到可以整半替换,
这个模型换的就是那半。音频编解码用 Mimi,
8 个码本、每秒 12.5 帧。
量过的
语音
160 句固定文本,和父模型逐句配对比较(同一句两边各念一遍再相减,这样比只看平均值更灵敏),
念出来的音频交给第三方识别器 paraformer-zh 听回文字:
| 轴 | 本模型 | 对父模型 |
|---|---|---|
| 错字率(CER) | 0.0962 ± 0.0176 | 无法区分(+0.0059,误差 ±0.0314) |
| 音质自动分(UTMOS) | 2.4399 ± 0.0498 | 无法区分(-0.0127,误差 ±0.0985) |
| 说不出话的比例 | 0/160 | 同 |
| 音色相似度(12 音色 × 20 句) | 见过 0.6487 / 没见过 0.6044 | 父模型 0.6470 / 0.6054,误差 ±0.015,没有退化 |
单看一边的错字率只能仅报告,理由见开头。有判定资格的是右边那两条配对读数。
音色相似度的满分不是 1.0。 把存好的参考音频原样解码回来再重新抽一次说话人向量, 和存档的比也只有 0.8409——中间差掉的是编码器压缩时丢掉的身份信息,每条生成音频都要付这笔。 所以上游报的 0.6472 应该读成「拿到了编码器允许范围的 76%」。
0.0962 不要拿去和级联那条路的 0.0359 直接比。 160 条参考文本里有 71 条含阿拉伯数字, 这 71 条的错字率是 0.1482,其余 89 条是 0.0548。差的大半不是发音, 是「2021 念成二零二一」这种正确行为被判官罚——而级联那条也被同样罚, 两个数在这一项上不可比。把数字折叠成中文读法之后,全部是 0.0730。
这几行可以自己重算,仓库里有逐句读数:
python scripts/evaluate_speech.py \
--candidate artifacts/merge/speech-sft_merge-mos.jsonl \
--reference artifacts/merge/speech-parent-mos.jsonl
人耳
四个人盲听打分,每人拿一份随机顺序。这是这个项目唯一一把人耳尺子。 分两个包发出去,两个包问的问题不一样,同一个 edge-tts 在两个包里不是同一个分,别混着引:
| 包 | 系统 | MOS(1–5) | 缺陷标记 |
|---|---|---|---|
| 听模型(分辨两套模型) | edge-tts(参照上限) | 4.023 ± 0.108 | 0 |
| 我们的 graft 臂 | 2.738 ± 0.125 | 43 | |
上游官方 sft_omni |
2.738 ± 0.119 | 42 | |
| 听合成器(分辨两个合成器) | edge-tts | 3.871 ± 0.064 | |
| voxcpm | 3.476 ± 0.157 |
我们和上游官方权重到小数点后三位相同。在这之前,「与官方无法区分」只有 UTMOS 一把自动尺子撑着,现在有一把不可能继承 UTMOS 盲点的仪器落在了同一处。
但这个 panel 不作门控判定,算分脚本自己会拒绝出判词:每系统 20 条, 要认证 0.29 的差需要每人 197 到 271 条。20 条买到的是两样自动指标给不了的东西—— 人耳立刻能听见的粗大缺陷,以及 UTMOS 的排序和人是否一致(排序比量级省样本量得多)。 自然度的门控仍然在 UTMOS,它在 n=160 上有资格。
这句话的读法要精确。 盲听包里的音频出自 graft 臂,不是 sft_merge 本身。
两者的关系是量过的:sft_merge 的 Talker 和音频投影层与父逐位相同,
且它对父的错字率与音质分配对判定都是无法区分。这条链就是全部依据。
2.738 在 1 到 5 的刻度上落在「听着费力」和「勉强可以」之间。它不好听, 只是没比上游差——差距主要来自编码器而不是模型,换个 Talker 也改善不了。
延迟
| 实测 | 预算 | |
|---|---|---|
| 用户说完到出第一声 | 145.1 ± 4.1 ms,P95 189 | 3000 ms |
| 发出打断到真的停 | P50 0.26 ms / P95 0.40 ms(40 轮) | 200 ms |
打断不是软停。 取消之后 GPU 占用在 0.5 秒内从 41% 掉到 0;同一轮完整跑完要 4953 ms, 被取消的那一轮只花 132 ms。停的是计算本身,不是「不再往外发」。
产品指标
608 条提问上重算:
| 本模型 | 父模型 | |
|---|---|---|
| 回复中位数 | 55 字 / 念 11.8 秒 | 26.0 秒 |
| 用户说一秒,模型说几秒 | 6.2 | 13.7 |
| 问完 5 秒时还没说完 | 81% | 93% |
| 空回复 / 复读 | 0 / 1 |
长度就是这个 checkpoint 存在的理由。另一个候选 sft_dpo2 判据全过,
但回复念 24.2 秒——30 秒级的回复是会让人不想用第二次的缺陷。
对话质量
| 值 | 状态 | |
|---|---|---|
| 盲评对父模型(608 条) | 0.6424 ± 0.0468 | 仅报告:开跑前登记的位置检查没过(实测残余偏倚 -0.0033) |
长度受控对 sft_len |
0.5918 ± 0.0664 | 过关:位置 0.5138、长度偏差 0.5092,都在中性附近 |
判官是 deepseek-ai/DeepSeek-V3.2,提示词的 sha256 和选边种子都写进了产出文件。
「长度受控」的意思是先把两边回复长度配平再比,免得判官其实在奖励长度。
控住长度、判官实测中性、位置也干净之后合并体仍然赢 59.2%,那只能是质量那个 Δ 的贡献——
前面「两个 Δ 接近垂直」的预测在这里兑现了。
边界
这些是量到底的结论,判据都写在跑之前。
一、「把回复变短没有顺带弄坏对话能力」这件事,我们证不了
衡量对话能力的办法本身也在衡量长度,两件事混在一个数里分不开。四把尺子都试过,各死各的:
| 尺子 | 为什么不行 |
|---|---|
chat_nll(原版) |
两批中立参照对「谁更好」只有 20.3% 给出一致答案,而分差和参照长度的相关系数是 +0.601——它在量长度 |
| 按参照长度分层再比 | 控住长度之后一致性掉到 1.9%,信号全没了 |
| 改看「答得上率」 | 拿一次已知的质量崩坏去试它,它读不出来(+0.0872 落在 ±0.1061 的噪声里) |
| 长度配平过的参照集 | 一致性升到 94.9%,原毛病没了,但那批参照本身偏短,模型变短之后自然更配它——分不开「答得更好」和「长度刚好对上」 |
要闭合这一轴大约需要 3600 条提问,现有 608 条,差六倍。
选 sft_merge 就是选了这个代价:要 12 秒的回复,就得接受这一轴无法认证;
要一份判据全过的交付,就得接受回复念 24 秒。
需要说明的是,测不了的只是改变长度的干预。对不改长度的干预,chat_nll 原来那把
尺子本身没有这个混淆——sft_dpo2 就是这么过的,两批中立参照一致率 58.9%,够格。
二、情绪能换,但做不成一个可以调大调小的旋钮
换一条带情绪的参考录音,输出就带情绪,这一半是真的:同一个人的平静版和激动版两条参考, 输出音高差 +54.7 Hz,12 个音色全是这个方向,不用训练。
卡住的是「重训一个能控情绪的版本」这条路,它的前提是情绪标签可信,而四个人的盲听把这个前提证伪了:
人对 emotion2vec 自动标签的一致率是 31–42%,而人对人是 41–65%。
两个数都低说明这个任务本来就难;但人彼此对得上、却都对不上标签,那是标签系统性偏离人耳。
条件化训练也真跑过一轮:主判据音高零效应,四条情绪臂对中性全部无法区分, 而同批的对照臂先过了——所以是真的没效果,不是仪器读不出来。
情绪和音色纠缠是学界的公开问题,解法要独立编码器加解耦损失再重训。 架构改动这个项目一开始就排除了。
三、打断做到了执行,没做到判断
调用方说停就真停,见上面的 0.26 ms 和 GPU 归零。但「什么时候该停」没做—— 那需要一批「模型正在说话、用户同时开口」的录音,并且标明每次重叠是不是真的想打断 (用户也可能只是「嗯嗯」附和)。我们手上两份训练语料都没有这种数据, 而自己把两段录音混在一起合成重叠,模型能学到的只有「有没有人声」,那用一个语音活动检测器就够了, 不需要训练。所以这个判断留在调用方。
其余量到的短板
12 个音色只有 6 个真的听得出是不同的人。 拿生成的音频去 12 选 1 认人(20 句固定文本):
serena、eric、uncle_fu、dylan、arthur 是 20/20,moon 17/20;
vivian 16/20、jennifer 6/20 属于边缘;cherry、ethan、momo 是 0/20,chelsie 1/20——
这四个听起来塌成了 serena 和 moon。别把后四个当独立音色投放。
不是测法的问题:把参考音频原样解码回去认,12 个全认对。是生成的音频里带的身份信息不够。
推理侧也没有参数能救——说话人引导强度扫过三档,四个坏音色没有一个上到 15/20,
而 moon 反而掉到 10/20。
多轮只测到三轮。 不崩,上下文利用率 82% 且不随轮次下降, 但「能接上前文回答」的比例逐轮走低:42%、24%、14%。要说明的是第 2、3 轮的问题是刻意设计的省略句, 题目本身在变难,这个下降里有多少来自上下文、多少来自题目,我们没有拆开。
这是把历史一起送进模型量出来的。语音进语音出的那条路(Thinker-Talker 端到端)做不到多轮: 用户那一轮在模型眼里是音频 token,而历史只能以文本传递,所以它每轮都是第一轮。 要多轮就走「识别成文字再生成」的那条路。
自然度的天花板不在模型。 与真人的差距里 79% 来自编解码器、21% 来自 Talker, 而我们已经拿到编解码器允许范围的 92.2%。
没有发布的那个候选
sft_dpo2 五条判据全过,「对话建模不退」那一轴是认证过的(一致率 58.9% 够格,
两批中立参照都判无法区分)——因为它没改长度,chat_nll 那把尺子对它有效。
它的权重不在这个仓库里。写在这里是因为取舍应当可见:它换来的是 24.2 秒的中位回复。
仓库里有什么
README.md ← 这张卡
sft_merge_768.pth 456 MB 成品(Thinker 那半按 fp32 落盘)
tokenizer/ 464 KB tokenizer.json + tokenizer_config.json
configs/ 许可记录、对外数字真源、语音系统提示
中间产物不在这里(t2a_*、sft_dpo*、sft_len)。
怎么加载
模型类来自上游 MiniMind-O 的 model/ 包
(model_omni.MiniMindOmni)。这是一个嫁接体,加载有一处不显然的地方:
config = model_omni.OmniConfig(hidden_size=768, num_hidden_layers=8, use_moe=False)
Thinker 要加宽(intermediate_size=3584、num_key_value_heads=8),
Talker 必须保持上游默认——两半的配置不同。一起加宽会让 Talker 那 20 个张量形状对不上,
而且不会报错,只是那半停在随机初始化上照样吐码,解出来是噪声。
可运行的权威实现是代码仓库里
src/mindsurf_omni/service/native.py
的 load_native_model:它会核对参数总数、逐条检查缺失的张量,对不上就报错而不是凑合跑。
音频编解码器要另外取:kyutai/mimi,仓库里钉了 revision。
怎么采样
自己写推理代码的话,这几个值是我们量过之后定下来的,直接照抄比用库的默认值省事:
| 值 | 为什么 | |
|---|---|---|
temperature |
0.0 | 贪心解码,同一个问题给同一个答案。0.7 时同题问三遍的字符相似度只有 0.270 |
repetition_penalty |
1.1 | 贪心会复读,这个数是量出来的下限,见下表 |
top_p |
0.9 | 上游默认,没测过 |
max_tokens |
512 | 上游默认,没测过 |
608 条探针上,复读(连续重复 20 字以上)的回复数:
| 设置 | 复读的回复 | 最长的重复段 |
|---|---|---|
T 0.4 + 惩罚 1.0 |
1/608 | 226 字 |
T 0.0 + 惩罚 1.05 |
2/608 | 486 字 |
T 0.0 + 惩罚 1.1 |
0/608 | 10 字 |
T 0.0 + 惩罚 1.2 |
0/608 | 0 |
1.2 也干净,但把回复中位数从 59 字拉到 69 字,念出来多一秒多,所以取 1.1。
温度从 0.7 换到 0.0 之后,拿 608 条探针盲评过质量:判官 deepseek-ai/DeepSeek-V3.2,
胜率 0.5073 ± 0.0529,判定是无法区分——也就是没证据说更好,也没证据说更差,
换它是为了稳定性和不复读。逐条读数在代码仓库的
artifacts/sampling/。
降温治不了编造。 「南京有什么好吃的」答出过「鸿门大酒楼」,那是 139M 模型的事实性问题, 跟采样无关,温度调低只会让它每次稳定地编同一个。
音频那一侧的采样参数写死在上游的生成函数里,请求参数碰不到,也不建议动: 关掉它的 1.05 重复惩罚,24 条里有 3 条退化成复读。
许可与署名
不可商用。 约束来自文本基座——它从训练数据继承 CC-BY-NC-4.0, 这条传导到每一个衍生物,包括本模型。
必须署名 Mimi(CC-BY-4.0,钉了 revision)。
不在这个仓库里的
语音识别器(SenseVoice)和情绪标注器(emotion2vec)没有随附。两者都挂 FunASR Model Open Source License Agreement v1.1,该协议没有明确的再分发条款, 所以我们依赖它们、但不替它们分发。请自行从 ModelScope 获取。
训练语料同理,没有随附。
人工盲听材料在另一个仓库: oscar0403/mindsurf-omni-listening。