端侧语音助手这个方向,我从两年前就开始折腾,中间推翻重来过三次架构。最早那版是把云端那套东西硬塞进一块开发板里,结果唤醒后要等四秒才出声,体验还不如一个十几块的语音模块。后来把全链路拆开,一段一段抠延迟、抠内存,才慢慢做到今天这个能用的状态——唤醒到首字出声 800ms 出头,整句回答在一秒半内起播,断网状态下也能完整跑完 ASR、LLM、TTS 三段。
这篇东西讲的就是这条 ASR-LLM-TTS 全链路怎么从零搭起来。它解决的核心问题很具体:在没有网络、没有云端算力、只有一块巴掌大的板子和几百兆内存的前提下,让设备听清你说的话、想明白你要什么、再用自然的声音答出来。适合三类人看——正在做智能硬件原型的开发者、想把大模型能力下沉到设备端的算法同学,以及单纯好奇"这东西到底是怎么跑起来的"的爱好者。我会把选型逻辑、参数怎么算、坑在哪,全部摊开讲。
1. 先把账算明白:端侧语音助手的四道坎
动手写第一行代码之前,我建议你先花半天时间把预算算清楚。端侧项目和云端项目最大的区别,是云端你只需要关心"效果好不好",端侧你必须同时关心"效果、延迟、内存、功耗"这四个互相打架的东西。我见过太多人上来就下载一个几 G 的模型往板子里拷,拷到一半发现存储不够,换个量化版本跑起来发现一句话要等八秒,最后项目烂尾。
1.1 一条语音指令要跑完的五段路
从你对着设备说话,到设备回你一句话,中间其实有五个环节,很多人只盯着中间三个模型,忽略了前后两段,结果体验卡在莫名其妙的地方。
第一段是音频前端。麦克风采集、降噪、回声消除、增益控制,这一段决定了后面模型拿到的信号干不干净。第二段是唤醒与端点检测,判断"你是不是在叫它"以及"你是不是说完了"。第三段是ASR,把音频转成文本。第四段是LLM,理解意图并生成回复。第五段是TTS,把文字变成声音播出来。
关键认知是:这五段的延迟是累加的,但优化的空间不是均分的。我实测下来,端点检测(判断你说完没有)那 300 到 600 毫秒的等待,往往是整条链路里最容易被忽略、又最影响体感的部分。你说话停顿一下,设备就以为你说完了,开始识别,识别出来半句话,这个体验比慢两秒还糟糕。
所以我在架构上做的第一件事,不是选模型,而是把这条链路切成"可以并行"和"必须串行"两块。唤醒之后的音频缓冲、ASR 的流式输出、LLM 的首 token 生成,这三件事完全可以流水线重叠,只有 TTS 必须等 LLM 吐出足够长的文本片段才能开始。这个思路后面会反复用到。
1.2 端侧和云端的差距不在模型,在预算
很多人的第一反应是"端侧就是用小模型",这话对了一半。真正的差距在于,云端你可以用几十路并发摊薄成本,端侧你只有一份资源,而且是硬约束。
我给自己定的资源红线是这样的:内存占用峰值不超过 1.2GB,模型文件总体积不超过 800MB,待机功耗控制在 1.5W 以内,满载不超过 6W。这几个数字不是拍脑袋来的,是根据市面上常见的边缘计算模组(4GB 内存、16GB 存储、无风扇被动散热)反推出来的。风扇一加,产品形态就废了;内存一超,系统就开始杀进程,跑一晚上必崩。
注意:一定要在真实板子上测内存峰值,不要在开发机上测。很多推理引擎在 x86 上的内存行为和 ARM 上完全不同,我吃过这个亏,开发机上显示占 700MB,上板直接 OOM。
还有一个容易被忽略的点是存储的读写寿命。端侧设备如果是长期运行,日志、缓存、临时音频文件会持续写 flash,一年下来能把便宜片子写坏。我的做法是把所有临时音频放在内存盘里,日志按大小滚动,单个文件不超过 2MB,总量不超过 20MB。
1.3 三个必须钉死的指标
我给这个项目定义了三个验收指标,后面所有的优化都围着它们转。
| 指标 | 定义 | 我的目标值 | 实测值 |
|---|---|---|---|
| 唤醒响应延迟 | 说完唤醒词到设备给出提示音 | ≤ 300ms | 220ms |
| 首字出声延迟 | 用户说完最后一个字到扬声器出第一个音 | ≤ 1200ms | 850ms |
| 语音识别字错率 | 安静环境下 5 米内普通话短句 | ≤ 8% | 6.4% |
注意第一个指标和第二指标的区别。唤醒是独立的小模型,跑得很快;首字出声则是整条链路的合力,它才是用户真正能感知到的"这玩意儿灵不灵"。我见过一些项目演示视频里看起来很流畅,实际是剪过的,真实延迟两秒以上,用户说一句话要等一下才回应,那种感觉非常别扭。
第三个指标容易被低估。字错率 8% 听起来还行,但如果错的是关键词(比如"打开客厅灯"识别成"打开客厅等"),整个意图就废了。所以我在 ASR 后面加了一层热词纠错,把设备支持的指令词表作为先验注入,实际把关键指令的准确率拉到了 95% 以上。
1.4 我的选型结论和取舍
绕了一圈,最后的架构是这样的:唤醒用超轻量的关键词检测模型,参数量在 10 万级别,常驻内存不到 3MB;ASR 用流式端到端模型,量化后 60MB 左右;LLM 用 1.5B 级别参数量的中文模型,4bit 量化后约 900MB;TTS 用轻量端到端方案,声学模型加声码器合计 40MB。
这个组合的核心权衡是:把参数预算的大头给 LLM,ASR 和 TTS 尽量精简。理由很实在——ASR 和 TTS 是"转换"任务,模型再大,边际收益也有限;而 LLM 是"理解加生成"任务,参数量直接决定它能不能听懂人话。我试过把 LLM 降到 0.5B,模型体积是省了 600MB,但回答质量断崖式下跌,经常答非所问,用户问三句它懵两句,那还不如不做。
另一个取舍是放弃全离线的高精度声纹识别和多轮复杂对话。不是做不到,是资源不够分。与其每样都做得很勉强,不如把单轮问答做到极致。这个决定在当时被质疑过,但实际用下来,用户对"一句话就能办好一件事"的满意度,远高于"能聊十轮但每轮都卡"。
2. 硬件与软件底座怎么搭:把预算花在刀刃上
选型阶段最忌讳的是"先买板子再想做什么"。我第二次重构时就是因为手里有一块现成的板子,硬往上凑,结果发现 NPU 的算子支持列表里没有我需要的那个卷积变体,整个 ASR 模型跑在 CPU 上,延迟翻了三倍。这一节讲的是怎么反过来,从需求推硬件。
2.1 算力平台的真实选型标准
看芯片参数表的时候,别看 TOPS 那个数字,那个数字含水量极高。我判断一块板子能不能用,只看四件事。
第一,NPU 或 GPU 的算子覆盖度。去翻它的算子支持文档,重点看三类:卷积的各种变体、注意力机制相关的矩阵运算、以及量化后的定点算子。我遇到的坑是某个平台支持 INT8 卷积但不支持 INT8 的 LayerNorm,结果模型里一小部分算子回退到 CPU,整体速度掉了 40%。
第二,内存带宽。这一点被严重低估。端侧推理的瓶颈很多时候不是算力不够,是数据搬运跟不上。1.5B 参数的模型,每生成一个 token 都要把权重过一遍,如果你的模型是 4bit 量化(约 900MB),那每个 token 至少要读 900MB 的数据。内存带宽 10GB/s 的话,理论下限就是 90ms 一个 token,这就是为什么有些板子算力标得很高但生成速度上不去。
第三,CPU 单核性能。别小看这个,音频前端处理、VAD、重采样、日志、进程调度,全压在 CPU 上。我用过一款大小核设计的芯片,小核性能太弱,音频线程被调度到小核上,结果出现周期性的爆音,查了三天才定位到是调度问题。解决办法是给音频线程绑核并提高优先级。
第四,散热和供电的稳定性。这条最土但最要命。满载跑十分钟后如果芯片降频,你的延迟就会从 800ms 漂移到 1500ms,而且这种漂移是渐进的,测试的时候不容易发现。我的做法是强制跑三十分钟压力测试,同时记录每轮推理的耗时曲线,看有没有抬头趋势。
2.2 麦克风与音频前端的门道
这一块是纯硬件活,但软件同学必须懂,否则后面调模型会怀疑人生。
我用的是双麦克风方案,间距 6 厘米。为什么不上四麦或六麦?因为四麦以上的阵列需要更复杂的波束成形算法,那套算法在端侧跑起来要吃掉一个核,而且标定工艺复杂,小批量试产根本做不好一致性。双麦做简单的差分降噪加固定波束,效果在小房间场景里够用,成本还低。
具体的信号链是这样的:两个麦克风采集 16kHz、16bit 单声道数据,先做直流偏置消除,再做双麦差分抑制远场噪声,然后过一层轻量的谱减降噪,最后做自动增益控制,把音量拉到统一的动态范围。这里有个细节,自动增益一定要放在降噪之后,顺序反了会把噪声也一起放大,后面怎么降都降不干净。
提示:回声消除(AEC)是必须做的,哪怕你的设备不怎么放音乐。因为 TTS 播报的时候扬声器的声音会被麦克风收进去,如果没有 AEC,设备会把自己的声音当成用户说话,出现"自己跟自己对话"的诡异现象。我第一次遇到的时候还以为是模型出 bug 了。
AEC 的实现有两种路子:用芯片自带的硬件 AEC,或者软件做。硬件 AEC 省 CPU 但参数不好调,软件 AEC 灵活但要吃掉 5% 到 10% 的一个核。我最后选了软件方案,因为硬件 AEC 的收敛速度在我的结构上(扬声器和麦克风距离只有 3 厘米)表现很差,残留回声明显。
2.3 软件栈分层与进程隔离
端侧项目最容易烂的地方不是算法,是软件结构。我第一版把所有东西塞在一个进程里,结果 ASR 模型加载失败直接把整个服务带崩了,连唤醒都失效,设备变成砖。第二版改成多进程,稳定性质变。
我的分层是这样的:最底层是音频服务进程,负责采集、播放、AEC、降噪,这个进程优先级最高,任何情况下不能被杀;中间是推理服务进程,按需加载 ASR、LLM、TTS 三个模型,用完可以释放;最上层是业务逻辑进程,处理意图、管理状态机、对接外部接口。
进程之间用共享内存传音频数据,用本地 socket 传控制指令。为什么用共享内存?因为音频数据量大,16kHz 单声道每秒 32KB,如果走 socket 拷贝,CPU 开销不小,而且会有额外延迟。共享内存加环形缓冲区,生产者写、消费者读,零拷贝。
这里有个经验:环形缓冲区的大小要算清楚。我设的是 2 秒容量,也就是 64KB。太小了,推理进程稍微卡一下音频就丢了;太大了,端到端延迟会被缓冲区撑起来。2 秒是个平衡点,因为最长的单次推理间隔不会超过 1.5 秒。
2.4 存储与内存预算的计算过程
这部分我给一个具体的算法,你可以直接套。
模型体积方面,参数量 N、量化位宽 B,体积约等于 N × B / 8 字节,再加上 10% 的元数据和算子表开销。比如 1.5B 参数、4bit 量化,就是 1.5e9 × 0.5 = 750MB,加 10% 约 825MB。ASR 模型 20M 参数、8bit 量化,约 20MB 加开销 22MB。TTS 40M 参数、8bit,约 44MB。合计不到 900MB,16GB 存储完全够。
运行时内存要复杂一些。除了模型权重常驻,还要算三块:KV Cache、音频缓冲、运行时开销。
KV Cache 的算法是:2 × 层数 × 注意力头数 × 每头维度 × 序列长度 × 批次 × 每元素字节数。以 1.5B 模型为例,28 层、12 头、每头 128 维、上下文 1024 token、batch 1、FP16 存储:2 × 28 × 12 × 128 × 1024 × 2 字节 ≈ 176MB。如果 KV Cache 也量化到 INT8,能压到 88MB 左右,代价是生成质量轻微下降,实测在短对话场景几乎无感。
音频缓冲按 2 秒共享内存加 1 秒播放缓冲算,约 100KB,可以忽略。
运行时开销(推理引擎、算子 workspace、Python 解释器如果用了的话)我预留 200MB,比较宽裕。
加起来:825 + 22 + 44 + 176 + 200 ≈ 1267MB。超了我定的 1.2GB 红线。所以我把 LLM 的上下文从 1024 砍到 768,KV Cache 降到 132MB,同时把 ASR 和 TTS 改成用时加载、用完释放(释放后内存归还给系统),峰值降到 1.1GB 左右。这个细节很关键——不是所有模型都要常驻,只有唤醒模型必须常驻,其他都可以按需加载,代价是首次加载有 200 到 500 毫秒的延迟,可以用"唤醒后立即预加载 ASR"这个动作把它藏起来。
3. 唤醒与 ASR:把第一公里走稳
唤醒和识别是用户接触设备的第一公里,这一段做不好,后面 LLM 再强也没用。我的原则是:唤醒宁可漏一点,也不能误;识别宁可慢一点,也不能断。
3.1 两级唤醒架构与误唤醒率控制
一级唤醒用的是超轻量的关键词检测模型,输入是 1 秒的音频窗口,每 100 毫秒滑动推理一次,输出是"是否包含唤醒词"的概率。模型很小,10 万参数级别,量化后 100KB 出头,跑一次不到 3 毫秒,可以常驻内存持续跑。
二级唤醒是复核。一级给出高置信度后,把前后各 0.5 秒的音频截出来,送进一个稍大的模型(约 50 万参数)做二次确认,同时做简单的说话人验证(判断是不是注册用户的声音)。两级都通过才真正唤醒。
为什么要两级?因为单级模型要么误唤醒高、要么漏唤醒高,调不到理想状态。我实测过,单级方案在电视开着的情况下,一天误唤醒十几次,用户会疯。两级之后,误唤醒降到两三天一次,而唤醒率保持在 95% 以上。
误唤醒的另一个来源是背景音里的相似音节。我做过统计,中文里"小X小X"这种结构的词,在电视剧里出现频率很高。解决办法是在训练数据里塞进大量相似音节的负样本,同时给关键词的音素序列做加权,让模型更关注韵母部分。
注意:唤醒词不要选太短的,两个字的唤醒词误唤醒率是四个字的三倍以上。四字词听起来啰嗦,但工程上省事太多。
3.2 VAD 与端点检测:比识别模型更影响体验
唤醒之后进入监听状态,这时候 VAD(语音活动检测)就上场了。它的任务是判断"用户是不是在说话"和"用户是不是说完了"。
我用的是混合方案:能量 VAD 做粗筛,只在能量超过阈值时才启动小的神经网络 VAD 做精判。这样平均 CPU 占用很低,安静时几乎不耗资源。神经网络 VAD 用的是几百 KB 的小模型,每 10 毫秒一帧,输出语音概率。
端点检测的判定逻辑比 VAD 本身更讲究。我的规则是:连续 300 毫秒检测到静音,且前面至少有 200 毫秒的语音,才判定为"说完了"。这个 300 毫秒不是随便定的,太短了用户换气就会被切断,太长了响应就慢。我实测下来,250 到 350 毫秒是甜区,具体值可以根据目标用户的语速微调。
还有个技巧是动态调整。如果用户前面已经说了很长一句(超过 5 秒),说明他在讲复杂的事情,这时候把静音阈值放宽到 450 毫秒,给他更多停顿空间;如果只说了两个字,阈值收紧到 250 毫秒,快速响应。这个"看人下菜碟"的逻辑,让用户投诉率降了一大截。
另外一个坑是环境噪声突变。比如突然有人关门,能量猛地上去,VAD 会以为用户开始说话了,然后录进去一段噪声送给 ASR,模型瞎识别一通。我的处理是加一个"语音起始确认":能量突增后先观察 100 毫秒,如果这段时间里频谱的平坦度很高(噪声的典型特征就是频谱平坦),就判定为噪声,不触发。
3.3 流式ASR的模型裁剪与量化
ASR 我选的是流式端到端架构,基于 Conformer 类的编码器加 CTC 解码。选它的理由是流式能力好,每收到 320 毫秒的音频块就能吐出增量结果,不用等整句话说完。
模型参数从原始的 70M 裁剪到 20M,裁剪策略是:把编码器的层数从 12 层减到 8 层,注意力头从 8 头减到 4 头,隐藏维度从 512 降到 320,解码器保留。裁剪之后字错率从 5.1% 涨到 6.8%,这个损失可以接受。裁剪的时候一定要先裁剪再重训几轮,直接裁剪不重训,字错率会跌到 15% 以上,完全没法用。
量化方面,编码器用 INT8 的对称量化,解码器保持 FP16。为什么解码器不量化?因为解码器的输出直接决定最终文字,量化误差会被放大成错字。我实测过,解码器也量化到 INT8,字错率从 6.8% 涨到 9.2%,不划算。编码器量化基本无损,速度还能提升 1.8 倍。
量化校准集的构造很讲究。不能随便拿几千条音频就完事,要覆盖各种场景:安静室内、有背景音乐、远场、不同性别年龄。我用了 500 条覆盖 6 种场景的音频做校准,比用 2000 条单一场景的音频效果好得多。
3.4 识别结果后处理与热词注入
ASR 输出的是原始文本,直接用会有很多小问题。我在后面加了三层后处理。
第一层是标点恢复。流式 ASR 通常不带标点,但 LLM 需要标点来理解断句。我用一个极小的标点模型(2M 参数)在识别结果后面补标点,不影响速度。
第二层是热词纠错。把设备支持的所有指令词、设备名、房间名做成一个词表,用编辑距离加拼音相似度做匹配。比如"客厅"被识别成"客停",拼音完全一致,直接纠正。这层的规则要保守,编辑距离阈值设小一点,宁可漏纠不能错纠。
第三层是数字和单位规范化。"一百二十三"要转成"123","三点五"要转成"3.5",因为后面的 LLM 和业务逻辑对纯数字处理更稳。
这里说个踩过的坑:热词表不能太大。我一开始把几百个设备名全塞进去,结果纠错时出现了"张冠李戴",把"打开卧室灯"纠成了"打开卧室灯带"。后来按场景分组,只在当前场景的组内做匹配,问题解决。
4. 端侧LLM:能不能跑起来,跑起来好不好用
这一段是整个项目里最难的部分,也是我推倒重来次数最多的部分。核心矛盾很直白:模型小了不聪明,模型大了跑不动。
4.1 模型尺寸的确定:从内存反推参数量
先说结论:在 4GB 内存的板子上,做中文单轮问答,1.5B 参数是当前比较舒服的点。
推导过程是这样的。前面算过,除去系统和音频服务,能给模型的运行时内存大约是 1.2GB。模型权重加上 KV Cache 加上运行时开销必须塞进这 1.2GB。假设上下文 768 token,KV Cache 约 130MB,运行时开销 200MB,那留给权重的就是 870MB。按 4bit 量化算,参数量上限是 870MB / 0.5 字节 ≈ 1.7B。取整到 1.5B,留点余量。
这个推导的意义在于,它告诉你参数量的天花板是被内存带宽和容量共同决定的,跟你主观上想要多聪明没关系。如果你非要上 3B 模型,要么加内存,要么把上下文砍到 256 token(那样多轮对话就没法做了),要么接受 OOM 风险。
我还试过混合方案:常用指令走规则匹配,复杂问题才调 LLM。这个方案在小场景下很香,因为规则匹配几乎零延迟,能覆盖 60% 的高频指令。但对"通用助手"的定位来说不够优雅,用户问一句"今天适合穿什么",规则库就傻眼了。最后我保留了规则作为快速通道,但主路径还是 LLM。
4.2 量化方案对比与实测
量化是端侧 LLM 的必修课,但不是越激进越好。我做了四组对比实验,都是在同一块板子上、同样的输入、测生成速度和回答质量。
| 量化方案 | 模型体积 | 生成速度(词/秒) | 主观质量 | 适用场景 |
|---|---|---|---|---|
| FP16 | 3.0GB | 2.1 | 基准 | 跑不动,仅作对照 |
| INT8 | 1.5GB | 5.8 | 几乎无损 | 内存充足时首选 |
| 4bit 分组量化(组大小128) | 900MB | 11.4 | 轻微下降 | 我的最终选择 |
| 4bit 分组量化(组大小64) | 950MB | 9.6 | 基本无损 | 质量优先 |
关键发现是分组大小的影响比位宽还大。组大小 128 的时候,量化误差被限制在一个小范围内,效果明显好于整层统一量化。组大小降到 64,质量进一步回升接近 INT8,但体积和速度都变差了,性价比不高。
另一个经验是不要量化 embedding 层和输出层。这两层参数量占比不大(1.5B 模型里大约占 15%),但对语义影响很大。我把它们保持 INT8,其余层 4bit,体积从 900MB 涨到 980MB,质量提升明显,尤其是回答的用词准确度。
注意:量化后的模型一定要做回归测试。我准备了一套 200 条的问答集,每次换量化方案都跑一遍,看有没有出现"答非所问"或者"复读"的情况。4bit 量化最容易出现的退化是重复生成,模型卡在某个词上出不来,这个在长回答里特别明显。
4.3 推理引擎与KV Cache管理
推理引擎的选择上,我倾向支持 GGUF 或 ONNX 这两种格式的轻量方案,因为转换链路成熟、算子覆盖全。选引擎时看三个点:支不支持你板子的加速后端、支不支持流式输出、支不支持 KV Cache 复用。
KV Cache 复用这一条特别重要。单轮对话里,系统提示词是固定的,每次对话都要重新计算一遍系统提示词的 KV,那是白烧算力。我的做法是在服务启动时把系统提示词的 KV Cache 算好并固化下来,每次新对话直接复用,能省掉 100 到 200 毫秒的首 token 时间。1.5B 模型、300 token 的系统提示词,这个优化非常值。
内存管理上,KV Cache 我预分配而不是动态增长。预分配 768 token 的空间,用满了就触发滑动窗口,把最早的一批对话裁掉。滑动窗口的边界要落在完整的句子处,不然模型会看到半句话,输出质量下降。
缓存淘汰策略我试过两种:FIFO 和"保留系统提示 + 最近 N 轮"。后者明显更好,因为多轮对话里最早的几轮往往已经不重要了,但系统提示词永远重要。
4.4 提示词与上下文窗口的工程化处理
端侧 LLM 的提示词要短、要硬、要给例子。我最初写的提示词是自然语言描述"你是一个智能助手,请根据用户的话……",效果很一般,模型经常自由发挥。后来改成结构化的三段式:
[角色] 你是设备语音助手,只回答与设备控制和生活建议相关的问题。 [约束] 回答不超过40字;不确定时说"这个我不太清楚";禁止编造设备不存在的能力。 [示例] 用户:把客厅的灯调暗一点 回答:好的,客厅灯已调到30%亮度。 用户:明天会下雨吗 回答:抱歉,我没有联网能力,查不了天气。这个格式的好处是约束明确、输出长度可控。40 字的限制很重要,因为 TTS 合成 40 字大约 6 秒,再长用户就等不及了,而且端侧生成长文本的速度撑不住。
上下文窗口我最终定在 768 token,分配是:系统提示 250、历史对话 350、当前输入 100、预留输出 68。历史对话只保留最近两轮,更早的直接丢。为什么只留两轮?因为单轮问答占绝大多数,多轮对话里超过两轮的信息价值很低,还挤占输入空间。
还有一个工程细节是输出的 JSON 化。业务逻辑需要知道模型想干什么(是控制设备还是纯聊天),所以我让模型输出固定格式,比如先输出一个意图标签再输出正文。解析的时候要容错,模型有时候会多输出几个字,用正则兜底,解析失败就降级成纯聊天。
4.5 首token延迟优化
用户感知最强的不是生成速度,是首 token 延迟——从说完话到屏幕上出现第一个字(或者扬声器出第一个音)。这个指标我优化到了 380 毫秒,做了四件事。
第一,ASR 流式输出。不等整句识别完,识别到 80% 置信度的稳定片段就先送给 LLM 做前缀填充。这个做法有风险,如果后面识别改了,前缀就错了。我的处理是只对置信度极高的片段做预填充,其余等最终结果。
第二,prompt 预热。前面说的系统提示 KV 固化,省 150 毫秒。
第三,投机采样。用一个极小的草稿模型先猜几个 token,主模型验证。端侧资源紧,我用的草稿模型只有 30M 参数,接受率大约 65%,整体能快 1.4 倍。但这个方案对内存有额外要求,内存吃紧的时候要能动态关掉。
第四,限制输出长度并强制提前结束。让模型学会说短句,一句"好的,已打开"比"好的,我已经为您打开了客厅的灯,请问还有其他需要吗"的体验好得多,而且省了 2 秒的生成和合成时间。
5. TTS:让回答听得下去
TTS 是最容易被敷衍的一环。很多人觉得"能出声就行",但机械音会瞬间拉低整个产品的档次。我在这块花的功夫不比 LLM 少。
5.1 两段式与端到端的选择
主流方案有两种:两段式(声学模型把文本转成声学特征,声码器把特征转成波形)和端到端(直接文本到波形)。
端到端方案音质好、韵律自然,但推理慢,而且流式支持差。两段式灵活,声学模型可以流式出特征,声码器按帧合成,能做到边生成边播放。
我最终选的是两段式,理由是流式能力对我的场景是刚需。用户等 1.2 秒才听到第一个音已经够难受了,如果还要等整句合成完(可能要 3 秒)才开始播,那体验就废了。
声学模型的参数量我控制在 20M,用了非自回归结构,一次前向出一整段梅尔频谱,速度快。声码器用基于 GAN 的轻量方案,20M 参数左右,每次合成 20 毫秒的音频块,延迟很低。
5.2 声码器与流式合成
流式合成的关键在分块策略。文本按标点切句,每句再按字切块,每块 5 到 8 个字送入声学模型,输出对应的梅尔频谱段,然后声码器按 20 毫秒一帧合成波形,推进播放缓冲区。
这里有个细节叫重叠拼接。块与块之间如果不做处理,会有明显的咔哒声。我的做法是每块前后各多合成 10 毫秒,拼接时做交叉淡化,重叠部分线性混合。这个 10 毫秒是试出来的,5 毫秒不够消除边界效应,20 毫秒又会吃掉太多时间。
声码器的量化我用 INT8,音质损失几乎听不出来。但要注意,声码器对量化的敏感度高于声学模型,量化校准的时候要用带各种音素的音频,不能只用单调的测试音,否则会出现某些韵母发虚的情况。
5.3 拼接播放与打断处理
播放这一块看着简单,实际坑很多。
首先是缓冲区管理。播放缓冲区要够大,能扛住推理的抖动。我设的是 500 毫秒容量,也就是 16KB。推理进程偶尔卡顿 200 毫秒,播放不会断。缓冲区太小会卡顿,太大则打断响应慢。
然后是打断处理。用户在 TTS 播报过程中说"停"或者直接说新指令,要能立刻停止播放并转入监听。这个逻辑要做得干脆:收到打断信号,立刻清空播放缓冲区、停止声码器推理、切换状态机到监听。我第一版没清缓冲,用户说完"停",设备还啰嗦了两秒才停,非常尴尬。
还有个隐蔽的问题是AEC 的收敛延迟。TTS 刚起播的时候,AEC 还没收敛,残留回声会被 VAD 当成用户说话,导致自打断。解决办法是在起播后 200 毫秒内屏蔽 VAD 的触发,等 AEC 收敛稳定。这个 200 毫秒是实测出来的,不同结构的设备可能不一样,要自己测。
5.4 音色与韵律的调优经验
音色这块,端侧做声音克隆不现实,但做有限音色选择是可以的。我准备了三个音色:一个偏中性用于日常回答,一个偏低沉用于播报信息,一个偏轻快用于确认指令。切换音色通过声学模型的说话人嵌入实现,不用换模型,只换一个 256 维的向量,几乎零成本。
韵律调优的经验是:语速比音色更重要。端侧合成的语音如果语速太快,听起来像机器人念经;太慢又显得迟钝。我调到每分钟 260 字左右,比正常人类说话略慢一点,听感最舒服。另外,句末的降调要明显一点,中文里句末上扬容易听成疑问句,会造成理解偏差。
标点符号对韵律的影响很大。逗号处要加 150 毫秒停顿,句号加 300 毫秒,问号要在句末做升调处理。这些规则写在文本前端里,比让模型自己学更可控。
6. 全链路联调:把延迟从4秒压到1.2秒
单模块都调通了之后,真正的挑战才开始。我第一版联调跑出来的首字出声延迟是 4.2 秒,用户说完话要愣四秒,那感觉就像设备死机了。
6.1 流水线并行与分段触发
优化的核心思路是把串行变成并行。
原始链路是:录完整句 → 识别完整句 → 生成完整回答 → 合成完整语音 → 播放。这是彻底串行的,每一段都要等前一段完成。
改造成流水线之后:录音的同时做流式识别,识别出稳定片段就送给 LLM 做预填充;LLM 生成到第一个完整短句(比如 8 个字)就切出来送 TTS;TTS 合成出前 200 毫秒音频就开始播放。三段重叠起来,总时间从"求和"变成"取最大值加首段延迟"。
效果是:4.2 秒降到 1.4 秒,后面又抠了一些细节降到 850 毫秒。
这里的关键决策是切句时机。切太早,模型还在组织语言,切出来的半句可能语义不完整,TTS 念出来很怪;切太晚,就失去了并行的意义。我的规则是:遇到句号、问号、感叹号就切,或者累计到 12 个字且遇到逗号也切。后者是为了应对模型输出长句的情况。
6.2 各环节耗时实测与瓶颈定位
我给每一段都埋了点,跑了一千次真实交互,统计出这样的分布(单位毫秒,取 P50 和 P95):
| 环节 | P50 | P95 | 优化手段 |
|---|---|---|---|
| 端点检测 | 300 | 450 | 动态阈值 |
| ASR 识别 | 180 | 320 | INT8 量化 + 流式 |
| LLM 首 token | 380 | 620 | KV 复用 + 投机采样 |
| TTS 首包 | 160 | 280 | 流式合成 + 预加载 |
| 音频通路 | 60 | 120 | 共享内存 + 绑核 |
看这张表能发现,LLM 的首 token 是最大头。所以后续优化的重心都放在那里:把系统提示词从 400 token 压到 250 token,首 token 从 380 降到 300;把输出长度限制写死在提示词里,让模型尽快出第一个句号。
还有一个隐藏成本是模型切换。ASR 用完要释放内存给 LLM,LLM 生成完要释放给 TTS。这个切换如果每次都做,会有 200 到 400 毫秒的加载开销。我的做法是:唤醒时预加载 ASR,识别完成后不释放 ASR(它只有 22MB),而是直接加载 LLM;LLM 加载的同时 tts 模型也在后台预热。三个模型其实可以共存,只要总内存不超就行。这一改动省掉了大部分切换开销。
6.3 音频通路的踩坑
音频通路是最不起眼但最容易出问题的地方。我踩过的坑列几个。
采样率不一致。麦克风采集是 16kHz,但某个环节的库默认重采样到 44.1kHz,来回转换引入了延迟和失真。排查方法是在每个环节打印采样率,一路查下去。最后的做法是全链路统一 16kHz,只在最终播放前重采样一次到 48kHz。
缓冲区欠载导致的爆音。播放时如果数据供不上,会出现刺耳的爆音。原因是声码器合成速度偶尔跟不上播放速度。解决办法是加大预缓冲,起播前先攒够 300 毫秒的数据,同时给声码器线程提高优先级。
时钟漂移。长时间播放后,采集时钟和播放时钟有微小偏差,累积下来会导致缓冲区慢慢被填满或掏空。我的处理是定期做一次微调,检测缓冲区水位,超过 80% 就丢一小段,低于 20% 就补一段静音。丢和补都要控制在 5 毫秒以内,听不出来。
6.4 稳定性与内存泄漏
端侧设备要连续跑几个月,稳定性比性能还重要。
内存泄漏是最常见的问题。我的做法是每小时打印一次内存快照,连续观察 24 小时,看峰值有没有缓慢抬升。有个隐蔽的泄漏是推理引擎的临时张量没释放,每次推理漏几十 KB,跑一天就漏几百 MB。定位方法是用内存分析工具抓堆栈,看哪个分配点没配对释放。
另一个问题是长时间运行后的性能衰减。跑 8 小时后延迟从 850 毫秒涨到 1300 毫秒,重启就好。查下来是日志文件太大导致写盘变慢,以及某些缓存没清理。解决办法是日志严格限大小,缓存加 TTL,每小时做一次轻量的资源回收。
提示:加一个看门狗进程,监控推理服务的响应时间。连续三次超过阈值就重启推理进程。这个简单的机制救了我好几次,尤其是在长时间压测的时候。
7. 常见问题速查与避坑清单
这一节是我这两年里攒下来的问题库,按现象归类,遇到问题可以先在这里查一遍。
7.1 问题排查速查表
| 现象 | 可能原因 | 排查方法 | 解决方式 |
|---|---|---|---|
| 唤醒后无反应 | 状态机卡住 / ASR 加载失败 | 看进程日志和状态标志 | 加状态超时回退,5 秒无响应自动复位 |
| 识别总是漏掉开头几个字 | 端点检测起始判定太晚 | 打印 VAD 触发时间戳 | 前向保留 300ms 音频环形缓冲 |
| 回答一半就断 | 输出长度限制或 KV 窗口溢出 | 检查生成长度和窗口位置 | 调整滑动窗口边界到句末 |
| TTS 有咔哒声 | 分块拼接无重叠 | 听波形找断点 | 加 10ms 交叉淡化 |
| 设备自问自答 | AEC 未收敛 | 关闭 TTS 看是否复现 | 起播后 200ms 屏蔽 VAD |
| 远场识别率骤降 | 增益控制把噪声放大 | 对比近场和远场频谱 | AGC 移到降噪之后 |
| 长时间运行变卡 | 内存碎片 / 日志膨胀 | 内存快照对比 | 定时回收 + 日志限容 |
| 误唤醒频繁 | 唤醒词太短 / 负样本不足 | 统计误唤醒录音 | 换四字词 + 补相似音节负样本 |
用这张表的时候有个技巧:先怀疑音频,再怀疑模型。我遇到过的"模型 bug"里,有七成其实是音频通路的问题——采样率错了、声道反了、增益爆了、缓冲区串了。所以排查顺序永远是:先抓原始音频听一遍,确认信号是干净的,再看模型输入输出。
7.2 几条用时间和返工换来的经验
第一条,流式比精度更重要。我早期花了两个月优化 ASR 的字错率,从 8% 降到 6%,用户毫无感知;后来花两周做流式输出,把首字延迟从 1.8 秒降到 0.9 秒,用户反馈立刻变好。端侧场景里,体感延迟的权重远高于绝对精度。
第二条,所有的等待都要给反馈。用户说完话,如果 500 毫秒内没有任何动静,会以为设备没听见。我的做法是收到语音结束就立刻播一个极短的"叮"声,同时在指示灯上给出状态。这个 30 毫秒的音效,能把主观等待感降低一半。
第三条,别在端侧追求通用。云端助手可以跟你聊哲学,端侧助手最好只做它擅长的那几十件事。我第一版试图让它什么都能聊,结果什么都聊不好,还经常生成一堆无用长文拖慢 TTS。收敛到"设备控制 + 生活提醒 + 简单问答"三个域之后,准确率和速度都上去了。
第四条,测试集要用真实的脏数据。我最初用录音棚里录的干净音频做测试,指标漂亮得不行,上线后投诉一片。后来改成用真实家庭环境录的音频——有电视声、有炒菜声、有小孩哭、有方言口音——重新调了一遍模型和阈值,才真正能用。这份脏数据集的制作花了整整一周,但它是整个项目里性价比最高的投入。
第五条,给用户一个"慢一点但听清了"的兜底。当 ASR 置信度低的时候,不要硬猜,直接回一句"没听清,你再说一遍好吗"。这比自作聪明地执行一个错误指令要好得多,尤其是控制类指令,执行错了可能很麻烦。
我个人在实际操作中的体会是,端侧这条链路最难的地方不在任何一个单点技术,而在资源约束下的全局平衡。你每给一个模块多一点内存,就有另一个模块要挨饿;你每为一个环节多争取 100 毫秒,另一个环节就要让步。真正让项目跑起来的,是那些看起来很土的决定:砍掉不必要的能力、接受可控的精度损失、用规则兜底、把等待藏进流水线。
如果让我再做一个类似的设备,我会从第一天就把"脏数据测试集"和"全链路埋点"这两件事做在前面。前面省下的这几天,后面要花几周去补。还有一个小的技巧送给你:在设备上留一个隐藏的调试入口,能实时打印每个环节的耗时和内存,用手机连上看一眼就知道问题在哪。这个入口在我调优的后期,比任何工具都管用。