基于Streamlit与Ollama构建本地语音AI助手:从原理到实践
语音识别(ASR)与语音合成(TTS)技术是实现人机自然交互的核心。ASR通过声学模型和语言模型将语音信号转化为文本,而TTS则通过文本分析、韵律生成和声学合成将文本转换为语音。这些技术的价值在于打破交互壁垒,提升效率,并保障数据隐私。在应用场景上,它们广泛用于智能家居、车载系统、无障碍工具及个人助手。本文聚焦于利用开源工具链,如Streamlit框架和Ollama模型服务,结合本地部署的Whis
1. 项目概述:为什么我们需要一个本地语音AI助手?
最近在折腾一个挺有意思的小项目:用语音控制一个运行在本地的AI助手。听起来是不是有点未来感?但实现起来,其实比想象中要接地气得多。这个项目的核心,就是让AI不再只是一个需要你打字、点击的“网页应用”,而是变成一个能听、会说、能思考的“本地伙伴”。想象一下,你在厨房做饭,手上沾满面粉,突然想查个菜谱,或者想让它帮你算算烤箱温度和时间,直接开口问就行;或者你在写代码,想让它解释一段复杂的逻辑,不用切换窗口打字,直接语音对话,效率提升不止一点半点。
这个项目我称之为“Voice-Controlled Local AI Agent”,它由几个关键部分组成:一个能听懂你说话的 语音识别模块 ,一个在你自己电脑上运行的、私密的 大语言模型(LLM) ,一个能和你自然对话的 语音合成模块 ,以及一个把所有东西粘合在一起的 轻量级Web界面 。我选择了 Streamlit 来快速搭建交互界面,用 Ollama 来本地部署和运行开源大模型(比如 Llama 3、Mistral 等),再配合 SpeechRecognition 和 pyttsx3 这类库来处理语音的输入输出。
这么做的最大好处是什么? 隐私和可控性 。所有的对话数据、你的语音指令,都只在你的本地机器上处理,不会上传到任何云端服务器。这对于处理敏感信息、或者单纯不想被记录的用户来说,是巨大的吸引力。其次,它 完全离线 (模型下载好后),不受网络波动影响,响应速度也取决于你本机的算力。最后,它 高度可定制 ,你可以选择不同能力、不同大小的模型,调整语音合成的音色和语速,打造一个完全属于你自己的AI助手。
2. 技术栈选型与核心思路拆解
2.1 为什么是 Streamlit + Ollama 这个组合?
搭建一个AI应用,前端交互和后端模型服务是两个大头。市面上框架很多,我选择 Streamlit 和 Ollama ,是经过一番权衡的。
Streamlit 本质上是一个为数据科学家和机器学习工程师设计的工具,它允许你用纯Python脚本快速创建交互式Web应用。对于这个语音AI项目,它的优势非常明显:
- 开发速度极快 :你不需要懂HTML、CSS、JavaScript,只需要关注Python逻辑。一个脚本就能定义整个应用的布局、交互和状态管理。
- 状态管理简单 :通过
st.session_state,可以很方便地在多次用户交互间保持数据(比如对话历史、模型状态)。 - 丰富的组件 :内置了按钮、滑块、文本输入/输出、音频播放器等组件,我们需要的录音、播放、文字展示都能轻松实现。
- 热重载 :修改代码后保存,页面自动刷新,调试体验流畅。
而 Ollama 则是本地运行大模型的“瑞士军刀”。它提供了一个非常简单的命令行工具和API,让你能够一键拉取、运行和管理各种开源大模型。
- 开箱即用 :一条命令
ollama run llama3就能把Meta的Llama 3模型跑起来,并开启一个类OpenAI的API接口(默认在http://localhost:11434)。 - 模型管理方便 :可以轻松切换不同模型(
ollama list,ollama pull),无需复杂的配置。 - 资源友好 :Ollama会针对你的硬件(特别是GPU)进行优化,对于消费级显卡(如NVIDIA RTX系列)支持很好,能有效利用显存。
- API标准化 :它提供的API与OpenAI的ChatCompletion接口高度相似,这意味着我们写代码时,可以很容易地适配,未来如果想换到其他兼容OpenAI的本地服务(如LM Studio)或云端API,改动成本也很低。
这个组合完美契合了“快速原型”和“本地化”的需求。Streamlit负责“面子”(交互),Ollama负责“里子”(智能),两者通过HTTP API通信,架构清晰,耦合度低。
2.2 语音交互模块的技术考量
语音交互分为“听”和“说”两部分。
语音识别(ASR) :我们需要将用户的麦克风输入转换成文字。这里有几个选择:
- 云端API(如Google Speech-to-Text, Whisper API) :识别准确率高,但需要网络,有隐私和成本问题,不符合“本地化”核心诉求。
- 本地离线库 :如 SpeechRecognition (Python库)。它本身是一个封装层,背后支持多种引擎。其中, CMU Sphinx 是离线的,但准确率一般,尤其是中文。而 Whisper (OpenAI开源的模型)的本地版本是当前的最佳选择。
- 最终选择 :为了平衡准确率和本地化,我选择了 faster-whisper 。它是Whisper模型的一个高效实现(使用CTranslate2),推理速度更快,内存占用更少,并且可以完全离线运行。我们可以通过Python包方便地集成它。
语音合成(TTS) :将AI回复的文字转换成语音播放出来。
- 云端API :同样存在网络和隐私问题。
- 本地离线库 : pyttsx3 是一个跨平台的离线TTS引擎,它调用操作系统自带的语音合成功能(在Windows上是SAPI5,在macOS上是NSSpeechSynthesizer,在Linux上是eSpeak)。优点是零配置、完全离线、免费。缺点是音质比较“机械”,可选声音有限。
- 高级本地方案 :如果想追求接近真人的音质,可以考虑 Coqui TTS 或 VITS 等基于神经网络的本地TTS模型,但它们模型较大,部署更复杂。
- 折中方案 :对于本项目原型, pyttsx3 的简单可靠是首选。它足以验证流程,后续如果需要,可以替换为更先进的本地TTS引擎。
音频采集与播放 :Streamlit自带 st.audio 组件可以播放音频,但录制麦克风输入需要一点技巧。我们可以用 streamlit-webrtc 组件,它提供了强大的实时音视频处理能力,非常适合做录音。或者,也可以用一个更简单的方法:利用浏览器的MediaRecorder API通过前端组件录音,然后传回后端处理。为了保持简洁,我会先采用一个基于JavaScript的简单录音组件集成到Streamlit中。
2.3 整体架构与数据流
整个应用的数据流是这样的:
- 用户触发录音 :在Streamlit界面上点击“开始录音”按钮。
- 前端录音 :浏览器通过JavaScript捕获麦克风音频流,并编码(如WAV格式)后传回Streamlit后端。
- 语音转文字 :Streamlit后端收到音频数据,调用本地的 faster-whisper 模型进行识别,得到文本指令。
- 文本指令处理 :将识别出的文本显示在界面上,并作为用户消息添加到对话历史中。
- 调用AI模型 :Streamlit后端将包含历史对话的上下文,通过HTTP请求发送给本地运行的 Ollama API (
http://localhost:11434/api/chat)。 - 获取AI回复 :Ollama运行所选的大模型,生成回复文本,通过API返回。
- 文字转语音 :Streamlit后端收到AI回复文本,调用 pyttsx3 生成语音音频(如MP3或WAV格式)。
- 播放与显示 :Streamlit将AI回复文本显示在界面上,同时使用
st.audio组件播放生成的语音。对话历史得以更新。
这个流程形成了一个完整的“语音输入 -> 文本理解 -> 智能思考 -> 语音输出”的闭环,全部在本地完成。
3. 环境准备与核心依赖安装
3.1 基础Python环境与Ollama部署
首先,确保你有一个Python环境(建议3.8以上)。创建一个新的虚拟环境是个好习惯。
# 创建并激活虚拟环境(以conda为例)
conda create -n voice-ai python=3.10
conda activate voice-ai
# 或者使用 venv
python -m venv voice-ai-env
source voice-ai-env/bin/activate # Linux/macOS
voice-ai-env\Scripts\activate # Windows
接下来是重头戏:安装 Ollama 。它不是一个Python包,而是一个需要单独安装的系统级工具。
- macOS/Linux :直接在终端执行安装脚本。
curl -fsSL https://ollama.com/install.sh | sh - Windows :从 Ollama官网 下载安装程序并运行。
安装完成后,打开一个新的终端,启动Ollama服务(它通常会作为后台服务运行)。然后,拉取一个模型,比如轻量且性能不错的 llama3.2:1b (10亿参数版本,对硬件要求低)或 mistral:7b 。
# 拉取并运行模型,这也会启动服务
ollama run llama3.2:1b
# 或者先拉取,后续通过API调用
ollama pull llama3.2:1b
运行后,Ollama的API服务默认就在 http://localhost:11434 上启动了。你可以用 curl 测试一下:
curl http://localhost:11434/api/chat -d '{
"model": "llama3.2:1b",
"messages": [{ "role": "user", "content": "Hello, who are you?" }],
"stream": false
}'
如果看到返回的JSON数据,说明Ollama配置成功。
注意 :首次运行
ollama run或ollama pull会下载模型文件,体积从几百MB到几个GB不等,请确保网络通畅和足够的磁盘空间。模型会保存在~/.ollama/models(Linux/macOS)或C:\Users\<你的用户名>\.ollama\models(Windows)目录下。
3.2 Python依赖包安装
在我们的项目虚拟环境中,安装所需的Python库。创建一个 requirements.txt 文件:
streamlit>=1.28.0
openai>=1.0.0 # 使用Ollama的OpenAI兼容客户端
faster-whisper>=0.9.0
pyttsx3>=2.90
soundfile>=0.12.0 # 用于音频文件处理
numpy>=1.24.0
pydub>=0.25.1 # 可选,用于音频格式转换
streamlit-webrtc>=0.44.0 # 可选,用于高级音频录制
然后安装它们:
pip install -r requirements.txt
这里解释几个关键包:
openai:虽然我们不用OpenAI的云端服务,但它的新版Python SDK支持自定义API base URL,我们可以将其指向本地的Ollama服务,这样写代码的接口就和调用ChatGPT API几乎一样,非常方便。faster-whisper:需要额外依赖。在Linux上可能需要安装ffmpeg和libcublas(如果用CUDA)。Windows和macOS通常通过pip安装即可,但若遇到问题,请参考其GitHub主页。pyttsx3:在Linux上可能需要安装espeak和libespeak1。在Windows和macOS上通常无需额外配置。streamlit-webrtc:这是一个功能强大的组件,但配置稍复杂。如果只想快速实现录音,我们可以先用一个更简单的替代方案。
3.3 解决可能的音频处理依赖
音频处理是容易踩坑的地方。 faster-whisper 依赖 ffmpeg 来处理各种音频文件。请确保系统已安装FFmpeg并添加到PATH环境变量。
- Ubuntu/Debian :
sudo apt update && sudo apt install ffmpeg - macOS (使用Homebrew) :
brew install ffmpeg - Windows : 从 FFmpeg官网 下载编译好的版本,解压后将
bin目录路径(例如C:\ffmpeg\bin)添加到系统的PATH环境变量中。
安装后,在命令行输入 ffmpeg -version 确认安装成功。
4. 核心模块代码实现详解
4.1 构建Streamlit应用骨架与界面
我们先搭建一个基本的Streamlit应用界面。创建一个名为 app.py 的文件。
import streamlit as st
import openai
import numpy as np
import io
import tempfile
import os
from datetime import datetime
# 先导入,具体实现稍后填充
# from faster_whisper import WhisperModel
# import pyttsx3
# 设置页面标题和布局
st.set_page_config(
page_title="本地语音AI助手",
page_icon="🤖",
layout="wide"
)
# 初始化session state,用于存储对话历史和状态
if "messages" not in st.session_state:
st.session_state.messages = [] # 存储对话历史,格式:[{"role": "user"/"assistant", "content": "..."}]
if "audio_bytes" not in st.session_state:
st.session_state.audio_bytes = None # 存储最新生成的TTS音频字节
if "processing" not in st.session_state:
st.session_state.processing = False # 防止重复提交
# 配置Ollama客户端
openai.api_base = "http://localhost:11434/v1" # Ollama的OpenAI兼容端点
openai.api_key = "ollama" # 任意非空字符串即可,Ollama不验证
# 侧边栏 - 配置区域
with st.sidebar:
st.header("⚙️ 配置")
model_name = st.selectbox(
"选择Ollama模型",
["llama3.2:1b", "mistral:7b", "llama3.1:8b", "qwen2.5:7b"], # 根据你本地有的模型调整
index=0
)
st.caption(f"当前模型: `{model_name}`。确保已在终端运行 `ollama run {model_name}` 或已拉取。")
# 语音识别模型选择(Whisper)
whisper_model_size = st.selectbox(
"Whisper模型大小 (越大越准,越慢)",
["tiny", "base", "small", "medium", "large-v2"],
index=2 # 默认small,平衡精度和速度
)
st.caption("模型首次加载需要时间,后续调用会缓存。")
# TTS配置
tts_enabled = st.checkbox("启用语音回复 (TTS)", value=True)
if tts_enabled:
tts_rate = st.slider("语速", 100, 300, 200) # 默认200%
tts_volume = st.slider("音量", 0.0, 1.0, 0.9) # 默认0.9
# 对话控制
if st.button("清空对话历史", type="secondary"):
st.session_state.messages = []
st.session_state.audio_bytes = None
st.rerun()
# 主界面
st.title("🎤 本地语音AI助手")
st.markdown("""
这是一个完全运行在你本地电脑上的AI语音助手。
你的语音、对话内容**不会离开你的电脑**,请放心使用。
""")
# 显示对话历史
chat_container = st.container()
with chat_container:
for message in st.session_state.messages:
with st.chat_message(message["role"]):
st.markdown(message["content"])
# 录音与输入区域 - 暂时用文本输入替代,下一步实现录音
st.divider()
st.subheader("输入指令")
input_col1, input_col2 = st.columns([4, 1])
with input_col1:
# 临时:文本输入框
text_input = st.text_input(
"在此输入文本指令,或使用下方录音功能",
placeholder="例如:帮我写一个Python函数计算斐波那契数列",
label_visibility="collapsed"
)
with input_col2:
# 录音按钮占位
record_button = st.button("🎤 开始录音", use_container_width=True, disabled=st.session_state.processing)
# 如果文本输入被提交
if text_input and not st.session_state.processing:
st.session_state.processing = True
# 将用户输入添加到历史并显示
with chat_container:
with st.chat_message("user"):
st.markdown(text_input)
st.session_state.messages.append({"role": "user", "content": text_input})
# 这里后续会调用AI和TTS
# 暂时先模拟一个回复
with chat_container:
with st.chat_message("assistant"):
st.markdown("(这里是AI回复...)")
st.session_state.messages.append({"role": "assistant", "content": "(这里是AI回复...)"})
st.session_state.processing = False
st.rerun()
# 如果录音按钮被点击(待实现)
if record_button:
st.info("录音功能正在开发中...")
# 后续将在这里集成录音逻辑
这个骨架搭建了基本的界面:侧边栏用于配置模型和参数,主区域显示对话历史,底部有输入区域。目前,我们先用文本输入来测试流程。
4.2 实现本地语音识别(ASR)模块
现在,我们来集成 faster-whisper ,实现录音后的语音转文字功能。首先,我们需要一个方法让用户录音。由于Streamlit原生不支持直接录音,我们可以用一个简单的前端组件 streamlit-audiorecorder (需安装)或者自己写一点JavaScript。为了更可控,我们使用一个轻量级的自定义组件方法,结合 streamlit 的 html 功能。
不过,为了简化并专注于核心逻辑,我们调整一下思路: 提供一个上传音频文件的功能 ,让用户可以先录制好音频(用手机或电脑自带录音工具),再上传进行识别。这能验证ASR模块是否工作。同时,我们预留接口,后续可以替换为真正的实时录音。
首先,完善ASR模块。在 app.py 顶部添加导入和初始化:
from faster_whisper import WhisperModel
import torch
import warnings
warnings.filterwarnings("ignore")
# 初始化Whisper模型(放在侧边栏配置之后,或惰性加载)
@st.cache_resource
def load_whisper_model(model_size="small", device="cpu", compute_type="int8"):
"""
加载Whisper模型并缓存。
参数:
model_size: Whisper模型大小,如 "tiny", "base", "small", "medium", "large-v2"
device: "cpu" 或 "cuda"
compute_type: 计算精度,如 "int8", "float16", "float32"。int8省内存但可能略损失精度。
"""
st.info(f"正在加载 Whisper-{model_size} 模型,首次加载较慢...")
# 检查CUDA是否可用
if device == "cuda" and not torch.cuda.is_available():
st.warning("CUDA不可用,回退到CPU。")
device = "cpu"
compute_type = "int8" if compute_type == "int8" else "float32"
model = WhisperModel(model_size, device=device, compute_type=compute_type)
st.success(f"Whisper-{model_size} 模型加载完成!")
return model
# 在侧边栏配置中增加设备选项
with st.sidebar:
# ... 之前的模型选择 ...
asr_device = st.radio(
"Whisper运行设备",
["cpu", "cuda"],
index=0 if not torch.cuda.is_available() else 1,
help="选择cuda需要NVIDIA GPU且已安装PyTorch CUDA版本。"
)
compute_type = st.selectbox(
"计算精度",
["int8", "float16", "float32"],
index=0,
help="int8最省内存,float32最精确但最慢。"
)
# 在合适的地方(如点击处理时)加载模型
whisper_model = load_whisper_model(whisper_model_size, device=asr_device, compute_type=compute_type)
def transcribe_audio(audio_file_path):
"""
使用加载的Whisper模型转录音频文件。
返回识别出的文本。
"""
try:
# faster-whisper 的转录接口
segments, info = whisper_model.transcribe(audio_file_path, beam_size=5, language="zh")
# 将识别的片段拼接成完整文本
text = "".join([segment.text for segment in segments])
return text.strip()
except Exception as e:
st.error(f"语音识别失败: {e}")
return None
然后,我们在主界面增加一个音频文件上传器:
# 在主界面输入区域下方增加
st.divider()
st.subheader("或上传音频文件进行识别")
uploaded_audio = st.file_uploader("选择WAV/MP3等音频文件", type=["wav", "mp3", "m4a", "flac"])
if uploaded_audio is not None and not st.session_state.processing:
# 保存上传的音频到临时文件
with tempfile.NamedTemporaryFile(delete=False, suffix=".wav") as tmp_file:
tmp_file.write(uploaded_audio.getvalue())
tmp_path = tmp_file.name
st.audio(uploaded_audio, format='audio/wav')
with st.spinner("正在识别语音..."):
transcribed_text = transcribe_audio(tmp_path)
os.unlink(tmp_path) # 删除临时文件
if transcribed_text:
st.success(f"识别结果: {transcribed_text}")
# 将识别结果自动填入输入框(模拟用户输入)
# 这里我们需要一个方法来更新text_input的值,但Streamlit的输入框状态是只读的。
# 因此,我们直接使用识别结果作为用户输入,触发AI对话。
st.session_state.processing = True
user_input_for_ai = transcribed_text
# 显示用户消息
with chat_container:
with st.chat_message("user"):
st.markdown(user_input_for_ai)
st.session_state.messages.append({"role": "user", "content": user_input_for_ai})
# 接下来会调用AI,我们先在这里标记,后续统一处理
# 为了逻辑清晰,我们重构一下,将“处理用户输入并调用AI”写成一个函数。
4.3 集成Ollama实现AI对话
现在,我们来实现与Ollama对话的核心函数。我们将使用 openai 库,但将其指向本地Ollama服务。
def chat_with_ollama(messages, model="llama3.2:1b"):
"""
调用本地Ollama服务进行对话。
messages: 对话历史列表,格式同OpenAI API,例如
[{"role": "user", "content": "你好"}, {"role": "assistant", "content": "你好!有什么可以帮助你的?"}]
model: Ollama中已拉取的模型名称。
返回AI回复的文本内容。
"""
try:
# 使用OpenAI客户端,但配置了base_url指向Ollama
client = openai.OpenAI(
base_url="http://localhost:11434/v1",
api_key="ollama", # Ollama不需要真正的key,但需要非空
)
response = client.chat.completions.create(
model=model,
messages=messages,
stream=False, # 我们先处理非流式,简化逻辑
temperature=0.7, # 创造性,0-1,越高越随机
max_tokens=500, # 生成的最大token数
)
return response.choices[0].message.content
except openai.APIConnectionError as e:
st.error(f"无法连接到Ollama服务: {e}. 请确保Ollama正在运行 (`ollama serve` 或 `ollama run <模型名>`)")
return None
except openai.APIStatusError as e:
st.error(f"Ollama API返回错误: {e.status_code} - {e.response.text}")
return None
except Exception as e:
st.error(f"调用AI时发生未知错误: {e}")
return None
接下来,我们创建一个统一的函数 process_user_input ,来处理无论是文本输入还是语音识别得到的用户输入。
def process_user_input(user_text: str, model_name: str, tts_enabled: bool, tts_rate: int, tts_volume: float):
"""
处理用户输入:调用AI,生成回复,并处理TTS。
"""
if not user_text.strip():
return
# 1. 调用Ollama获取AI回复
with st.spinner("AI正在思考..."):
ai_response = chat_with_ollama(st.session_state.messages, model=model_name)
if ai_response is None:
st.error("获取AI回复失败。")
return
# 2. 将AI回复添加到对话历史并显示
st.session_state.messages.append({"role": "assistant", "content": ai_response})
with chat_container:
with st.chat_message("assistant"):
st.markdown(ai_response)
# 3. 如果启用了TTS,将AI回复转为语音
if tts_enabled and ai_response:
audio_bytes = text_to_speech(ai_response, rate=tts_rate, volume=tts_volume)
if audio_bytes:
st.session_state.audio_bytes = audio_bytes
# 播放音频
st.audio(st.session_state.audio_bytes, format='audio/wav')
现在,我们需要修改主界面的输入处理逻辑,调用这个统一函数。同时,我们也要实现 text_to_speech 函数。
4.4 实现本地文本转语音(TTS)模块
我们来集成 pyttsx3 。需要注意的是, pyttsx3 是同步操作,并且在某些环境下(如某些Linux桌面或服务器环境)可能需要额外配置。我们将其封装成一个函数。
import pyttsx3
import threading
import queue
import time
# 由于pyttsx3的引擎初始化可能不是线程安全的,我们全局初始化一次
@st.cache_resource
def init_tts_engine():
"""初始化并缓存TTS引擎"""
try:
engine = pyttsx3.init()
# 设置默认参数(会被每次调用的参数覆盖)
engine.setProperty('rate', 200)
engine.setProperty('volume', 0.9)
return engine
except Exception as e:
st.error(f"初始化TTS引擎失败: {e}. 请检查系统语音合成支持。")
return None
def text_to_speech(text: str, rate=200, volume=0.9, save_to_file=None):
"""
使用pyttsx3将文本转换为语音,并返回音频字节(WAV格式)。
参数:
text: 要合成的文本
rate: 语速 (默认200)
volume: 音量 (0.0到1.0,默认0.9)
save_to_file: 可选,保存为文件路径
返回:
audio_bytes: WAV格式的音频字节,如果失败返回None
"""
engine = init_tts_engine()
if engine is None:
return None
# 设置本次合成的属性
engine.setProperty('rate', rate)
engine.setProperty('volume', volume)
# pyttsx3默认不直接返回字节流,我们需要将其保存到临时文件或内存
# 这里我们选择保存到临时文件,然后读取字节
import tempfile
import os
try:
with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as tmp_file:
tmp_path = tmp_file.name
# 保存语音到临时文件
engine.save_to_file(text, tmp_path)
engine.runAndWait() # 阻塞直到合成完成
# 读取临时文件内容
with open(tmp_path, 'rb') as f:
audio_bytes = f.read()
# 如果调用者指定了保存路径,则复制一份
if save_to_file:
import shutil
shutil.copy(tmp_path, save_to_file)
# 删除临时文件
os.unlink(tmp_path)
return audio_bytes
except Exception as e:
st.error(f"语音合成失败: {e}")
return None
现在,我们更新主程序逻辑,将文本输入、音频文件上传的后续处理都指向 process_user_input 函数。同时,我们需要一个地方来触发这个处理。我们可以通过一个“发送”按钮,或者当输入框有内容且用户按下回车时触发。为了简化,我们给文本输入框加上一个“发送”按钮,并重构界面。
修改主界面的输入部分:
# 主界面 - 输入区域重构
input_col1, input_col2, input_col3 = st.columns([5, 1, 1])
with input_col1:
text_input = st.text_input(
"输入文本指令",
placeholder="直接输入,或上传音频识别后点击发送",
label_visibility="collapsed",
key="text_input_widget" # 给一个key以便操作
)
with input_col2:
send_text_button = st.button("发送", use_container_width=True, type="primary")
with input_col3:
record_button = st.button("🎤 录音", use_container_width=True, disabled=True) # 录音功能暂未完全实现
# 处理文本发送
if send_text_button and text_input and not st.session_state.processing:
st.session_state.processing = True
# 显示用户消息
with chat_container:
with st.chat_message("user"):
st.markdown(text_input)
st.session_state.messages.append({"role": "user", "content": text_input})
# 处理并获取AI回复
process_user_input(text_input, model_name, tts_enabled, tts_rate, tts_volume)
st.session_state.processing = False
# 清空输入框(通过操作session_state)
st.session_state.text_input_widget = ""
st.rerun()
# 处理上传的音频文件识别后的自动发送
# 我们需要一个标志来记录是否刚完成识别并需要自动发送
if 'last_transcribed' not in st.session_state:
st.session_state.last_transcribed = None
if uploaded_audio is not None and not st.session_state.processing:
if st.session_state.last_transcribed != uploaded_audio.file_id: # 避免重复处理同一个文件
st.session_state.processing = True
# ... (之前的保存和识别代码) ...
if transcribed_text:
# 显示用户消息
with chat_container:
with st.chat_message("user"):
st.markdown(transcribed_text)
st.session_state.messages.append({"role": "user", "content": transcribed_text})
# 处理并获取AI回复
process_user_input(transcribed_text, model_name, tts_enabled, tts_rate, tts_volume)
st.session_state.last_transcribed = uploaded_audio.file_id
st.session_state.processing = False
st.rerun()
至此,一个具备 文本对话 、 音频文件识别对话 和 语音合成 功能的本地AI助手核心就完成了。你可以运行 streamlit run app.py 来测试。确保Ollama服务正在运行(在另一个终端执行 ollama run llama3.2:1b )。
5. 功能进阶与优化
5.1 实现真正的实时语音录制
之前的方案依赖于上传预录制的音频文件。要实现真正的“点击即录”,我们需要在浏览器中直接录音。一个相对简单的方法是使用 streamlit-webrtc 组件。但它的配置和数据处理稍复杂。这里提供一个简化版的思路,使用 audio_recorder_streamlit 这个第三方组件(需安装)。
首先安装组件:
pip install audio-recorder-streamlit
然后在 app.py 中导入并使用:
from audio_recorder_streamlit import audio_recorder
# 在输入区域替换之前的录音按钮占位
record_col1, record_col2 = st.columns([1, 3])
with record_col1:
audio_bytes = audio_recorder(text="", icon_size="2x", pause_threshold=3.0) # pause_threshold是静音自动停止的秒数
with record_col2:
if audio_bytes:
st.audio(audio_bytes, format="audio/wav")
# 提供一个按钮,将录音发送给后端识别
if st.button("识别并发送此段录音"):
# 将音频字节保存到临时文件
with tempfile.NamedTemporaryFile(delete=False, suffix=".wav") as tmp_file:
tmp_file.write(audio_bytes)
tmp_path = tmp_file.name
with st.spinner("识别中..."):
transcribed_text = transcribe_audio(tmp_path)
os.unlink(tmp_path)
if transcribed_text:
# 清空录音显示,避免重复发送
# 这里需要一点技巧来重置组件状态,一个简单方法是使用session_state和rerun
st.session_state.processing = True
with chat_container:
with st.chat_message("user"):
st.markdown(transcribed_text)
st.session_state.messages.append({"role": "user", "content": transcribed_text})
process_user_input(transcribed_text, model_name, tts_enabled, tts_rate, tts_volume)
st.session_state.processing = False
# 通过rerun刷新页面,清空录音
st.rerun()
这个组件会在页面上显示一个麦克风按钮,点击开始录音,再次点击停止。录音数据会以字节形式返回。我们将其保存为临时文件,然后调用之前的 transcribe_audio 函数进行识别。
5.2 流式输出与打字机效果
目前AI的回复是等全部生成后才显示。为了更好的体验,我们可以实现流式输出,让文字一个字一个字地显示出来(打字机效果)。Ollama API支持流式响应( stream=True ),我们需要修改 chat_with_ollama 函数和前端显示逻辑。
首先,修改 chat_with_ollama 函数,使其支持流式:
def chat_with_ollama_stream(messages, model="llama3.2:1b"):
"""
流式调用Ollama服务。
返回一个生成器,每次yield一个token或一段文本。
"""
try:
client = openai.OpenAI(
base_url="http://localhost:11434/v1",
api_key="ollama",
)
stream = client.chat.completions.create(
model=model,
messages=messages,
stream=True, # 启用流式
temperature=0.7,
max_tokens=500,
)
for chunk in stream:
if chunk.choices[0].delta.content is not None:
yield chunk.choices[0].delta.content
except Exception as e:
yield f"[错误: {e}]"
然后,修改 process_user_input 函数中显示AI回复的部分:
def process_user_input(user_text: str, model_name: str, tts_enabled: bool, tts_rate: int, tts_volume: float):
if not user_text.strip():
return
# 1. 调用Ollama获取AI回复(流式)
with st.spinner("AI正在思考..."):
# 先创建一个占位符用于流式输出
with chat_container:
with st.chat_message("assistant"):
response_placeholder = st.empty()
full_response = ""
# 收集所有流式片段,用于后续TTS
response_parts = []
for chunk in chat_with_ollama_stream(st.session_state.messages, model=model_name):
full_response += chunk
response_parts.append(chunk)
# 更新占位符,实现打字机效果
response_placeholder.markdown(full_response + "▌")
# 流结束,移除光标
response_placeholder.markdown(full_response)
# 2. 将完整的AI回复添加到对话历史
ai_response = full_response
st.session_state.messages.append({"role": "assistant", "content": ai_response})
# 3. TTS(使用完整的回复文本)
if tts_enabled and ai_response:
audio_bytes = text_to_speech(ai_response, rate=tts_rate, volume=tts_volume)
if audio_bytes:
st.session_state.audio_bytes = audio_bytes
st.audio(st.session_state.audio_bytes, format='audio/wav')
5.3 对话历史管理与上下文长度
目前,我们将所有对话历史都发送给模型。对于较长的对话,这可能会超出模型的上下文窗口,导致性能下降或遗忘早期内容。我们需要管理上下文长度。
一个简单的策略是只保留最近N轮对话,或者当总token数超过某个阈值时,丢弃最早的对话。我们可以利用 tiktoken 库(OpenAI的tokenizer)来粗略估算token数,但需要注意不同模型的tokenizer不同。这里实现一个简单的轮数限制:
# 在侧边栏增加一个上下文长度的设置
with st.sidebar:
max_history_rounds = st.slider("保留对话轮数", 1, 20, 10, help="只将最近N轮对话发送给AI,以控制上下文长度。")
# 在准备发送给AI的messages时进行截断
def get_recent_messages(full_messages, max_rounds):
"""
从完整的对话历史中,提取最近 max_rounds 轮对话。
保证以user消息开始(如果最近一轮是assistant,则再往前取一轮user)。
"""
if max_rounds <= 0:
return []
# 取最后 max_rounds*2 条消息(因为一轮包含user和assistant各一条)
recent = full_messages[-(max_rounds*2):]
# 确保第一条消息的角色是'user',如果不是,则去掉第一条
if recent and recent[0]["role"] != "user":
recent = recent[1:]
return recent
# 在调用 chat_with_ollama_stream 时,使用截断后的历史
messages_to_send = get_recent_messages(st.session_state.messages, max_history_rounds)
for chunk in chat_with_ollama_stream(messages_to_send, model=model_name):
...
更高级的策略可以计算token数,并智能地保留最重要的历史(如系统提示、最近几轮、以及可能被摘要化的早期历史)。
6. 部署、调试与常见问题
6.1 如何在不同环境下运行
本地开发 :按照上述步骤即可。确保Ollama服务在运行,并且Streamlit应用使用的端口(默认8501)未被占用。
局域网内共享 :Streamlit默认只监听本地回环地址(127.0.0.1)。如果你想在同一个Wi-Fi下的其他设备(如手机、平板)上访问这个助手,需要让Streamlit监听所有网络接口。
streamlit run app.py --server.address 0.0.0.0 --server.port 8501
然后,在其他设备的浏览器中输入 http://<你的电脑IP地址>:8501 即可访问。注意防火墙设置。
部署到服务器 :你可以将这套应用部署到云服务器(如AWS EC2、Google Cloud VM等)。步骤类似:
- 在服务器上安装Ollama和Python环境。
- 拉取所需的模型。
- 运行Streamlit应用。为了持久化,可以使用
nohup、systemd服务或screen/tmux。 - 由于Streamlit内置服务器不适合高并发生产环境,若需要对外服务,建议使用Nginx反向代理,或者将核心逻辑移植到FastAPI等框架,并用更专业的ASGI服务器(如Uvicorn)部署。
6.2 常见问题与排查
1. Ollama连接失败 ( APIConnectionError )
- 症状 :应用报错“无法连接到Ollama服务”。
- 排查 :
- 首先,在终端运行
ollama serve或ollama run <模型名>,确保Ollama服务进程正在运行。 - 检查Ollama API地址是否正确。默认是
http://localhost:11434。在app.py中我们配置了openai.api_base = "http://localhost:11434/v1"。 - 用
curl http://localhost:11434/api/tags测试API是否正常响应,应返回已拉取的模型列表。
- 首先,在终端运行
2. Whisper模型加载慢或识别失败
- 症状 :首次加载Whisper时卡住很久,或识别时出错。
- 排查 :
- 首次加载慢 :正常。
faster-whisper需要下载模型文件(几百MB到几个GB),并转换为优化格式。耐心等待,后续调用会很快。 - 内存不足 :较大的模型(如
large-v2)需要较多内存和显存。如果加载失败,尝试换用更小的模型(如base或small),或在load_whisper_model函数中设置compute_type="int8"来减少内存占用。 - CUDA错误 :如果设置了
device="cuda"但报错,请确认已安装支持CUDA的PyTorch (torch.cuda.is_available()为True)。否则回退到device="cpu"。
- 首次加载慢 :正常。
3. TTS没有声音或报错
- 症状 :
pyttsx3初始化失败,或合成后没有音频输出。 - 排查 :
- Linux系统 :确保安装了
espeak和libespeak1。例如在Ubuntu上:sudo apt install espeak libespeak1。 - 权限问题 :在某些服务器或无桌面环境,可能缺少音频驱动。
pyttsx3可能无法工作。可以考虑备选方案,如使用gTTS(需要网络)离线缓存,或换用其他本地TTS引擎(如Coqui TTS),但这会显著增加复杂度。 - 临时文件写入失败 :检查
tempfile.gettempdir()目录是否有写入权限。
- Linux系统 :确保安装了
4. 录音组件不工作
- 症状 :点击录音按钮没反应,或浏览器提示需要麦克风权限。
- 排查 :
- Streamlit应用必须通过 HTTPS 或 localhost 访问,浏览器才允许访问麦克风。确保你通过
http://localhost:8501访问。 - 检查浏览器是否屏蔽了麦克风权限。在浏览器地址栏附近,应该有一个麦克风图标,点击并允许站点使用麦克风。
audio-recorder-streamlit组件可能有版本兼容性问题。查看其GitHub页面或尝试更新。
- Streamlit应用必须通过 HTTPS 或 localhost 访问,浏览器才允许访问麦克风。确保你通过
5. AI回复质量差或胡言乱语
- 症状 :AI的回答不相关、重复或没有逻辑。
- 排查 :
- 模型太小 :1B或7B参数模型的能力有限,对于复杂任务可能表现不佳。尝试更大的模型(如13B、70B),但需要更强的硬件。
- 提示词(Prompt) :我们发送的
messages就是提示词。确保对话历史清晰。可以在messages开头加入一个系统提示({"role": "system", "content": "你是一个有帮助的助手。"}),来引导AI行为。 - 温度(Temperature) :
temperature参数控制随机性。太高(接近1)会导致回答随机、不连贯;太低(接近0)会导致回答死板、重复。尝试调整到0.7-0.9之间。 - 上下文长度 :如果对话历史太长,模型可能“忘记”了开头的内容。尝试减少
max_history_rounds。
6.3 性能优化建议
- 模型选择 :在速度和效果间权衡。语音识别用
whisper-small通常是不错的平衡点。AI模型方面,Llama 3.2 1B速度极快但能力较弱,Mistral 7B是较好的通用选择。根据你的硬件(特别是GPU VRAM)选择。 - 硬件加速 :确保Ollama和Whisper都尽可能使用GPU(CUDA)。对于Ollama,它通常会自动检测并使用GPU。对于Whisper,在
load_whisper_model中设置device="cuda"。 - 缓存 :我们使用
@st.cache_resource缓存了Whisper模型和TTS引擎,这能避免每次交互都重新加载,大幅提升响应速度。 - 异步处理 :对于TTS这种耗时操作,可以考虑使用异步(
asyncio)或后台线程,避免阻塞主交互线程,让用户在AI生成文本后就能立刻看到,而语音稍后播放。
这个项目就像一个乐高积木,每个模块都可以被替换或升级。你可以把Ollama换成其他本地API服务(如LM Studio),把Whisper换成其他ASR引擎,把pyttsx3换成更自然的TTS模型。核心在于,你拥有了一个完全在本地运行的、私密的、可定制的语音AI助手原型。
更多推荐

所有评论(0)