线下会议录音系统的开发复盘:八条可复用的工程实践
前段时间做完了一个线下会议录音系统:把一屋子人的现场讨论录下来,实时出带发言人的字幕,会后自动生成带发言人标签的逐字稿和纪要。私有化部署,数据不出内网,纯 CPU 也能跑,全程不依赖外网。
拆开是三个服务:Java 的业务服务、Python 的推理服务、Vue 的前端,加上一整套 PowerShell 部署脚本,合计不到一万行。开发节奏很紧——同一天里 29 次提交,基本是”发现问题 → 定位 → 改一处 → 立刻实测”。
这篇文章不复述功能,也不贴架构图讲得多漂亮。我想写的是这一个项目里真正沉淀下来的东西:那些下次做类似系统时,能直接少走一遍弯路的判断方法。

一、先把约束钉死,再谈选型
这个项目最省时间的一步,是在写第一行代码之前,把三条硬约束写清楚了:
约束一:客户内网不通外网。 所有模型必须能离线下载、离线加载。推论很直接——模型体积和依赖体积不是开发体验问题,是产品指标。
约束二:数据不出内网,包括录音。 录音可能是经营决策、人事、法务内容,这是最敏感的资产。这条直接决定了两个设计:删除会议不删录音(误删不可再生),AI 服务不落业务库(少一个数据出口)。
约束三:to B 交付,现场装不上就是失败。 客户的机器往往是 Windows、无管理员权限、杀毒软件拦安装、只有内网镜像。”依赖能不能装上”的优先级高于”技术先不先进”。
有了这三条,80% 的选型就不用纠结了。比如:
- 为什么放弃 FunASR? 它依赖 torch 全套,700MB+、几十个包,国内网络装一次可能几十分钟甚至失败——对”U 盘 30 分钟装完”这个交付指标是致命的。
- 为什么 sherpa-onnx 是关键? 它一个运行时同时提供 ASR、VAD、说话人分割、声纹四个能力,官方还维护了这些模型的 ONNX 版本。没有它,这四个能力要拼四个框架,依赖体积和版本冲突会直接毁掉约束三。
- 为什么不用 Whisper? 中文会议场景基准上明显弱于同代中文模型(会议场景 CER 19.11 对 5.88 量级),而且体积大、CPU 上更慢。这条不是偏好,是被数字否掉的。
方法:先写”尺子”,再量每个候选。我的尺子按权重排是:许可证能商用 → 能离线、能 CPU → 中文效果好 → 依赖体积小 → 有官方 ONNX 导出(避免自己转模型,那是无穷坑的开始)→ 单一运行时。
先挑技术再找理由的人,最后都会在交付现场还债。
二、先让全链路通,再逐段换真的
我见过太多项目死在”每个模块都要做到完美再拼起来”。这个项目走的是另一条路:先让全链路用假数据跑通,再一段一段换成真的,每换一段都用真实数据验证。
支撑这件事的是把 Mock 当成一等公民,而不是临时脚手架:
- Java 侧
AiClient接口有两个实现:MockAiClient和HttpAiClient - Python 侧有
AI_MODE=mock - 前端、后端、联调、演示,没有 AI 服务也全都能跑
三个收益,都是真金白银:
前端不用等模型下载完。模型几个 G,下载要几十分钟,前端不可能干等。
演示不依赖现场机器有没有 GPU 或模型。演示前一天晚上模型加载失败这种事,遇到一次就够了。
出问题能一刀切开。换成 mock 还错,说明是 Java 或前端的问题,不用在 AI 侧瞎找。
代价也要说清楚:Mock 的数据形状必须跟着契约改,否则它就成了”能跑但假”的陷阱。所以每次改 DTO 都要同步 Mock,这条已经写进提交习惯。
实际的落地顺序是十六个阶段:骨架 → 换真模型 → 补摘要降级 → 上传闭环 → 说话人分离 → 实时判人 → 指定人数 → 录音回放 → 换声纹模型 → 性能优化 → 方言 → 真实进度 → Word 导出 → 设置页 → 续录 → 声纹注册。每一步都有明确的验收标准,而不是”大概能用了”。

三、性能优化:先定位,再动手
这是整个项目里方法论最值得复述的一次。
现象:5 分钟音频要处理 68.9 秒。直觉一定是”ASR 太慢了,换个更快的模型”。
但加了分阶段计时之后,数字是这样的:说话人分离独占 61.4 秒,占 89%。ASR 根本不是瓶颈。
如果当时凭直觉去换 ASR 引擎,可能花两天时间,最后整体只快了两三秒。
定位准了,优化就变得很具体:
| 手段 | 依据 | 收益 |
|---|---|---|
| 离线分离换回 CAM++ | 与 ERes2Net 结果完全一致,快 2.2 倍 | 时间 ÷2.2 |
| 分割窗口步长 0.1 → 0.25 | 一致性 Rand=1.00、切点漂移 0.02s | 时间 ÷2.4 |
| 线程按核数分配(ASR 8 / 分割 8 / 声纹 4) | 12/12 比 8/4 慢 50%(过度订阅) | 显著 |
| 合并同人段改为”记区间、最后一次切片” | 去掉 O(n²) 的数组拼接 | 显著 |
启动预热 + Ollama keep_alive=30m |
把 20.6 秒冷加载挪出关键路径 | 约 20 秒 |
结果:5 分钟音频从 68.9 秒降到 14–16 秒(实时倍率 4.4x → 18–21x),57 秒四人会议从 15.5 秒降到 5.7 秒,而且句数、发言人、文本逐句完全一致,只有切点漂移 ≤0.2 秒。
这里有个细节值得单独说:Ollama 默认空闲 5 分钟就卸载模型。会议是间歇性的——录完一场、过半小时再点”生成纪要”,就要再付一次 4.9GB 模型的加载成本。实测同一个请求:首次 24.5 秒(其中 20.6 秒在加载),常驻后只要 5.2 秒。一个 keep_alive 参数,省掉的是用户以为”卡住了”的 20 秒。

四、被否决的方案,比成功经验更值钱
优化过程里有四个”看起来应该更快”的想法,实测全是否定的:
- ASR 批量解码:1661ms,比逐条 1109ms 更慢。
- 多开识别器实例 + 线程池并行:2/3/4/6 实例分别 8.1/9.7/10.0/11.7 秒,全都比单实例的 5.8 秒慢——因为 onnxruntime 内部已经是多线程,再叠一层只会互相抢核。
- Ollama 并行请求:只快 12%,不值得增加复杂度。
- 分割窗口步长调到 0.5:确实快,但切点漂移最大 1.22 秒,只能取 0.25。
这些结论我全部写进了代码注释,而不是写在文档或会议纪要里。
原因很实际:下一个想”优化”的人(很可能就是三个月后的我)会先看到代码。写在别处的否决结论,等于没写。
五、阈值必须标定,不能拍脑袋
这个项目里最”不工程”、也最容易翻车的部分,是声纹阈值。
所有阈值——在线判人 0.45/0.62、离线聚类 0.65/0.85、注册匹配 0.45——都不是拍脑袋定的,而是用两段”已知真相”的音频交叉标定出来的:一段真实 4 人会议,一段真实单人独白。
标定出来的两条结论,反直觉但很重要:
第一,不同模型的相似度刻度不能共用。 同样是 0.65–0.78 这个区间,CAM++ 能把 4 人分对、单人识别成 1 人;换 ERes2Net,同一个区间会把单人拆成两人。CAM++ 同人相似度 0.60–0.78,ERes2Net 同人在 0.79 左右——照搬旧阈值只会更差。
第二,阈值绑定步长。 分割窗口步长从 0.1 改成 0.25 之后,切出来的段变了,早前在 0.1 下标出的 0.88–0.97 直接失效,会把人数认错。改步长必须重标定,这不是建议,是硬依赖。
还有个取舍值得记:注册匹配阈值取 0.45 偏严,因为 ERes2Net 同人最低约 0.598、异人最高约 0.230。为什么不取中间值?因为把张三标成李四,比显示”发言人 2”糟糕得多。错误的方向决定了阈值该往哪边偏。
所以标定脚本(speaker_calib.py、bench_model_compare.py)在这个项目里是交付物,不是一次性工具——换模型、改步长、换采样参数,都要能重跑一遍。
六、做了又拆掉的功能,也要留档
这是整个项目里唯一一个”实现了又被撤回”的功能,我觉得它的价值比很多成功经验都大。
为什么做:auto 模式默认走方言引擎,1 分钟会议要 30 多秒。用户的抱怨很具体:”逐句识别挺慢的”。于是设计了两遍方案——第一遍用快引擎 9 秒出稿(可看、可改、可导出),后台再用慢引擎精修并覆盖,精修失败也不影响第一稿。
为什么撤:同一轮改造里加了”续录时间轴偏移”,为了简化链路、避免”精修把用户刚改过的稿子覆盖掉”这类冲突,把精修移除了,恢复单遍。
留下的后果:auto 变慢了(现在等于单遍慢引擎),而 README 和运行指南还写着”两遍转写”——文档与行为不一致,这是最该被记录的那类债。
如果将来要恢复,我记下的建议是:不要”精修后整段覆盖”,而是生成一份可对比的新版本让用户选择合并。用户的手工修改,不能被任何后台任务静默吞掉——这大概就是当初撤回的真实原因。
方法:功能撤回不是”没发生过”。它留下了残留字段、过期的依赖文件、和一份撒谎的文档,都必须显式记下来。
七、契约与降级:两个不起眼但高频的坑
第一个坑:契约是三处同步,不是一处。
改一次请求体加个字段,Java 的 record、Python 的 pydantic 模型、MockAiClient 三处必须同时改。漏一处就是运行时 500——这个坑踩过至少两次。
如果重来一次,我会在第一版就把契约定义成 JSON Schema,两侧生成代码,而不是手写。
还有一个更隐蔽的版本:WebSocket 的包装层只透传了 meetingId,新增的 asrEngine、seqOffset 参数从来没有生效过。因为不报错,这个 bug 藏了整整一天——前端调参数、后端没收到、行为不变,所有日志都”正常”。

第二个坑:内存态必须有降级路径。
任务进度用 SSE + 内存注册表实现,没有引入 Redis 或消息队列。理由很实在:一期是单机单实例,多一个必装组件就违反”现场装得上”的约束;而且进度是易失数据,服务重启后任务本来就没了,持久化没有意义。
代价是:服务重启后,正在跑的任务会丢,前端查不到。处理方式不是报错,而是降级——详情页发现任务 ID 查不到,就当没有精修,直接显示已完成的稿子。
这个”内存态要有降级路径”的模式,后来在项目里复用了好几次。
顺便说一个相关决定:设置(比如用哪个 ASR 引擎)按请求下发,不让 AI 服务记住。好处是无状态、多实例不会不一致;代价是设置变更”下一场会议才生效”。所以 UI 上就照实写”下一场会议生效”——文案跟着架构走,不承诺做不到的事。
八、验证纪律,和一份”诚实清单”
这个项目有一条验证习惯,我觉得值得所有做 AI 应用的团队抄走:
改完必须起真实服务 + 用真实音频跑一遍,并记录数字。只看编译通过就交付,上面那种”参数从未生效”的 bug 会一直藏着。
配套的还有几个工程细节:spring-boot:run 被终止后 fork 的 java 子进程会残留,锁住 H2 文件(错误 90028);验证脚本每轮换端口,Java 还连着旧端口,表现为”莫名超时”。这些都不是技术难题,但每一条都能吃掉半天。
最后,我给这个项目写了一份”已知不一致与遗留问题”,明确标注它不是待办承诺,而是给后来者的地图:
auto现在很慢,而文档还在承诺两遍转写(优先级最高)requirements-real.txt已过期,还列着 700MB 用不上的 torch 依赖- 两遍转写的残留字段还在实体里,已无写入方
- 声纹库只有增删查,缺同名覆盖审计、声纹更新、异常注册纠正
- 任务状态不持久,长会议跑一半重启就白跑
- 长会议没有分段,只有真实进度,没有断点续跑
写这份清单的时候我有个体会:一个项目的成熟度,不取决于它有多少功能,而取决于它敢不敢把自己的窟窿列出来。 藏着的问题不会消失,只会在下一个人手里爆炸。
结语
回头看,这个项目真正的收获不是那套语音链路,而是几条可以迁移到任何项目上的方法:
先钉约束,再谈选型;先通链路,再换真的;先测再优化,别凭直觉;阈值靠标定,不靠手感;否决要写进注释;撤回要写进文档;契约要三处同步;内存态要有降级;最后,把所有不一致诚实地列出来。
技术栈会过时,这几条不会。
而且在 AI 项目里,还有一条隐含的优先级:最贵的成本不是模型推理,是验证成本。 任何能让”验证一次”变便宜的设计——Mock、分阶段计时、可重跑的标定脚本、真实数据回归——都值得优先投入,因为它们决定了你敢不敢改代码。