从模型到证据:一个本地优先系统的两次收束 ——离线语音管线,和它长出的手环链路

71 阅读19分钟

从模型到证据:一个本地优先系统的两次收束

——离线语音管线,和它长出的手环链路

版本:2026-10 · 体裁:系统演化 + 设计哲学 + 可检索的工程记录

适合读者:本地 AI / 语音工程 / 移动与嵌入式 / 产品技术负责人

说明:产品名、公司、域名、账号、设备地址、模型名与内部路径已隐去;数据来自评测记录、任务日志与真机运行。


一句话结论

本地优先不是“数据不出机器”,而是一条可检查的证据链。 模型可以换,证据链不能缺。两次收束——离线语音管线、手环到手机的链路——最后都落到同一件事:让每个事实只有一个写者,让每个静默失败都有守卫。


快速导读

如果你只有 3 分钟

  • 一个项目,两半:先把中文语音转写完全留在本机;后来长出一条“手环录音 → 手机转写”的链路,中间没有云。
  • 第一半的四个证据点:输入覆盖、引擎来源、缓存语义、网络出口。
  • 第二半的三个单一事实源:状态、身份、版本。
  • 关键数字:5,049 句评测、4 小时 414 MB、BLE 1.9 / 31.7 KB/s、Wi-Fi 54–148 KB/s、249 次提交、582 个测试。
  • 结论:先让失败可见,再让它变快。

如果你要解决具体问题

你正在遇到的问题去哪一节
音频有声音,转写却是空的2.2
本地识别该选哪个模型2.1、2.3
模型每次都要重新加载2.3
BLE 传文件慢得不像话3.1
手机显示在线,但命令全被拒3.2
界面状态来回跳3.2
固件镜像比源码旧3.2
哪些状态/功能应该删掉2.4、3.2
这套方法什么时候不适用附录 B.3

如果你只记住三句话

  1. 模型可以换,证据链不能缺。
  2. 一个事实有两个写者,它迟早会自相矛盾。
  3. 先量仪器和协议,再怪模型和射频。

本文的组织约定

第 2、3 节各写一半系统,关键设计按 问题 → 决策 → 机制 → 代价 → 验证 组织;第 4 节抽共同点;第 5 节写效度威胁;附录 B 是面向检索的索引与检查清单。人读判断和代价,机器读规则和失败模式。


摘要

本文记录一个本地优先系统的两次收束。第一半是一条离线中文语音管线:目标机器是 2013 年以后、无 GPU 的 x86-64,运行时只有模型下载与用户主动发起的总结请求会联网;引擎由 5,049 句、五个场景的标注语音选出;覆盖率守卫在分段器丢音频时重做整文件。第二半是一条“手环 → 手机 → 转写”的链路:蓝牙传命令与状态,Wi-Fi 传录音与固件;状态、身份、版本各自收成一个写者。

两半的共同点不是技术栈,而是失败形态:音频会在不报错的情况下消失,模型会在不报错的情况下答错,设备会在显示“在线”时拒绝所有命令,固件会在所有代码看起来都新的时候是旧的。 文章给出这些失败的检测方式、两次仪器故障、以及一组可复用的规则。

关键词:本地优先;离线语音识别;BLE;状态机;单一事实源;可复现测量


1. 引言:本地优先的信任问题

1.1 云端换走了什么

云端语音识别的便利,是用三样东西换来的:

  • 数据离开机器:音频、转写、上下文都经过别人的服务器;
  • 网络成为前提:没有网、网络抖动、服务限流,都会变成产品不可用;
  • 结果不可复现:模型版本、解码参数、服务端策略都可能在你不知情时变化。

本地优先要把这三样换回来:数据留在本机、断网可用、同一输入给出同一结果。但本地不是“把云端的模型下载下来”这么简单。本地有自己的一套失败方式,而且大多数是静默的。

1.2 一个项目,两次收束

这个项目先做了一条离线中文语音管线:桌面窗口、命令行、本地服务三个入口,共用一条 verse-pipeline。后来它长出了第二条链路:手环录音,手机取回并在本机转写,中间没有云。

fig2-three-entry-points.png 两半看起来是不同的问题——一个是音频管线,一个是软硬件链路——但它们在同一个位置失败:用户看不见的地方。 音频被分段器丢掉,管线报告成功;设备显示在线,却拒绝所有命令;固件镜像比源码旧,而所有代码看起来都是新的。

所以本文把它们放在一起写。不是因为它们功能多,而是因为它们是同一个问题的两个层面:本地优先系统的产品不是模型,而是证据链。

1.3 本文不做什么

  • 不比较模型和云厂商。 文中不出现模型名、提供商、价格与区域。
  • 不把 5,049 句当 benchmark。 它是这个项目的评测集,不是公开榜单。
  • 不写产品功能清单。 两半系统只是夹具;文章关心的是失败形态和收束方式。
  • 不声称这套方法适用于所有团队。 适用边界在附录 B.3。

2. 第一半:离线语音管线

2.1 约束先于架构

项目先写约束,再写代码。约束不是愿望清单,每一条都排除了一些看起来合理的方案。

编号约束后果
C1RustFFI 必须被隔离在少数 crate 里
C2长进程不得有内存泄漏风险排除 whisper-rs 这类已知会累积泄漏的绑定
C3中文为主排除以英文为中心、中文错误率过高的模型家族
C4仅 CPU,2013 年以后基线模型必须在没有 GPU 时仍有可用速度
C5必须能从中国大陆获取多源下载不是优化,是前提
C6终端用户零配置默认路径不需要做决定
C7只构建、只支持 Windows桌面入口的边界

Whisper 是大多数人的第一反应,但在这个项目里它是错的答案:中文 CER 16–23%,large-v3-turbo 在四线程 i7 上大约只有 0.5 倍实时;whisper-rs 还存在持续累积的内存泄漏。纯 Rust 的 candle 路线也排除——它的 ASR 路径就是 Whisper。零 FFI 不是目的,可维护的隔离才是。

最终选择是 sherpa-onnx 的 Rust API。默认引擎 SenseVoice-Small int8(228 MB),第二引擎 Qwen3-ASR-0.6B int8(982 MB),比较用 5,049 句、五个场景,而不是模型卡或三个样本。

2.2 四个静默失败点

这条管线把“可信”拆成四个可以分别检查的位置:

位置失败形态守卫
输入分段器判断“没有语音”,整段音频被丢掉,管线却报告成功覆盖率守卫:测量保留比例,不合理就整文件重识别
引擎模型选错、默认值凭直觉、比较用错尺子,数字看起来很合理5,049 句评测;三次比较;仪器先校准
状态缓存命中只发最终结果,界面空白却显示成功;续跑编造结果缓存命中发完整事件序列;续跑按 span,身份不匹配就重做
边界运行时偷偷联网,或者“离线”只是纪律只有两个 crate 能发起连接;读 manifest 就能检查

fig1-offline-pipeline.png

四个位置不是并列功能,而是一条链:输入丢了,引擎再准也没用;引擎选错,状态管理只会把错误结果缓存得更久;边界破了,“本地”就不再是本地。

2.3 三代演化

第一代:接一个模型,能跑就行。 最先考虑的是 Whisper,比较用三个样本,模型按文件加载,VAD 阈值按推理定在 0.30。所有选择都能在 demo 里工作,但没有一条经得起真实材料。

第二代:测量推翻直觉。 5,049 句、五个场景把“我觉得”换成“在这批材料上”:

  • VAD 阈值从 0.30 降到 0.05,对话 CER 9.07% → 6.84%,空输出 17 → 1;
  • 引擎比较从三个样本扩展到 5,049 句,Paraformer 被删除;
  • 标点从“引擎属性”变成“独立阶段”,再被测量证明两个保留引擎都不需要;
  • 两次仪器故障被记录:概率探针全报 0.001,release 二进制比源码旧十四小时。

第三代:把静默失败围起来。 覆盖率守卫在保留比例低于 0.70 且存在足够可判断声音时,整文件重识别;5,049 句触发 9 次,9 次全部改善。缓存命中发完整事件序列;模型常驻把加载成本从每文件 1.56 秒降到 1564 + (N−1) × 290 毫秒;只有两个 crate 能发起连接。

引擎结果(200 句对话):

SenseVoiceParaformer + 标点Qwen3-ASR
CER6.00%5.90%5.40%
标点 F184.3%81.4%83.0%
时间44.6 s109.2 s186.0 s

Paraformer 在部分场景更准,但耗时 2.3 倍、磁盘 2 倍,优势用户不可感——删除。Qwen3 保留为第二架构,不是默认。默认仍是 SenseVoice:标点最好、最快、体积四分之一。

4 小时文件单独测过:931 段、454 秒(31.8 倍实时)、峰值 RSS 414 MB;守卫没有触发。

2.4 代价

代价具体是什么
只支持 WindowsTauri 不带浏览器引擎,系统下限被 WebView2 抬到 Windows 10 1803
依赖 ffmpeg 可执行文件LGPLv3 构建,由安装器作为 sidecar 放置
仍然有 FFIsherpa-onnx 被隔离在 verse-asr;换引擎仍是一次本地改动
不许 unsafe损坏模型无法被捕获,只能终止进程
守卫不是修复只抓“丢了音频”,抓不住“保留音频但识别错”
模型许可SenseVoice 不是 Apache-2.0,商用需要复核

3. 第二半:手环到手机

3.1 两种无线,一条分工

交接文档说 Wi-Fi 优先。第一次在真机上打开,Wi-Fi 与蓝牙共存层直接 abort,板子复位。修好之后,结论不是“修好了”,而是:这条共存路径不能放在主路径上。

分工反转:

  • BLE:发现、配对、命令、状态、少量控制。
  • Wi-Fi:录音文件、固件镜像。

BLE 文件协议实测 1.9 KB/s(240 字节/往返);换成通知流,同样链路 31.7 KB/s,零丢失——同一个链路,换一个仪器,数字差 8.6 倍。2M PHY 是负结果,31.7 KB/s 不变,瓶颈是每连接事件的帧数。Wi-Fi 实测 54–148 KB/s,手机成为上传主机。

fig3-band-chain.png

3.2 三个单一事实源

这条链路的信任,不取决于传得多快,而取决于三个事实各只有一个写者。

事实源失败形态答案
状态界面显示在线,连接早已断开;状态词两秒内闪过三个都真实的事实一个 Model、命名事件、纯函数派生;四条规则;删掉未配对、断开按钮、抑制标志、退避
身份手环在线、读正常、所有写命令被拒;界面看起来完全正常owner 门放在认证时;“最后连接者”改为“所有已知 peer”
版本固件镜像比源码旧,APK 带着旧镜像发布,所有代码看起来都是新的固件随 APK 分发;镜像与源码同一个来源;版本关系可检查

fig4-states.png

状态最后只有三个:没有已配对手环、未连、手环自己的词。身份故障的根因是 gPeerBda 只记“最近一个连接”,且断开不清理;谁最后连上谁才可能被认出来,他走了以后手环谁也不认。版本故障是镜像放在 APK 里由人工复制,源码和镜像可以不同版本,没有任何自动检查。

3.3 三代演化

第一代:相信交接文档。 Wi-Fi 优先,BLE 控制,文件走网络。真机复位。

第二代:测量之后反转分工。 BLE 1.9 KB/s、通知流 31.7 KB/s;Wi-Fi 54–148 KB/s;ADPCM 4:1;2M PHY 负结果。BLE 管命令和状态,Wi-Fi 管大文件和固件。

第三代:三个单一事实源。 约 40 个可变字段 → 一个 Model;四条状态规则;owner 门从连接时移到认证时;分区表换 min_spiffs,OTA 走 Wi-Fi,固件随 APK 分发。

关键数字:应用后台四秒再回前台,恢复连接 2014 毫秒;OTA 1,593,344 字节约 18 秒;固件镜像 1,539,708 字节,min_spiffs 槽 1,966,080 字节。

3.4 代价与未解

项状态
第二条 central 路径未验证。只有一部手机、一支手环
iOS没有配对列表、没有解除 API,需要自己的“哪支手环是我的”答案
自动回滚没有。坏镜像需要线刷
热点方案已定,功能未建
固件进 CI没有。tools/ci.sh 不构建 firmware/
Android 16/17局域网访问正在被收紧,当前靠目标 SDK 的隐式授权

4. 两次收束的共同点

4.1 失败都是静默的

两半系统最危险的故障,形态完全一样:

  • 音频被分段器丢掉,管线输出空文本并报告成功;
  • 缓存命中只发最终结果,界面空白却显示成功;
  • 设备显示在线,读正常,所有写命令被拒;
  • 固件镜像比源码旧,而所有代码看起来都是新的;
  • 门禁退出 0,日志里却有失败。

它们不抛异常,页面正常,接口 200,健康检查是绿的,只是答案错了。用户看不见的失败,最后会以更贵的方式被看见。

4.2 每个事实一个写者

两半系统最后都收敛到同一张表:

系统事实单一写者/守卫
离线管线输入覆盖率守卫:保留比例 + 可判断声音
离线管线引擎5,049 句评测 + 仪器校准
离线管线状态缓存完整事件序列;按 span 续跑
离线管线边界两个出口 crate;回环监听;manifest 可查
手环链路状态一个 Model;四条规则;删除可摇摆状态
手环链路身份owner 门在认证时;已知 peer 表
手环链路版本固件随 APK 分发;镜像与源码同源

单一事实源不是口号,是可检查的结构:同一份状态不允许出现第二个写入口;两套实现必须共用同一套折叠/派生规则;跨语言/跨端契约要有断言。

4.3 仪器先于系统

两次收束里,最值得记录的都不是模型或协议,而是仪器故障:

  • 离线管线的概率探针全报 0.001:Silero v5 需要前一个窗口的最后 64 个样本作为上下文,没有它们,模型输出噪声。修好后先拿健康文件校准到 1.000,其他读数才有意义。
  • 同一项调查用了十四小时前的 release 二进制,报告 0 spans;差异不是代码路径,是旧构建。
  • BLE 用轮询测带宽,把 1.7 → 3.7 KB/s 当成链路;换成通知流是 31.7 KB/s,差 8.6 倍。
  • 传输日志报告“打算发送的负载”,不是实际写出的字节;失败因此读成成功。

两条规则:仪器要同时给出一个已知正确和一个已知错误的读数;在相信差异之前,先重建/校准。 一个从未失败过的检查,可能只是没有在检查。

4.4 删除有判据

两半系统都删掉了大量“看起来有用”的东西:

  • 离线管线:Whisper、Paraformer、独立标点模型、解码 PCM 缓存、随包声学模型;
  • 手环链路:约 40 个界面字段、未配对状态、断开按钮、抑制重试的标志、扫描退避、人工复制固件的流程。

删除的判据不是“没人用”,而是“成本超过收益,且理由能写下来”。能删掉的功能,才是被理解过的功能。

4.5 这意味着什么

对本地优先系统,有五个可迁移的判断:

  1. 模型是可替换件,证据链才是产品。 换一个引擎,评测、守卫、缓存、边界仍然要成立。
  2. 离线是依赖图的性质,不是设置项。 如果“离线”只能靠自觉,它迟早会被一个便利的 HTTP 调用破坏。
  3. 静默失败要有守卫,不是靠更仔细。 人不会每次都检查;结构必须替人检查。
  4. 默认值要来自测量,并写清测量条件。 一个数字如果不知道怎么来的,就不知道它什么时候会变。
  5. 平台会收回你依赖的能力。 Android 16/17 的局域网权限不是远期风险,是发布计划的一部分。

一句话:先把失败变成可见的,再谈把它变快。


5. 效度与代价

5.1 这些数字不证明什么

  • 单机、单数据集。 5,049 句、五个场景来自这个项目;没有跨设备、跨人群对照,不是通用基准。
  • 没有用户研究。 所有指标是 CER、F1、延迟、内存、吞吐;没有“用户是否更愿意用”的证据。
  • 部分根因未知。 11 个低置信度文件的根因至今没有确定;记录为未知,而不是编一个最没被推翻的假设。
  • 单设备、单手机。 第二条 central、iOS、不同 Android 厂商都没有覆盖。
  • 平台版本是预测。 Android 16/17 的行为来自公开文档与预览,正式版可能不同。
  • 时间数字有机器依赖。 换 CPU、编译器、量化或信道,数字会变。

5.2 代价清单

系统代价
离线管线只支持 Windows;依赖 ffmpeg 可执行文件;仍有 FFI;不许 unsafe 导致损坏模型只能终止进程;守卫只抓丢音频
手环链路没有自动回滚;热点未建;固件不进 CI;iOS 没有对应物;Android 局域网权限正在收紧

6. 结论

两次收束最后都落到同一句话:

本地优先系统的产品不是模型,而是证据链;先把失败变成可见的,再谈把它变快。

模型可以换,证据链不能缺。离线是依赖图的性质;状态、身份、版本各只有一个写者;仪器先于系统;删除要有判据。做到这些,系统才从“能演示”走到“敢每天用”。


附录 A:关键数字

项数值
评测语料5 个场景,5,049 句
默认引擎 / 第二引擎SenseVoice-Small int8 228 MB / Qwen3-ASR-0.6B int8 982 MB
VAD 阈值 / 对话 CER0.30 → 0.05 / 9.07% → 6.84%
覆盖率守卫0.70;5,049 句触发 9 次,9 次改善
4.01 小时文件931 段,454 秒,31.8× 实时,峰值 RSS 414 MB
模型加载每文件 1.56 s;keeper 1564 + (N−1)×290 ms
BLE 文件 / 通知流1.9 KB/s / 31.7 KB/s
Wi-Fi 传输54–148 KB/s
状态字段约 40 个 → 1 个 Model
应用恢复连接2014 ms(含 adb 与轮询)
OTA1,593,344 字节,约 18 秒
工程规模249 次提交,13 个 crate,35.6k 行 Rust,582 个测试

附录 B:面向检索的索引

B.1 概念词典

概念定义别名 / 检索词
证据链输入覆盖、引擎来源、缓存语义、网络出口四个可检查点evidence chain
覆盖率守卫测量分段器保留的音频比例,不合理就整文件重识别coverage guard
单一写者一个事实只有一个写入路径,其他路径只读/派生single writer
配对 vs 归属配对解决链路加密与身份;归属解决“谁能命令”pairing vs ownership
仪器校准在相信读数前,先拿已知正确/已知错误的结果验证仪器instrument calibration
协议上限吞吐瓶颈可能来自每往返/每连接事件的帧数,不是射频protocol ceiling

B.2 检查清单

  • 硬约束写下来了吗?每条排除了什么?
  • 哪个阶段最可能静默失败?管线测量它了吗?
  • 缓存命中是否发布与真跑相同的事件序列?
  • 断点续跑的失败模式是“重做”还是“编造”?
  • 默认值是推理来的还是测量来的?覆盖了几个场景?
  • 仪器是否同时给出已知正确和已知错误的读数?
  • 每个事实有几个写者?第二个写者在哪里?
  • “可见”和“可用”是同一件事吗?
  • 固件镜像和源码的版本关系有自动检查吗?
  • 哪些状态/功能可以删掉,而不是增加一个开关?
  • 平台的下一个版本会关掉哪条路径?
  • 没有备用设备时,哪些结论只能标记为“未验证”?

B.3 适用边界

适用于:本地优先的语音/视觉/文本管线,以及“设备—手机—界面”必须共享状态、身份、版本的软硬件链路。

不完全适用于:依赖云端大模型实时能力的低延迟产品、需求高度模糊的探索期产品、遗留系统重写、强合规/强安全场景、纯性能调优。


附录 C:个人贡献与规模

本项目由单人完成,覆盖架构、核心实现、桌面端、命令行、本地服务、手机端、固件链路与评测工具。

  • 周期:2026-10-05 → 2026-10-22,11 个有产出的工作日;
  • 提交:249 次,单一作者;
  • 规模:13 个 crate,约 3.56 万行 Rust;手机端约 1.72 万行(Rust + Dart);固件主程序约 4,361 行(不含 vendored Opus);754 个 tracked files;
  • 质量:582 个 #[test],5,049 句评测语料,桌面/手机/固件链路均有真机验证;
  • 交付:Windows 桌面端、命令行、本地服务、Android 应用与手环固件。

这些数字只说明投入强度;真正的工作量在两次收束、两次仪器故障和四次删除里。


本文所有案例来自 Verse 项目;产品名、公司、域名、账号、设备地址、模型名与内部路径已移除。