把"听、说、看"三件事塞进一个 0.1B 的模型里,第一次听到这个指标时我的第一反应是"这不可能",因为光是让模型认得几个字的词表就吃掉一大块参数。后来真正动手把一条语音输入的链路拆开算了一遍,才发现标题里那个"小千倍"的对比其实藏着一个很容易被误读的前提:它不是把一个千亿级的全模态旗舰等比缩小,而是换了一套完全不同的架构假设——放弃大模型那种"一个 Transformer 吃下所有模态"的做法,改成"多个小专用编码器 + 一个共享的轻量主干 + 多个轻量解码头"的拼装结构。
这篇东西写给两类人:一类是想在端侧或者低算力设备上跑多模态交互的工程师,一类是对"小模型到底能做到什么程度"好奇、想搞清楚参数量与能力边界关系的同行。我会把模块参数账本、模态离散化与帧率配平、三阶段训练顺序、端侧实时性预算这几块拆开讲透,中间夹一些我自己踩过的坑,比如视觉 token 压缩过度导致 OCR 直接崩掉、量化之后语音质量先于文本崩掉这种只有跑过才知道的问题。文中的代码和配置都是可以照着改的骨架,参数是我在实测中用过的量级,你可以根据自己的硬件往下调。
1. 0.1B 究竟能装下什么:把"听说看"拆成可计算的参数预算
1.1 千倍差距不在比例上,而在架构假设上
千亿参数级的闭源全模态模型,走的是"early fusion"路线:图像 patch、音频帧、文本 token 全部投影到同一个高维空间,扔进一个超深的主干里让注意力自己去学跨模态关联。这条路线的代价是参数量和算力都被主干吃掉,但它换来了极强的跨模态推理能力——模型可以在"看到画面里有人在推门"和"听到门轴响"之间建立因果联系。
0.1B 这个量级根本玩不起 early fusion。原因很朴素:主干只有 50M 级别的时候,它连把一个 32k 词表的 embedding 表撑起来都吃力(32k × 512 就是 16M,占了近三分之一),再让它去承载视觉和语音的高维特征,等于让一个刚学会走路的人去背三个人的行李。
所以工程上的实际选择是"late fusion + 模块化拼装":每个模态有自己的专用编码器,编码器把原始信号压成很短的离散 token 序列,然后这些 token 走同一个轻量 decoder。跨模态的"理解"不发生在编码器内部,而发生在 decoder 的注意力层里——只是这个 decoder 足够浅、足够窄,所以它学到的只是"把不同来源的 token 按位置关系串起来"这种浅层关联,而不是深度语义对齐。
这个取舍的后果必须提前认清:0.1B 的全模态模型能听懂"把桌上那个红色的杯子拿给我"这种短指令配合画面定位,但它做不了"比较这两张图里人物的情绪差异并解释原因"这种需要深层跨模态推理的任务。搞清楚这个边界,后面所有的工程决策才有依据。
1.2 四条通路的参数账本:一次真实的配平过程
我在做参数分配的时候,用的是"先定主干、再砍外围"的顺序,而不是平均分配。下面这张表是我最终跑通的一版配置,hidden 512、12 层、FFN 2048 的 decoder-only 主干,词表 32k:
| 模块 | 结构要点 | 参数量(约) | 占比 |
|---|---|---|---|
| 文本主干 | 12 层 decoder,hidden 512,FFN 2048,RoPE | 54M | 51% |
| 词表 embedding | 32k × 512,与输出头共享权重 | 16M(含在主幹内) | 15% |
| 视觉编码器 | 12 层 ViT,hidden 384,patch 14,输入 224 | 22M | 21% |
| 视觉重采样器 | 2 层 cross-attention,256 token 压到 64 | 6M | 6% |
| 语音编码器 | 4 倍下采样卷积 + 8 层 conformer,hidden 256 | 15M | 14% |
| 语音码本预测头 | 8 层 RVQ,每层 1024 码本 | 8M | 7% |
| 模态 embedding 与投影 | 4 组 role token + 线性投影 | 1M | 1% |
表里加起来超过 100M 一点,实际部署时通过共享 embedding 和去掉部分偏置项压到了 98M 左右。这里有个很值得说的细节:词表 embedding 在小模型里是纯粹的负担。32k 词表占了 15% 的参数,但真正高频使用的 token 可能只有 8k 左右。我的做法是把词表裁到 24k,剩下的用字节级 fallback 处理,省下来的 4M 参数直接加到了视觉重采样器上——因为实测下来视觉 token 的压缩质量对下游任务的影响,远大于词表覆盖率的边际收益。
另一个反直觉的点是语音解码头。很多人会觉得"说"这件事应该交给一个独立的轻量 TTS 模型,不占主干参数。我一开始也这么想,但实测发现独立 TTS 和主干的语义空间对不上,模型经常"想说的"和"说出来的"不是一回事。后来改成主干直接预测 RVQ 的第一层码本(语义层),剩下 7 层交给一个小自回归头,一致性才稳住。
1.3 为什么敢把编码器整个冻起来
这是整套方案里省算力最狠的一招,也是最容易被人质疑的一招:视觉编码器和语音编码器在训练全程都是冻结的,只训适配层和主干。
理由有两个层面。工程层面,冻结编码器意味着你不需要为它们准备反向传播的显存,训练时的峰值显存能压到原来的三分之一左右,这在单卡 24G 的环境里是能不能跑起来的区别。算法层面,一个在千万级图像上预训练过的视觉编码器,它提取的特征已经有很好的泛化性,你用一个 6M 的重采样器去"翻译"这些特征,比解冻整个编码器去微调要稳得多——小模型微调大编码器,极容易把编码器原本的特征空间破坏掉,最后得到一个既不会看图也不会听话的四不像。
提醒:冻结不等于不管。编码器的输入归一化参数、图像 resize 策略、音频的重采样率必须和编码器预训练时完全一致,任何一处不一致都会让冻结的特征质量断崖式下跌,而且这种错误在 loss 曲线上看不出来,只会在最终评测时表现为"模型像个瞎子"。
2. 把声音和画面塞进同一个 token 序列:离散化与帧率配平
2.1 语音走离散码本,但只有第一层进主干
连续音频信号要进 Transformer,绕不开离散化。我用的是残差向量量化(RVQ)的思路:每一帧音频先量化成第一层码本索引,然后用这一层的残差再量化第二层,依次叠 8 层,8 层码本叠加起来能还原出相当接近原始波形的音频。
关键在于只有第一层码本进主干,剩下 7 层交给声码器条件。这个设计的原因是这样的:RVQ 的第一层承载的是音素、语调这类语义信息,后面的层承载的是音色、气息、录音环境这类声学细节。主干是干语义活的,它只需要知道"说的是什么",不需要知道"声音多沙哑"。如果 8 层全塞进主干,序列长度直接翻 8 倍,主干那点注意力预算瞬间被吃光。
代码上的处理大概是这样,思路是把音频切成 10ms 帧移的窗口,编码器做 4 倍下采样,得到 25Hz 的帧率,也就是每秒 25 个 token:
# 语音侧 token 化:16kHz 输入,帧移 10ms,编码器 4 倍下采样 frame_shift_ms = 10 sample_rate = 16000 hop = sample_rate * frame_shift_ms // 1000 # 160 samples downsample = 4 # conformer 前端卷积下采样 token_rate = 1000 / (frame_shift_ms * downsample) # 25 token/s # 每帧只把 RVQ 第 0 层送进主干,1~7 层作为声码器条件缓存 semantic_ids = rvq_encode(wav) # shape: [T, 8] trunk_input = semantic_ids[:, 0] # shape: [T] -> 主干输入 vocoder_cond = semantic_ids[:, 1:] # shape: [T, 7] -> 留给解码头这里有个实测踩过的坑:码本坍塌。小模型训 RVQ 头的时候,很容易出现只用了几十个码本、其他码本全是死的情况,表现为合成出来的声音永远是同一个调子。我的处理是加码本使用率监控 + 每 500 步做一次码本重置(把长期不被选中的码本重新初始化成当前 batch 里的随机帧),配合 0.25 的 commitment loss 权重,坍塌率能从 40% 压到 5% 以内。
2.2 视觉侧最容易被忽视的一步是压缩率
224×224 的输入配 patch 14,切出来是 16×16,也就是 256 个 patch token。这个数量对一个 12 层、hidden 512 的小主干来说太重了——256 个视觉 token 加上语音和文本,序列长度轻松破千,而小主干的注意力窗口和 KV cache 根本撑不住多轮。
所以必须有重采样器。我用的是 2 层 cross-attention 的结构,用 64 个可学习的 query 去"查询"那 256 个 patch 特征,输出 64 个 token。压缩比 4:1。如果是视频,按 2fps 采样,每帧 64 token,就是 128 token/s。
注意这个 4:1 的压缩比不是随便定的。我做过一组对照:压缩到 32 token 的时候,简单场景描述还行,但只要图里有文字(比如菜单、路牌、仪表盘读数),识别率直接掉到 30% 以下;压缩到 64 token 时文字识别能到 70% 上下;128 token 能到 85%,但序列长度翻倍,实时性没了。后来我加了个自适应压缩:先用一个极轻的分类头判断这张图是不是"高密度文本图",是的话走 256 token,不是的话走 32 token。这个 0.3M 的小分类头,把整体体验拉回来一大截。
2.3 一次真实的帧率配平:谁在吃 token 预算
这一步很多人会跳过,但它直接决定了系统的实时性上限。把三种输入的 token 产生速率摆到一起看:
| 输入模态 | 采样/帧移 | 压缩后 token 率 | 一句话/一帧的典型长度 |
|---|---|---|---|
| 文本 | 按字/子词 | 约 4 token/s(中文口语) | 20 token |
| 语音 | 10ms 帧移,4 倍下采样 | 25 token/s | 75 token(3 秒话) |
| 图像 | 单帧 | 32~256 token/帧 | 64 token(默认档) |
| 视频 | 2fps | 64~512 token/s | 128 token(1 秒) |
这张表最直接的一个结论是:视频是 token 预算的黑洞。同样是一秒钟的输入,语音只花 25 个 token,视频能花掉 128 个。所以我后来做的一个硬性约束是:默认情况下视频流按 1fps 采样,只有在检测到画面剧烈变化(帧间差异超过阈值)时才临时提到 2fps。这个策略在监控类场景里几乎没有损失,因为大部分时间画面是静止的。
另一条经验是关于语音的:25Hz 的语义 token 率对中文来说刚好够。我试过降到 12.5Hz(8 倍下采样),语音识别准确率只掉了不到 1 个点,但生成时的韵律明显变平,说话像念稿;升到 50Hz 则收益极小、成本翻倍。25Hz 是这条曲线上的拐点。
3. 三阶段训练的真实顺序:先对齐、再指令、最后才敢碰流式
3.1 第一阶段的目标不是"说得好",而是"接得上"
对齐阶段我只训两个东西:视觉重采样器和语音到主干的投影层。主干冻结,编码器冻结,学习率给到 1e-3(适配器可以用这么高的学习率,因为它们是从随机初始化开始的),batch 尽量开大。
这个阶段用的数据是纯配对数据:图像-描述对、语音-转写对。目标函数也很朴素,就是下一 token 预测。很多人会在这个阶段就急着混入对话数据,我的经验是不要——对齐阶段的任务是让投影层学会"把视觉/语音特征翻译成主干能理解的 token",如果这时候混入复杂的指令数据,模型会同时被"翻译"和"执行"两个目标拉扯,收敛会变慢而且不稳定。
判断对齐是否成功的标准很简单粗暴:给模型一张图,让它续写描述,如果生成的内容和图像内容相关(哪怕语法很差),说明接上了;如果生成的全是和图无关的通用句子,说明投影层还没学好,继续训。这个阶段的 loss 通常会从 8 左右降到 3.5 上下,降到 3 以下往往意味着过拟合到训练集的描述模板上了。
提醒:对齐阶段的验证集一定要包含"负样本",也就是图文不匹配的样本。我踩过一次坑,模型在正常样本上表现很好,但实际上它是靠图像的整体色调在猜描述,根本没看内容。加了负样本对之后,这个捷径被堵住了。
3.2 指令微调阶段的模态配比与 loss 权重陷阱
全参数解冻进入指令微调,学习率降到 2e-4 配 cosine 衰减,这个阶段最容易出问题的地方是 loss 被文本主导。
原因很简单:一条"看图回答问题"的样本里,图像 token 是 64 个,问题文本是 20 个 token,答案文本是 30 个 token。但 loss 只算在答案的 30 个 token 上,模型优化的是这 30 个位置。如果整个 batch 里 80% 的样本是纯文本对话,那模型学到的绝大多数梯度都来自文本,视觉和语音通路的参数几乎没被有效更新——表现出来就是"文本能力稳步上升,多模态能力原地踏步"。
我的处理是两个层面结合。数据配比上,强制保证每个 batch 里至少 40% 的样本含视觉输入、至少 30% 含语音输入。loss 层面,给不同模态的答案 token 加权重,视觉问答的答案 loss 权重乘 1.5,语音生成的 loss 权重乘 2.0——因为语音 token 序列短,单样本贡献的梯度本来就少。
# 指令微调阶段的关键配置(实测可用) stage: sft learning_rate: 2.0e-4 lr_scheduler: cosine warmup_ratio: 0.03 per_device_batch_size: 16 grad_accum: 4 max_seq_len: 1024 modality_mix: text_only: 0.30 vision_in: 0.40 audio_in: 0.30 loss_weight: text_answer: 1.0 vision_answer: 1.5 audio_answer: 2.0 freeze: vision_encoder: true audio_encoder: true还有个细节:语音生成的 loss 要用 teacher forcing 下的逐帧交叉熵,但第一层码本和后续层的 loss 必须分开加权。第一层错了,整句话的语义就错了;后面几层错了只是音色不对。我给第一层的权重是 1.0,后面七层加起来 0.5。一开始我用平均权重,结果模型把注意力全放在音色细节上,语义准确率反而上不去。
3.3 流式与打断:把双工行为写进训练样本
前两个阶段训完,模型能"看图说话"、"听话回话",但它是回合制的——你说完它才开口。真实交互里用户会打断、会停顿、会在它说话的时候插话,这些行为不写进训练数据,模型永远学不会。
我的做法是构造三类合成样本。第一类是"打断样本":把一段模型应该生成的回复截断在某个位置,后面接上用户的插话,标签改成让模型立即停止生成并转向听。第二类是"犹豫样本":在用户语音里插入 300~800ms 的停顿和"嗯""那个"这类填充词,训练模型不要一听到静音就抢话。第三类是"背景噪声样本":叠加环境噪声,让 VAD 的判定不要过于敏感。
这三类样本加起来大概占流式阶段数据的 25%,训完之后的体验差异非常明显——不做这一步的模型,在实际使用中抢话率能到 40% 以上,做了之后能压到 10% 以下。流式训练的另一个关键是训练时的分块方式必须和推理时一致,训练用整段、推理用 160ms 分块,会导致首包质量明显掉档,这个不一致我调了两天才定位到。
4. 端侧跑通的最后一段路:量化、KV cache 与实时性预算
4.1 首包延迟怎么算:把每一毫秒拆开看
模型训完只是走了一半,能不能在设备上跑出"像人一样接话"的体验,取决于首包延迟。我把整条链路拆成六段,用端侧(中端手机 NPU)的实测数据列出来:
| 环节 | 典型耗时 | 优化手段 |
|---|---|---|
| VAD 判定用户说完 | 200~300ms | 能量阈值 + 轻量分类器双判,可压到 180ms |
| 语音编码器分块前向 | 40~60ms | 160ms 窗口、80ms 步进,重叠计算复用 |
| 主干首包前向(约 20 token) | 30~50ms | KV cache 预分配、int8 量化 |
| 第一层码本自回归解码 | 60~100ms | 8 层码本并行预测,减少自回归步数 |
| 声码器合成首块音频 | 30~50ms | 保留 fp16,不量化 |
| 音频缓冲与输出 | 40ms | 双缓冲,边收边播 |
| 合计 | 400~600ms | 实测中位数约 520ms |
520ms 这个数字是什么概念?人类对话中的平均轮转间隔大约是 200ms,所以这个延迟会让人感觉"它反应稍微慢半拍",但还在可接受范围内。想再往下压,最有效的是砍 VAD 判定时间——用更激进的端点检测策略,代价是偶尔会把用户的句中停顿误判成说完。
4.2 量化到 int8 之后,先崩的往往不是文本
这一条是我花时间最多才搞明白的。整模型 int8 量化之后,文本生成几乎无损,视觉描述掉了一点点,但语音合成质量断崖式下跌,出来的声音发闷、有金属感。
原因在于声码器对权重扰动极其敏感。声码器做的是从码本索引重建波形,这个映射是高频率、精细的,int8 的量化误差在这个环节被放大成可听见的噪声。而主干做的是语义层面的下一个 token 预测,本身对数值精度就不敏感。
所以我的量化策略是分层的:
# 分层量化配置:主干和编码器 int8,声码器与码本头保留 fp16 quantize --model omni_0.1b \ --bits 8 \ --exclude "vocoder.*" \ --exclude "rvq_head.*" \ --exclude "audio_embed.*" \ --calib-samples 512 \ --calib-mode minmax排除掉的这几个模块加起来不到 10M 参数,对整体体积影响很小,但语音质量能恢复到接近 fp16 的水平。同样的道理也适用于视觉重采样器——它输出的是要进主干的连续特征,量化误差会直接污染下游,所以我也把它排除了。
另一个坑是 KV cache。12 层、hidden 512 的模型,在 1024 上下文、batch 1 的情况下,KV cache 大概是 12 × 2 × 1024 × 512 × 2 字节 ≈ 25MB(fp16),量化到 int8 能压到 12MB 左右。听起来不多,但端侧可用内存往往只有几百 MB,再加上视觉编码器的中间激活,很容易就顶到上限。我的处理是把视觉编码器的激活做完即释放,并且把 KV cache 按滑窗 512 分配,超出部分做重要性淘汰。
4.3 一套能复现的验收集:别只看平均值
小模型的评测最忌讳只看平均分。我在验收的时候会跑四组测试,每组都看分布而不是看均值。
第一组是指令跟随的短句集,200 条 5 字以内的指令("开灯""放大""停"),看准确率。这组考验的是模型在最简输入下的稳定性,0.1B 的模型在这组上能做到 92% 左右。
第二组是带噪语音集,在干净语音上叠加不同信噪比的噪声,看识别率随信噪比下降的曲线。这组最能暴露编码器的鲁棒性,我的经验是信噪比降到 10dB 时准确率如果掉超过 15 个点,说明语音编码器的前端处理不够。
第三组是视觉密集文本集,专门放仪表盘、菜单、标签这类图,看 OCR 相关的问答准确率。这组是自适应压缩策略的试金石,不测这组你根本不知道自己压缩过度了。
第四组是打断响应集,人工模拟在模型说话中途插话,统计"多久停下来"和"停下来后是否听对了新指令"。这组的指标是响应延迟的中位数和 P90,P90 特别重要,因为它反映的是最差体验。
提醒:这四组测试一定要在量化之后跑一遍,而且要跑同一批样本做前后对比。我在一次发版前就是因为只跑了量化前的评测,上线后才发现语音合成在某些音素上出现了周期性杂音,回滚花了不少时间。
5. 这类小模型的真实落点:它不是缩小版旗舰
5.1 适合做什么,不适合做什么,得说清楚
跑通之后最常被问到的问题是"它能替代大模型吗",答案显然是不能,但把边界说清楚比说"不能"有用得多。
适合的场景有几个共同特征:任务短、模态输入明确、对延迟敏感、对绝对准确率要求不那么苛刻。比如设备端的语音唤醒加简短指令响应、无障碍场景里的实时朗读和环境描述、工业巡检时的"这个表读数是多少"、儿童玩具的对话交互。这些场景里,500ms 的响应延迟和 90% 的准确率是能接受的,而且数据不出设备这件事本身就有价值。
不适合的场景也很明确:长文档理解、多步推理、需要跨模态深层因果判断的任务、多轮长上下文对话。这些任务不是"再优化一下"能解决的,是参数量本身撑不住——0.1B 模型的注意力层能建立的关联跨度有限,你让它记住三轮之前的画面细节,它做不到。
我在实际项目里的一个判断标准是:如果这个任务的正确答案需要模型"回忆"超过 512 个 token 之前的输入,就不要用小模型。这条线划下来,大部分实时交互类任务都在线内,大部分分析类任务都在线外。
5.2 组合拳:小模型做前端,大模型做兜底
真正落地的方案基本都是两级结构。0.1B 的模型放在设备端做常驻的前端处理:负责唤醒、端点检测、简单指令的即时响应、把复杂请求预处理成结构化的文本描述。遇到超出能力范围的请求,它把压缩后的上下文(一段转写文本加一句图像描述,通常不到 100 个 token)发给云端的大模型,拿到结果后再由本地的语音模块读出来。
这个结构的好处在于,绝大部分交互(我实测的数据是 70% 以上)在本地就闭环了,不需要联网,延迟低、隐私好;剩下 30% 才走云端,成本和带宽都可控。而且因为本地模型已经做完了模态压缩,发给云端的只是纯文本,传输量比直接上传音频和图像小两个数量级。
设计这个两级结构时有个容易忽略的细节:本地模型要能判断"我不行"。这需要在指令微调阶段专门加入一类"转交样本",训练模型在遇到能力边界时输出一个特定的转交标记。这类样本大概占 5%,但效果非常明显——不加的话,模型会硬着头皮瞎答,用户得到的是一本正经的错误信息,体验比直接说"我需要联网查一下"差得多。
最后分享一个我觉得最有用的实践体会:做这类小模型,最大的时间成本不在训练,而在定义清楚每个模态的 token 预算。我前期反复调模型结构,收益都不大,后来把各模态的 token 率列成一张表,按实时性倒推每一路能给多少预算,问题一下就清晰了。参数可以调、结构可以换,但 token 预算这条约束定下来之后,很多设计选择其实是被唯一确定的。