从零手搓“贾维斯”:构建超低延迟的 LLM 实时语音对话系统 (附全套开源代码)
从零手搓“贾维斯”:构建超低延迟的 LLM 实时语音对话系统 (附全套开源代码)
在当前的大模型时代,做一个文本对话机器人很简单,但要想做一个像人类一样实时听、实时想、实时说,且不抢话、没有回音死循环的语音助手,工程难度将呈指数级上升。
本文将详细复盘如何利用 Qwen3-ASR、SiliconFlow (Qwen2.5-7B) 和 CosyVoice,在本地从零构建一套工业级的实时语音流式交互架构,并重点解决异步并发死锁与声学回声套娃这两个终极难题。
🏗️ 一、 架构设计:微服务与环境隔离
在引入开源语音模型时,最大的忌讳就是把 ASR(语音识别)和 TTS(语音合成)装在同一个 Python 环境中。底层 PyTorch 和 CUDA 版本的冲突会让你陷入无尽的依赖地狱 (Dependency Hell)。
因此,我们采用了基于 WebSocket 的微服务架构:
- Windows 客户端 (
client_vad.py):负责麦克风录音、VAD (静音检测)、以及扬声器播放。 - ASR + LLM 服务端 (
server_asr.py):运行在独立的 Conda 环境 A 中。负责将音频转文字,并流式请求大模型。 - TTS 服务端 (
server_tts.py):运行在独立的 Conda 环境 B 中。负责接收文字,使用 CosyVoice 进行 3s 极速音色克隆 (Zero-Shot),并流式吐出音频。
[ Windows 客户端 (录音/播放/VAD) ]
| (WebSocket 端口 8765)
[ WSL2 环境 A: Qwen3-ASR + LLM 服务端 ]
| (WebSocket 端口 8766)
[ WSL2 环境 B: CosyVoice TTS 服务端 ]
🎙️ 二、 TTS 服务端:打破并发死锁,榨干 GPU 性能
CosyVoice 的 3s 极速复刻 (Zero-Shot) 效果惊艳,但深度学习模型的推理是同步阻塞的。如果直接在 WebSocket 的 async for 循环中跑推理,会直接卡死整个网络事件循环,导致音频无法实时发送。
核心优化:引入多线程 (threading) 与线程安全队列 (asyncio.Queue)。让 GPU 在后台线程全力推理,主协程只负责把队列里的音频数据瞬间通过网络发走。
server_tts.py (CosyVoice 环境)
运行前请准备一段 3~5 秒的 prompt.wav 人声文件放在同级目录,并修改 PROMPT_TEXT。
import asyncio
import websockets
import argparse
import numpy as np
import os
import sys
import threading
# 将第三方的 Matcha-TTS 加入系统路径,防止找不到模块
ROOT_DIR = os.path.dirname(os.path.abspath(__file__))
sys.path.append('{}/third_party/Matcha-TTS'.format(ROOT_DIR))
from cosyvoice.cli.cosyvoice import AutoModel
from cosyvoice.utils.common import set_all_random_seed
# ================= 极速复刻配置 =================
PROMPT_WAV_PATH = "prompt.wav"
PROMPT_TEXT = "希望你以后能够做的比我还好呢。" # 替换为音频中实际说的文字
async def tts_handler(websocket, cosyvoice):
client_ip = websocket.remote_address
print(f"🔗 ASR/LLM 服务端已连接: {client_ip}")
try:
async for message in websocket:
if isinstance(message, bytes):
text = message.decode('utf-8').strip()
else:
text = message.strip()
if not text:
continue
print(f"🔊 开始极速合成: {text}")
set_all_random_seed(0)
# 建立线程安全的队列
queue = asyncio.Queue()
loop = asyncio.get_running_loop()
def run_inference():
try:
# 使用绝对/相对文件路径,防止底层 C++ 解码器崩溃
for i in cosyvoice.inference_zero_shot(text, PROMPT_TEXT, PROMPT_WAV_PATH, stream=True):
tts_audio_tensor = i['tts_speech'].numpy().flatten()
audio_np = (tts_audio_tensor * 32768).astype(np.int16)
loop.call_soon_threadsafe(queue.put_nowait, audio_np.tobytes())
loop.call_soon_threadsafe(queue.put_nowait, b"TTS_END")
except Exception as e:
loop.call_soon_threadsafe(queue.put_nowait, e)
# 启动后台独立推理线程,释放主协程网络传输能力
threading.Thread(target=run_inference, daemon=True).start()
# 主协程:拿到数据瞬间通过网络发走
while True:
chunk = await queue.get()
if isinstance(chunk, Exception):
print(f"⚠️ Zero-Shot 推理报错: {repr(chunk)}")
await websocket.send(b"TTS_END")
break
await websocket.send(chunk)
if chunk == b"TTS_END":
print(f"✅ 合成完毕")
break
except websockets.exceptions.ConnectionClosed:
print(f"❌ 客户端断开连接")
except Exception as e:
print(f"⚠️ TTS WebSocket 出错: {repr(e)}")
async def main(args):
print(f"⏳ 正在通过 AutoModel 加载模型: {args.model_dir} ...")
cosyvoice = AutoModel(model_dir=args.model_dir)
print(f"✅ 模型加载成功!采样率: {cosyvoice.sample_rate}Hz")
if not os.path.exists(PROMPT_WAV_PATH):
print(f"\n❌ 致命错误:找不到参考音频文件 '{PROMPT_WAV_PATH}'!")
return
async with websockets.serve(lambda ws: tts_handler(ws, cosyvoice), "0.0.0.0", args.port):
print(f"🚀 CosyVoice 【多线程极速版】已启动: ws://0.0.0.0:{args.port}")
await asyncio.Future()
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument('--port', type=int, default=8766)
parser.add_argument('--model_dir', type=str, default='pretrained_models/CosyVoice2-0.5B')
args = parser.parse_args()
asyncio.run(main(args))
🧠 三、 ASR + LLM 服务端:构建完美的“生产者-消费者”流水线
最影响语音对话体验的,是 LLM 和 TTS 之间的配合。如果等 LLM 生成完一整段话再去合成语音,延迟会高得无法忍受。
核心优化:
- 标点符号断句:只要 LLM 吐出逗号、句号等标点,立刻将这半句话截断。
- 生产者-消费者队列:LLM(生产者)将半句话扔进
text_queue后,一秒都不等,立刻去生成下一句话。后台专属协程(消费者)盯着队列,拿到半句话就发给 TTS 并将返回的音频透传给客户端。真正实现了“边想、边合成、边播报”。
server_asr.py (Qwen3-ASR 环境)
import asyncio
import websockets
import json
import logging
import numpy as np
import re
from qwen_asr import Qwen3ASRModel
from openai import AsyncOpenAI
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
# ================= 1. 配置参数 =================
ASR_MODEL_PATH = "/mnt/d/develop/asr/Qwen3-ASR-1.7B" # 替换为真实的本地绝对路径
CHUNK_BYTES_THRESHOLD = 16000
SILICONFLOW_API_KEY = "你的_SiliconFlow_API_KEY"
LLM_MODEL_NAME = "Qwen/Qwen2.5-7B-Instruct" # 推荐使用 7B 模型,极致响应速度
llm_client = AsyncOpenAI(
api_key=SILICONFLOW_API_KEY,
base_url="https://api.siliconflow.cn/v1"
)
TTS_WS_URL = "ws://127.0.0.1:8766"
# ================= 2. 核心服务端逻辑 =================
async def main():
logging.info(f"正在加载 ASR 模型 {ASR_MODEL_PATH} ...")
asr = Qwen3ASRModel.LLM(
model=ASR_MODEL_PATH,
gpu_memory_utilization=0.5,
max_new_tokens=32,
max_model_len=4096
)
logging.info("✅ ASR 模型加载完成。等待连接...")
async def handle_client(websocket):
client_ip = websocket.remote_address
logging.info(f"🔗 客户端已连接: {client_ip}")
state = None
audio_buffer = bytearray()
chat_history = [
{"role": "system",
"content": "你是一个聪明、幽默的语音助手。由于是以语音形式交互,你的回答必须尽量简短、口语化,不要使用复杂的排版或长篇大论。"}
]
try:
async for message in websocket:
if isinstance(message, str):
data = json.loads(message)
if data.get("event") == "start":
state = await asyncio.to_thread(
asr.init_streaming_state, unfixed_chunk_num=2, unfixed_token_num=5, chunk_size_sec=2.0
)
audio_buffer.clear()
elif data.get("event") == "stop":
if state:
logging.info("【探针】收到 VAD 停止信号,处理尾部音频...")
if len(audio_buffer) > 0:
wav16k = np.frombuffer(audio_buffer, dtype=np.int16).astype(np.float32) / 32768.0
await asyncio.to_thread(asr.streaming_transcribe, wav16k, state)
audio_buffer.clear()
await asyncio.to_thread(asr.finish_streaming_transcribe, state)
final_text = state.text
state = None
logging.info(f"【探针】ASR 最终识别结果: '{final_text}'")
await websocket.send(json.dumps({"type": "final", "text": final_text}))
if final_text and final_text.strip():
chat_history.append({"role": "user", "content": final_text})
try:
await websocket.send(json.dumps({"type": "llm_start"}))
stream = await llm_client.chat.completions.create(
model=LLM_MODEL_NAME,
messages=chat_history,
stream=True,
max_tokens=512,
temperature=0.7
)
llm_full_response = ""
sentence_buffer = ""
async with websockets.connect(TTS_WS_URL) as tts_ws:
# 【核心重构:生产者-消费者模型】
text_queue = asyncio.Queue()
async def tts_worker():
while True:
text = await text_queue.get()
if text is None:
break
await tts_ws.send(text)
while True:
audio_bytes = await tts_ws.recv()
if audio_bytes == b"TTS_END":
break
await websocket.send(audio_bytes)
tts_task = asyncio.create_task(tts_worker())
async for chunk in stream:
if chunk.choices and chunk.choices[0].delta.content:
text_chunk = chunk.choices[0].delta.content
llm_full_response += text_chunk
sentence_buffer += text_chunk
await websocket.send(json.dumps({"type": "llm_partial", "text": text_chunk}))
# 遇到逗号也断句,让 TTS 能最快拿到首句进行合成
if re.search(r'[,。!?、,!?\n]', text_chunk):
if sentence_buffer.strip():
text_queue.put_nowait(sentence_buffer.strip())
sentence_buffer = ""
if sentence_buffer.strip():
text_queue.put_nowait(sentence_buffer.strip())
text_queue.put_nowait(None)
# 等待最后一句音频发送完毕
await tts_task
chat_history.append({"role": "assistant", "content": llm_full_response})
await websocket.send(json.dumps({"type": "llm_stop"}))
logging.info(f"【探针】整轮交互完成: {llm_full_response}")
except Exception as e:
logging.error(f"调用失败: {e}")
await websocket.send(json.dumps({"type": "llm_partial", "text": f"\n[系统异常]\n"}))
await websocket.send(json.dumps({"type": "llm_stop"}))
elif isinstance(message, bytes):
if state:
audio_buffer.extend(message)
if len(audio_buffer) >= CHUNK_BYTES_THRESHOLD:
wav16k = np.frombuffer(audio_buffer, dtype=np.int16).astype(np.float32) / 32768.0
audio_buffer.clear()
await asyncio.to_thread(asr.streaming_transcribe, wav16k, state)
if state.text:
await websocket.send(json.dumps({"type": "partial", "text": state.text}))
except websockets.exceptions.ConnectionClosed:
logging.warning(f"❌ 客户端断开连接")
finally:
if state:
await asyncio.to_thread(asr.finish_streaming_transcribe, state)
async with websockets.serve(handle_client, "0.0.0.0", 8765, ping_interval=None):
logging.info("🚀 ASR+LLM 语音后端已启动: ws://0.0.0.0:8765")
await asyncio.Future()
if __name__ == "__main__":
asyncio.run(main())
🎧 四、 客户端:消灭“回音套娃”与声学物理延迟
当使用外放喇叭时,麦克风会把喇叭的声音重新录进去,导致 AI 不断跟自己说话(回音死循环)。
你可能会想到在代码里加一个 is_ai_speaking 的标志位来闭麦。但实际上,网络传输极快,当代码收到 llm_stop 标志时,喇叭物理层面的振膜可能还需要 1~2 秒才能把缓存里的声音播放完! 此时开启麦克风,依然会录入尾音。
核心优化:引入专用的同步播放队列 (play_queue),并在播放结束指令到来时,强制加入 1.5 秒的声学垫片 (Acoustic Tail Pad),等待声卡缓存物理播放完毕,同时等待房间内的声波回音彻底消散,再开启麦克风。
client_vad.py (Windows 环境)
import pyaudio
import numpy as np
import torch
import asyncio
import websockets
import json
import collections
# ================= 1. 配置参数 =================
WS_URL = "ws://172.27.27.243:8765" # 替换为你的服务器IP
SAMPLE_RATE = 16000
CHUNK_SIZE = 512
FORMAT = pyaudio.paInt16
CHANNELS = 1
PRE_SPEECH_PAD_FRAMES = 15
# ================= 2. 加载 VAD 模型 =================
print("⏳ 正在从本地加载 Silero VAD 模型...")
VAD_LOCAL_PATH = r"D:\develop\asr\silero-vad-master"
vad_model, utils = torch.hub.load(
repo_or_dir=VAD_LOCAL_PATH,
model='silero_vad',
source='local',
force_reload=False,
onnx=False
)
(get_speech_timestamps, save_audio, read_audio, VADIterator, collect_chunks) = utils
vad_iterator = VADIterator(
vad_model,
threshold=0.35,
min_silence_duration_ms=800,
speech_pad_ms=100
)
TTS_SAMPLE_RATE = 22050
# ================= 3. 核心客户端逻辑 =================
async def mic_client():
p = pyaudio.PyAudio()
player_stream = p.open(format=pyaudio.paInt16, channels=1, rate=TTS_SAMPLE_RATE, output=True)
try:
async with websockets.connect(WS_URL, ping_interval=None) as websocket:
print("✅ 成功连接到服务端!")
audio_queue = asyncio.Queue() # 录音队列
play_queue = asyncio.Queue() # 专门用于同步播放的队列
loop = asyncio.get_running_loop()
ring_buffer = collections.deque(maxlen=PRE_SPEECH_PAD_FRAMES)
# 状态标志,用于判断 AI 是否正在说话
state_flags = {"is_ai_speaking": False}
def audio_callback(in_data, frame_count, time_info, status):
loop.call_soon_threadsafe(audio_queue.put_nowait, in_data)
return (None, pyaudio.paContinue)
p2 = pyaudio.PyAudio()
stream = p2.open(format=FORMAT, channels=CHANNELS, rate=SAMPLE_RATE, input=True,
frames_per_buffer=CHUNK_SIZE, stream_callback=audio_callback)
stream.start_stream()
print("\n🎤 麦克风已激活,请开始说话...\n")
# ---------- 1. 录音与发送协程 ----------
async def capture_and_send():
is_speaking = False
while True:
data = await audio_queue.get()
# 如果 AI 正在说话,直接丢弃麦克风声音(硬件级闭麦)
if state_flags["is_ai_speaking"]:
ring_buffer.clear()
if is_speaking:
is_speaking = False
await websocket.send(json.dumps({"event": "stop"}))
continue
audio_float32 = torch.from_numpy(np.frombuffer(data, dtype=np.int16).copy()).float() / 32768.0
speech_dict = vad_iterator(audio_float32, return_seconds=False)
if speech_dict and 'start' in speech_dict and not is_speaking:
is_speaking = True
await websocket.send(json.dumps({"event": "start"}))
for buffered_data in ring_buffer:
await websocket.send(buffered_data)
ring_buffer.clear()
elif speech_dict and 'end' in speech_dict and is_speaking:
is_speaking = False
await websocket.send(data)
await websocket.send(json.dumps({"event": "stop"}))
if is_speaking:
await websocket.send(data)
else:
ring_buffer.append(data)
# ---------- 2. 接收网络数据协程 ----------
async def receive_results():
async for message in websocket:
if isinstance(message, str):
res = json.loads(message)
if res["type"] == "partial":
print(f"\r🗣️ [你]: {res['text']} ", end="", flush=True)
elif res["type"] == "final":
print(f"\r🎯 [你]: {res['text']} \n", flush=True)
elif res["type"] == "llm_start":
state_flags["is_ai_speaking"] = True
print(f"🤖 [AI]: ", end="", flush=True)
elif res["type"] == "llm_partial":
print(res["text"], end="", flush=True)
elif res["type"] == "llm_stop":
# 往播放队列塞入结束符
await play_queue.put(b"STOP_PLAYBACK")
elif isinstance(message, bytes):
await play_queue.put(message)
# ---------- 3. 绝对同步的音频播放协程 ----------
async def play_audio_task():
while True:
chunk = await play_queue.get()
if chunk == b"STOP_PLAYBACK":
# ========================================================
# 【终极防套娃核心】:增加 1.5 秒的硬件级声学垫片
# 强行等待声卡底层物理播放完毕,并等待房间回声消散
# ========================================================
await asyncio.sleep(1.5)
# 清空在这 1.5 秒内麦克风偷偷录入的废料音频
while not audio_queue.empty():
audio_queue.get_nowait()
ring_buffer.clear()
state_flags["is_ai_speaking"] = False
print("\n\n🎤 请继续说话...", flush=True)
else:
# 阻塞式顺序播放
await asyncio.to_thread(player_stream.write, chunk)
await asyncio.gather(capture_and_send(), receive_results(), play_audio_task())
except Exception as e:
print(f"\n⚠️ 发生异常: {e}")
finally:
if 'stream' in locals() and stream.is_active():
stream.stop_stream()
stream.close()
if 'p2' in locals():
p2.terminate()
if 'player_stream' in locals() and player_stream.is_active():
player_stream.stop_stream()
player_stream.close()
if 'p' in locals():
p.terminate()
if __name__ == "__main__":
try:
asyncio.run(mic_client())
except KeyboardInterrupt:
print("\n👋 客户端已关闭。")
总结
至此,一套完整的流式语音大模型底座已搭建完毕。它不仅拥有毫秒级的 VAD 响应断句,还实现了多并发异步的文本/音频吞吐,并且彻底杜绝了外放设备的声学回声。
这套工程架构可以说是目前搭建本地 LLM 语音助手的最优解之一。无论是替换更强大的 ASR,还是接入更聪明的云端大模型,这套极低耦合度的微服务底座都能完美兼容!
更多推荐



所有评论(0)