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项目,它的优势非常明显:

  1. 开发速度极快 :你不需要懂HTML、CSS、JavaScript,只需要关注Python逻辑。一个脚本就能定义整个应用的布局、交互和状态管理。
  2. 状态管理简单 :通过 st.session_state ,可以很方便地在多次用户交互间保持数据(比如对话历史、模型状态)。
  3. 丰富的组件 :内置了按钮、滑块、文本输入/输出、音频播放器等组件,我们需要的录音、播放、文字展示都能轻松实现。
  4. 热重载 :修改代码后保存,页面自动刷新,调试体验流畅。

Ollama 则是本地运行大模型的“瑞士军刀”。它提供了一个非常简单的命令行工具和API,让你能够一键拉取、运行和管理各种开源大模型。

  1. 开箱即用 :一条命令 ollama run llama3 就能把Meta的Llama 3模型跑起来,并开启一个类OpenAI的API接口(默认在 http://localhost:11434 )。
  2. 模型管理方便 :可以轻松切换不同模型( ollama list , ollama pull ),无需复杂的配置。
  3. 资源友好 :Ollama会针对你的硬件(特别是GPU)进行优化,对于消费级显卡(如NVIDIA RTX系列)支持很好,能有效利用显存。
  4. 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 整体架构与数据流

整个应用的数据流是这样的:

  1. 用户触发录音 :在Streamlit界面上点击“开始录音”按钮。
  2. 前端录音 :浏览器通过JavaScript捕获麦克风音频流,并编码(如WAV格式)后传回Streamlit后端。
  3. 语音转文字 :Streamlit后端收到音频数据,调用本地的 faster-whisper 模型进行识别,得到文本指令。
  4. 文本指令处理 :将识别出的文本显示在界面上,并作为用户消息添加到对话历史中。
  5. 调用AI模型 :Streamlit后端将包含历史对话的上下文,通过HTTP请求发送给本地运行的 Ollama API http://localhost:11434/api/chat )。
  6. 获取AI回复 :Ollama运行所选的大模型,生成回复文本,通过API返回。
  7. 文字转语音 :Streamlit后端收到AI回复文本,调用 pyttsx3 生成语音音频(如MP3或WAV格式)。
  8. 播放与显示 :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等)。步骤类似:

  1. 在服务器上安装Ollama和Python环境。
  2. 拉取所需的模型。
  3. 运行Streamlit应用。为了持久化,可以使用 nohup systemd 服务或 screen / tmux
  4. 由于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() 目录是否有写入权限。

4. 录音组件不工作

  • 症状 :点击录音按钮没反应,或浏览器提示需要麦克风权限。
  • 排查
    • Streamlit应用必须通过 HTTPS localhost 访问,浏览器才允许访问麦克风。确保你通过 http://localhost:8501 访问。
    • 检查浏览器是否屏蔽了麦克风权限。在浏览器地址栏附近,应该有一个麦克风图标,点击并允许站点使用麦克风。
    • audio-recorder-streamlit 组件可能有版本兼容性问题。查看其GitHub页面或尝试更新。

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助手原型。

Logo

免费领 100 小时云算力,进群参与显卡、AI PC 幸运抽奖

更多推荐