在 reComputer Mini 上部署本地语音 LLM,本质上是在一台 CPU 边缘设备上串起 ASR、LLM、TTS 三个推理模块,让设备在没有网络的情况下完成“听清问题、理解问题、播出回答”的完整交互。实际场景中有人把它当成两件事:先让 ASR 识别语音并输出文字,再把文字交给 LLM 处理,最后交给 TTS 合成语音。这个思路没有错,但真正落地时会发现,链路里每个环节都有独立的技术栈、独立的模型格式和独立的内存占用,直接用“拼装”的方式很难跑得顺。
reComputer Mini 属于边缘设备,CPU 性能有限,内存带宽有限,没有独立大显存,因此在选型时不是选“效果最好”的模型,而是选“在本地跑得动且效果可接受”的模型。链路里需要优先考虑三件事:
一是模型体积。ASR 模型、LLM 模型、TTS 模型总体积不宜过大,否则重新部署和首次加载时间会明显变长,设备存储也容易被挤占。
二是推理框架。同一个模型在不同框架里的算子支持和量化支持差异很大,选择 ONNX Runtime、llama.cpp、sherpa-onnx 这类框架,更容易在 CPU 设备上获得稳定表现。
三是交互约束。本地 LLM 的生成速度远低于云端大模型,因此链路设计上要做成“分段并行式输入输出”,而不是要求用户一次性说完一长段话,也不能在 LLM 生成期间阻塞其它处理。
如果希望在 reComputer Mini 上做实验,比较务实的路径是:先用 ASR 完成语音转文字,再把文字送入本地 LLM 生成回答,最后用 TTS 把回答转成语音。下文按照这条链路逐步展开,适合已经接触过边缘设备基础操作、但还没有完整跑通过语音 LLM 链路的开发者参考。
1. 先理解离线语音助手链路里每个环节的边界
在开始安装依赖之前,先搞清楚这条链路中 ASR、LLM、TTS 各自承担什么职责,以及它们之间的数据格式如何衔接。这样后面排查问题时,才能准确判断问题出在哪个环节。
1.1 ASR 环节:音频输入如何变成文本
ASR 全称 Automatic Speech Recognition,作用是接收音频,输出文本。这一环节的关键参数有三个:采样率、音频通道数、识别语言。大多数开源 ASR 模型默认按 16kHz 单声道处理,如果你的麦克风默认是 48kHz,就需要先用工具做重采样,否则识别结果可能出现明显丢字或乱码。
在 reComputer Mini 这类设备上,推荐使用 sherpa-onnx 配合较成熟的 ASR 模型。sherpa-onnx 的一大优势是它把音频处理、解码和模型推理封装得比较完整,对外暴露的接口相对简单,适合在资源受限设备上做集成。ASR 的输出是一条带时间信息的文本,但它通常不携带说话人身份、情绪或声纹特征,后面几个环节不需要依赖这些信息。
ASR 环节的实际耗时通常与音频长度成正比。10 秒音频在 CPU 设备上可能需要 2 到 5 秒才能完成识别,具体取决于模型大小和线程数量。写代码时不要让主流程等待识别完成后再做任何事,建议先把识别结果写入内存队列,再由下游轮询。
1.2 LLM 环节:文本如何变成回答文本
LLM 环节负责理解用户输入并生成回答。这是整条链路中计算量最大、延迟最高的一环。本地 LLM 的生成过程是逐 token 进行的,每个 token 大约对应一到两个汉字或几个英文字符,因此生成速度通常以 tokens/s 衡量。
选模型时不能只看榜单分数。同一模型在 7B 参数量、14B 参数量和量化版本之间,内存占用和推理速度差异非常大。reComputer Mini 的内存容量有限,比较合适的起点是参数量在 3B 到 8B 之间、量化等级在 Q4 左右的模型。参数量更大的模型即使能加载,生成速度也可能低到不可用。
LLM 环节的输入是 ASR 得到的文本,输出也是文本。为了让回答更稳定,可以在提示词里明确要求“用简洁自然的中文回答”,也可以给系统提示词限定回答长度。本地小模型的指令遵循能力不如云端大模型,提示词写得太复杂反而容易让模型输出偏离主题。
1.3 TTS 环节:回答文本如何变成语音
TTS 全称 Text-to-Speech,作用是输入文本,输出音频。这一环节直接影响用户体验,如果合成出来的语音音色生硬、停顿奇怪,整条链路的功能完成度会大打折扣。
在 CPU 边缘设备上,TTS 的实现方式通常分为两类:一类是端到端神经网络模型,直接从文本生成梅尔频谱再转音频;另一类是两段式结构,先生成中间声学特征,再通过声码器合成波形。sherpa-onnx 也提供了 ONNX 格式的 TTS 模型支持,可以把文本转换成指定的采样率、指定格式的音频数据。
TTS 环节要注意文本长度。如果 LLM 生成 500 字,TTS 合成时间会明显拉长,而且朗读出来用户未必愿意听。实际链路里可以对 LLM 输出做截断或后处理,只保留前两句或关键结论。合成音频的采样率也要和播放设备对齐,常见设置在 22050Hz 或 24000Hz,输出后再交给音频播放模块,不要直接在回调里播放未经过采样率处理的裸数据。
1.4 三个环节之间如何衔接
三个环节的数据格式都不相同:ASR 接收 PCM 音频,输出文本字符串;LLM 接收文本字符串,输出回答字符串;TTS 接收回答字符串,输出 PCM 音频。因此链路中的核心数据结构就是两个字符串和一个音频缓冲,只要接口约定清晰,每个环节都可以独立替换。
实际项目里可以定义这样的数据流向:
text复制麦克风音频 PCM(16kHz/mono) -> ASR 识别文本 -> LLM 回答文本 -> TTS 合成音频 PCM -> 扬声器播放
每一个环节都应该有独立的输入输出接口。比如 ASR 模块只负责“输入音频,输出文本”,不应该顺带做业务流程判断;LLM 模块只负责“输入提问,输出回答”,不应该顺带管理会话历史;TTS 模块只负责“输入文本,输出音频”,不应该顺带决定要不要朗读。这样拆分后,后续替换模型或调整参数时,不会牵动整条链路。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 在 reComputer Mini 上准备运行环境和依赖
reComputer Mini 的硬件形态类似紧凑型开发板,通常预装 Linux 系统。部署前先把系统、Python、ONNX Runtime、sherpa-onnx 等基础依赖准备好,再下载模型。顺序不能反,如果先下载模型再装依赖,一旦环境不兼容,又要重新处理模型路径和格式,排查成本更高。
2.1 确认系统与硬件基础状态
先确认设备系统架构和内存大小,这决定了后面能跑什么量化等级的模型。在主板上执行:
bash复制uname -a
nproc
free -h
df -h /
lsb_release -a 2>/dev/null | head -20
uname -a 用于看内核版本和架构,nproc 看 CPU 线程数,free -h 看内存总量和空闲量,df -h / 看根分区剩余空间。在动手下载模型之前,这三项数据必须确认到位。如果内存只有 8GB,就不适合下载 7B 以上的高字节模型;如果剩余磁盘只有两三 GB,也需要先清理空间或使用容量更小的模型。
确认完硬件后,再确认系统是否有可用的音频设备。边缘设备上常见的问题是系统识别不到麦克风或扬声器,这会导致后续 ASR 和 TTS 代码无法调试。可以执行:
bash复制arecord -l
aplay -l
arecord -l 列出录音设备,aplay -l 列出播放设备。如果输出为空,说明系统还没有加载音频驱动或设备未枚举成功,要先解决音频设备问题,否则后面的代码写得再完整也无法听到最终效果。
2.2 创建 Python 虚拟环境并安装推理依赖
不建议直接在系统 Python 里安装一堆依赖,边缘设备的系统 Python 往往由系统包管理,直接修改容易影响系统工具。推荐创建独立虚拟环境:
bash复制python3 -m venv ~/speech_llm_env
source ~/speech_llm_env/bin/activate
pip install --upgrade pip
然后安装核心推理依赖。链路中 ASR 和 TTS 都使用 sherpa-onnx,LLM 使用 llama.cpp 的 Python 绑定,因此主要安装这三个包:
bash复制pip install sherpa-onnx
pip install llama-cpp-python
如果安装 llama-cpp-python 时需要进行本地编译,耗时可能比较长,并且要求系统里有 cmake、gcc、g++ 等工具链。可以先检查这些工具是否存在:
bash复制which cmake
which gcc
which g++
如果缺少编译工具,在 Debian 系系统上可以执行:
bash复制sudo apt update
sudo apt install -y build-essential cmake
这里要提醒一点:如果原始安装源下载速度较慢,可以考虑更换可靠的 PyPI 镜像源,但不建议为了性能随意修改模型格式或使用非标准预编译包。安装完成后,用 pip list | grep sherpa 确认版本成功安装即可。
2.3 下载 ASR、LLM、TTS 模型并检查文件格式
模型可以存放在独立目录中,方便后面按名称加载。在虚拟环境中执行示例命令:
bash复制mkdir -p ~/models/asr ~/models/llm ~/models/tts
ASR 模型建议下载 sherpa-onnx 官方示例中已经导出的 ONNX 格式模型。这类模型通常包括 encoder.onnx、decoder.onnx、joiner.onnx 和对应的 tokens.txt 词表文件。注意有些模型还需要 lexicon.txt 或 hotwords.txt,加载时要根据模型说明决定是否传入。
LLM 模型建议下载 GGUF 格式文件。GGUF 是 llama.cpp 生态使用的模型格式,可以在 CPU 上高效运行。选取模型时除了看参数量,还要看量化等级名称,比如 q4_k_m,这类文件体积适中,推理质量损失相对可控。如果模型文件是 PyTorch 格式或 safetensors 格式,需要先完成格式转换,转换过程比较耗时,建议直接下载已经转换好的 GGUF 文件。
TTS 模型同样使用 ONNX 格式。sherpa-onnx 的 TTS 模型通常包含 tts.onnx 或分阶段的多个 ONNX 文件,以及 tokens.txt、lexicon.txt 等辅助文件。下载后可以先把三个目录下的文件名列出来:
bash复制ls -lh ~/models/asr
ls -lh ~/models/llm
ls -lh ~/models/tts
确认文件不是 0 字节,并记录文件路径。后续代码里会频繁用到这些路径,建议在配置文件中统一保存。
2.4 环境准备常见坑
环境准备阶段最常出现的坑有三个。
第一个坑是模型下载不完整。机器上看着文件存在,但大小明显小于预期。加载时往往报“file not found”或格式错误,实际上并不是文件路径错了,而是文件没有下载完整。检查方式是用 ls -lh 查看文件大小,并重新下载。
第二个坑是 llama-cpp-python 编译失败。编译失败通常发生在缺少 cmake 或 g++ 的干净系统上。检查方式是先运行 cmake --version,如果提示 command not found,则先安装工具链后再执行 pip install。编译过程也可能因为内存不足被终止,可以在编译时减少并行任务数。
第三个坑是音频设备不识别。代码能运行但听不到声音,往往不是模型问题,而是 ALSA 或 PulseAudio 配置问题。建议先执行 aplay -l 和 arecord -l,确认设备枚举结果。如果设备没有枚举出来,后续所有调试都缺少听感反馈,会很难判断链路是否真的正常。
3. 编写 ASR 识别模块,把麦克风音频转成文本
ASR 模块是链路的第一级。这一节先实现读取音频文件或 PCM 数据,再调用 sherpa-onnx 识别,输出文本。为了便于调试,先不直接接入麦克风循环,先用本地音频文件验证识别结果,确认模型加载无误后再进入实时流程。
3.1 使用 sherpa-onnx 加载 ASR 模型
在 ~/speech_llm_project/asr_module.py 中写入以下代码。这里的目标是封装一个 recognize 函数,输入音频数据或音频文件路径,输出识别文本。
python复制import sherpa_onnx
import numpy as np
def build_recognizer(model_dir: str) -> object:
recognizer = sherpa_onnx.OfflineRecognizer.from_models(
tokens=model_dir + "/tokens.txt",
encoder=model_dir + "/encoder.onnx",
decoder=model_dir + "/decoder.onnx",
joiner=model_dir + "/joiner.onnx",
num_threads=4,
sample_rate=16000,
feature_dim=80,
enable_endpoint_detection=False,
)
return recognizer
这段代码的关键在于 sample_rate 必须和模型要求的采样率一致,常见值是 16000。feature_dim 表示声学特征维度,不同模型可能不同,要以模型说明或示例代码为准。num_threads 对识别速度影响比较明显,在边缘设备上不一定要设置为 CPU 的最大线程数,有时候设置为物理核心数反而更稳定。
如果模型需要额外的 lexicon.txt,可以继续传入参数,例如:
python复制recognizer = sherpa_onnx.OfflineRecognizer.from_models(
tokens=model_dir + "/tokens.txt",
encoder=model_dir + "/encoder.onnx",
decoder=model_dir + "/decoder.onnx",
joiner=model_dir + "/joiner.onnx",
lexicon=model_dir + "/lexicon.txt",
num_threads=4,
sample_rate=16000,
)
不同模型对参数的兼容性不同,如果代码在执行时提示缺少必要参数,应该以模型官方示例代码为准,不要随意忽略。
3.2 从音频文件生成识别结果
先实现一个简单的文件识别函数,方便在命令行里快速测试。
python复制import wave
def recognize_file(recognizer: object, audio_path: str) -> str:
with wave.open(audio_path, "rb") as wf:
sample_rate = wf.getframerate()
samples = wf.readframes(wf.getnframes())
channels = wf.getnchannels()
if sample_rate != 16000:
raise ValueError(f"audio sample rate must be 16000, got {sample_rate}")
if channels == 2:
pcm = np.frombuffer(samples, dtype=np.int16).reshape(-1, 2).mean(axis=1)
pcm = pcm.astype(np.int16)
else:
pcm = np.frombuffer(samples, dtype=np.int16)
stream = recognizer.create_stream()
stream.accept_waveform(sample_rate, pcm.tolist())
recognizer.decode_stream(stream)
return stream.result.text.strip()
这里把双声道音频合并为单声道,因为绝大多数 ASR 模型按单声道设计。如果测试音频本身是单声道,channels == 1 的分支直接使用原始数据,不会引入额外损耗。accept_waveform 接收的是采样点列表,不是字节串,所以这里先用 np.frombuffer 把字节数据转成 int16 数组。
在 main 中测试这个函数:
python复制if __name__ == "__main__":
r = build_recognizer("/home/user/models/asr")
text = recognize_file(r, "/home/user/test_audio/test.wav")
print("ASR result:", text)
假如测试音频时长 5 秒,正常输出会在 ASR result: 之后显示一段文字。如果输出为空字符串,先检查音频是否为 16kHz 单声道,以及录音内容是否太短。
3.3 参数说明:采样率、声道和线程
边缘设备上 ASR 参数最容易出错,下面用表格整理典型的参数含义和调整方式。
| 参数 | 含义 | 默认值或常见值 | 调整影响 | 错误表现 |
|---|---|---|---|---|
sample_rate |
音频采样率 | 16000 | 必须与模型输入对齐,重采样会增加耗时 | 识别结果乱码或为空 |
channels |
音频通道数 | 1 | 双声道要先合并 | 识别质量下降 |
num_threads |
推理线程数 | 4 | 调大可能提速,但会抢占内存带宽 | 推理卡顿或无显著提速 |
feature_dim |
声学特征维度 | 80 | 必须与模型匹配 | 初始化报错 |
enable_endpoint_detection |
端点检测开关 | False | 开启后自动切分语音端点 | 长句可能被误切 |
音频输入推荐统一标准化为 16kHz、单声道、16bit PCM。在接收麦克风数据时,如果系统默认采集格式不是这个组合,要先在音频客户端侧完成重采样,而不是直接塞给 ASR 模块。
注意:不要在 ASR 回调函数里做耗时的模型加载或变量重赋值。reComputer Mini 这类设备上模型加载通常需要几秒,连续调用时如果每次都重新加载,会导致识别延迟飙升。
4. 编写 LLM 回答模块,用 GGUF 模型生成文本
LLM 模块是整条链路计算量最大的环节。本地小模型虽然生成速度和云端模型没法比,但通过合理的参数设置,完全可以在离线场景实现可用的回答效果。
4.1 使用 llama-cpp-python 加载本地模型
在 ~/speech_llm_project/llm_module.py 中写入以下代码:
python复制from llama_cpp import Llama
def build_llm(model_path: str) -> Llama:
llm = Llama(
model_path=model_path,
n_ctx=2048,
n_threads=4,
n_gpu_layers=0,
verbose=False,
)
return llm
def ask(llm: Llama, user_text: str, max_tokens: int = 256) -> str:
messages = [
{
"role": "system",
"content": "你是一个运行在离线设备上的语音助手。请用简洁自然的中文回答,直接给出结论,不要输出多余解释。",
},
{
"role": "user",
"content": user_text,
},
]
result = llm.create_chat_completion(
messages=messages,
max_tokens=max_tokens,
temperature=0.7,
top_p=0.9,
)
return result["choices"][0]["message"]["content"].strip()
n_ctx=2048 表示上下文窗口大小。它既包含用户输入的 token 数,也包含模型生成的 token 数。在边缘设备上不要开得太高,窗口越大,KV cache 占用的内存越多。n_threads=4 可以根据 CPU 情况调整。n_gpu_layers=0 表示不使用 GPU 层,因为这里明确走 CPU 推理。
ask 函数的关键在于维护一个系统提示词。系统提示词对本地模型的回答风格影响很大,可以结合实际场景反复调整。比如希望回答尽量短,可以写“直接给结论,不超过两句话”;希望回答带步骤,可以换成“先用一句话总结,再列出要点”。
4.2 理解本地 LLM 的生成速度和参数取舍
本地 LLM 是逐 token 生成,这是它和云端 API 最本质的区别。云端模型在服务端拥有大规模 GPU,可以在极短时间内输出长文本,而边缘设备上的 3B 到 8B 量化模型,生成速度一般在几十 tokens/s 甚至更低。
因此在实际链路中要控制 max_tokens。如果用户问题复杂,可以允许生成 512 token;如果只是想得到简短结论,建议限制在 128 到 256 token 之间。生成的 token 越多,等待时间越长,TTS 需要朗读的文本也越长。
temperature 表示采样温度,值越大越随机,值越小越稳定。在语音助手场景中,回答不稳定会让用户感觉断断续续,所以不要使用过高的 temperature。top_p 是核采样参数,它控制从概率累积达到该阈值的候选中采样。0.9 是比较常见的值,如果发现回答重复,可以适当降低到 0.8 或换用不同采样参数。
下面是不同参数的调整影响:
| 参数 | 作用 | 调大影响 | 调小影响 |
|---|---|---|---|
max_tokens |
最大生成长度 | 回答更长,等待更久 | 回答可能被截断 |
temperature |
采样随机性 | 回答更发散 | 回答更保守 |
top_p |
候选词范围 | 候选更多,更随机 | 候选更少,更稳定 |
n_ctx |
上下文窗口 | 可处理更多历史内容 | 内存占用更小 |
n_threads |
推理线程数 | 可能更快,但占用高 | 速度下降,系统更空闲 |
在边缘设备上不要追求极致速度,因为线程数调太高会抢占内存带宽,反而可能导致推理速度下降。建议先用默认线程数跑一轮,再通过命令行观察 CPU 占用和生成速度,逐步找到当前设备的最优配置。
4.3 验证 LLM 模块的请求与输出
在 main 中执行:
python复制if __name__ == "__main__":
llm = build_llm("/home/user/models/llm/qwen2.5-3b-instruct-q4_k_m.gguf")
while True:
user_text = input("请输入问题:")
answer = ask(llm, user_text)
print("LLM answer:", answer)
这里的 input 只用于验证模块本身,接入完整链路后会改由 ASR 模块提供 user_text。如果输入“介绍一下本地语音助手”,正常输出应当是一段完整的中文回答。如果输出为空,可能原因是上下文窗口里系统提示词与用户问题占比过大,或者模型没有正确生成结束符,需要增大 max_tokens 或检查模型文件是否正常。
实际运行时,可以观察 print 输出时间。第一次生成较慢,多次提问后速度会趋于稳定。如果设备内存不足,加载模型时可能被系统杀掉,表现是进程直接退出或报 kill。这种情况下要么换更小的量化模型,要么关闭其它占用内存的服务。
注意:本地模型生成的文本不一定严格符合预期,不要在后处理中对格式做过度假设。链路里可以设计一个简单的文本清洗函数,把多余换行、空格和特殊引号做归一化,再交给 TTS,避免朗读时出现异常停顿。
5. 编写 TTS 语音合成模块,让回答变成可播放音频
TTS 模块负责把 LLM 输出的文本转换成语音。这里的实现思路和 ASR 类似,也使用 sherpa-onnx。不同之处在于,ASR 是音频转文本,TTS 是文本转音频,输入输出方向相反。
5.1 加载 TTS 模型并合成音频
在 ~/speech_llm_project/tts_module.py 中写入以下代码:
python复制import sherpa_onnx
import numpy as np
import soundfile as sf
def build_tts(model_dir: str) -> object:
tts = sherpa_onnx.OfflineTts.from_model(
model=model_dir + "/tts.onnx",
tokens=model_dir + "/tokens.txt",
lexicon=model_dir + "/lexicon.txt",
dict_dir=None,
num_threads=4,
sample_rate=24000,
)
return tts
def synthesize(tts: object, text: str, output_path: str) -> None:
result = tts.generate(text)
samples = np.array(result.samples, dtype=np.float32)
sf.write(
output_path,
samples,
samplerate=result.sample_rate,
format="WAV",
)
这里 sample_rate 要严格匹配模型要求。有些 TTS 模型输出 22050Hz,有些输出 24000Hz,不一致时合成结果会出现音调异常或播放失败。tts.generate(text) 返回的对象里包含 samples 和 sample_rate 两个关键字段,写入 WAV 文件时直接使用 result.sample_rate,避免主观指定错采样率。
sf.write 需要 soundfile 库。安装 soundfile 时,系统需要具备 libsndfile 支持。可以提前执行:
bash复制pip install soundfile
如果合成时提示无法写入文件,优先检查目标目录是否存在。示例中输出路径的父目录不存在时,soundfile 会直接报错,所以最好在合成前自动创建目录。
5.2 对 TTS 文本做长度控制和清洁
LLM 输出可能包含换行、列表符号、Markdown 标记等,TTS 不适合直接朗读这些字符。比较实用的一个做法是简单清洗后再交给 TTS:
python复制import re
def clean_text_for_tts(text: str) -> str:
text = re.sub(r"[*#_`>|-]", "", text)
text = re.sub(r"\s+", " ", text).strip()
if len(text) > 200:
text = text[:200]
return text
这里通过正则去掉了常见 Markdown 符号和多余空白,并把长度限制在 200 字以内。实际项目中文本长度限制要根据 TTS 合成速度和用户耐心设置,没有固定标准。如果 LLM 经常输出长回答,也可以让 LLM 在提示词里“只输出一句话”,而不是在 TTS 环节强制截断。
TTS 合成前还需要注意文本末尾的标点。部分 TTS 模型对结尾标点敏感,如果文本没有结尾标点,合成语音可能出现尾部拖音。可以在清洗后检查最后一个字符是否为句号、问号或感叹号,如果不是,自动补一个句号。
clean_text_for_tts 是示例逻辑,具体正则要根据你自己使用的模型来调整。有些中文 TTS 模型对英文数字有自己的朗读规则,不要随意替换字符。
5.3 验证 TTS 输出音频
在 main 中执行:
python复制if __name__ == "__main__":
tts = build_tts("/home/user/models/tts")
text = "你好,这是本地语音助手生成的回答。"
result = synthesize(tts, clean_text_for_tts(text), "/home/user/test_audio/tts_output.wav")
print("TTS output saved.")
正常执行后,用播放器播放生成的 WAV 文件,应该能听到完整且自然的一句中文。如果声音变形或音调不对,问题通常出在采样率配置上,需要检查 sample_rate 参数是否与模型匹配。如果完全没有声音,则检查模型文件是否完整、text 是否为空字符串。
TTS 模块单独验证通过后,链路里剩下的事情就是把它和 ASR、LLM 串起来。
6. 串起完整链路:ASR 到 LLM 再到 TTS 的离线对话流程
前面的模块都是独立封装,现在要把它串成一个完整的离线对话流程。这里的核心设计原则是流程清晰、状态可观察、失败可定位。
6.1 命令行主程序:完成一轮离线对话
在 ~/speech_llm_project/main_demo.py 中写入以下代码。这个版本先不接入实时麦克风,而是用音频文件模拟用户输入,形成“输入音频 -> 文本 -> 回答 -> 音频”的最小闭环。
python复制import time
import os
from asr_module import build_recognizer, recognize_file
from llm_module import build_llm, ask
from tts_module import build_tts, synthesize, clean_text_for_tts
ASR_MODEL_DIR = os.path.expanduser("~/models/asr")
LLM_MODEL_PATH = os.path.expanduser("~/models/llm/qwen2.5-3b-instruct-q4_k_m.gguf")
TTS_MODEL_DIR = os.path.expanduser("~/models/tts")
def main(user_audio_path: str) -> None:
recognizer = build_recognizer(ASR_MODEL_DIR)
llm = build_llm(LLM_MODEL_PATH)
tts = build_tts(TTS_MODEL_DIR)
t0 = time.time()
user_text = recognize_file(recognizer, user_audio_path)
t1 = time.time()
print("ASR time: %.2f s" % (t1 - t0))
print("User text:", user_text)
answer = ask(llm, user_text, max_tokens=256)
t2 = time.time()
print("LLM time: %.2f s" % (t2 - t1))
print("Answer:", answer)
tts_text = clean_text_for_tts(answer)
output_path = "/home/user/speech_llm_project/answer.wav"
synthesize(tts, tts_text, output_path)
t3 = time.time()
print("TTS time: %.2f s" % (t3 - t2))
print("Output:", output_path)
if __name__ == "__main__":
main("/home/user/test_audio/question.wav")
这个程序执行了三个清晰步骤:ASR 识别用户问题,LLM 生成回答,TTS 合成回答语音。每一步都打印耗时,便于确认瓶颈在哪个环节。在实际边缘设备上,LLM 时间通常占大头,ASR 和 TTS 的时间相对较小。
运行后预期输出类似:
text复制ASR time: 1.50 s
User text: 请介绍一下本地语音助手
LLM time: 12.00 s
Answer: 本地语音助手能够在没有网络的情况下完成语音识别、文本理解和语音合成。
TTS time: 2.20 s
Output: /home/user/speech_llm_project/answer.wav
这里的时间只是示例,不是固定指标。如果 LLM 时间远超预期,要考虑模型量化等级、线程数和上下文窗口大小。
6.2 排队机制:不要让下游长时间阻塞
在真实链路中,用户可能连续说话,而 LLM 生成回答需要几十秒。如果采用同步顺序执行,用户必须等整个链路结束后才能进行下一次交互,体验很差。
更合理的实现思路是引入队列。ASR 识别完成后,把结果放入文本队列;LLM 消费队列并生成回答;TTS 拿到回答后合成音频。这样 ASR 可以继续处理新音频,不需要等 LLM 全部生成完毕。对于边缘设备,主要限制是内存,所以队列长度不宜过大,一般 5 到 10 条即可。
简单示例:
python复制import queue
user_text_queue = queue.Queue(maxsize=10)
answer_text_queue = queue.Queue(maxsize=10)
ASR 线程把识别结果放入 user_text_queue,LLM 线程从中取出文本进行推理,推理结果放入 answer_text_queue,TTS 线程再从该队列取出文本合成音频。这样可以在单台 reComputer Mini 上实现“边说边答”的基础形态。
需要说明的是,多线程并不一定意味着更快。如果内存不足以同时运行 ASR、LLM、TTS 三个模型,串行执行反而更稳定。多线程的价值在于让音频采集不阻塞,同时避免扬声器播放阻塞后续合成。落地时可以根据设备内存和实际表现选择串行或并行。
6.3 多轮对话与上下文管理
本地 LLM 的上下文窗口有限,多轮对话时如果每轮都把全部历史送入模型,窗口很快就会占满。比较推荐的做法是保留最近几轮对话记录,而不是完整历史。
例如,可以维护一个 history 列表,每轮问答结束后,把用户问题和模型回答追加进去。构造消息时,系统提示词放在最前面,然后取最近四轮记录,最后追加当前用户问题。这样既保留了一定上下文连贯性,又不会让窗口超出模型能力。
python复制history = []
def ask_with_history(llm: Llama, user_text: str, history: list) -> str:
messages = [
{
"role": "system",
"content": system_prompt,
}
]
messages.extend(history[-8:])
messages.append({"role": "user", "content": user_text})
result = llm.create_chat_completion(
messages=messages,
max_tokens=256,
)
answer = result["choices"][0]["message"]["content"].strip()
history.append({"role": "user", "content": user_text})
history.append({"role": "assistant", "content": answer})
return answer
history[-8:] 表示取最近 8 条消息,约等于最近四轮对话。这个数量取决于上下文窗口的大小和平均回答长度。如果窗口是 2048,回答又很长,保留 8 条消息也可能超出限制,这时可以进一步减少。生产环境里还可以结合回答长度动态控制历史条数。
7. 调试链路时常见的问题与定位方法
链路一旦串起来,报错信息可能来自 ASR、LLM、TTS 或音频播放任意一层。定位问题时不要从头到尾重新看代码,而是按“输入、格式、资源、日志”的顺序逐层排查。
7.1 问题:模型加载慢且占用大量内存
现象:每次运行都要等待较长时间才能加载完模型,运行期间系统内存接近满载。常见原因是链路启动时同时初始化三个模型,且每个模型都占用了独立内存。
处理建议:确认内存足够后再加载三个模型。内存不足时,可以改为按需加载,比如只在 ASR 识别完成后再加载 LLM,避免三个模型同时驻留内存。也可以用更小的量化模型替代大模型。
这里的核心权衡是“常驻内存换延迟”和“按需加载换内存”。如果设备内存紧张,按需加载更安全;如果设备能同时容纳,常驻内存可以节省每一轮的加载时间。
7.2 问题:ASR 识别结果为空或明显乱码
现象:代码没有报错,但打印 User text: 之后是空字符串或与录音内容完全无关的文字。
排查顺序:
- 检查音频格式是否为 16kHz、单声道、16bit PCM。
- 检查音频文件本身是否有内容,可以在命令行用播放器试听。
- 检查
sample_rate和feature_dim是否与模型匹配。 - 检查是否有多余的静音导致模型没有检测到有效语音。
- 检查模型路径是否指向正确的
tokens.txt和encoder.onnx。
在边缘设备上,最常见原因是麦克风采集到的采样率与模型要求不一致。采集端是 48kHz 时,必须重采样到 16kHz 后再进入 ASR,而不是直接修改模型参数。
7.3 问题:LLM 回答很长且生成时间过久
现象:生成时间远超预期,回答内容也很啰嗦。
处理建议:限制 max_tokens,并在系统提示词中要求“一句话回答”。如果回答经常被截断,可以适当增加 max_tokens,但不要超过上下文窗口容量。如果生成速度很慢,优先检查 n_threads 是否设置过高或过低,以及设备是否在运行其它高负载服务。
还有一种情况是回答在末尾被截断,导致 TTS 朗读时感觉不完整。这通常不是 bug,而是 max_tokens 不足以覆盖完整回答。可以在代码中断言回答以标点符号结束,如果没有结束标点,就拼接一个句号,避免 TTS 尾音异常。
7.4 问题:TTS 合成出的音频无声或破音
现象:代码正常执行生成了 WAV 文件,但播放时没有声音,或者有明显失真。
排查顺序:
- 播放前用
ls -lh确认 WAV 文件大小,如果只有几百字节,说明生成的数据太少。 - 检查
clean_text_for_tts是否把文本全部替换为空。 - 检查
sample_rate是否与模型输出匹配。 - 用
ffprobe或soundfile.info查看生成的 WAV 文件采样率和声道数。 - 检查扬声器输出设备是否被系统正确路由。
破音问题最常见的来源是显式指定了错误的 sample_rate。只要写入文件时使用 result.sample_rate,一般能避免这类问题。
7.5 排查清单:一轮对话后逐个环节确认
如果完整链路已经跑通但效果不佳,建议按下面清单逐项确认:
| 检查项 | 检查方式 | 正常结果 |
|---|---|---|
| ASR 模型加载 | 单独运行 asr_module.py 测试文件 |
能识别出测试音频内容 |
| LLM 模型加载 | 单独运行 llm_module.py 手动输入问题 |
能生成合理回答 |
| TTS 模型加载 | 单独运行 tts_module.py 生成测试语音 |
能播放一句中文 |
| 音频采样率 | 查看 WAV 文件属性 | 与模型要求一致 |
| 模型路径 | 打印实际路径并 ls -lh |
文件存在且大小正常 |
| 内存占用 | 运行期间执行 free -h |
剩余内存大于 1GB |
这个清单适合在联调之前先跑一遍,能快速定位基础环境问题,而不是在完整链路上反复猜测。
8. 生产化建议与下一步改进方向
链路跑通之后,还只是完成了功能原型。要把这套离线语音助手推到一个更稳定的状态,还需要处理电源策略、交互反馈、错误恢复和模型替换等工程问题。
8.1 用户体验上的关键改进点
离线语音助手最容易出现的问题是“用户说完话后没有任何反馈”。由于本地 LLM 生成需要几十秒,用户在这段时间里不知道系统是否还在工作,体验会大打折扣。
推荐做法是在 ASR 识别完成后,立刻播放一声提示音或显示“正在思考”,让用户明确知道问题已经被接收。LLM 生成期间,可以循环播放微弱的等待音,或者通过 LED 状态灯提示系统忙碌。TTS 播放结束后,再恢复待唤醒状态。
另一个改进点是加入唤醒词。目前链路每次都需要手动触发,实际成品更常见的做法是检测到唤醒词后再进入 ASR 流程。sherpa-onnx 提供关键词检测能力,可以在本地加载一份小型唤醒词模型,实现“说出唤醒词后开始监听”,这是从“命令行 Demo”走向“离线语音助手”的重要一步。
8.2 模型替换时要重新检查哪些参数
模型并不是“下载后直接替换路径”就能稳定运行的。替换 ASR 模型时,tokens.txt、encoder.onnx、decoder.onnx、joiner.onnx 的配套关系必须一致。替换 TTS 模型时,lexicon.txt 和 tokens.txt 也要对应。替换 LLM 模型时,量化等级不同,内存占用和生成速度会明显变化。
建议把模型目录做成配置项,而不是硬编码在代码里。比如维护一个 config.yaml:
yaml复制asr:
model_dir: /home/user/models/asr
sample_rate: 16000
feature_dim: 80
llm:
model_path: /home/user/models/llm/qwen2.5-3b-instruct-q4_k_m.gguf
n_ctx: 2048
n_threads: 4
tts:
model_dir: /home/user/models/tts
sample_rate: 24000
代码里通过读取配置文件加载模型,这样切换不同设备或不同模型时,不需要改动业务代码。需要注意,配置里的 sample_rate 不是随手填的,而是模型官方说明里的值。
8.3 日志、异常与资源监控
生产环境里不能只靠 print 观察。建议统一封装日志模块,记录各环节耗时、识别文本、LLM 是否生成失败、TTS 合成时长。这样即使设备无人值守,也能通过日志回溯问题。
下面是一个简单的时间记录示例:
python复制import logging
logging.basicConfig(
filename="/home/user/speech_llm_project/logs/pipeline.log",
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
)
def timed_section(name, func, *args, **kwargs):
import time
start = time.time()
result = func(*args, **kwargs)
elapsed = time.time() - start
logging.info("section=%s elapsed=%.2f", name, elapsed)
return result
在正式链路里,ASR、LLM、TTS 都要包一层这样的计时器。如果某一天用户在离线设备前连续问问题,你只需要看日志就能判断是识别慢、生成慢还是合成慢。
还需要关注设备内存。运行期间可以定时执行 free -m,当空闲内存低于阈值时,主动释放不再使用的模型或重启服务。边缘设备长期运行最怕内存缓慢增长,最终导致系统 OOM。实践中可以给 LLM 生成过程设置最大等待时间,超过时间后直接提示“回答超时”,而不是让进程一直卡住。
8.4 从单设备到产品化可能遇到的调整
这套链路在单台 reComputer Mini 上跑通后,如果要走向产品或长期部署,可以考虑以下几个方向:
一是把麦克风采集和扬声器播放独立成服务。音频采集、语音推理、文本生成各自成为独立进程,通过队列或本地 Socket 通信,这样即使其中一环崩溃,其它环节还可以继续工作。二是把模型加载状态暴露成接口,方便业务系统查询当前是否处于“可识别”“正在思考”“正在播放”状态。三是在边缘设备前面增加一层轻量的权限控制,避免设备被无关人员任意触发长时间推理,尤其是在无人值守场景下。
实际生产环境中还可以考虑外接 USB 麦克风阵列、拾音距离优化和回声消除。边缘设备的板载麦克风在安静室内场景可用,但在客厅、工位等环境里容易混入噪声,影响 ASR 识别率。类似问题不是模型能完全解决的,需要从硬件和音频前端入手。
8.5 更适合新手练习的三种扩展示例
如果刚把链路跑通,建议按以下三个方向逐步扩展,每一个都能帮你更深入理解这条链路的工程细节:
第一,把当前的一次性文件输入改成循环麦克风输入。核心是使用音频库持续采集 PCM 数据,在检测到语音结束点后把音频交给 ASR。可以先从没有端点检测的简单版本开始,等基础链路稳定后再加入静音检测。
第二,加入多轮对话历史。当前示例每次提问都是独立请求,加入历史后需要控制上下文窗口。可以先从只保留最近两轮开始,观察回答连贯性,再逐步增加历史条数。
第三,加入错误重试机制。比如 LLM 生成为空时,自动重新生成一次;TTS 合成失败时,把文本输出到屏幕而不是让链路中断。边缘设备上的失败处理首先要保证“不崩溃”,其次才是“更智能”。
9. 一次完整实验的参考时间与资源预期
很多开发者在跑本地语音 LLM 链路时,最想知道的是“这台设备到底能不能跑”。由于不同设备的内存、CPU 和模型差异很大,无法给出统一指标,但可以通过一次受控实验估算出相对可靠的预期。
9.1 实验设计思路
选一段约 10 秒的中文语音,内容是一个简单问题。按固定参数运行完整链路,记录 ASR、LLM、TTS 三个环节的耗时时长。接着把 LLM 的 max_tokens 从 64 调整到 128、256,分别记录生成时长和回答长度变化。再尝试把 n_threads 设为 CPU 物理核心数和超线程数,观察推理速度差异。
通过这组实验,可以得出这台设备上最合适的预算参数。比如发现 max_tokens 在 128 时回答已经完整,那么在业务中就不需要设置 512,避免无意义的等待。
实验的初衷是找到“这台设备上的可接受组合”,而不是追求跑出最大的 tokens 数。如果你的应用场景对回答长度要求不高,用较小的 max_tokens 能明显减少等待时间。
9.2 实验过程中建议记录的数据
建议把每轮实验数据记录到表格中,方便对比:
| 实验编号 | 模型量化 | max_tokens | n_threads | LLM 耗时 | 回答是否完整 | 总体耗时 |
|---|---|---|---|---|---|---|
| 1 | Q4_K_M | 64 | 4 | 待记录 | 待确认 | 待记录 |
| 2 | Q4_K_M | 128 | 4 | 待记录 | 待确认 | 待记录 |
| 3 | Q4_K_M | 128 | 6 | 待记录 | 待确认 | 待记录 |
| 4 | Q4_K_M | 256 | 4 | 待记录 | 待确认 | 待记录 |
这样记录的价值在于,当你未来更换模型或调整设备时,不需要重新摸索所有参数。直接把历史数据和当前设备配置做对比,就能快速判断新配置是否更优。
9.3 如何判断“可以接受”
判断标准不应该是“生成速度越快越好”,而是链路是否满足实际交互需求。如果用户问一个问题,等待 20 秒后能听到完整回答,而你的产品定位是“离线信息查询”,那么这套链路是可接受的。如果需求是连续自由对话,20 秒等待可能已经超出用户耐心,这时就需要换更大的模型或更快的设备。
常见取舍包括:
- 换参数量更低、更快的模型,回答质量可能有轻微下降。
- 使用更高线程数,可能提升速度但增加系统负载。
- 缩短回答长度,明显减少等待时间。
- 使用更大的上下文窗口,但内存占用随之增加。
在 reComputer Mini 这类设备上,建议先选择“回答短、响应快”的配置,再根据实际效果逐渐放开长度限制。
10. 写在最后:把链路当成一个可替换的组件系统
这套离线语音助手链路的核心收益,不在于某一段代码写得多漂亮,而在于 ASR、LLM、TTS 被拆分成了可独立替换的组件。任何一个环节升级时,只需要替换模型文件、调整参数和验证输出,不需要重写整条链路。
实际项目里最需要留意的仍然是三个基础问题:音频数据格式是否统一,模型文件是否和框架参数对齐,运行设备的内存是否足够承载三个模型同时推理。只要这三个问题管住,链路基本能稳定运行。
下一步比较推荐的动作是:先跑通第 3 到第 5 章的三个独立模块,再运行第 6 章的完整闭环,然后按第 8 章加入日志和队列机制。如果刚开始接触这套链路,不用急着做唤醒词、多轮对话和硬件优化,先把“一句话输入、一句回答、一段语音输出”闭环跑顺,再逐步丰富细节。
