1. 项目概述:一个能听会做的本地AI助手

最近在捣鼓本地大模型应用,总想着怎么让它更“接地气”,能直接听懂人话并干点实事。网上很多Demo要么是纯聊天,要么就是调用云端API,总感觉少了点掌控感和实用性。于是,我花时间折腾了一个完全在本地运行的语音控制AI助手。它的核心流程很直观:你对着麦克风说话,它把语音转成文字,理解你的意图,然后在一个安全的“沙箱”环境里执行相应的本地操作,比如创建文件、写段代码或者总结文本。整个过程中,你在界面上能清晰地看到它“听到”了什么、“理解”了什么、最后“做”了什么,这种透明性对于建立信任至关重要。

这个项目特别适合那些对AI应用开发感兴趣,希望将大模型能力与具体本地任务结合起来的开发者。无论你是想学习如何集成语音识别(STT)和大型语言模型(LLM),还是想构建一个具有安全边界的自动化小工具,这个项目都能提供一个清晰的、模块化的起点。它基于Python构建,核心用到了Streamlit做交互界面,Whisper处理语音转文字,Ollama来运行本地大模型进行意图理解。整个设计强调“本地优先”和“安全可控”,所有文件操作都被限制在一个指定的输出文件夹内,避免了任意写文件可能带来的风险。接下来,我会详细拆解从设计思路到每一行代码的考量,分享在搭建过程中踩过的坑和总结的经验。

2. 核心架构与设计思路拆解

2.1 为什么选择“本地优先”与“沙箱化”?

在项目启动前,我首先明确了两个核心设计原则: 本地优先 操作沙箱化 。这并非突发奇想,而是基于实际需求和安全考量。

本地优先 意味着所有核心处理(语音识别、意图理解)都尽可能在用户自己的机器上完成。这带来了几个关键好处:首先是 隐私性 ,你的语音数据和对话内容无需上传到任何第三方服务器;其次是 可控性 ,你不必担心API服务宕机、变更计费策略或限制调用频率;最后是 可定制性 ,你可以自由选择或微调本地的Whisper和Ollama模型,以适应特定的口音、专业术语或响应风格。当然,本地优先的代价是对硬件有一定要求,并且需要处理复杂的本地依赖。为此,我在设计中加入了 降级策略 ,例如当本地Whisper模型因环境问题无法工作时,可以回退到使用一个可选的API进行转录(需用户自行配置),确保基础功能不中断。

操作沙箱化 则是安全性的基石。一个能执行本地操作的AI助手,如果权限过大,将是极其危险的。想象一下,如果它误解了你的指令,试图删除系统文件或覆盖重要文档,后果不堪设想。因此,我引入了严格的“输出目录”限制。所有由AI助手创建、写入的文件,都只能在一个预先定义好的 output/ 文件夹内进行。这个目录在逻辑上就是一个沙箱。在代码层面,这通过路径检查和净化来实现:任何文件操作请求,都会将其目标路径与沙箱目录进行解析和对比,确保最终的操作不会逃逸出去。同时,对文件名进行消毒(移除非法字符,防止路径遍历攻击),并可以限制允许创建的文件扩展名(例如只允许 .txt , .py , .md )。这样,即使AI的意图识别出了错,它所能造成的破坏也被严格限制在沙箱内,系统其他部分安然无恙。

2.2 模块化管道:清晰的责任边界

为了让系统易于理解、调试和扩展,我采用了高度模块化的管道式架构。整个流程被分解为几个职责单一的独立模块,通过清晰的接口进行串联。这种“高内聚、低耦合”的设计,使得你可以单独测试或替换任何一个环节,而不必牵一发而动全身。

整个管道的核心数据流如下: 音频输入 -> 语音转文本 -> 意图检测 -> 工具执行 -> 结果输出 。对应地,代码结构也围绕此展开:

  • app.py :这是应用的门面,负责构建Streamlit交互界面。它处理麦克风输入、文件上传、显示所有中间状态(转录文本、检测到的意图、工具执行日志)和最终结果。其职责是“展示”和“调度”,不包含核心业务逻辑。
  • stt.py :语音转文本模块。它封装了与Whisper模型的交互细节,负责加载模型、处理音频数据、返回转录文本。这里集中处理了音频格式转换、模型调用异常等细节,并为降级到API转录提供了接口。
  • intents.py :意图检测模块。这是AI的“大脑”所在。它接收转录文本,通过调用本地Ollama模型,分析用户想要执行哪个操作(创建文件、写代码等)。这里定义了系统支持的所有意图类别,并包含了基于关键词的降级检测逻辑。
  • tools.py :工具执行模块。这是AI的“双手”。根据 intents.py 传来的指令和参数,它执行具体的操作,如安全地创建文件、向文件写入内容、调用LLM总结文本等。所有涉及文件读写的危险操作,其安全边界检查都集中在这里。
  • pipeline.py :管道协调模块。它像一条流水线的控制器,按顺序调用上述模块,传递数据,处理模块间的异常,并确保整个流程能顺利运转或优雅降级。
  • config.py :配置模块。集中管理模型路径、Ollama服务地址、沙箱目录路径、支持的文件类型等运行时参数。修改配置只需动这一个文件。

注意 :这种模块化设计在初期似乎增加了代码量,但它带来的维护性和可调试性收益是巨大的。当语音识别不准时,你可以直接查看 stt.py 的日志和输出;当AI错误理解了意图,你可以检查 intents.py 中输入给Ollama的提示词和返回结果。问题被隔离在很小的范围内,定位和修复速度极大提升。

3. 技术栈选型与核心细节解析

3.1 前端与交互:为什么是Streamlit?

对于一个快速原型和演示项目,前端框架的选择需要在开发效率、交互能力和部署简易性之间权衡。我选择了 Streamlit ,主要原因有三点:

  1. 极速开发 :Streamlit允许你用纯Python脚本快速创建数据应用。对于这个以管道和数据流为核心的AI项目,我可以用最少的代码将音频组件、文本显示框、状态提示和按钮组合起来,实时展示管道每一步的输出。无需学习HTML/CSS/JavaScript,就能获得一个功能完整、外观尚可的Web界面。
  2. 原生支持媒体输入 :Streamlit提供了 st.audio_input 组件,可以直接在浏览器中捕获麦克风音频,并以字节流的形式传递给后端处理。这省去了自己处理WebRTC或复杂前端音频编码的麻烦。同时,我还保留了 st.file_uploader 用于上传预录制的音频文件,以及 st.text_area 用于手动输入或编辑文本,作为备用交互通道,提升了应用的鲁棒性。
  3. 状态管理与实时更新 :Streamlit的“脚本重运行”模型非常适合这种线性管道。每次用户点击“运行”按钮,整个脚本从上到下执行一遍,自然完成了从输入到输出的全过程。我可以方便地使用 st.write st.code st.info 等组件,将转录文本、意图分类结果、工具执行日志清晰地、按步骤地展示在UI上,实现了设计目标中强调的“透明性”。

当然,Streamlit在处理复杂、多状态的单页面应用(SPA)时有其局限性,但对于这个专注于演示端到端管道的项目来说,它是最佳选择。

3.2 语音转文本:本地Whisper与降级策略

语音识别的准确性是整个流程的入口,至关重要。我选择了OpenAI开源的 Whisper 模型,具体是通过 Hugging Face Transformers 库来调用。选择本地Whisper模型(如 base small 版本)而非在线API,是为了贯彻“本地优先”,确保语音数据不离线。

实操细节与避坑

  • 模型加载 :在 stt.py 中,使用 pipeline(“automatic-speech-recognition”, model=”openai/whisper-base”) 来加载模型。首次运行时会下载模型,建议在网络通畅环境下进行。为了提高响应速度,可以将模型加载放在全局范围,避免每次识别都重复加载。
  • 音频预处理 :Streamlit传来的音频数据可能是各种格式(如webm)。Whisper对WAV格式的16kHz单声道音频支持最好。因此,需要用到 librosa pydub 库进行音频重采样和格式转换。这是一个常见的坑点,如果音频格式不对,识别结果会非常差甚至报错。
  • 降级策略 :本地语音识别依赖 ffmpeg 等底层库,在某些系统上安装可能很麻烦。为了不让环境问题卡住用户,我设计了一个降级路径。在 stt.py 中,尝试本地识别,如果捕获到特定异常(如 RuntimeError ,提示缺少 ffmpeg ),则自动切换到一个备用的在线STT API(例如,可以配置一个开源的或商业的STT服务端点)。在UI上,会明确提示用户当前使用的是“本地模式”还是“降级API模式”。

心得 :永远不要假设用户的运行环境是完美的。对于像音频处理这类依赖复杂、容易出错的环节,一定要有“Plan B”。清晰的错误提示和自动降级能力,比一个功能强大但脆弱的系统更有用。

3.3 意图理解:Ollama本地大模型驱动

这是项目的智能核心。我们需要让AI理解“请创建一个叫test.py的文件”和“帮我写一个Python函数计算斐波那契数列”这两句话的不同意图。我使用了 Ollama 来在本地运行大语言模型(如Llama 3.1、Mistral或Qwen等)。

为什么是Ollama? Ollama极大地简化了在本地运行和部署大模型的过程。它提供了统一的命令行和API接口,让你可以像调用服务一样使用各种优化过的模型。相比于直接使用 transformers 库加载原生模型,Ollama管理模型下载、内存优化,并提供高效的推理后端,对开发者更加友好。

意图检测的实现 : 在 intents.py 中,我设计了一个 提示词模板 ,将用户的转录文本包装成一个分类任务。例如:

请分析用户的以下请求,判断其意图属于哪一类:
1. create_file - 用户明确要求创建一个新文件。
2. write_code - 用户要求编写或生成代码。
3. summarize_text - 用户要求总结一段文本。
4. general_chat - 其他通用对话或问题。

用户请求:“{user_input}”

请只返回意图类别的名称,不要返回任何其他解释。

然后,通过Ollama的API(通常是 http://localhost:11434/api/generate )将这个提示词发送给模型,并解析返回的文本。为了提高可靠性,可以设置一个较长的超时时间,并对返回内容进行清洗和标准化(转为小写,去除空格),再映射到预定义的意图枚举上。

降级方案:关键词匹配 本地模型服务可能因为Ollama未启动、模型未加载或网络问题而不可用。为此,我实现了一个简单的基于正则表达式或关键词列表的降级检测器。例如,如果检测到“创建”、“文件”、“新建”等词,就归类为 create_file ;包含“代码”、“编程”、“写一个函数”则归类为 write_code 。虽然精度远不如LLM,但能保证在最基本的功能层面,管道不会因为一个环节的失败而完全崩溃。

3.4 安全工具执行:被束缚的“双手”

tools.py 模块是所有动作发生的地方,也是安全防护的重点。每个工具函数在执行前,都必须通过安全检查。

create_file(filename) 函数为例,其安全执行流程如下:

  1. 路径解析与沙箱检查 :接收到的 filename 可能是“test.py”或“../secrets.txt”。首先,使用 os.path.join(config.OUTPUT_DIR, filename) 将其与沙箱目录拼接。然后,使用 os.path.commonpath() os.path.abspath() 检查最终路径是否仍然在 config.OUTPUT_DIR 的范围内。如果试图跳出沙箱,则立即抛出异常并终止操作。
  2. 文件名消毒 :使用 re.sub(r'[<>:“|?*\\/]’, ‘_’, filename) 移除文件名中的非法字符,防止操作系统层面的问题。
  3. 扩展名过滤(可选) :可以配置一个允许的扩展名列表 [‘.txt’, ‘.py’, ‘.md’, ‘.json’] 。检查目标文件的扩展名是否在列表中,如果不在,可以拒绝操作或强制改为默认扩展名(如 .txt )。
  4. 执行操作 :通过安全检查后,才执行 open(filepath, ‘w’).close() 来创建空文件。同时,记录日志:“已在沙箱内安全创建文件:{filepath}”。

write_code_file(filename, code_content) 和涉及文件写入的函数都遵循类似的流程。对于 summarize_text(text) 这类不涉及文件系统的工具,则相对简单,直接调用Ollama模型,使用一个总结性的提示词来处理输入文本即可。

核心安全原则 :永远不要相信来自上游(尤其是AI模型)的输入。所有来自意图检测模块的参数,在用于文件系统操作前,都必须视为不可信的,并进行严格的验证和净化。

4. 完整实现与核心代码剖析

4.1 项目结构与环境搭建

首先,我们来看项目的目录结构。清晰的目录是模块化架构的直观体现。

voice_ai_agent/
├── app.py          # Streamlit 主应用界面
├── pipeline.py     # 核心流程管道
├── stt.py          # 语音转文本模块
├── intents.py      # 意图检测模块
├── tools.py        # 安全工具执行模块
├── config.py       # 配置文件
├── requirements.txt # 项目依赖
└── output/         # 安全沙箱输出目录(自动创建)

requirements.txt 依赖清单

streamlit>=1.28.0
transformers>=4.35.0
torch>=2.0.0
librosa>=0.10.0
pydub>=0.25.1
requests>=2.31.0
ollama>=0.1.0
python-dotenv>=1.0.0

安装提示 :除了Python包,系统可能需要安装 ffmpeg 。在Ubuntu上可以用 sudo apt install ffmpeg ,在macOS上用 brew install ffmpeg ,Windows用户可以从官网下载二进制文件并配置环境变量。

config.py 配置详解

import os
from dotenv import load_dotenv

load_dotenv()  # 从 .env 文件加载环境变量

# 路径配置
OUTPUT_DIR = os.path.abspath(‘./output’)  # 安全沙箱目录
os.makedirs(OUTPUT_DIR, exist_ok=True)  # 确保目录存在

# 模型配置
WHISPER_MODEL = “openai/whisper-base”  # 本地Whisper模型
OLLAMA_BASE_URL = “http://localhost:11434"  # Ollama服务地址
OLLAMA_MODEL = “llama3.1:8b”  # 用于意图理解和聊天的模型

# 安全配置
ALLOWED_EXTENSIONS = [‘.txt’, ‘.py’, ‘.md’, ‘.json’, ‘.csv’]  # 允许创建的文件类型

# 降级配置(可选)
FALLBACK_STT_API_URL = os.getenv(‘FALLBACK_STT_API_URL’, None)  # 备用STT API
FALLBACK_STT_API_KEY = os.getenv(‘FALLBACK_STT_API_KEY’, None)

使用 python-dotenv 管理像API密钥这样的敏感信息,避免硬编码在代码中。

4.2 核心模块代码实现

stt.py - 语音转文本模块

import torch
from transformers import pipeline
import librosa
import io
import numpy as np
import requests
from config import WHISPER_MODEL, FALLBACK_STT_API_URL, FALLBACK_STT_API_KEY
import logging

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

class SpeechToText:
    def __init__(self):
        self.local_pipe = None
        self._init_local_model()
    
    def _init_local_model(self):
        “””尝试初始化本地Whisper模型”””
        try:
            # 指定设备,如果有GPU则使用GPU加速
            device = “cuda:0” if torch.cuda.is_available() else “cpu”
            self.local_pipe = pipeline(
                “automatic-speech-recognition”,
                model=WHISPER_MODEL,
                device=device
            )
            logger.info(f”本地Whisper模型加载成功,运行在 {device} 上。”)
        except Exception as e:
            logger.warning(f”本地Whisper模型加载失败: {e}。将使用降级模式。”)
            self.local_pipe = None
    
    def transcribe(self, audio_bytes, sample_rate=16000):
        “””
        核心转录函数。
        参数:
            audio_bytes: 音频字节数据
            sample_rate: 目标采样率
        返回:
            transcribed_text: 识别出的文本
            mode: 使用的模式 (‘local’ 或 ‘fallback’)
        “””
        # 首先尝试本地模型
        if self.local_pipe is not None:
            try:
                # 将字节数据转换为numpy数组
                audio_np, orig_sr = librosa.load(io.BytesIO(audio_bytes), sr=None)
                if orig_sr != sample_rate:
                    audio_np = librosa.resample(audio_np, orig_sr=orig_sr, target_sr=sample_rate)
                
                # 调用模型识别
                result = self.local_pipe(audio_np)
                return result[‘text’].strip(), ‘local’
            except Exception as e:
                logger.error(f”本地转录失败: {e},尝试降级方案。”)
        
        # 本地模型失败,尝试降级API
        if FALLBACK_STT_API_URL:
            return self._transcribe_via_api(audio_bytes), ‘fallback’
        else:
            raise Exception(“本地转录失败且未配置降级API。”)
    
    def _transcribe_via_api(self, audio_bytes):
        “””调用备用STT API(示例,需根据具体API调整)”””
        # 这里以假设的API为例,实际需替换为真实端点
        headers = {‘Authorization’: f’Bearer {FALLBACK_STT_API_KEY}’}
        files = {‘file’: (‘audio.wav’, audio_bytes, ‘audio/wav’)}
        try:
            resp = requests.post(FALLBACK_STT_API_URL, files=files, headers=headers, timeout=30)
            resp.raise_for_status()
            return resp.json().get(‘text’, ‘’)
        except requests.exceptions.RequestException as e:
            logger.error(f”降级API调用失败: {e}”)
            raise Exception(“所有语音转录方式均失败。”)

# 全局实例,避免重复加载模型
stt_engine = SpeechToText()

这个类封装了语音识别的所有复杂性,并实现了自动降级。 _init_local_model 方法在初始化时尝试加载模型,如果失败(例如缺少 ffmpeg ), local_pipe 会保持为 None ,后续调用会自动走降级路径。

intents.py - 意图检测模块

import ollama
import re
from typing import Optional, Tuple
from config import OLLAMA_BASE_URL, OLLAMA_MODEL
import logging

logger = logging.getLogger(__name__)

# 定义系统支持的意图
INTENT_CREATE_FILE = “create_file”
INTENT_WRITE_CODE = “write_code”
INTENT_SUMMARIZE = “summarize_text”
INTENT_CHAT = “general_chat”
INTENT_UNKNOWN = “unknown”

class IntentDetector:
    def __init__(self):
        self.ollama_client = ollama.Client(host=OLLAMA_BASE_URL)
        self.keyword_patterns = {
            INTENT_CREATE_FILE: re.compile(r’创建|新建|建立一个|生成.*文件|create.*file’, re.IGNORECASE),
            INTENT_WRITE_CODE: re.compile(r’代码|编程|写.*函数|实现.*算法|python.*代码|write.*code’, re.IGNORECASE),
            INTENT_SUMMARIZE: re.compile(r’总结|概述|概括|摘要|summarize|brief’, re.IGNORECASE),
        }
    
    def detect_via_llm(self, text: str) -> Optional[str]:
        “””使用Ollama LLM进行意图分类”””
        prompt = f”””
        请分析用户的以下请求,判断其意图属于哪一类:
        1. {INTENT_CREATE_FILE} - 用户明确要求创建一个新文件。
        2. {INTENT_WRITE_CODE} - 用户要求编写或生成代码。
        3. {INTENT_SUMMARIZE} - 用户要求总结一段文本。
        4. {INTENT_CHAT} - 其他通用对话或问题。

        用户请求:“{text}”

        请只返回意图类别的名称,不要返回任何其他解释。
        “””
        try:
            response = self.ollama_client.generate(
                model=OLLAMA_MODEL,
                prompt=prompt,
                options={‘temperature’: 0.1}  # 低温度,使输出更确定
            )
            intent = response[‘response’].strip().lower()
            # 清理响应,只保留已知意图
            for known_intent in [INTENT_CREATE_FILE, INTENT_WRITE_CODE, INTENT_SUMMARIZE, INTENT_CHAT]:
                if known_intent in intent:
                    return known_intent
            return INTENT_UNKNOWN
        except Exception as e:
            logger.error(f”Ollama意图检测失败: {e}”)
            return None  # 返回None表示LLM检测失败,触发降级
    
    def detect_via_keyword(self, text: str) -> str:
        “””基于关键词的降级检测方法”””
        for intent, pattern in self.keyword_patterns.items():
            if pattern.search(text):
                return intent
        # 如果没有匹配到任何工具意图,则视为普通聊天
        return INTENT_CHAT
    
    def detect(self, text: str) -> Tuple[str, str]:
        “””
        主检测函数,返回 (意图, 检测模式)
        模式: ‘llm’ 或 ‘keyword’
        “””
        if not text or not text.strip():
            return INTENT_UNKNOWN, ‘none’
        
        # 优先使用LLM
        llm_intent = self.detect_via_llm(text)
        if llm_intent is not None:
            return llm_intent, ‘llm’
        
        # LLM失败,降级到关键词匹配
        logger.warning(“LLM意图检测失败,使用关键词降级模式。”)
        keyword_intent = self.detect_via_keyword(text)
        return keyword_intent, ‘keyword’

# 全局实例
intent_detector = IntentDetector()

这个模块的核心是 detect 方法,它实现了LLM优先、关键词降级的策略。 detect_via_llm 方法构造了一个清晰的分类提示词,并设置较低的 temperature 以获得稳定的输出。 detect_via_keyword 方法虽然简单,但在LLM服务不可用时,能提供一个基本可用的后备方案。

tools.py - 安全工具执行模块

import os
import re
import logging
from typing import Optional
from config import OUTPUT_DIR, ALLOWED_EXTENSIONS, OLLAMA_MODEL
import ollama

logger = logging.getLogger(__name__)

class SafeFileTool:
    def __init__(self):
        self.ollama_client = ollama.Client()
    
    def _sanitize_filename(self, filename: str) -> str:
        “””净化文件名,移除非法字符”””
        # 移除路径分隔符和非法字符
        filename = re.sub(r'[<>:“|?*\\/]’, ‘_’, filename)
        # 限制长度(可选)
        if len(filename) > 255:
            name, ext = os.path.splitext(filename)
            filename = name[:250] + ext
        return filename
    
    def _ensure_safe_path(self, filename: str) -> str:
        “””确保最终路径在沙箱内”””
        # 净化文件名
        safe_name = self._sanitize_filename(filename)
        # 拼接目标路径
        target_path = os.path.join(OUTPUT_DIR, safe_name)
        # 获取绝对路径并检查是否在沙箱内
        abs_target = os.path.abspath(target_path)
        abs_output = os.path.abspath(OUTPUT_DIR)
        
        # 关键安全检查:防止目录遍历攻击
        if not abs_target.startswith(abs_output):
            raise PermissionError(f”禁止的操作:尝试访问沙箱外路径 {abs_target}”)
        
        # 检查文件扩展名(如果配置了白名单)
        if ALLOWED_EXTENSIONS:
            _, ext = os.path.splitext(safe_name)
            if ext and ext.lower() not in ALLOWED_EXTENSIONS:
                # 可以拒绝或强制修改扩展名,这里选择拒绝
                raise ValueError(f”不支持的文件扩展名 ‘{ext}’。允许的扩展名: {ALLOWED_EXTENSIONS}”)
        
        return abs_target
    
    def create_file(self, filename: str) -> str:
        “””在沙箱内创建一个空文件”””
        try:
            safe_path = self._ensure_safe_path(filename)
            # 确保目录存在
            os.makedirs(os.path.dirname(safe_path), exist_ok=True)
            # 创建文件
            with open(safe_path, ‘w’) as f:
                pass  # 创建空文件
            logger.info(f”文件创建成功: {safe_path}”)
            return f”已在安全沙箱内创建空文件: {os.path.basename(safe_path)}”
        except (PermissionError, ValueError) as e:
            logger.error(f”创建文件失败(安全限制): {e}”)
            return f”操作被拒绝: {e}”
        except Exception as e:
            logger.error(f”创建文件失败: {e}”)
            return f”创建文件时出错: {e}”
    
    def write_code_file(self, filename: str, requirement: str) -> str:
        “””根据要求生成代码并写入文件”””
        try:
            safe_path = self._ensure_safe_path(filename)
            
            # 使用LLM生成代码
            prompt = f”””
            根据以下要求,生成完整、可运行的代码。
            要求:{requirement}
            请只返回代码本身,不要包含任何解释或Markdown代码块标记。
            “””
            response = self.ollama_client.generate(
                model=OLLAMA_MODEL,
                prompt=prompt,
                options={‘temperature’: 0.2}
            )
            code_content = response[‘response’].strip()
            
            # 写入文件
            os.makedirs(os.path.dirname(safe_path), exist_ok=True)
            with open(safe_path, ‘w’, encoding=‘utf-8’) as f:
                f.write(code_content)
            
            logger.info(f”代码文件写入成功: {safe_path}”)
            return f”已生成代码并写入文件: {os.path.basename(safe_path)},共 {len(code_content)} 字符。”
        except (PermissionError, ValueError) as e:
            logger.error(f”写入代码文件失败(安全限制): {e}”)
            return f”操作被拒绝: {e}”
        except Exception as e:
            logger.error(f”写入代码文件失败: {e}”)
            return f”生成或写入代码时出错: {e}”
    
    def summarize_text(self, text: str) -> str:
        “””总结提供的文本”””
        try:
            prompt = f”””
            请用简洁的语言总结以下文本的核心内容:
            “{text}”
            
            总结要求:
            1. 抓住核心观点。
            2. 不超过3句话。
            3. 使用中文。
            “””
            response = self.ollama_client.generate(
                model=OLLAMA_MODEL,
                prompt=prompt,
                options={‘temperature’: 0.3}
            )
            summary = response[‘response’].strip()
            return f”总结结果:{summary}”
        except Exception as e:
            logger.error(f”文本总结失败: {e}”)
            return f”总结文本时出错: {e}”
    
    def general_chat(self, message: str) -> str:
        “””通用聊天响应”””
        try:
            response = self.ollama_client.generate(
                model=OLLAMA_MODEL,
                prompt=message,
                options={‘temperature’: 0.7}
            )
            return response[‘response’].strip()
        except Exception as e:
            logger.error(f”聊天响应失败: {e}”)
            return “抱歉,我现在无法处理您的消息。请检查Ollama服务是否运行。”

# 全局工具实例
file_tool = SafeFileTool()

这是安全性的核心体现。 _ensure_safe_path 方法是所有文件操作的守门员,它执行了路径净化、目录遍历攻击防护和文件类型检查。每个公开的工具方法( create_file , write_code_file )在操作前都必须调用此方法。注意,错误处理被细分为安全错误( PermissionError , ValueError )和一般运行时错误,并返回友好的用户提示,这在UI展示时很重要。

pipeline.py - 流程协调模块

import logging
from stt import stt_engine
from intents import intent_detector, INTENT_CREATE_FILE, INTENT_WRITE_CODE, INTENT_SUMMARIZE, INTENT_CHAT
from tools import file_tool

logger = logging.getLogger(__name__)

class VoiceAIPipeline:
    def __init__(self):
        self.steps_log = []  # 用于记录管道每一步的结果,用于UI展示
    
    def clear_log(self):
        “””清空步骤日志”””
        self.steps_log = []
    
    def run(self, audio_bytes=None, text_input=None):
        “””
        运行完整管道。
        参数:
            audio_bytes: 音频字节数据(优先)
            text_input: 直接文本输入(备用)
        返回:
            final_output: 最终输出文本
            steps_log: 管道步骤详情列表
        “””
        self.clear_log()
        
        # 步骤1: 获取文本(语音转录或直接输入)
        if audio_bytes is not None:
            self.steps_log.append({‘step’: ‘音频输入’, ‘status’: ‘接收’, ‘data’: f’音频数据长度: {len(audio_bytes)} bytes’})
            transcribed_text, stt_mode = stt_engine.transcribe(audio_bytes)
            self.steps_log.append({‘step’: ‘语音转文本’, ‘status’: stt_mode, ‘data’: transcribed_text})
            user_text = transcribed_text
        elif text_input and text_input.strip():
            self.steps_log.append({‘step’: ‘文本输入’, ‘status’: ‘手动输入’, ‘data’: text_input})
            user_text = text_input
        else:
            return “未提供音频或文本输入。”, self.steps_log
        
        if not user_text.strip():
            return “未能识别出有效文本。”, self.steps_log
        
        # 步骤2: 意图检测
        intent, detection_mode = intent_detector.detect(user_text)
        self.steps_log.append({‘step’: ‘意图检测’, ‘status’: detection_mode, ‘data’: f’识别为: {intent}’})
        
        # 步骤3: 工具执行与路由
        final_output = “”
        if intent == INTENT_CREATE_FILE:
            # 简单提取文件名:假设格式为“创建[一个]文件[叫]xxx”
            # 这里可以做得更智能,比如用LLM提取参数。此处为简化示例。
            filename = “new_file.txt”  # 默认文件名
            # 尝试从文本中提取文件名
            import re
            match = re.search(r’(?:文件|叫|名为)[\s::]*([\w\u4e00-\u9fa5\-\.]+)’, user_text)
            if match:
                filename = match.group(1)
                if not any(filename.endswith(ext) for ext in [‘.txt’, ‘.py’, ‘.md’]):
                    filename += ‘.txt’  # 默认加.txt扩展名
            final_output = file_tool.create_file(filename)
        
        elif intent == INTENT_WRITE_CODE:
            # 假设用户请求包含代码要求,直接将其作为要求
            requirement = user_text
            # 尝试提取文件名,否则使用默认名
            filename = “generated_code.py”
            import re
            match = re.search(r’(?:文件|保存为)[\s::]*([\w\u4e00-\u9fa5\-\.]+\.\w+)’, user_text)
            if match:
                filename = match.group(1)
            final_output = file_tool.write_code_file(filename, requirement)
        
        elif intent == INTENT_SUMMARIZE:
            # 这里假设用户输入的就是要总结的文本。更复杂的实现可以从上下文中获取。
            text_to_summarize = user_text
            final_output = file_tool.summarize_text(text_to_summarize)
        
        elif intent == INTENT_CHAT:
            final_output = file_tool.general_chat(user_text)
        
        else:
            final_output = “未能理解您的意图。请尝试更清晰的指令,如‘创建一个文件’或‘写一段Python代码’。”
        
        self.steps_log.append({‘step’: ‘工具执行’, ‘status’: intent, ‘data’: final_output})
        return final_output, self.steps_log

# 全局管道实例
pipeline = VoiceAIPipeline()

管道模块像胶水一样把各个部分粘合起来。它按顺序调用各个模块,并记录每一步的状态和结果到 steps_log 中,这个日志会被 app.py 用来在UI上展示完整的流程追踪。注意,在意图路由部分,参数提取(如从“创建一个叫hello.py的文件”中提取“hello.py”)目前用的是简单的正则匹配,这是一个可以优化的点。

app.py - Streamlit 主界面

import streamlit as st
import audio_recorder_streamlit as ars  # 一个简化录音的组件,需安装
from pipeline import pipeline
import time

st.set_page_config(page_title=“本地语音AI助手”, layout=“wide”)
st.title(“🎤 本地语音AI助手”)
st.markdown(“””这是一个完全在本地运行的AI助手。你可以通过语音让它创建文件、写代码、总结文本,或者和它聊天。所有操作都被限制在安全的沙箱目录内。”””)

# 初始化session state,用于保存状态
if ‘steps_history’ not in st.session_state:
    st.session_state.steps_history = []
if ‘final_output’ not in st.session_state:
    st.session_state.final_output = “”

# 侧边栏:配置和信息
with st.sidebar:
    st.header(“配置与信息”)
    st.info(“””
    **当前模式:**
    - 语音识别: 本地 Whisper
    - 意图理解: 本地 Ollama (Llama 3.1)
    - 文件操作: 沙箱模式 (`./output/`)
    “””)
    if st.button(“清空历史记录”):
        st.session_state.steps_history = []
        st.session_state.final_output = “”
        st.rerun()
    st.divider()
    st.caption(“””
    **支持的操作意图:**
    - 创建文件 (如‘创建一个test.txt文件’)
    - 编写代码 (如‘写一个Python函数计算阶乘’)
    - 总结文本 (如‘总结一下这段话’)
    - 通用聊天 (其他对话)
    “””)

# 主界面分为两列
col_input, col_output = st.columns([1, 1])

with col_input:
    st.header(“输入”)
    input_mode = st.radio(“选择输入方式:”, [“麦克风录音”, “上传音频文件”, “手动输入文本”], horizontal=True)
    
    audio_bytes = None
    text_input = “”
    
    if input_mode == “麦克风录音”:
        st.write(“点击下方按钮开始录音(最多30秒):”)
        # 使用 audio_recorder_streamlit 组件,更简单
        audio_bytes = ars.audio_recorder(sample_rate=16000, pause_threshold=2.0)
        if audio_bytes:
            st.audio(audio_bytes, format=“audio/wav”)
            st.success(“录音完成!点击‘运行管道’进行处理。”)
    
    elif input_mode == “上传音频文件”:
        uploaded_file = st.file_uploader(“上传音频文件 (WAV, MP3)”, type=[‘wav’, ‘mp3’, ‘ogg’])
        if uploaded_file is not None:
            audio_bytes = uploaded_file.read()
            st.audio(audio_bytes, format=uploaded_file.type)
            st.success(“文件已上传!点击‘运行管道’进行处理。”)
    
    else:  # 手动输入文本
        text_input = st.text_area(“直接输入您的指令:”, height=150, placeholder=“例如:创建一个名为‘笔记.txt’的文件”)
    
    # 运行按钮
    if st.button(“🚀 运行管道”, type=“primary”, use_container_width=True):
        if audio_bytes or (text_input and text_input.strip()):
            with st.spinner(“AI正在处理中…”):
                # 调用管道
                final_output, steps_log = pipeline.run(audio_bytes=audio_bytes, text_input=text_input)
                # 保存结果到session state
                st.session_state.final_output = final_output
                st.session_state.steps_history = steps_log
            st.rerun()  # 触发界面更新
        else:
            st.warning(“请先提供音频或文本输入。”)

with col_output:
    st.header(“输出与管道状态”)
    
    # 显示最终输出
    if st.session_state.final_output:
        st.subheader(“最终结果”)
        st.write(st.session_state.final_output)
        # 如果是文件操作,提供一个查看沙箱的链接/按钮
        if “文件” in st.session_state.final_output or “写入” in st.session_state.final_output:
            if st.button(“📁 查看沙箱输出目录”):
                st.info(“请在项目根目录的 `output/` 文件夹中查看生成的文件。”)
    
    # 显示管道步骤详情
    if st.session_state.steps_history:
        st.subheader(“管道执行详情”)
        for i, step in enumerate(st.session_state.steps_history):
            with st.expander(f”步骤 {i+1}: {step[‘step’]} ({step[‘status’]})”, expanded=(i==0)):
                st.write(f”**详情:** {step[‘data’]}”)
    
    # 如果还没有运行过,显示提示
    elif not st.session_state.final_output:
        st.info(“运行管道后,这里将显示处理结果和每一步的详细信息。”)

# 底部:沙箱文件列表(增强透明性)
st.divider()
st.subheader(“沙箱文件列表 (output/)”)
try:
    import os
    from config import OUTPUT_DIR
    files = os.listdir(OUTPUT_DIR)
    if files:
        for f in files:
            st.code(f, language=“text”)
    else:
        st.caption(“沙箱目录为空。”)
except Exception as e:
    st.error(f”无法读取沙箱目录: {e}”)

这个Streamlit应用提供了清晰的交互界面。左侧是输入区,支持三种方式;右侧是输出区,实时展示管道每一步的结果和最终输出。使用 session_state 来保持状态,使得交互更加流畅。界面底部的沙箱文件列表,直接展示了 output/ 目录下的内容,将系统的“后台”操作透明化,增强了用户信任感。

5. 部署、运行与常见问题排查

5.1 如何启动与运行项目?

  1. 环境准备 :确保你的Python版本在3.8以上。克隆项目代码后,在终端进入项目目录。
  2. 安装依赖 :运行 pip install -r requirements.txt 。这一步可能会花费一些时间,特别是安装PyTorch和Transformers库。
  3. 启动Ollama服务 :你需要先安装并运行Ollama。访问Ollama官网下载对应操作系统的版本。安装后,打开终端,运行 ollama pull llama3.1:8b 来拉取模型(这是一个约5GB的模型,请确保网络通畅)。然后运行 ollama serve 来启动服务。服务默认运行在 http://localhost:11434
  4. 运行应用 :在项目根目录下,运行 streamlit run app.py 。Streamlit会自动在浏览器中打开应用界面(通常是 http://localhost:8501 )。

首次运行提示 :第一次运行时会下载Whisper模型(约300MB-1GB,取决于你配置的模型大小),请耐心等待。如果遇到 ffmpeg 错误,请根据前面的提示安装系统级的ffmpeg。

5.2 典型问题与解决方案速查表

在实际搭建和运行过程中,你几乎一定会遇到下面这些问题。这里我整理了详细的排查步骤和解决方案。

问题现象 可能原因 排查步骤与解决方案
启动Streamlit后,页面报错或空白 1. 依赖未正确安装。
2. Python环境冲突。
1. 检查终端是否有错误输出。运行 pip list | grep -E “streamlit|transformers|torch” 确认关键包已安装。
2. 建议使用虚拟环境( venv conda )隔离项目。
录音或上传音频后,转录失败 1. 本地Whisper模型加载失败。
2. 音频格式不支持。
3. ffmpeg 未安装。
1. 查看Streamlit运行日志(终端),确认是否有模型下载或加载错误。
2. 尝试使用“手动输入文本”模式,绕过语音识别,测试后续流程。
3. 在系统终端运行 ffmpeg -version 确认已安装。对于WAV文件,可以尝试在 stt.py 中强制指定音频格式。
意图检测返回 unknown 或错误意图 1. Ollama服务未运行或模型未加载。
2. 提示词不够清晰,模型理解偏差。
3. 网络问题导致API调用失败。
1. 在浏览器中访问 http://localhost:11434/api/tags ,查看Ollama模型列表。运行 ollama list 确认模型存在。
2. 在 intents.py 中,尝试简化提示词,或增加示例(few-shot)。
3. 检查 config.py 中的 OLLAMA_BASE_URL 是否正确。查看终端是否有连接超时错误。
文件创建失败,提示“操作被拒绝” 1. 路径安全检查未通过(试图跳出沙箱)。
2. 文件扩展名不在允许列表中。
1. 检查你输入的指令中是否包含 ../ 等路径遍历字符。查看 tools.py _ensure_safe_path 函数的日志。
2. 检查 config.py 中的 ALLOWED_EXTENSIONS 列表,确保你要创建的文件类型在其中。
应用运行缓慢,响应延迟高 1. 本地模型(Whisper/Ollama)首次加载或推理速度慢。
2. 硬件资源(CPU/内存/GPU)不足。
1. 这是本地模型的通病。确保模型已加载到内存(首次调用后)。对于Whisper,可尝试更小的模型(如 tiny base )。对于Ollama,可尝试量化程度更高的模型(如 llama3.1:8b-q4_K_M )。
2. 关闭其他占用大量资源的程序。如果有NVIDIA GPU,确保PyTorch安装了CUDA版本,并检查Whisper和Ollama是否在利用GPU。
Ollama返回“model not found” 指定的模型名称错误或未拉取。 运行 ollama list 确认模型名称。在 config.py 中修改 OLLAMA_MODEL 为列表中的正确名称。使用 ollama pull <model_name> 拉取模型。

5.3 调试技巧与实操心得

  1. 分模块测试 :不要一次性运行整个管道。在开发或排查问题时,单独测试每个模块。例如,你可以写一个简单的脚本直接调用 stt_engine.transcribe() 测试音频文件,或者直接调用 intent_detector.detect() 测试文本意图识别。这能快速定位问题模块。
  2. 充分利用日志 :我为每个模块都配置了Python的 logging 模块。在开发时,将日志级别设置为 DEBUG INFO ,可以在终端看到非常详细的运行信息,包括模型加载状态、API调用参数、中间结果等。这是定位复杂问题的利器。
  3. 透明化是调试的朋友 :这个项目UI设计的一个核心理念就是透明化。管道每一步的结果都展示在界面上。当结果不符合预期时,你可以清晰地看到是语音识别错了(看“语音转文本”步骤),还是意图理解错了(看“意图检测”步骤),亦或是工具执行出错了(看“工具执行”步骤)。这种设计极大地简化了调试过程。
  4. 从简单到复杂 :如果你第一次运行失败,不要急于处理复杂指令。先从最简单的开始:在“手动输入文本”框中输入“你好”,看是否能走到“通用聊天”并得到回复。然后尝试“创建一个文件”,最后再测试语音输入。这样可以逐步验证每个环节是否正常。
  5. 沙箱目录权限 :确保运行Streamlit的用户对项目根目录下的 output/ 文件夹有读写权限。在Linux/macOS上,可能需要检查文件夹权限 chmod 755 output

6. 项目扩展与优化方向

这个项目是一个功能完整但基础的原型。如果你希望将其用于更实际的场景,或者作为一个学习起点进行深化,以下几个方向值得尝试:

6.1 增强意图识别的准确性

目前的意图检测虽然有两层保障,但仍有提升空间。

  • 结构化输出 :可以要求Ollama模型以JSON格式返回结果,例如 {“intent”: “create_file”, “parameters”: {“filename”: “test.py”}} 。这比让模型返回纯文本更稳定,也更容易提取参数。可以通过在提示词中指定“请以JSON格式回复”来实现。
  • 多轮对话与上下文记忆 :当前的实现是单轮对话,AI没有记忆。可以引入一个简单的对话历史管理,将之前的几轮问答也作为上下文喂给意图检测模型,这样它能更好地理解指代(如“把它保存到刚才那个文件里”)。
  • 意图置信度 :可以让LLM在返回意图的同时,给出一个置信度分数。如果置信度低于某个阈值(如0.7),可以触发一个澄清流程,让用户确认意图,而不是盲目执行。

6.2 扩展工具集

目前只实现了四个核心工具,完全可以扩展成一个更强大的本地AI助手工具箱。

  • 文件操作 :增加读取文件、列出沙箱目录、删除沙箱内文件(需二次确认)等功能。
  • 系统信息 :添加获取系统状态(如CPU/内存使用率)、查询时间、执行简单的系统命令(需极度谨慎,可限制白名单命令)的工具。
  • 网络工具 :在安全可控的前提下,添加获取天气、查询词典、搜索特定网站(通过API)等功能。
  • 应用集成 :通过操作系统的自动化接口(如AppleScript for macOS, PowerShell for Windows),实现打开应用、控制音乐播放等高级操作。

6.3 提升用户体验与可靠性

  • 语音反馈 :除了文字输出,可以集成一个本地TTS(文本转语音)引擎,让AI用语音回答,实现真正的全语音交互。 pyttsx3 edge-tts 是不错的起点。
  • 操作确认 :对于文件创建、写入等有潜在风险的操作,可以在UI上增加一个确认步骤,显示“即将创建文件:/output/test.py”,让用户点击确认后再执行。
  • 管道状态持久化 :将每次交互的输入、管道步骤、输出保存到本地数据库(如SQLite)或日志文件中,便于后续分析和复盘。
  • 配置界面 :在Streamlit侧边栏增加一个高级配置页面,允许用户动态切换Whisper/Ollama模型、修改沙箱路径、调整温度参数等,而无需修改代码。

6.4 架构升级

  • 异步处理 :语音识别和LLM推理都是耗时操作。可以使用 asyncio stt.py intents.py 中的部分调用改为异步,避免在Streamlit运行时阻塞UI,提升响应感。
  • 插件化工具系统 :将 tools.py 重构为一个插件系统。每个工具作为一个独立的Python类注册到系统中。这样,扩展新工具只需要添加一个新的类文件,而不需要修改核心管道代码。
  • 前端优化 :如果Streamlit的交互体验不能满足需求,可以考虑用更灵活的前端框架(如Gradio、NiceGUI)重构UI,或者用FastAPI构建后端,用Vue/React构建独立前端,实现更丰富的交互。

这个项目的价值不仅在于它实现的功能,更在于它展示了一种构建安全、透明、可扩展的本地AI应用的模式。它没有追求最前沿的模型或最复杂的功能,而是把重点放在了 可靠性、安全性和可解释性 上。在实际操作中我发现,一个能让用户看清每一步、并且知道自己不会闯祸的AI助手,远比一个能力强大但行为莫测的“黑箱”更让人愿意使用。希望这个详细的拆解能为你构建自己的AI应用提供一个坚实的起点。记住,从一个小而核心的管道开始,确保它每一步都可靠、透明,然后再逐步添加新的能力和优化体验,这是最稳妥也最高效的路径。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐