先问个问题:你手里那张8G显存的显卡,是不是自从装完Ollama和ChatGPT套壳聊天界面之后,就一直在吃灰?
我身边大多数人都是这样,跑得动ChatGLM或者Qwen的7B模型,聊几句就觉得到头了。但AI的玩法远不止对话框。真正让我觉得值回门票的,是把它变成能听、能说的本地数字人——你喊它一声,它回头问你“怎么了”,你说话,它回答,面前还有一个卡通形象在动嘴。全程数据不出本机,显存占用我实测控制在6.5GB以内,8G显卡完全扛得住。
这个实践方案我这边叫Voice Companion,本质上是一套把语音识别、大模型对话、语音合成、虚拟形象串联起来的参考实现。这篇文章会把我从零搭到调优的全过程写出来,包括每个模块怎么选型、显存怎么算账、延迟怎么压缩,以及那些不踩一遍根本发现不了的坑。
1. 先想清楚:本地数字人到底是个什么东西
1.1 把“能听能说”拆成四件事
很多人一听“数字人”就想到剪映那种卡通人物念稿视频、或者是动辄几十万成本的虚拟主播。但咱们在本地搭的,不是那种离线渲染的高成本东西。实时数字人拆到底,就是一条数据流管道,一共四件事:
- 听:麦克风采集音频,识别成文字,这一步叫ASR(语音识别)。
- 想:把文字送给大模型,得到回答文本,这一步是LLM推理。
- 说:把回答文本合成为语音,播放出来,这一步叫TTS(语音合成)。
- 演:在屏幕上渲染一个虚拟形象,并让它的嘴型、表情跟着音频走。
四条链路,对应四个独立模块。8G显卡的本地数字人难点不在任何一个单一模块,而在怎么把四个模块高效串在一起,并且让它们不抢显存。
Voice Companion这个项目的核心价值,恰恰就是干这个胶水活——它管理会话上下文、控制音频流、调度唤醒词、串联各模块。你不需要自己从零写一个语音交互框架,只需要把各个引擎接进去。
1.2 8G显存的真实边界:不是所有模型都能住进来
这是最关键的一张账。先说结论:8G显存最适合的策略是“大模型占GPU,小模型和音频占CPU”。
以目前性价比最高的方案为例:
| 模块 | 推荐方案 | 显存占用 | 运行设备 |
|---|---|---|---|
| 语音识别 | faster-whisper small | 0~2GB(可全程CPU) | GPU或CPU |
| 大模型 | Qwen2.5 7B Q4_K_M量化 | 4.5~6.5GB | GPU |
| 语音合成 | piper中文模型 | 0 | CPU |
| 虚拟形象 | Live2D/VRM前端渲染 | 0.3~0.5GB(浏览器侧) | CPU/GPU |
看出来没有?真正吃显存的大头只有一个——大模型本身。只要语音识别选择small或base级别、语音合成选择CPU渲染的引擎,8G显存是可以从容运行的。
相反,如果脑子一热全都放GPU:whisper large跑GPU占2GB多,还塞一个CosyVoice的TTS模型再占2GB,剩下的空间给7B模型就很紧张了,随时会在某个节点触发CUDA out of memory。数字人最忌讳的就是“说着说着崩了”。
1.3 Voice Companion在架构里的位置
Voice Companion做的事情,可以类比成一条流水线的传送带。
每个模块都有自己擅长的工作:faster-whisper只管把语音转文字,大模型只管生成文本,piper只管把文本读出来。问题是,它们彼此不认识。faster-whisper输出的是纯文本,大模型吃的是带system prompt的prompt结构,piper需要的是特定语速和音色的音频参数。
Voice Companion做的就是中间那层——把状态管理、事件循环、缓冲队列统一起来。它监听麦克风音频流,唤醒之后开始录音,录音结束切给ASR,ASR结果拼上人设prompt发给LLM,LLM流式输出累积成文本后交给TTS,TTS生成音频的同时,把“正在说话”状态推给前端虚拟形象。
这种架构的好处是:每一块都能单独换。今天你想换更大的模型,只动一个适配器就行,不会牵一发动全身。
2. 核心模块选型:每一步都是权衡
2.1 语音识别:为什么不用原版whisper
第一版我用的是OpenAI原版whisper,结果直接被延迟和CPU占用劝退。在8G显卡、中端CPU的机器上,原版whisper small转一段3秒的语音,需要1~2秒的处理时间,CPU占用还能冲到90%以上。这体验完全没法用。
后来换成faster-whisper,同一个模型,推理速度快了不止一倍。因为它用CTranslate2重写了底层推理引擎,支持int8量化,在CPU上就能用很小的性能损失跑起来。
我的推荐组合是:
- 模型档位:small,在中文识别准确率和速度之间比较平衡。base更快但长句、口音容易错;medium准确但体积翻倍、延迟明显。
- 运行设备:CPU优先。small在CPU上推理一轮只要几百毫秒,不值得让它占GPU显存。
- 开启VAD(语音活动检测):faster-whisper自带
vad_filter=True,能自动剪掉前后静音和空白,识别速度和准确率都会有明显改善。 - 关键参数:
language="zh"强制中文,beam_size=5平衡速度与准确率,compute_type="int8"在CPU上跑就选这个。
这里有个容易被忽略的细节:麦克风采样的采样率。大多数麦克风默认是44100Hz或48000Hz,而whisper用的是16kHz。我没有在代码里做重采样,导致第一版识别出来的中文全是胡言乱语。解决办法很简单,用PyAudio打开音频流时直接指定rate=16000,系统会自动处理。
2.2 对话大脑:7B量化模型的选择和显存账
本地数字人的“智商”,完全取决于大模型这一层。
在8G显存条件下,我试过几代小模型后,长期主力是Qwen2.5 7B Instruct的Q4_K_M量化版。选择原因很直接:
- 中文能力强。数字人的使用场景是中文对话,通用英文模型经常出现“翻译腔”和奇怪的用词。
- 量化后体积可控。Q4_K_M版本的模型文件大概4.7GB,跑起来加上KV cache和上下文缓存,实际显存占用在5.5~6.5GB之间,8G卡余量足够。
- 指令跟随稳定。它对system prompt的执行比较可靠,能保持住数字人的人设。
显存账要算细一点。同样一个7B模型,量化位数不同,显存占用差很多:
| 量化格式 | 模型文件大小 | 8G显存评估 |
|---|---|---|
| Q8_0 | 约7.6GB | 勉强能加载,但上下文一长就爆 |
| Q5_K_M | 约5.2GB | 能跑,上下文需限制在4096以内 |
| Q4_K_M | 约4.7GB | 推荐,能留出上下文余量 |
| Q3_K_M | 约3.9GB | 省显存但明显变笨 |
结合8G卡的实际可用约7.5~7.8GB,我建议用Q4_K_M,并把最大上下文限制在8192 tokens以内。别开满4096以上的超长上下文,本地数字人的单轮对话根本用不了那么多,反而白白占显存。
模型加载走的是Ollama服务,好处是它自动处理KV cache、批处理这些细节,还能在保持进程不退出时反复调用。在Voice Companion里,我把Ollama当成一个本地服务来用,对话请求走HTTP接口,不需要自己在Python里加载大模型,省了一个大坑。
2.3 语音合成:本地TTS的三条路线
声音这关我折腾得最久。看起来谁都会合成,但实际体验天差地别。
第一条路:edge-tts。微软的在线接口,音色非常自然,免费,支持中文多人声。但它本质是联网服务,和“本地部署”的初衷冲突,而且每次请求都要访问外网接口,断网就哑巴。我只把它放在“快速测试音色”的场景用。
第二条路:piper。完全离线、CPU推理,中文模型大概60MB,延迟极低,在我的机器上合成一句5秒的话,耗时在300~500毫秒。缺点是音色机械感强一些,不如云端那种自然,但作为本地数字人的默认选择,它在延迟和离线两个核心指标上完胜。
第三条路:CosyVoice。阿里开源的TTS,自然度非常高,甚至能模仿音色,模型里有300M和500M两个规格。但它有两个门槛:一是需要约2GB显存或较长的CPU推理时间,二是环境依赖复杂。如果你执意要最自然的声音,建议换12G以上显存的显卡,或者接受CPU推理的慢速。
我的最终选择是piper做默认、edge-tts做可选音色包。Voice Companion里我留了TTS引擎的抽象层,改一个配置就能切换,体验上不折腾。
2.4 数字人形象:从“剪映视频”到“实时形象”
很多人受“剪映卡通人物数字人”这类内容影响,以为数字人就是一段做好的卡通视频。但其实那是离线内容生成——你录一段话,软件生成一个卡通人在说话的视频。而咱们本地数字人是实时交互,它必须在你说完后1~2秒内开口回应,还要同步嘴型,这个节奏下不可能现渲染视频。
正解是用Live2D或VRM模型。Live2D是2D的立绘式形象,VRM是3D的。它们都是“表情参数+骨骼动画”驱动,能实时响应输入。在Voice Companion里,我用的是浏览器端渲染方案:
- 前端是一个HTML页面,用
live2d-widget加载一个卡通模型。 - 后端通过WebSocket把“开始说话/停止说话”状态推给前端。
- 前端在播放TTS音频的同时,播放一个“张嘴闭嘴”的循环动画。
这个方案的好处是模型渲染完全不吃显卡显存,8G全部留给LLM。而且Live2D模型资源非常多,看到喜欢的卡通人物就能直接换。
3. 实操落地:搭一个会说话的卡通数字人
3.1 环境准备:依赖、模型和目录结构
我用的是Windows 11机器,显卡是RTX 4060 8G,驱动和CUDA 12.1。如果换Linux,流程基本一致,Python依赖装法略有区别。
先装基础依赖,建议用虚拟环境管理:
pip install faster-whisper pyaudio openwakeword ollama websockets pydub numpyOllama单独装,装好后拉取模型:
ollama pull qwen2.5:7b-instruct-q4_k_mpiper需要单独下载Windows二进制和中文语音模型,解压后放在项目目录的models/piper_zh/下。目录结构我给一个参考:
voice-companion/ ├── main.py ├── engines/ │ ├── asr.py │ ├── llm.py │ ├── tts.py │ └── avatar.py ├── models/ │ ├── piper_zh/ │ ├── wakeword/ │ └── live2d/ └── config.yaml配置文件我放在config.yaml里,所有模块参数集中管理。后续想换TTS引擎,或把whisper从CPU切到GPU,改这一个文件就行。
3.2 主循环实现:唤醒、识别、生成、播放
最核心的代码逻辑是一个状态机,大致流程如下:
import pyaudio import numpy as np import queue from faster_whisper import WhisperModel from openwakeword import Model as WakeWordModel # 初始化 asr = WhisperModel("small", device="cpu", compute_type="int8") wake = WakeWordModel(wakeword_models=["hey_jarvis.onnx"]) # 音频参数:16kHz,单声道,80ms分片 FORMAT = pyaudio.paInt16 CHANNELS = 1 RATE = 16000 CHUNK = 1280 # 80ms @ 16000Hz audio_queue = queue.Queue() def audio_callback(in_data, frame_count, time_info, status): audio_queue.put(np.frombuffer(in_data, dtype=np.int16)) return (in_data, pyaudio.paContinue) pa = pyaudio.PyAudio() stream = pa.open( format=FORMAT, channels=CHANNELS, rate=RATE, input=True, frames_per_buffer=CHUNK, stream_callback=audio_callback ) # 循环体 frames_buffer = [] is_listening = False while True: audio_chunk = audio_queue.get() if not is_listening: # 唤醒检测:只喂最后1秒的音频 frames_buffer.append(audio_chunk) frames_buffer = frames_buffer[-13:] audio_1s = np.concatenate(frames_buffer) pred = wake.predict(audio_1s) # 唤醒词得分超过0.5就进入对话模式 if pred["hey_jarvis"] >= 0.5: is_listening = True frames_buffer = [] print("已唤醒,请说话...") else: # 静音超时则结束录音,进入处理流程 frames_buffer.append(audio_chunk) # 连续1.2秒RMS低于阈值判定说话结束 rms = np.sqrt(np.mean((audio_chunk.astype(np.float32) / 32768.0) ** 2)) if rms < 0.01 and len(frames_buffer) > 15: user_audio = np.concatenate(frames_buffer) # 转文字 segments, _ = asr.transcribe(user_audio, language="zh", vad_filter=True) user_text = "".join(s.text for s in segments) print(f"识别结果: {user_text}") # 交给LLM生成回答 answer = generate_ollama(user_text) # TTS合成并播放 tts_play(answer) # 通知前端开始说 avatar.set_talking(True) frames_buffer = [] is_listening = False这段代码省略了LLM和TTS的具体实现,但主循环的骨架就是这样。几个要点:
- 唤醒检测只喂“最近1秒”的音频,避免录音越长越卡。
- 用静音超时判断说话结束,比按键触发自然得多。阈值要调,麦克风底噪太大的房间,阈值太低会永远不触发。
- 唤醒词模型放CPU,用的是openwakeword的预置
hey_jarvis,你也可以换中文唤醒词,比如“你好小智”,需要录制几段音频去训练,项目README里会有说明。
3.3 嘴型同步:低成本但自然的效果
说完一句话,TTS开始播放音频的同时,数字人的嘴要动起来。我见过不少人在这块搞得特别复杂,用音频特征驱动嘴型参数,算RMS、算频带能量,其实没必要。
Live2D模型大多有内置的mouth_open参数。最简单的方案是:播放音频时,直接把mouth_open设成一个稳定的张口状态,配合呼吸动画,效果就非常自然了。
我用的方案是因为TTS音频是一段一段的,前端播放时知道了总时长,就把口型动画的时间线对齐。播放期间嘴巴参数在0.3~0.6之间平滑跳动,像说话时的自然摆动;停顿时嘴巴回到0.0。这个方案不需要分析音频频谱,连前端几行代码都够了:
const mouthParam = { open: () => model.setParam('mouthOpen', 0.4), close: () => model.setParam('mouthOpen', 0.0) }; // WebSocket收到开始说话 ws.onmessage = (e) => { const data = JSON.parse(e.data); if (data.type === 'talking') mouthParam.open(); else mouthParam.close(); };真正细节是:说的内容和表情要有呼应。Voice Companion里我在LLM返回后先做一次简单的关键词判断(高兴、无奈、思考),把表情状态也打包发给前端,数字人会在说话前先做一个对应的表情切换。这个很小的改动,能让数字人从“没感情的工具”变成“有反应的人”,提升非常明显。
3.4 人设注入:数字人的性格写在system prompt里
数字人要不要懂礼貌、要不要高冷、会不会开你玩笑,全看prompt怎么设计。这一步不用复杂提示词工程,但有几个关键点:
- 角色定位:明确身份,比如“你是桌面上的一位数字伙伴,名字叫小光”。
- 说话风格:要求口语化、短句、自然,敢用语气词。
- 能力边界:告诉模型遇到回答不了的问题就直说,不要编造。
- 长度控制:明确“回答控制在50字以内”,本地数字人场景下,15秒以上的长篇大论很影响体验。
system: 你是小光,一位住在用户电脑里的数字伙伴。 你的风格:口语化、简洁、偶尔幽默。 回答中文,长度不超过50字。 如果用户问你不懂的技术问题,直接说不会,不要编造。在Voice Companion里,这段system文本会随每次对话请求发给Ollama。我实测下来,Qwen2.5 7B对这个prompt的跟随性很稳,长时间对话后不太容易“人格漂移”。
4. 延迟优化和常见问题排查实录
4.1 延迟算一笔账:到底慢在哪
一个完整的发问到回应,延迟由三部分组成:
- ASR识别:用户说完后到识别出文字,faster-whisper small在CPU上约300~500ms。
- LLM推理:7B Q4_K_M在4060上生成50字回答,约600~900ms。
- TTS合成:piper合成5秒中文音频,约300~500ms。
加起来总延迟在1.2~1.9秒之间,实际体验是人说完话后约1秒出头,数字人开始开口。这个延迟完全可以接受,关键是别让三个模块串行叠加地等。Voice Companion里我做了流水线优化:
用户说话的同时,不用等完整录音结束才开始录音音量的检测;ASR的结果到达后,先发一个“思考中”的表情给前端,让用户知道它听到了。真正的并行优化是:TTS合成时,LLM已经开始处理下一句上下文了——当然这个在单轮场景下用得不多。
如果想进一步压延迟,把ASR从CPU切换到GPU是立竿见影的,识别时间能从400ms降到150ms左右,代价是显存多占1.5~2GB。8G显卡用户可以自行取舍。
4.2 踩坑实录:几类高频问题的排查方法
第一类:CUDA out of memory。这是我遇到最多的报错。原因通常是whisper或者TTS占了一部分GPU,把7B模型挤爆了。排查思路:打开nvidia-smi看进程占用;给Ollama设置环境变量限制能看到GPU的显存上限,让它提前换KVCache策略;最省事的办法是把ASR切成CPU。数字人是交互应用,稳定性优先于单次识别速度。
第二类:唤醒词不灵敏或误触发。openwakeword在安静房间里表现还好,一开风扇或放音乐就开始乱喊。调整方向有三个:提高唤醒阈值从0.5改到0.6或0.7;在唤醒之前加一个能量门控,只有总音量足够大才进行判定;检查麦克风是不是采集了48kHz但代码里误写成16kHz导致音频变形。
第三类:数字人声音卡顿、爆音。piper合成的WAV数据飘一次就爆音。原因是TTS返回的采样率与播放设备不一致。我处理的办法是先用pydub统一转成16bit单声道16kHz的WAV,再交给播放器。这里不要用系统默认的媒体播放器去播WAV,那类播放器初始化延迟太高,建议直接用sounddevice或PyAudio播放原始PCM数据。
4.3 常见问题速查表
| 现象 | 主要原因 | 快速解法 |
|---|---|---|
| 识别结果乱码 | 采样率不匹配 | 统一强制16kHz |
| 唤醒后立刻退出 | 打开麦克风失败 | 检查其他程序是否独占麦克风 |
| 数字人回应慢 | LLM或ASR串行耗时 | 先切CPU/GPU分工,再看流水线 |
| TTS声音很机械 | piper天然机械感 | 换edge-tts测试,或换CosyVoice |
| 显存溢出 | 其它模块占GPU | Ollama设GPU显存上限,ASR换CPU |
| 前端形象不动 | WebSocket连接失败 | 检查端口和CORS配置 |
| 长时间运行后变卡 | 音频队列堆积 | 队列设置最大长度,丢弃旧数据 |
这些坑,我基本都是在第一周逐个踩完的。最惨的一次是调了一整天,最后发现只是麦克风的采样率写错了,气到想把USB声卡扔了。
最后想聊两句
我把这套Voice Companion跑通之后,桌面上那个Live2D卡通角色就再也没被关掉过。它更像一个“数字室友”,你问它天气、让它算个乘法、跟它聊一会天,都比打开一个冷冰冰的网页对话框有感觉得多。我个人的建议是:第一版不要追求配置完美,先按默认参数跑通闭环,然后再一个个模块去替换和优化。先能用,再谈好用,本地AI折腾这个路子,比一开始就想着上完美方案要实在得多。
如果你想往下一步扩展,可以试试把唤醒词换成中文的、把形象从Live2D换成VRM 3D模型、或者让它在回答时自动唤起本地的一些自动化脚本。8G显卡的上限其实挺高的,看你怎么用。