自托管语音转文字 AI 助手self-hosted speech to text AI assistant正在从小众实验进入更广泛的工程实践。S.A.T.U.R.D.A.Y 作为这类项目的代号代表了一套运行在自己服务器上的语音对话闭环本地采集音频本地完成语音转文本再把文本交给本地或局域网内的大模型处理最后把回答播报出来。相比直接调用云端语音服务这种做法的核心收益是可控性音频数据不离开你的设备识别模型和对话模型可以随时替换接口也不会因为第三方策略调整而突然变化。这篇文章会从实际搭建的角度出发讲清楚一个自托管语音助手涉及哪些模块、各层代码怎么组织、音频参数和模型参数如何选择、跑通之后如何验证以及遇到高延迟、乱码、显存不足、连接失败等常见问题时该怎么排查。你可以把它当作一份从零开始的工程笔记也可以当作一个本地 AI 应用开发的最小骨架来参考。1. 先理解 S.A.T.U.R.D.A.Y 的架构和语音转文本链路1.1 一个自托管语音助手到底要管哪些事日常使用的语音助手比如手机里的语音输入法和智能音箱用户只看到“说话后出结果”背后的链路却不短。一个自托管版本需要自己管理下面这段链路语音采集从麦克风或音频文件获得音频数据。语音转文本把音频片段送给语音识别模型得到文字。语义处理把文字作为用户问题发给大模型或预先配置的规则引擎。结果输出把模型返回的文本直接显示或者再经过语音合成播报。会话控制记录上下文、处理唤醒词、管理超时和异常。本地实现的成本在于每一层都有独立的配置、依赖和性能瓶颈。云服务把这些封装成了 API自托管则需要自己理解并掌控每一层。学习和调试语音 AI最大的收获也来自这里你能亲眼看到音频从波形变成概率分布再从概率分布变成完整句子的过程。1.2 五段式数据链路一个最小可运行的自托管语音助手可以抽象成下面的五段式链路麦克风音频 - 16kHz WAV - 转写模型 - 用户文本 - 大模型 - 回答文本 - TTS 音频每一段都要关注延迟。语音采集通常不是瓶颈而转写模型在 CPU 和 GPU 上的耗时差别明显。大模型推理会占用额外资源。如果只是做“语音转文字记录”可以跳过对话模型直接输出文本文件。这也是 S.A.T.U.R.D.A.Y 这类项目适合逐步拆开验证的原因单独跑通每一段再组合成完整助手遇到问题时就不会整条链路都黑盒。1.3 自托管方案的取舍自托管并非在所有场景都优于云端 API。它的优势是隐私、离线可用、成本可控代价是硬件投入和运维成本。维度云端语音 API自托管语音助手首次接入注册、拿到密钥即可调用需要准备模型、服务、音频链路音频数据上传到第三方留在本地离线运行依赖网络可以完全离线模型迭代服务方控制自己控制延迟受网络影响取决于本地硬件和模型大小运维成本低需要自己处理日志和部署实际项目里二者也可以混合识别走本地转写模型对话走内网大模型合成走本地语音合成。这样既能离线也能利用更强的模型能力。选择的关键是先把链路跑通再决定哪些环节必须留在本地。2. 环境准备与依赖清单2.1 硬件和系统要求S.A.T.U.R.D.A.Y 最小的跑通条件是 Linux 开发机加上 Python 3.10 和 4GB 内存。没有 GPU 也能运行只是模型大小和实时性受限。下面是一张常用的配置参考表这里的数值表示“这个组合下中文识别基本可用”的体验不同系统会有浮动。运行场景CPU内存GPU建议模型学习环境只验证识别4 核8 GB无tiny / base开发环境做完整助手8 核16 GB可选 4 GB 显存small / medium生产环境要较好中文识别16 核32 GB推荐 8 GB 以上显存large-v3操作系统建议 Debian 12 或 Ubuntu 22.04 以上。Windows 配合 WSL2 也可以跑但音频设备透传比 Linux 麻烦建议先使用开发机部署。2.2 Python 环境和核心依赖进入项目目录后先创建虚拟环境再安装依赖。下面的依赖覆盖了语音采集、HTTP 服务、语音转文字和模型请求这些核心功能python3 -m venv .venv source .venv/bin/activate pip install -U pip pip install fastapi uvicorn faster-whisper sounddevice numpy websockets httpx pyyamlfaster-whisper 是 Whisper 模型的 CTranslate2 实现加载模型、做 GPU/CPU 推理都比原版 Whisper 直接是自托管语音识别项目里最常被选用的库。sounddevice 负责从麦克风录制数据numpy 用于处理音频数组fastapi 和 uvicorn 提供 WebSocket 服务httpx 用来请求本地大模型接口。如果项目自带requirements.txt或pyproject.toml优先使用项目锁定的版本因为 faster-whisper 依赖的 ctranslate2 在不同版本上推理行为和显存占用有明显差异。Linux 下 sounddevice 依赖 PortAudio 系统库音频处理又依赖 FFmpeg先安装sudo apt-get update sudo apt-get install -y ffmpeg libportaudio22.3 项目目录结构这里以 S.A.T.U.R.D.A.Y 常见的工程组织方式为例目录结构用于说明模块边界实际仓库结构以项目为准saturday/ ├── config.yaml # 音频、转写模型、大模型参数 ├── requirements.txt # Python 依赖 ├── transcriber.py # 语音转文本服务 ├── assistant.py # 大模型对话接口 ├── server.py # WebSocket/HTTP 服务入口 └── client.py # 麦克风采集与请求脚本这个结构刻意保持分层transcriber 只负责音频转文本不关心音频来源assistant 只负责文本问答不关心音频格式server 负责把两层串起来。后续替换任何一个模块都不会牵连其他部分。3. 配置文件核心字段解释3.1 一份可以跑通的 config.yamlS.A.T.U.R.D.A.Y 的配置集中在 config.yaml。下面是加了注释的版本server: host: 0.0.0.0 # 监听所有网卡方便局域网设备接入 port: 8765 audio: sample_rate: 16000 # 语音识别标准采样率 channels: 1 # 单声道 chunk_duration: 2 # 每次送入识别的音频时长单位秒 speech_to_text: model_size: small device: auto # 可写 cpu 或 cuda compute_type: int8 # cpu 用 int8gpu 用 float16 language: zh # 固定中文不写则自动检测 beam_size: 5 vad_filter: true # 过滤静音段 assistant: provider: ollama base_url: http://127.0.0.1:11434 model: qwen2.5:7b system_prompt: 你是一个本地语音助手请用简洁自然的中文回答。 timeout: 60每个字段都有默认值但建议显式配置因为音频参数和模型参数直接决定识别效果。assistant 部分通过provider抽象不同的本地推理服务Ollama 只是其中一种后面想换成 vLLM、llama.cpp 或其他 OpenAI 兼容接口时只需要改动这一小段配置和对应的请求封装。3.2 为什么音频固定为 16kHz 单声道Whisper 系列模型训练时会把输入重采样到 16kHz所以自托管识别链路的音频标准就是 16000Hz、16bit、单声道。如果麦克风采集到的是 48kHz 立体声模型虽然能处理但会先重采样增加 CPU 开销还会因为混音信息造成识别波动。工程上正确的做法是在采集端就统一采样率而不是依赖模型内部转换。这个参数有问题时的现象很典型短句子识别正常长句子却开始丢字或者同一个句子偶尔多字、偶尔少字。排查时先确认采集端和识别端采样率是否一致再去看模型逻辑。3.3 模型大小和计算类型怎么选识别模型的选择建议按“先跑通再提升”的顺序。下面是 faster-whisper 常用模型在中文场景下的参考模型参数量CPU 实时性显存占用中文识别tiny39M很好小于 1 GB勉强可用base74M很好约 1 GB可用small244M一般约 2 GB较好medium769M较慢约 5 GB好large-v31.5B慢约 10 GB最好compute_type 决定模型权重的精度。CPU 上推荐 int8推理速度快精度损失较小GPU 上推荐 float16显存占用和速度均衡int8_float16 适合在 NVIDIA GPU 上进一步压缩显存占用。如果配置错误常见表现是加载模型时报 dtype 错误或者推理速度慢到不可接受。模型权重首次加载时会下载到本地缓存。对于无法联网或网络受限的机器需要先在能联网的机器上下载好模型目录再手动拷贝到目标机器的缓存路径否则首次启动会卡在下载阶段。4. 核心代码实现解析4.1 语音转写模块transcriber.py 的核心是把音频文件或音频数组交给 faster-whisper返回完整文本。这里是一个最简实现from pathlib import Path import yaml from faster_whisper import WhisperModel config yaml.safe_load(Path(config.yaml).read_text()) stt config[speech_to_text] model WhisperModel( stt[model_size], devicestt[device], compute_typestt[compute_type], ) def transcribe_file(wav_path: str) - str: segments, info model.transcribe( wav_path, languagestt.get(language), beam_sizestt.get(beam_size, 5), vad_filterstt.get(vad_filter, True), ) text .join(segment.text for segment in segments) return text.strip()关键点有三个。第一第一次调用WhisperModel时会下载模型权重到本地缓存网络不通时必须先手动把模型目录放到指定路径。第二vad_filterTrue会过滤大量静音段对“按键说话”的场景很有用但正式演讲或包含明显背景声的录音不应该盲目开启。第三language固定为 zh 可以避免识别器在中文里夹杂英文音译但也会降低中英混说场景的准确率。4.2 AI 对话接口assistant.py 负责把用户文本发送给本地大模型接口并返回回答。下面示例兼容 Ollama 的/api/chat接口其他提供 OpenAI 兼容接口的本地推理服务也可以按同样思路适配import json from pathlib import Path import httpx import yaml config yaml.safe_load(Path(config.yaml).read_text()) assistant_cfg config[assistant] class Assistant: def __init__(self): self.base_url assistant_cfg[base_url] self.model assistant_cfg[model] self.system_prompt assistant_cfg[system_prompt] self.timeout assistant_cfg.get(timeout, 60) def answer(self, user_text: str) - str: payload { model: self.model, messages: [ {role: system, content: self.system_prompt}, {role: user, content: user_text}, ], stream: False, } with httpx.Client(base_urlself.base_url, timeoutself.timeout) as client: resp client.post(/api/chat, jsonpayload) resp.raise_for_status() return resp.json()[message][content]这里要保持 assistant 层不依赖具体音频格式只接收字符串、返回字符串。这样识别模块可以替换大模型也可以任意切换因为调用接口都收敛在这个类里。实际项目中如果大模型接口不稳定还应该在这里补上重试、超时和错误日志。4.3 WebSocket 服务端server.py 是整合入口负责接收客户端发来的音频路径或文本请求。一个完整但不过度设计的版本可以这样组织import asyncio import json from fastapi import FastAPI, WebSocket, WebSocketDisconnect from transcriber import transcribe_file from assistant import Assistant app FastAPI() assistant Assistant() app.websocket(/ws/transcribe) async def ws_transcribe(ws: WebSocket): await ws.accept() await ws.send_json({type: hello, message: ready}) try: while True: message await ws.receive_text() data json.loads(message) if data[type] file: text await asyncio.to_thread(transcribe_file, data[path]) await ws.send_json({type: transcript, text: text}) elif data[type] ask: text data[text] answer await asyncio.to_thread(assistant.answer, text) await ws.send_json({type: answer, text: answer}) except WebSocketDisconnect: print(client disconnected)服务端收到file类型消息时会把音频路径交给转写模块收到ask消息时会把文本交给大模型。asyncio.to_thread用于避免模型推理阻塞事件循环因为 faster-whisper 的转写和 httpx 的请求都是阻塞调用不能直接放在异步回调里。这个细节决定服务端能否同时处理多个客户端请求。4.4 客户端采集与发送client.py 先通过 sounddevice 录音再生成 16kHz 单声道 WAV 文件最后通过 WebSocket 发给服务端。下面的示例录制 5 秒并发送文件路径import asyncio import json import tempfile import wave import numpy as np import sounddevice as sd import websockets SAMPLE_RATE 16000 def record_wav(duration: int) - str: frames int(SAMPLE_RATE * duration) audio sd.rec(frames, samplerateSAMPLE_RATE, channels1, dtypeint16) sd.wait() data audio.flatten() tmp tempfile.NamedTemporaryFile(suffix.wav, deleteFalse) with wave.open(tmp.name, wb) as wf: wf.setnchannels(1) wf.setsampwidth(2) wf.setframerate(SAMPLE_RATE) wf.writeframes(data.tobytes()) return tmp.name async def main(): path record_wav(duration5) async with websockets.connect(ws://127.0.0.1:8765/ws/transcribe) as ws: hello json.loads(await ws.recv()) print(hello) await ws.send(json.dumps({type: file, path: path})) result json.loads(await ws.recv()) print(识别结果:, result.get(text)) if __name__ __main__: asyncio.run(main())这段代码在开发环境足够用但有一个明显限制必须把 WAV 文件路径发给服务端所以要求客户端和服务端在同一文件系统。生产环境或局域网部署时应该直接发送音频二进制帧由服务端完成临时文件或内存缓冲管理。这个差异要在设计接口时提前想清楚。5. 运行服务与功能验证5.1 启动服务先确保本地大模型服务已经就绪。以 Ollama 为例ollama serve ollama pull qwen2.5:7b然后启动 S.A.T.U.R.D.A.Y 服务source .venv/bin/activate uvicorn server:app --host 0.0.0.0 --port 8765看到下面的日志说明服务已经启动INFO: Uvicorn running on http://0.0.0.0:8765 INFO: Application startup complete.5.2 用一段固定音频验证转写如果没有麦克风也可以直接用 ffmpeg 生成测试音频或者使用公开的语音样本。这里以一段已有的音频为例先转成 16kHz 单声道再送入识别ffmpeg -i test.mp3 -ar 16000 -ac 1 test.wav然后直接调用转写函数验证python -c from transcriber import transcribe_file; print(transcribe_file(test.wav))正常输出会打印出音频里的文字。如果打印为空先检查音频里是否确实有人声其次检查采样率是否已经是 16000最后检查模型是否下载完整。这一步很重要因为它把网络、麦克风、大模型从验证范围中隔离开只验证转写链路本身。5.3 跑通一次完整语音问答启动服务后在另一个终端运行python client.py --duration 5对着麦克风说 5 秒内容比如“今天天气怎么样”。服务端日志会看到请求进入随后客户端会打印识别结果和大模型回答。5.4 预期结果和性能基线识别结果不要求一字不差但关键词应该正确。以“今天天气怎么样”为例正常输出应该是识别结果: 今天天气怎么样。 助手回答: 我目前没有天气数据建议查看本地天气服务。性能方面可以参考下面的大致指标。这些数值依赖具体硬件只能用于发现异常不能当作基准承诺阶段CPU small 模型GPU small 模型音频重采样和预处理可忽略可忽略5 秒音频转写1 到 3 秒0.3 到 0.8 秒7B 大模型首字返回2 到 10 秒1 到 4 秒如果转写耗时超过音频时长太多说明模型或 compute_type 需要调整。比如一段 5 秒的音频CPU 上 small 模型如果超过 5 秒才返回就要优先考虑换 int8 或换更小模型。6. 多语言、流式转写和扩展方向6.1 多语言支持config.yaml 中把language改成en、ja、ko就能切换识别语言。不写language时模型会先自动检测再进入正式识别通常多耗 0.5 秒左右。中英混说是语音识别常见的痛点。Whisper 对整段语言切换的适应能力一般固定languagezh时英文单词可能被音译成汉字。如果应用场景是编程助手或中英混合的日常对话可以让language留空并通过后处理把不合理的音译文本修正回来。关于语言参数还有一个容易忽略的点language只影响识别阶段不会影响大模型回答的语言。大模型是否用中文回答仍然由 assistant 的 system prompt 控制。所以多语言适配要同时改识别配置和对话提示词。6.2 从整段识别升级到流式转写上面的最小实现是整段录音完成后才识别体验接近“说完再出结果”。想达到更接近实时对话的效果有两个改进方向客户端按 1 到 2 秒切块发送音频数据服务端滚动拼接音频并做增量转写。使用 faster-whisper 的 VAD 边界检测在检测到语音停顿后自动截断当前片段而不是等固定时长。增量转写比看上去复杂因为重复识别会导致重复文本需要在主识别之外维护“已确认文本”和“待确认文本”两个缓冲区。初次实现建议先完成固定切块再考虑基于 VAD 的截断。切块长度也需要权衡太短会增加重复识别次数太长又体现不出实时效果。工程上常用 1 秒到 2 秒作为切块时间。6.3 扩展方向一个自托管语音助手可以顺势扩展成多个工具会议纪要把录音文件批量送入转写模块输出 Markdown 摘要。家庭语音控制在 assistant 层加入意图识别和命令白名单例如“关灯”“开空调”。录音检索对转写文本建立索引实现本地录音的关键词搜索。多设备接入服务端监听局域网手机、开发板、桌面客户端都能发送音频。扩展时最需要遵守的原则是保持模块边界。识别、对话、合成、控制各自独立替换模型或增加设备时就不需要重写整条链路。换句话说不要让 assistant 层知道录音文件的格式也不要在转写层放业务命令逻辑。7. 常见问题排查7.1 识别结果为空或乱码现象请求正常返回但没有文本或者文本里是乱码、重复片段。检查顺序音频是否真的包含人声是否静音。采样率是否是 16000声道是否为单声道。language是否设置成了错误语言代码。模型缓存是否完整必要时删除后重新下载一次。这个问题的根因往往在音频输入端而不是模型。先用 ffprobe 查看音频信息ffprobe test.wav如果看到的是 48000Hz 或 2 channels就要在采集端先做重采样和降混音而不是依赖模型去纠正。7.2 转写延迟过高现象5 秒音频识别用了 10 秒以上。可能原因和对应处理如下原因处理模型过大换成 small 或 basecompute_type 不对CPU 用 int8GPU 用 float16同时跑大模型推理把转写和对话拆到两台机器或错峰运行beam_size 太大从 5 降到 1速度提升精度略降没有使用 VAD开启 vad_filter减少静音片段处理排查时可以先看 CPU 或 GPU 占用。如果模型加载时内存已经接近上限说明需要换小模型或加内存。不要只盯着代码优化先确认资源瓶颈在哪一层再针对性调整。7.3 GPU 显存不足现象加载模型时提示 CUDA out of memory。处理优先级把 model_size 从 large 降到 medium 或 small。compute_type 从 float16 改为 int8_float16。关闭其他占用显存的进程。确认模型加载的 device 确实写成 cuda而不是 auto 时被错误选择到 cpu。第一优先是选择与显存匹配的模型。8GB 显存跑 medium 比较合适跑 large 会比较紧张。如果业务确实需要 large就要考虑使用量化版本或分批处理。7.4 WebSocket 连接不上现象客户端连接报错或连接后收不到 hello 消息。检查顺序服务是否真的启动端口有没有写错。客户端 ws 地址是否少了或多了一层路径。防火墙是否放行端口。如果是局域网连接需要确保 server host 不是 127.0.0.1而是 0.0.0.0。一个容易忽略的点是 WebSocket 路径必须与服务端路由完全一致。fastapi 里定义的是/ws/transcribe客户端连接地址也必须带上这个后缀否则会返回 404。7.5 麦克风采集报错现象无法打开麦克风或者录音只有噪声。先检查 PortAudio 系统库和可用设备python -c import sounddevice; print(sounddevice.query_devices())如果列表为空或缺少默认设备安装 libportaudio2 后重新尝试。如果设备有多个需要在 sounddevice 初始化时显式指定 device 参数否则可能选到默认声卡。另一个常见原因是录音电平过低可以先录制一段音频并查看波形峰值确认输入强度正常。8. 最佳实践与可复用清单8.1 上线前检查清单每次调整完环境或模型后建议按下面清单快速过一遍音频文件是 16kHz、单声道、16bit PCM。识别模型已确认可加载或已手动放入缓存目录。compute_type 与当前设备匹配。服务端口未被占用WebSocket 路径一致。客户端和服务端在同一局域网时server host 使用 0.0.0.0。大模型接口连通能返回一次完整回答。日志能记录每次请求的开始、结束时间和识别文本长度。这份清单能在排查问题时避免重复验证已知环节。每次变更只改一个变量是排错效率最高的方式。8.2 生产环境的额外保障配置外置化config.yaml 不应硬编码在镜像里用环境变量或独立配置文件注入。日志与监控至少记录识别耗时、模型推理耗时、完整文本和错误堆栈便于回溯。权限控制WebSocket 服务默认不应暴露到公网局域网内建议增加 token 校验。模型版本固定尽量固定 faster-whisper 和相关依赖版本避免升级后识别行为变化。回滚方案模型变更前保留上一版配置和依赖 lock 文件。资源隔离转写和大模型推理都很吃资源核心服务不建议和数据库等业务混布在同一台小机器上。这些条目看起来偏运维但自托管服务的稳定性恰恰取决于这些细节而不是识别准确率本身。8.3 下一步学习路径对刚接触自托管语音 AI 的开发者建议按以下顺序练习先用 tiny 模型跑通转写理解音频格式和模型加载流程。再把大模型接进来形成完整问答链路。然后优化延迟和准确率先后调整模型、compute_type、VAD。最后研究流式转写和命令控制把项目引向实际应用。每一步都要保留下可运行的快照尤其是依赖版本和配置。后续实验失败时可以随时回到稳定状态而不是从头开始排查。整个过程中最有价值的能力是能准确判断“问题出在音频端、识别端还是对话端”。一旦建立了这个判断力自托管语音助手的开发效率会有明显提升。
