这次我们来看一个可以直接照着敲代码的智能语音代理项目:VoiceAgent。它要解决的核心问题非常明确——让一台普通电脑能听懂人说话、让大模型思考并调用工具、再用语音把结果说出来,而且不是一问一答就结束,而是有上下文、有记忆、能执行任务的智能体对话系统。标题里反复提到的“级联式三明治架构”,指的就是这套系统的主干:ASR 语音识别放在前,LLM 大模型推理放在中间,TTS 语音合成放在后,三层模块串成一条完整流水线。中间的大模型是“夹心”,前后的语音模型是“面包”,每一层都可以独立替换,这也是它适合做教学和二次开发的重要原因。
如果你关心本地部署、大模型接入、接口 API 和批量任务,那这个项目值得仔细看一遍。从材料里的设计思路来看,VoiceAgent 并不强制你跑满血大模型,ASR、LLM、TTS 三层都支持按需选择:ASR 可以用本地 Whisper 也可以用云端接口,LLM 可以接 OpenAI 兼容 API 也可以接本地 Ollama 部署的大模型,TTS 可以用本地引擎也可以接在线语音合成。硬件紧张时全走 API,想要离线保护隐私时切到本地模型,灵活性很高。
这篇文章会从零拆解一套 VoiceAgent 的实现方案,内容包括项目目录设计、语音活动检测 VAD、语音识别 ASR 模块、大模型对话模块、语音合成 TTS 模块、主对话循环、Function Calling 工具调用、FastAPI 接口封装以及批量任务设计。读完以后,你能知道这个项目到底值不值得试,也知道怎么把代码跑起来,再按自己的场景替换模型和服务。
1. 核心能力速览
在进入代码之前,先把 VoiceAgent 的关键规格列出来。由于具体实现可以选择不同的模型和接口,下面的参数更多是能力边界说明,实际显存占用和启动耗时需要按你本机环境测试。
| 能力项 | 说明 |
|---|---|
| 项目类型 | 智能语音代理,属于大模型智能体应用 |
| 核心架构 | 级联式三明治架构,ASR - LLM - TTS 三层流水线 |
| 语音识别 | 可接入 Whisper、faster-whisper 或云端 ASR 接口 |
| 大模型推理 | 支持 OpenAI 兼容 API,也支持本地 Ollama 等模型服务 |
| 语音合成 | 可接入本地 TTS 引擎或在线 TTS 服务 |
| 记忆能力 | 通过 messages 上下文列表维护多轮对话 |
| 工具调用 | 支持 Function Calling,可让大模型调用外部函数 |
| 接口能力 | 可使用 FastAPI 封装为 HTTP / WebSocket 服务 |
| 批量任务 | 可设计目录批处理音频转写、批量文本合成 |
| 建议 Python | 3.9 及以上版本 |
| 适合场景 | 语音助手、语音控制、语音客服、教学实验、Agent 应用开发 |
更稳妥的判断是:VoiceAgent 不适合说成是一个“开箱即用的成品软件”,它更像一套结构清晰的智能语音代理代码骨架。你不需要从零设计模块边界,但需要自己准备 ASR、LLM、TTS 的服务或模型。如果只是想做语音聊天,直接选一套云服务也能实现;但如果想掌控整个语音智能体流程,想研究所谓“级联式三明治架构”在真实项目中怎么落地,这套代码设计是值得花时间学习的。
2. 级联式三明治架构拆解
很多语音助手项目会把语音识别、语义理解、语音合成揉在一起,代码复用性差。VoiceAgent 的核心设计是把整条链路拆成三层加一个控制层,这就是“三明治架构”的直观体现。
2.1 三明治结构定义
| 层级 | 模块 | 输入 | 输出 |
|---|---|---|---|
| 顶层 | VAD 语音活动检测 + ASR 语音识别 | 麦克风音频流 | 用户文本 |
| 中间夹心层 | LLM 大模型推理 | 系统提示词、历史消息、用户文本 | 回复文本或工具调用参数 |
| 底层 | TTS 语音合成 | 回复文本 | 播放音频 |
| 控制层 | 主循环、队列、状态管理 | 各模块输出 | 调度决策、错误处理 |
这样的设计有一个很直观的好处:如果线上 ASR 效果差,你只需要替换顶层模块;如果大模型回复质量不行,你只需要换模型或调整 Prompt;如果合成声音不好听,你只需要换 TTS 引擎。模块之间通过文本和队列解耦,联调起来比单体代码容易得多。
2.2 为什么中间是“夹心”
这里的“夹心”指的是大模型推理层,因为它是整个语音代理的智能核心。语音识别和语音合成虽然决定了交互体验,但不决定“这个 Agent 能干什么”。大模型层负责理解用户意图、维护上下文、决定是否调用工具、生成最终回复。正因如此,整套系统的能力上限主要取决于大模型层,而不是语音层的模型大小。
2.3 典型部署形态
从项目设计看,VoiceAgent 可以按以下三种形态部署:
- 纯在线 API 形态:ASR、LLM、TTS 全部用云服务,本机只跑调度代码,对硬件要求最低。
- 全本地形态:ASR 用本地 Whisper,LLM 用 Ollama 拉起本地模型,TTS 用本地语音库,完全离线运行。
- 混合形态:ASR 在线、LLM 本地、TTS 在线,或者反过来,目的是在保证效果的前提下控制资源占用。
实际项目中,混合形态最常出现。比如你只有一张普通显卡,显卡既要跑 ASR 又要跑大模型会非常吃紧,那把 ASR 放到云端,本地专注跑大模型,整体延迟反而更可控。这一点会在后面的性能观察部分展开。
3. 适用场景与使用边界
VoiceAgent 适合谁?如果你正在做语音助手、语音客服、会议记录转写、语音控制工具,或者想研究大模型智能体的完整链路,这套级联式三明治架构能帮你节省大量模块设计时间。尤其是刚入门智能体开发的读者,通过这个项目能直观看到“音频输入 -> 文本 -> 模型推理 -> 文本 -> 音频输出”的完整数据流。
如果你想要一个功能完整、界面华丽的语音机器人产品,那直接做成 VoiceAgent 还需要不少工程工作量,比如噪音消除、说话人分离、断句优化、多轮打断策略等。这个项目更适合作为核心骨架,在它基础上叠加业务逻辑。
还要特别注意语音数据的使用边界。麦克风录音涉及个人隐私,调用录音功能前要获得用户明确同意;批量处理他人音频前必须确认有合法处理权限;如果后续接入语音克隆、音色合成功能,务必确认声音来源和用途的授权。任何语音智能体项目都不应该被用来伪造声音、冒充他人身份或处理未授权的音频数据。
4. 环境准备与前置条件
建议使用 Linux 或 Windows 10 以上系统进行开发,macOS 也可以运行,重点看 ASR 和 LLM 模块是否支持。项目主要依赖 Python 生态,所以先把 Python 3.9 以上版本装好,然后创建独立虚拟环境,避免依赖冲突。
python -m venv venv source venv/bin/activate # Windows 使用:venv\Scripts\activate基础依赖建议安装以下库,具体版本以你实际安装到的最新稳定版为准:
pip install numpy sounddevice requests openai pyttsx3 fastapi uvicorn python-multipart如果你的 ASR 选择本地 Whisper,可以安装 openai-whisper 或 faster-whisper;如果只需要音频文件转写,也可以不安装麦克风相关库。麦克风设备在使用前要测试采样率,通常用 16kHz 单声道,采样率不匹配会导致识别结果出现大量乱码。
LLM 层如果选择本地模型,推荐用 Ollama 管理下载和启动。下面只是通用示例,具体模型名称需要按你本地拉取的模型调整:
ollama pull qwen2.5:7b ollama serve启动后 Ollama 会默认监听在 11434 端口,你的 LLM 模块通过 HTTP 请求访问它即可。如果下载模型时速度慢,可以检查镜像配置和磁盘剩余空间,避免模型文件下载到一半导致启动失败。
5. 项目代码实战:模块拆分
先看完整项目目录,后面每个模块按这个结构落地:
voiceagent/ ├── asr_engine.py # 语音识别模块 ├── llm_engine.py # 大模型对话模块 ├── tts_engine.py # 语音合成模块 ├── vad_engine.py # 语音活动检测 ├── agent.py # 主对话循环 ├── tools.py # 工具注册与调用 ├── server.py # FastAPI 接口服务 ├── config.yaml # 配置文件 └── requirements.txt # 依赖清单5.1 VAD 语音活动检测模块
VAD 的作用是判断用户是否在说话,避免把静音或环境噪音都送去 ASR,浪费算力和时间。webrtcvad 是一个使用比较广泛的轻量级库,配合麦克风音频流能实现“检测到语音再开始识别”的效果。
# vad_engine.py import webrtcvad class VoiceActivityDetector: def __init__(self, mode: int = 1, sample_rate: int = 16000): self.vad = webrtcvad.Vad(mode) self.sample_rate = sample_rate def is_speech(self, frame: bytes) -> bool: # frame 一般是 20ms 或 30ms 的 PCM 数据 return self.vad.is_speech(frame, self.sample_rate)使用时要控制帧长,常见做法是采集 30ms 的音频块。VAD 的模式取值 0 到 3,模式越高对非语音越严格。如果环境安静,可以用 2 或 3;如果环境嘈杂,模式太高会把轻微人声滤掉,需要调低。这个模块在“按住说话”的场景下不是必须的,但在“免提连续对话”场景下非常关键。
5.2 ASR 语音识别模块
ASR 模块负责把音频转换成文本。下面以本地 Whisper 为例,提供一个通用封装思路。它支持直接传入音频数组,也可以扩展为传入音频文件路径。
# asr_engine.py import numpy as np import whisper class ASREngine: def __init__(self, model_name: str = "base"): self.model = whisper.load_model(model_name) def transcribe(self, audio: np.ndarray, sample_rate: int = 16000) -> str: # whisper 内部会做重采样,这里直接传入单声道 float32 数组 result = self.model.transcribe( audio.astype(np.float32) / 32768.0, fp16=False, ) return result["text"].strip()如果你不熟悉 model_name 怎么填,可以先从 base 或 small 起步,效果不够再升级 medium。实际占用需要按模型大小和本机显存/内存情况测试。想省事也可以把 ASR 换成云端接口,只需把 transcribe 方法里的实现替换成调用 HTTP API,上层逻辑完全不用改,这就是三明治架构里模块替换的好处。
5.3 LLM 大模型对话模块
LLM 模块是“夹心层”,负责多轮对话和意图判断。为了同时兼容云端和本地模型,推荐使用 OpenAI 兼容接口方式,因为 Ollama、vLLM 等本地服务都支持 OpenAI 格式请求。
# llm_engine.py from openai import OpenAI class LLMEngine: def __init__(self, base_url: str, api_key: str, model_name: str): self.client = OpenAI(base_url=base_url, api_key=api_key) self.model_name = model_name def chat(self, messages: list[dict], temperature: float = 0.7) -> str: response = self.client.chat.completions.create( model=self.model_name, messages=messages, temperature=temperature, ) return response.choices[0].message.contentbase_url 指向你使用的服务。使用云端 API 时,把它改成服务商提供的地址;使用本地 Ollama 时,base_url 通常是http://127.0.0.1:11434/v1,api_key 可以填任意占位字符串。这里需要注意,不同服务的模型名称、上下文长度、超时时间都不一样,建议把模型名写进配置文件,而不是硬编码在代码里。
5.4 TTS 语音合成模块
TTS 模块负责把大模型返回的文本变成语音。先用 pyttsx3 展示最直接的本地合成方案,它不需要网络,但音色和自然度比较有限。
# tts_engine.py import pyttsx3 class TTSEngine: def __init__(self, rate: int = 180, voice_id: str | None = None): self.engine = pyttsx3.init() self.engine.setProperty("rate", rate) if voice_id: self.engine.setProperty("voice", voice_id) def speak(self, text: str) -> None: self.engine.say(text) self.engine.runAndWait()如果对音色自然度有更高要求,可以把 speak 方法替换为在线 TTS 或接入更高品质的本地语音合成模型。替换时建议保留相同的speak(text)方法签名,这样主循环代码不需要跟着改。比如把文本提交到服务端合成,再把返回的音频数据交给播放器播放,就可以避免把网络请求和语音播放逻辑耦合在一起。
6. 主对话循环与唤醒词
有了 ASR、LLM、TTS 三个模块,接下来就是把它们串起来。主对话循环的职责是:采集麦克风音频、用 VAD 判断用户说完、调用 ASR 转文字、交给 LLM 生成回复、再调用 TTS 播放。
# agent.py import queue import sounddevice as sd import numpy as np from vad_engine import VoiceActivityDetector from asr_engine import ASREngine from llm_engine import LLMEngine from tts_engine import TTSEngine class VoiceAgent: def __init__(self, asr: ASREngine, llm: LLMEngine, tts: TTSEngine): self.asr = asr self.llm = llm self.tts = tts self.vad = VoiceActivityDetector(mode=1) self.messages = [ {"role": "system", "content": "你是一个语音助手,回答要简洁自然。"} ] self.sample_rate = 16000 def record_until_silence(self, timeout: float = 5.0) -> np.ndarray: frames = [] recording = False silence_count = 0 def callback(indata, frames_count, time_info, status): nonlocal recording, silence_count frame = indata.copy() is_speech = self.vad.is_speech(frame.tobytes()) if is_speech: recording = True silence_count = 0 elif recording: silence_count += 1 if recording: frames.append(frame.copy()) if silence_count > 15: raise sd.CallbackStop() with sd.InputStream(samplerate=self.sample_rate, channels=1, dtype="int16", callback=callback): sd.sleep(int(timeout * 1000)) return np.concatenate(frames, axis=0).flatten() def run_once(self): print("请说话...") audio = self.record_until_silence() if len(audio) == 0: print("没有检测到语音") return user_text = self.asr.transcribe(audio, self.sample_rate) print(f"用户: {user_text}") self.messages.append({"role": "user", "content": user_text}) reply = self.llm.chat(self.messages) self.messages.append({"role": "assistant", "content": reply}) print(f"助手: {reply}") self.tts.speak(reply) def run(self): while True: try: self.run_once() except KeyboardInterrupt: break except Exception as exc: print(f"发生错误: {exc}")这个代码是语音智能体的最小骨架。聊天、录音、识别的顺序很清晰,也方便在 run_once 中插入工具调用逻辑。需要注意,代码里使用raise CallbackStop来结束麦克风采集,不同版本的 sounddevice 行为可能略有差异;也容易因为环境噪音导致采集时间过长,因此建议加入总超时时间和最大录音长度限制。
如果想要更完整的体验,可以在主循环前加入唤醒词检测。比如使用 open wake word 这类轻量级唤醒词工具,先监听唤醒词,唤醒后再进入对话状态。这需要额外的唤醒词模型文件,实际效果和资源占用都要以你选择的模型为准。这个项目本身提供的是对话框架,唤醒词更像一个附加模块,不要指望所有功能开箱即用。
7. Function Calling 与工具调用
大模型直接回答文本只是 Agent 的基线能力。想体现智能体价值,最直接的方式是让大模型能调用工具,比如查询时间、查询天气、操作数据库。OpenAI 兼容接口的 Function Calling 是当前最简单通用的实现方式。
先在 tools.py 里定义真实函数和描述信息:
# tools.py import datetime def get_current_time() -> str: return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") TOOL_DEFINITIONS = [ { "type": "function", "function": { "name": "get_current_time", "description": "获取当前时间,用户问时间时调用", "parameters": { "type": "object", "properties": {}, }, }, } ] TOOL_MAP = { "get_current_time": get_current_time, }把工具定义传给 LLM 后,如果模型判断需要调用工具,返回内容里会包含 tool_calls 字段,而不是直接给最终文本。主循环需要增加一段工具调用处理逻辑。
# llm_engine.py 扩展 def chat_with_tools(self, messages, tools=None): response = self.client.chat.completions.create( model=self.model_name, messages=messages, tools=tools, ) return response.choices[0].message拿到返回后,先检查message.tool_calls是否存在。如果存在,就按名称从 TOOL_MAP 里取出函数执行,把执行结果作为 tool 角色消息追加到 messages 里,再让模型基于工具结果生成最终回复。这个流程并不复杂,但能让语音助手从“聊天机器人”变成“能办事的语音智能体”。
实际项目中,工具调用的参数往往不是空对象。比如查询天气需要 city 参数,修改订单需要 order_id 参数。这时候需要设计好参数类型,并校验模型返回的参数格式。建议把所有工具参数都加上类型和默认值,模型返回 json 字符串后先解析,解析失败就返回错误信息给模型重新生成,不要直接抛异常导致对话中断。
8. 接口封装与批量任务
VoiceAgent 做成本地脚本虽然可以验证流程,但真实场景里更多要通过接口服务被其他系统调用。使用 FastAPI 把 ASR、LLM、TTS 能力分别暴露成 HTTP 接口,是一种灵活的封装方式。
# server.py from fastapi import FastAPI, UploadFile, File from pydantic import BaseModel from asr_engine import ASREngine from llm_engine import LLMEngine from tts_engine import TTSEngine app = FastAPI() asr = ASREngine(model_name="base") llm = LLMEngine(base_url="http://127.0.0.1:11434/v1", api_key="unused", model_name="qwen2.5:7b") class ChatRequest(BaseModel): message: str @app.post("/asr") async def transcribe_audio(file: UploadFile = File(...)): content = await file.read() # 这里需要把上传的音频解码为 numpy 数组,再调用 asr.transcribe return {"text": "识别结果"} @app.post("/chat") async def chat(req: ChatRequest): reply = llm.chat([{"role": "user", "content": req.message}]) return {"reply": reply}启动接口服务:
uvicorn server:app --host 127.0.0.1 --port 8000接口启动后,可以用 curl 快速验证:
curl -X POST http://127.0.0.1:8000/chat \ -H "Content-Type: application/json" \ -d '{"message": "你好"}'如果是批量任务,比如给一批文本文件生成语音,或者批量转写一批音频,建议设计为目录输入、目录输出,并加入任务日志。批量任务最容易踩的坑是内存和临时文件堆积,不要在 for 循环里无限制加载音频,一次只处理一条,成功一条写一条日志。还可以用 Python 的 concurrent.futures 控制并发数,避免把所有任务一次性撑爆显存或内存。
批量任务的接口参数可以参考下面的格式:
{ "input_dir": "./audio_input", "output_dir": "./text_output", "model_name": "small", "concurrency": 2 }对批量任务要特别重视失败重试。语音识别受网络、噪音、文件格式影响很大,单条失败不能导致整个任务中断。推荐做法是:每条任务记录状态,失败后最多重试两次,仍然失败就写入 error_log,把失败原因保存下来,任务结束后统一排查。这里的代码模板需要按实际项目调整,但设计思路是通用的。
9. 资源占用与性能观察
语音智能体是典型的 I/O 密集加部分计算密集应用。资源占用主要集中在 ASR 和 LLM 两层,TTS 层如果是本地合成也会消耗 CPU。
性能观察建议从四个维度展开。
第一是采集延迟,也就是用户说完话到录音结束的时间。VAD 静音判断过长会明显拉低体验,实际调试时需要观察静音帧数阈值,不要一味追求高准确率而牺牲响应速度。
第二是 ASR 转写耗时。同样是本地 Whisper,模型越大转写越慢。如果设备没有 NVIDIA 显卡,建议用 base 或 small 模型,并开启分句处理;如果显存充足,再用 medium 或 large 模型提升准确率。
第三是大模型首字延迟。这里重点观察从请求发出到收到第一个 token 的时间,而不是总生成时间。低延迟方案是使用流式输出,让 TTS 在模型生成一部分文本时就开始合成,这也是目前很多语音 Agent 做“连续对话”手感的关键。流式会让代码复杂度上升,但体验提升明显。
第四是并发时的资源隔离。把 VoiceAgent 包装成 FastAPI 服务后,如果同时有多个用户调用,ASR、TTS 和 LLM 的实例不能无脑共享。更好的做法是给 ASR 和 LLM 增加请求队列,避免同时进入多个重推理任务导致显存溢出。观察显存可以使用 nvidia-smi 命令,每隔几秒采样一次即可。
watch -n 2 nvidia-smi如果你用的是集成显卡或纯 CPU,最需要关注的是内存和 CPU 占用,尤其是 Whisper 在转换较大音频时可能瞬间占用大量内存。为了降低资源占用,建议启用量化模型、限制最大音频时长、控制对话上下文长度。上下文越长,LLM 请求体越大,首字延迟也会越高,因此历史消息不能无限累积,通常保留最近 10 到 20 条即可。
10. 常见问题与排查方法
语音链路比纯文本链路更容易出问题,因为涉及音频设备驱动、采样率、编码格式、网络请求。下面整理一份排查清单,遇到问题按表格顺序检查。
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 麦克风没有声音 | 设备驱动或采样率不匹配 | 打印 sounddevice 设备列表,检查默认输入设备 | 设置 devices 参数,指定正确麦克风 |
| 识别结果全是乱码 | 采样率不是 16k,或音频格式不对 | 打印录音数组长度和 dtype | 统一使用 16k、16bit、单声道 PCM |
| VAD 一直不触发 | 阈值过高或设备静音 | 观察录音波形或音量 | 降低 VAD mode,或先做音量增益 |
| ASR 耗时太高 | 模型过大或音频过长 | 使用 nvidia-smi 观察 GPU 占用 | 换 small 模型,限制音频时长 |
| LLM 请求超时 | 网络问题或上下文过长 | 检查服务地址,打开调试日志 | 缩短 messages 长度,调大 timeout |
| TTS 播放卡顿 | 播放阻塞主循环 | 查看 TTS 是否同步执行 | 把 TTS 丢到独立线程 |
| 接口服务端口冲突 | 8000 已被占用 | 查看端口监听情况 | 改端口启动 uvicorn |
| 批量任务中途停止 | 某条数据触发异常 | 查看 error_log 和输出目录 | 增加 try/except 和重试机制 |
排查时最重要的一点是分层验证。先单独测试 ASR:给一段音频文件,看能不能输出正确文本。再单独测试 LLM:直接构造 messages 调用接口,看能不能返回正常回复。最后测试 TTS:把固定文本转成语音,看能否播放。三层都正常,再把主循环打开,问题会少很多。很多语音项目卡住,不是因为模型不行,而是麦克风设备和音频格式的问题没有在最开始暴露。
代码依赖问题也很常见。openai、webrtcvad、sounddevice 这些库在不同 Python 版本下可能出现编译安装失败。对于 webrtcvad,Windows 用户如果安装失败可以尝试安装webrtcvad-wheels,这是一个社区维护的预编译版本,能省掉本地编译步骤。如果你是 Python 3.12 以上的新版本环境,尽量使用虚拟环境,不要直接往系统 Python 里安装大量依赖,避免版本冲突。
11. 最佳实践与下一步
第一次跑 VoiceAgent 时,建议先做最小配置:ASR 用 base 模型,LLM 用云端或本地的轻量对话模型,TTS 用本地引擎。先把整条语音链路跑通,确认音频采集、识别、生成、播放都没有问题,再去替换更好的模型和增加工具调用。不要一开始就追求最大模型,这会让你分不清问题到底出在模型上还是代码上。
工程化方面,把模型名称、API 地址、采样率、日志路径全部写进配置文件,不要散落在代码里。密钥信息放在环境变量或本地配置文件中,不要提交到公开代码仓库。批量任务必须加日志和失败重试,输出文件按日期或任务 ID 分目录管理,避免后面找不到结果。
要重点检查的是合规边界。这个项目涉及录音、语音识别和语音合成,处理真实用户语音前必须确保有授权。使用他人的声音素材做 TTS 测试也要确认来源合法。如果你打算把 VoiceAgent 接入客服、医疗、金融等场景,还要额外考虑语音数据脱敏和存储安全。
接下来最值得验证的功能是把主循环改成流式输出,并加入一个真实工具调用。流式输出会让对话不再“卡顿”,工具调用能让你的智能体真正完成任务。之后再考虑增加唤醒词、多说话人识别、语音情绪分析等进阶能力。按照这套“先跑通、再替换、最后扩展”的路线,VoiceAgent 可以成为你手上一个非常顺手的语音智能体开发底子。建议收藏备用,实际动手时照着模块逐步替换即可。