最近有个需求一直绕不开:在一台没有外网的环境里做一套私有语音助手,声音数据一个字都不准出本机。我把FunASR、DeepSeek、离线TTS串起来,端到端跑通之后,发现这套组合其实没有想象中那么高门槛,真正花时间的反而是装环境、模块对接和交互细节。这篇教程我把每一步怎么装、每一段代码怎么写的思路全部摊开讲,适合手里有一台Linux机器、想自己折腾离线语音助手的开发者,也适合做智能家居网关、终端语音交互这类项目的朋友。
先说结论:这套链路本质就是“听懂、思考、说话”三个环节,FunASR负责听,DeepSeek负责想,TTS负责说。最难的不是任何一个模型的效果,而是把三者的接口、依赖、延迟和异常处理理顺。下面从整体架构开始,一步步来。
1. 整条语音链路先拆开看:谁在听、谁在想、谁在说
1.1 三个模块的边界
FunASR是阿里达摩院开源的一套语音识别工具包,不能简单理解成一个模型,它内部包含了多个组件。我用的组合是paraformer-zh作为主识别模型,fsmn-vad做语音活动检测,另外挂了一个ct-punc标点恢复模型。这样做的好处是:VAD先把长音频里的说话片段找出来,去掉大量静音,标点模型再把识别出来的裸文本加上逗号句号,后面给大模型的时候语义边界会清楚很多。
DeepSeek负责的是“思考”这一层。它接收FunASR输出的文本,理解用户意图,生成回复文本。这里有个容易混淆的概念:DeepSeek不是单一模型名,而是一系列模型和API服务的总称。你可以本地拉它的开源权重,也可以直接调用官方API,两者在工程上差别很大,我后面会单独讲。
TTS负责把DeepSeek回复的文本变成音频。离线场景下我主要用MeloTTS,中文发音自然度比较满意,CPU也能跑得动。工程上我把TTS封装成一个输入文本、输出wav文件的函数,不关心内部是哪个模型。
1.2 为什么接口全部用文本
整个流水线在接口层只传递两种东西:文本和音频文件路径。
麦克风音频 → [FunASR] → 文本 → [DeepSeek] → 回复文本 → [TTS] → wav文件 → 播放器我刻意让三个模块之间不共享任何内部状态。FunASR只负责把音频变文本,DeepSeek只负责把问题变答案,TTS只负责把答案变声音。这种设计的最大好处是调试方便:任何一段输出出了问题,你都可以把中间文本打印出来,单独测某一个模块。比如发现回复不自然,那就拿同一个prompt直接测LLM,和ASR、TTS完全无关。
1.3 这套组合适合放在哪些场景
我实际把这套方案部署在办公室的Linux主机上,用途是离线的知识问答和定时提醒。更典型的场景还包括:无外网环境的工位助手、数据敏感区的语音录入终端、以及智能家居的本地语音网关。它的核心价值就两条:隐私可控、免费无限调用。和云服务比,少了很多“调用次数”的心理负担,想怎么问就怎么问。
2. 装环境之前的三个决策:硬件、TTS引擎、DeepSeek部署方式
2.1 先确认手里的机器能干什么
很多人一上来就装大模型,结果显卡扛不住,整个流程直接劝退。我建议先给机器分个档:
| 机器条件 | 可行的方案 | 预期体验 |
|---|---|---|
| 纯CPU,无GPU | FunASR小模型 + TTS + DeepSeek 1.5B/7B量化版 | 能跑通,但一句对话等待5~15秒 |
| 6~8GB显存 | 本地7B量化模型 + 全链路GPU加速 | 流畅,3秒内能听到回复 |
| 16GB以上显存 | 本地14B甚至更大模型 | 体验接近云API |
| 没有本地算力,但有网 | FunASR/TTS本地 + DeepSeek官方API | 速度快、回复质量高,但非纯离线 |
我自己的主力机器是i5 + RTX 3060 12GB,跑7B量化模型比较舒服。如果你只是验证流程,CPU方案也能跑通,不要因为显卡不行就放弃,后面我会给出对应的参数调整建议。
2.2 TTS引擎选型对比
TTS是整个链路里最容易“听出来廉价感”的环节。我对比过几款主流离线方案:
| 引擎 | 是否离线 | 中文自然度 | 部署难度 | 适合场景 |
|---|---|---|---|---|
| MeloTTS | 是 | 好 | 低 | 通用助手,推荐首选 |
| ChatTTS | 是 | 很好(带语气词) | 中 | 闲聊对话,但稳定性一般 |
| Piper TTS | 是 | 一般 | 极低 | 嵌入式、树莓派 |
| GPT-SoVITS | 是 | 非常好(可克隆音色) | 高 | 需要特定音色的场景 |
| Edge-TTS | 否 | 好 | 低 | 能联网但不想折腾时用 |
这里注意Edge-TTS虽然效果不错,但本质是云服务,不满足本教程的离线前提。我最终选MeloTTS,原因很简单:中文效果好、CPU能推理、几行代码就能跑起来。ChatTTS我也测过,对话语气很丰富,甚至能模拟口头禅和笑叹词,但参数多、生成稳定性差一些,放在后面对比。
2.3 DeepSeek的三种接入方式怎么选
DeepSeek接入我把它分成三条路线:
| 接入方式 | 适合人群 | 离线 | 成本 | 说明 |
|---|---|---|---|---|
| Ollama本地部署 | 想自己掌控一切 | 是 | 免费 | 一行命令拉模型,适合新手 |
| vLLM本地部署 | 生产环境、高并发 | 是 | 免费 | 吞吐高,但配置复杂 |
| 官方API | 快速验证、有网 | 否 | 按token计费 | 模型能力最强,接入最简单 |
我的建议很实际:如果你是第一次搭这套链路,先用Ollama本地部署跑通,后面即便切换到API,你的代码改动也只是一行base_url的问题。不要把某个具体接入方式焊死在你的架构里。
3. FunASR离线识别:把麦克风声音变成文字
3.1 Linux上装FunASR及可能缺的系统依赖
FunASR本身是个Python包,但Linux上装它经常栽在系统依赖上。我建议按顺序执行,避免装完才发现缺东西:
# 创建干净的Python环境,推荐3.10 conda create -n voice python=3.10 conda activate voice # 安装必要的系统级工具,Ubuntu/Debian系 sudo apt update sudo apt install -y ffmpeg portaudio19-dev # 安装Python依赖 pip install funasr modelscope torchaudio soundfile librosaffmpeg和portaudio19-dev是我踩坑后补上的。FunASR底层处理音频时要调librosa,而librosa解码各种格式音频依赖ffmpeg;如果你后面要接实时麦克风,sounddevice又依赖portaudio。这里一次性装好,后面能省很多事。
验证是否装好,直接进Python执行:
python -c "import funasr; print(funasr.__version__)"正常输出版本号就说明安装成功。如果用Apple Silicon Mac,portaudio19-dev这条不适用,需要自行通过Homebrew装portaudio,其余一致。
3.2 加载识别模型并跑通第一个音频文件
环境没问题之后,写一个最简识别脚本:
from funasr import AutoModel # 第一次运行会自动下载模型,耐心等一会儿 model = AutoModel( model="paraformer-zh", vad_model="fsmn-vad", vad_kwargs={"max_single_segment_time": 30000}, punc_model="ct-punc", device="cuda:0", # 没有GPU就改成 "cpu" hub="modelscope", ) res = model.generate( input="test.wav", batch_size_s=300, hotword="小助手", ) print(res[0]["text"])解释几个参数的意义。paraformer-zh是主识别模型,中文识别效果好,模型会从ModelScope自动下载,首次运行需要等下载完成。vad_model是语音活动检测,它的作用是先把一个长音频切分成若干“有人说话”的片段,避免在静音段浪费算力。punc_model是标点恢复,让输出带标点符号,这在后续给LLM使用时特别重要。batch_size_s控制一次批量处理的音频时长,设成300秒足够覆盖大多数录音。hotword是热词表,比如你的助手叫“小助手”,可以把这个词强化,识别率会明显提升。
注意model.generate返回的是一个列表,每个元素是一个字典,文本存在["text"]字段里。我第一次用的时候直接打印整个返回值,给调试造成了一点困惑,先提醒一下。
3.3 长音频和实时麦克风的处理思路
上面的脚本适合处理已经存在的wav文件。但语音助手场景通常是“录一段、识别一段、再录一段”,这里涉及两个问题:一是录音转存,二是静音过滤。
对于实时麦克风,我推荐一个既简单又稳定的做法:用sounddevice录音并直接保存成wav,然后把wav路径交给FunASR。示例代码:
import sounddevice as sd import soundfile as sf import numpy as np fs = 16000 duration = 3.0 # 录音时长可调 recording = sd.rec(int(fs * duration), samplerate=fs, channels=1, dtype="float32") sd.wait() sf.write("input.wav", recording, fs)这里采样率设成16000是FunASR模型比较友好的范围。录完之后再调用model.generate识别即可。如果你想要“检测到人说话才开始识别”的效果,可以用VAD模型对音频片段做静音过滤,或者更简单地在录音过程里实时计算音量,音量低于阈值就不保存。先把基础流程跑通,再优化交互体验。
3.4 FunASR实测表现和两个高频坑
实测下来,paraformer-zh对普通话的识别准确率很能打,即使有一些环境噪声也没问题。但在两个坑让我花了不少时间:
第一个坑是首次运行下载模型太慢。解决方案是提前手动下载模型文件放到缓存目录,或者配置MODELSCOPE_CACHE环境变量来指定模型缓存位置,避免每次都在同一个目录反复下载。
第二个坑是缺少音频后端导致读取wav报错。解决方法是装soundfile和ffmpeg。别小看这个问题,代码层面不会直接告诉你缺ffmpeg,而是报一个“librosa无法解析音频”的异常,排查方向容易被带偏。
4. DeepSeek大脑:本地模型和官方API怎么选、怎么搭
4.1 本地部署路线:Ollama拉取DeepSeek模型
Ollama是目前本地部署大模型最省心的方案,它把模型下载、服务启动、API接口都封装好了。安装和启动非常简单:
# Linux一键脚本,按官方文档执行 curl -fsSL https://ollama.com/install.sh | sh # 启动服务 ollama serve拉取模型时,去Ollama仓库搜索deepseek-r1等模型名,按你显存大小选择量化版本。以7B为例:
ollama pull deepseek-r1:7b拉取完成后,Ollama默认监听localhost:11434,提供OpenAI兼容的HTTP接口。Python调用示例:
import requests def ask_local_llm(prompt, model="deepseek-r1:7b"): resp = requests.post( "http://localhost:11434/api/chat", json={ "model": model, "messages": [ {"role": "system", "content": "你是一个简洁的语音助手,用中文口语化回答,控制在两句话以内。"}, {"role": "user", "content": prompt}, ], "stream": False, "options": {"temperature": 0.7, "top_p": 0.9}, }, timeout=60, ) return resp.json()["message"]["content"]这里temperature和top_p是LLM的采样参数。温度越高回复越发散,语音助手场景我建议适中偏低,0.7左右比较好,既能保证稳定又不会显得死板。timeout一定要设置,否则模型推理慢的时候请求会一直挂着,导致整个语音助手“假死”。
4.2 官方API路线:十几行代码接入
如果不想折腾显卡,或者对回复质量有更高要求,直接用官方API是最快的方式。DeepSeek API的兼容性做得比较好,直接用OpenAI SDK就能调:
from openai import OpenAI client = OpenAI( api_key="sk-你的key", base_url="https://api.deepseek.com" ) resp = client.chat.completions.create( model="deepseek-chat", messages=[ {"role": "system", "content": "你是一个简洁的语音助手。"}, {"role": "user", "content": "现在几点"}, ], stream=False, ) print(resp.choices[0].message.content)API路线的好处是模型大、推理快、不用维护本地服务,缺点也很明显:它要求有稳定的网络环境,且不是离线方案。我把它作为“快速原型”的首选,等把整个语音助手逻辑调通之后,再切换到本地模型做最终部署。
4.3 路由选择建议:用对话模型而非推理模型作为助手大脑
这点是我在真实使用中体会最深的:语音助手的“大脑”应该选对话模型,而不是推理模型。DeepSeek的推理模型(比如R1系列)在数学、逻辑题上很强,但它们的输出经常带着一大段思考过程,对语音播报来说完全是灾难——用户问一句话,模型先“内心复盘”半天,回复速度慢,输出也冗长。
语音交互对延迟极其敏感,用户说完话之后超过3秒还没有回应,就会明显觉得“卡”。所以我建议主力使用对话模型,回复质量足够,速度更快,输出更符合播报习惯。如果你只有推理模型可用,也可以通过system prompt让它不要输出思考过程,但效果不如直接用对话模型来得干净。
4.4 为语音交互定制system prompt
给LLM的system prompt是这一整套方案里性价比最高的优化点。语音和文字聊天的最大区别是:文字用户可以慢慢看长回答,语音用户听不了啰嗦内容。所以我把prompt明确写成:
你是运行在离线设备上的语音助手。 - 全部用中文回答 - 口语化、自然,像真人说话 - 回答控制在两句话以内,一般不超过40字 - 不要使用markdown、列表、括号备注 - 不要输出解释性内容实测下来,这样一套约束能把回复长度从平均100多字压到30字左右,TTS播报时长明显缩短,整个对话节奏舒服很多。说到底,语音助手的核心体验不是“聪明”,而是“快、短、准”。
5. 离线TTS:让声音不“机械”的选型与接入
5.1 用MeloTTS把文字合成中文语音
MeloTTS是我目前在离线TTS里的首选方案。它的安装和使用都很轻量:
pip install melotts使用代码:
from melo.api import TTS # 加载中文模型,cpu或cuda均可 model = TTS(language="ZH", device="auto") speaker_ids = model.hps.data.spk2id output_path = "reply.wav" # speed=1.0为正常语速,我建议设成1.1~1.2,听起来更自然 model.tts_to_file( "你好,我是你的离线语音助手,请问有什么需要帮助的?", speaker_ids["ZH"], output_path, speed=1.1, )第一次运行时MeloTTS会自动下载权重文件,等一会就好。device="auto"会自动检测CUDA,如果没有GPU就回退CPU。我实测CPU模式下合成一句10字以内的回复大约1秒多,完全可接受。
关于speed参数我想多说一句:TTS默认1.0语速在中文里听着偏慢,调到1.1或者1.2之后,声音节奏更接近真人说话,也显得更“聪明”。这是我调了好几个音色参数之后得出来的结论,你可以直接抄作业。
5.2 ChatTTS和Piper在何时替换
如果MeloTTS的音色不能满足你的需求,两个备选方案值得了解。
ChatTTS的优势是对话感极强,生成的语音带有停顿、笑叹和语气变化,很接近真人聊天。但它也有明显问题:需要较长的生成时间,GPU占了也不低,而且有时候会生成奇怪的语气词,打断语义。我的建议是,做“陪伴闲聊”类产品时试ChatTTS,做“任务问答”类助手时老老实实用MeloTTS。
Piper则是极轻量路线,模型极小、CPU上运行飞快,适合树莓派这种低算力终端。中文发音自然度一般,有点机械感,但在“能说话”和“说得好听”之间,Piper明显倾斜于前者。如果碰巧你的设备功耗极低,Piper是个实用的选择。
5.3 播放端:把生成的音频真正放出来
TTS生成的是wav文件,最后一步是把它播放出来。Linux命令行最简单:
aplay reply.wav在Python里我习惯这样写:
import subprocess def play_audio(file_path): subprocess.run(["aplay", file_path], check=True)这样最稳,不依赖额外的Python库。如果你在Windows上,可以把命令换成powershell -c (New-Object Media.SoundPlayer 'reply.wav').PlaySync()。核心思想一样:把播放逻辑和TTS逻辑解耦,替换成本很低。
6. 把三块拼成完整助手:工程细节和延迟优化
6.1 封装三个引擎接口
前面的环境搭建和单模块验证都跑通之后,就到了工程落地阶段。我强烈建议先把三个引擎封装成独立模块,每个模块只暴露一个稳定接口:
# asr_engine.py from funasr import AutoModel class ASREngine: def __init__(self, device="cuda:0"): self.model = AutoModel( model="paraformer-zh", vad_model="fsmn-vad", punc_model="ct-punc", device=device, hub="modelscope", ) def recognize(self, wav_path): res = self.model.generate(input=wav_path) return res[0]["text"]# llm_engine.py import requests class LLMEngine: def __init__(self, model="deepseek-r1:7b", base_url="http://localhost:11434"): self.model = model self.base_url = base_url self.system_prompt = ( "你是运行在离线设备上的语音助手。" "全部用中文回答,口语化自然," "回答控制在两句话以内,一般不超过40字。" "不要使用markdown、列表、括号,不要输出解释。" ) def chat(self, user_text): resp = requests.post( f"{self.base_url}/api/chat", json={ "model": self.model, "messages": [ {"role": "system", "content": self.system_prompt}, {"role": "user", "content": user_text}, ], "stream": False, "options": {"temperature": 0.7}, }, timeout=60, ) return resp.json()["message"]["content"]# tts_engine.py from melo.api import TTS class TTSEngine: def __init__(self): self.model = TTS(language="ZH", device="auto") self.speaker_id = self.model.hps.data.spk2id["ZH"] def synthesize(self, text, output_path="reply.wav", speed=1.1): self.model.tts_to_file(text, self.speaker_id, output_path, speed=speed) return output_path这样封装之后,三个模块之间完全解耦。想换DeepSeek的API?只改LLMEngine内部的chat方法就行。想换TTS?换TTSEngine一个文件即可。这个设计在后续迭代中的作用远比你想象的大。
6.2 主循环:录音、识别、生成、合成、播放
主循环代码核心逻辑就是循环:录音,识别,判断内容,请求LLM,合成语音,播放。
import sounddevice as sd import soundfile as sf import subprocess from asr_engine import ASREngine from llm_engine import LLMEngine from tts_engine import TTSEngine asr = ASREngine(device="cuda:0") llm = LLMEngine(model="deepseek-r1:7b") tts = TTSEngine() WAKE_WORD = "小助手" def record(duration=3.0, fs=16000): audio = sd.rec(int(fs * duration), samplerate=fs, channels=1, dtype="float32") sd.wait() wav_path = "input.wav" sf.write(wav_path, audio, fs) return wav_path def play(wav_path): subprocess.run(["aplay", wav_path], check=True) def main(): print("语音助手已启动,录音完成后自动识别...") while True: wav_path = record() text = asr.recognize(wav_path).strip() if not text: continue print(f"[用户] {text}") # 只有包含唤醒词才响应,避免把环境噪音当成命令 if WAKE_WORD not in text: continue # 去掉唤醒词本身,把剩余内容交给LLM clean_text = text.replace(WAKE_WORD, "", 1).strip() if not clean_text: continue reply = llm.chat(clean_text) print(f"[助手] {reply}") audio_path = tts.synthesize(reply) play(audio_path) if __name__ == "__main__": main()这里面有一个工程细节值得注意:record()函数每次都从头开始录固定时长,实际使用中会出现录到一半才开始说话、尾部被截断的问题。想要更稳,可以改成“检测到音量超过阈值才开始保存,安静后再停”,本质上需要VAD判断。简单起见,先跑通这个版本,再把录音段换成动态截取。
6.3 加入唤醒词,避免每句话都被响应
唤醒词在离线语音助手里不只是“魔法咒语”,更是一种噪声过滤机制。如果系统对每一句识别文本都去请求LLM,那邻居说话、电视声、敲键盘声全都会被当成指令,体验极差。
我用的是文本级唤醒:把唤醒词写进FunASR的hotword参数,强化识别,然后在主循环里判断if WAKE_WORD not in text,不包含就跳过。这套方案简单可靠,缺点是需要说完唤醒词才能继续,不像“小爱同学”那样支持边听边唤醒。如果你追求更自然的全双工体验,就得引入单独的唤醒词检测模型,那属于另一个更复杂的工程,不建议第一次就上。
6.4 延迟实测与优化手段
以我的机器(i5-12400 + RTX 3060 12GB)为例,跑完整的“录音3秒 + FunASR识别 + DeepSeek 7B生成 + MeloTTS合成 + 播放”链路,延迟分布大致如下:
| 环节 | 耗时 |
|---|---|
| 录音 | 3秒 |
| ASR识别 | 0.2~0.5秒 |
| LLM生成 | 1~4秒 |
| TTS合成 | 0.3~1秒 |
| 播放 | 语音时长 |
这里录音3秒是硬性的,用户不说话也在录。真正可以优化的是录音阶段:接上VAD之后,用户一开口再开始录音,能省掉大量等待。LLM生成是最大瓶颈,解决方式是换更小模型、加max_tokens限制、或者用API。TTS方面,MeloTTS已经够快,如果还不够,可以考虑把生成和播放切成异步流水线,TTS合成第一句的同时开始播放,形成“边合成边播”的效果。
7. 我在真实场景跑了两个月的几点体会
整套系统放在办公室用了一段时间,最大的感受是:技术层面的瓶颈远不如交互设计来得重要。最初我以为最麻烦的是ASR识别不准或者TTS声音难听,实际用了才发现,最难的是让用户知道什么时候该说话。按键触发和唤醒词都试过之后,我最终保留了“先录音、再判断是否包含唤醒词”的简单模式,虽然不够酷,但足够稳定,家里人也能轻松上手。
第二个体会是,CPU方案完全能跑,但体验是“能用”和“好用”的差距。如果你的主机器有NVIDIA显卡,全链路GPU加速带来的流畅度提升比换任何模型都明显。反过来,如果你只有一台旧笔记本,也不要气馁,把DeepSeek模型换成1.5B的量化版,然后接受10秒级别的响应,做定时提醒、百科问答这类场景依旧绰绰有余。
最后分享一个我一直在用的小技巧:把TTS语速调到1.1,把LLM的回复长度压缩到30字以内,整个助手的听感会一下子“聪明”起来。语音交互的体验核心不是模型多强,而是每一轮对话有多快、多短、多自然。你先把这条链路跑通,再根据实际反馈慢慢迭代,很快就能找到属于自己的最佳参数组合。