1. 项目概述:一个能听懂你说话的本地AI助手

最近我折腾了一个挺有意思的东西:一个完全在你本地电脑上运行的、能听懂你说话的AI助手。你对着麦克风说一句“帮我创建一个带重试逻辑的Python文件”,它就能麻利地给你生成代码并保存好。整个过程,从语音识别到意图理解,再到执行动作,全都在你自己的机器上完成,不需要联网把数据传到任何第三方服务器。这个项目我称之为“本地语音控制AI智能体”,它完美结合了 Groq 的快速语音转文字、 Ollama 的本地大语言模型推理以及 Streamlit 的轻量级Web界面。

为什么做这个?一方面,纯粹是技术人的“手痒”,想看看现有的开源工具链能拼凑出什么新花样;另一方面,也是觉得真正的“智能助理”应该更私有、更即时。市面上很多语音助手要么需要常驻后台的庞大服务,要么隐私性存疑。我这个方案的核心优势就是 完全本地化 模块化 。语音识别用了Groq提供的Whisper API(免费且速度快),大脑用了Meta的llama3.2模型通过Ollama在本地运行,最后用一个网页界面把它们串起来,操作和结果一目了然。

这个项目特别适合以下几类朋友:一是 Python开发者 ,想体验如何将多种AI服务集成到一个应用里;二是对 本地AI应用 语音交互 感兴趣的爱好者;三是任何希望拥有一个不依赖云服务、可定制化执行简单自动化任务的用户。即使你对AI模型训练不熟悉,只要会写点Python,跟着下面的步骤也能把它跑起来,并理解其背后的工作逻辑。

2. 架构设计与核心组件选型

构建这样一个系统,就像搭积木,关键在于为每个环节选择合适的“积木”,并设计好它们之间的连接方式。我的整体架构思路非常清晰: 语音输入 → 语音转文本 → 意图识别 → 工具执行 → 结果展示 。下面我来详细拆解每个组件的选型理由和背后的考量。

2.1 语音转文本:为什么放弃本地Whisper,选择Groq API?

语音识别的起点,我首先考虑的是OpenAI开源的Whisper模型。它的识别准确度有口皆碑,而且能在本地运行,符合我们“完全本地化”的终极理想。我最初的方案是使用 transformers 库或 openai-whisper 包来加载模型。

然而,在Windows环境下,我立刻遇到了第一个拦路虎: ffmpeg依赖问题 。Whisper处理音频文件(尤其是非标准格式)高度依赖ffmpeg。在Windows上安装ffmpeg本身不难,但将其添加到系统PATH时,问题来了。我的开发环境部分路径位于OneDrive同步文件夹中,这些路径包含空格(例如 C:\Users\My Name\OneDrive\... )。在命令行或Python子进程调用中,处理带空格的路径需要格外小心地添加引号,而Whisper的底层调用链有时并不能完美处理这种情况,导致频频报错“ffmpeg not found”。

与其花大量时间在Windows环境变量和路径转义这种与核心AI逻辑无关的“脏活”上,我决定转向一个更优雅的方案: Groq提供的Whisper API 。这个决定基于几个关键点:

  1. 免费与高速 :Groq以其LPU推理引擎闻名,速度极快。他们的Whisper API目前免费提供,识别速度远超我在本地CPU上运行Whisper large模型。
  2. 零环境依赖 :直接通过HTTP API调用,彻底避开了ffmpeg、PyTorch、CUDA等复杂的本地依赖。这对于项目快速部署和跨平台兼容性至关重要。
  3. 格式兼容性好 :API支持多种音频格式,省去了本地预处理音频文件的步骤。

注意 :使用API意味着音频数据需要离开你的机器发送到Groq的服务器。虽然Groq的隐私政策值得信赖,且语音片段通常不涉及高度敏感信息,但这与“100%本地化”的初衷有所妥协。这是为了开发效率和稳定性所做的权衡。如果你的应用场景对隐私要求极端严格,那么攻克本地Whisper部署仍是必须的。

2.2 大脑核心:为什么是Ollama + llama3.2?

意图识别和任务执行的核心需要一个语言模型。我的原则很明确: 必须100%在本地运行 。这样既能保证对话内容的绝对私密,也能在没有网络的环境下使用。

在众多本地推理方案中,我选择了 Ollama 。原因如下:

  • 极简的部署 :在Windows上,基本上就是下载一个安装包,点击运行,服务就起来了。命令行工具 ollama run 拉取和运行模型简单到不可思议。
  • 丰富的模型库 :Ollama维护了一个包含众多开源模型(Llama、Mistral、Gemma等)的仓库,一键拉取。
  • 高效的推理 :底层基于GGUF量化模型和高效的C++推理库,即使在消费级CPU上也能获得不错的响应速度。

对于模型,我选择了 Meta的llama3.2 (具体是 llama3.2:latest 版本)。在项目开发时,llama3.2在7B参数这个级别上,提供了速度、准确度和内存消耗的最佳平衡。它的指令跟随能力足够强,能够可靠地完成我需要的“意图分类”任务。你完全可以根据自己的硬件(如果有强力的GPU,可以尝试更大的模型)和偏好,替换为 mistral gemma 或更新的模型,Ollama使得这种切换成本极低。

2.3 用户界面:Streamlit如何成为快速原型的神器?

我需要一个让用户能轻松录音、看到识别结果和执行结果的地方。开发一个传统的桌面GUI(如Tkinter, PyQt)耗时耗力,而一个Web界面则具有天然的跨平台和易访问性。

Streamlit 几乎是此类AI应用原型开发的“标准答案”。它允许你完全用Python脚本创建交互式Web应用。其核心优势在于:

  • 开发速度极快 :将你的Python逻辑与 st.button st.write 等组件简单结合,一个界面就出来了。
  • 数据流清晰 :虽然其“从头到尾执行脚本”的模式在状态管理上需要一些技巧(后面会详述),但这种模式也让逻辑流程非常直观。
  • 丰富的组件 :内置音频录制、文件上传、聊天框等组件,与AI应用场景天然契合。

整个架构中,Streamlit扮演着“总控台”和“展示窗”的角色。它接收用户的麦克风输入,调用Groq API,将文本发送给本地的Ollama服务,解析返回的指令,调用相应的工具函数执行,最后将所有信息——原始语音、转写文本、AI识别的意图、执行结果——清晰地呈现在同一个页面上。

3. 核心模块实现与代码解析

有了清晰的架构,接下来就是动手实现。我将分模块拆解核心代码,并解释其中的关键决策和细节。你可以把我的代码仓库拉下来,对照着看。

3.1 语音录制与转写模块

首先,我们需要在Streamlit界面上让用户录音。Streamlit社区组件 streamlit-audiorecorder 可以完美实现这个功能。

import streamlit as st
from audiorecorder import audiorecorder
import requests
import os
from dotenv import load_dotenv

# 加载环境变量,保护API密钥
load_dotenv()
GROQ_API_KEY = os.getenv("GROQ_API_KEY")

def transcribe_audio(audio_bytes):
    """
    将录音字节数据发送到Groq Whisper API进行转写。
    """
    if not GROQ_API_KEY:
        st.error("GROQ_API_KEY未在.env文件中设置。")
        return None

    headers = {
        "Authorization": f"Bearer {GROQ_API_KEY}",
    }
    files = {
        "file": ("audio.wav", audio_bytes, "audio/wav")
    }
    data = {
        "model": "whisper-large-v3",
        "response_format": "json",
        "language": "zh" # 可指定语言,如'zh'中文,不指定则自动检测
    }

    try:
        response = requests.post(
            "https://api.groq.com/openai/v1/audio/transcriptions",
            headers=headers,
            files=files,
            data=data
        )
        response.raise_for_status() # 如果状态码不是200,抛出异常
        result = response.json()
        return result.get("text", "").strip()
    except requests.exceptions.RequestException as e:
        st.error(f"语音转写API调用失败: {e}")
        return None

# 在Streamlit界面中使用
st.title("本地语音AI助手")
audio = audiorecorder("点击录音", "点击停止")

if len(audio) > 0:
    # 显示音频播放器
    st.audio(audio.tobytes(), format="audio/wav")
    # 转写
    with st.spinner("正在识别语音..."):
        transcribed_text = transcribe_audio(audio.tobytes())
        if transcribed_text:
            st.session_state['transcribed_text'] = transcribed_text # 存入会话状态
            st.success(f"识别结果: {transcribed_text}")

实操心得 audiorecorder 组件录制的音频是PCM格式的字节流,需要以 audio/wav 格式发送给Groq API。确保在 files 参数中正确设置文件名和MIME类型。另外, 务必使用 python-dotenv 管理你的Groq API密钥 ,将 GROQ_API_KEY=your_key_here 写在 .env 文件中,并将其加入 .gitignore ,避免密钥泄露到GitHub。

3.2 意图识别:与大模型“约法三章”

拿到文本后,下一步是让本地LLM理解用户的意图。我们不能让模型自由发挥,必须引导它输出结构化的结果。这里我采用了 系统提示词工程 JSON格式强制输出

import requests
import json

OLLAMA_API_URL = "http://localhost:11434/api/generate" # Ollama默认API地址

def classify_intent(user_input):
    """
    调用本地Ollama服务,对用户输入进行意图分类。
    """
    system_prompt = """
    你是一个意图分类器。请将用户的指令严格分类为以下四种意图之一:
    1. WRITE_CODE: 用户要求编写、生成或修改代码。例如:“写一个Python函数计算斐波那契数列”、“帮我创建一个HTML登录页面”。
    2. CREATE_FILE: 用户要求创建一个指定类型或内容的文件。例如:“在output文件夹里新建一个叫notes.txt的文本文件”、“创建一个空的Markdown文档”。
    3. SUMMARIZE: 用户要求总结、概括一段文本内容。例如:“总结下面这篇文章”、“把这段长文本缩短”。
    4. GENERAL_CHAT: 不属于以上三类的任何其他对话、提问或闲聊。例如:“你好”、“今天天气怎么样?”、“解释一下量子计算”。

    你必须只输出一个合法的JSON对象,格式如下:
    {
        "intent": "WRITE_CODE" | "CREATE_FILE" | "SUMMARIZE" | "GENERAL_CHAT",
        "reason": "简要说明为什么这样分类"
    }
    不要输出任何其他文字、标记或解释。
    """

    payload = {
        "model": "llama3.2:latest", # 指定你拉取的模型
        "system": system_prompt,
        "prompt": user_input,
        "stream": False, # 我们不需要流式响应,一次性返回即可
        "options": {
            "temperature": 0.1 # 低温度保证输出确定性高,更稳定地遵循格式
        }
    }

    try:
        response = requests.post(OLLAMA_API_URL, json=payload, timeout=60)
        response.raise_for_status()
        response_data = response.json()

        # 提取模型生成的回复内容
        model_response = response_data.get("response", "").strip()
        # 尝试从回复中解析JSON
        # 模型有时会在JSON前后加上```json ```标记,需要处理
        if model_response.startswith("```json"):
            model_response = model_response[7:-3].strip()
        elif model_response.startswith("```"):
            model_response = model_response[3:-3].strip()

        intent_result = json.loads(model_response)
        return intent_result

    except json.JSONDecodeError as e:
        st.error(f"解析模型返回的JSON失败: {e}。原始响应: {model_response}")
        # 降级处理:根据关键词简单判断
        return {"intent": "GENERAL_CHAT", "reason": "JSON解析失败,默认归类为聊天"}
    except requests.exceptions.RequestException as e:
        st.error(f"调用Ollama API失败: {e}")
        return None

# 在Streamlit中接续使用
if 'transcribed_text' in st.session_state:
    user_text = st.session_state['transcribed_text']
    with st.spinner("正在分析您的意图..."):
        intent_result = classify_intent(user_text)
        if intent_result:
            st.session_state['intent_result'] = intent_result
            st.info(f"识别意图: **{intent_result['intent']}**")
            st.write(f"*理由*: {intent_result['reason']}")

注意事项 :大模型并不总是乖乖输出完美JSON。尽管有严格的系统提示,它有时仍会添加额外的解释或Markdown代码块标记。因此,在 json.loads() 之前,加入一段简单的清理逻辑(如去除```json和```)是提高鲁棒性的关键。同时,设置较低的 temperature (如0.1)可以减少输出的随机性,让模型更专注于遵循指令格式。

3.3 工具执行器:安全第一的本地操作

根据识别出的意图,我们需要执行相应的操作。这里必须牢记 安全原则 :一个来自语音或文本的指令,绝不能拥有直接操作整个文件系统的权限。我的策略是: 将所有生成的文件严格限制在一个特定的目录内 (例如项目根目录下的 output/ 文件夹)。

import os
import subprocess
from datetime import datetime

# 定义安全的工作目录
OUTPUT_DIR = "output"
os.makedirs(OUTPUT_DIR, exist_ok=True) # 确保目录存在

def execute_tool(intent, user_input, context=None):
    """
    根据意图执行对应的工具函数。
    """
    if intent == "WRITE_CODE":
        return handle_write_code(user_input)
    elif intent == "CREATE_FILE":
        return handle_create_file(user_input)
    elif intent == "SUMMARIZE":
        return handle_summarize(user_input, context) # context可能包含待总结的文本
    elif intent == "GENERAL_CHAT":
        return handle_general_chat(user_input)
    else:
        return {"status": "error", "message": f"未知的意图: {intent}"}

def handle_write_code(user_input):
    """处理代码生成请求。"""
    # 这里可以集成一个代码生成LLM调用,例如再次调用Ollama,但使用不同的提示词。
    # 为了简化演示,我们假设直接根据指令生成一个简单的Python文件。
    prompt_for_code = f"""
    用户要求: {user_input}
    请生成满足上述要求的完整Python代码。只输出代码本身,不要有任何解释。
    """
    # 调用Ollama生成代码(此处省略具体调用,结构与classify_intent类似)
    # generated_code = call_ollama_for_code(prompt_for_code)
    # 演示用:生成一个占位文件
    generated_code = f\"\"\"# 根据指令生成: {user_input}
import time
import random

def retry_operation(operation, max_attempts=3, delay=1):
    \"\"\"一个简单的重试装饰器/函数示例。\"\"\"
    for attempt in range(max_attempts):
        try:
            return operation()
        except Exception as e:
            print(f\"Attempt {attempt + 1} failed: {e}\")
            if attempt < max_attempts - 1:
                time.sleep(delay)
    raise Exception(f\"Operation failed after {max_attempts} attempts\")

if __name__ == \"__main__\":
    # 示例用法
    def unstable_function():
        if random.random() > 0.5:
            return \"Success!\"
        else:
            raise ValueError(\"Random failure\")
    
    result = retry_operation(unstable_function)
    print(result)
\"\"\"

    # 创建安全的文件名
    safe_name = "".join(c for c in user_input[:20] if c.isalnum() or c in (' ', '_')).rstrip().replace(' ', '_')
    filename = f"generated_code_{safe_name}_{datetime.now().strftime('%H%M%S')}.py"
    filepath = os.path.join(OUTPUT_DIR, filename)

    try:
        with open(filepath, 'w', encoding='utf-8') as f:
            f.write(generated_code)
        return {
            "status": "success",
            "message": f"代码文件已生成",
            "filepath": filepath,
            "preview": generated_code[:500] + "..." if len(generated_code) > 500 else generated_code
        }
    except IOError as e:
        return {"status": "error", "message": f"写入文件失败: {e}"}

def handle_create_file(user_input):
    """处理创建文件请求。"""
    # 简单实现:在output目录下创建一个以时间戳命名的空文件
    # 更复杂的实现可以解析用户输入中的文件名和类型
    filename = f"new_file_{datetime.now().strftime('%Y%m%d_%H%M%S')}.txt"
    filepath = os.path.join(OUTPUT_DIR, filename)
    try:
        with open(filepath, 'w', encoding='utf-8') as f:
            f.write(f\"File created based on request: {user_input}\\n\")
        return {"status": "success", "message": f"文件已创建", "filepath": filepath}
    except IOError as e:
        return {"status": "error", "message": f"创建文件失败: {e}"}

def handle_summarize(user_input, context):
    """处理文本总结请求。"""
    # 假设context中提供了要总结的文本。在实际应用中,可能需要一个文本输入框。
    text_to_summarize = context if context else user_input # 简单处理
    if not text_to_summarize or len(text_to_summarize.split()) < 10:
        return {"status": "error", "message": "文本过短,无需总结或未提供文本。"}
    
    # 调用Ollama进行总结(此处省略具体调用)
    # summary = call_ollama_for_summary(text_to_summarize)
    summary = f\"这是对文本的总结(演示): {text_to_summarize[:100]}...\" # 演示用
    return {"status": "success", "message": "总结完成", "summary": summary}

def handle_general_chat(user_input):
    """处理一般聊天。"""
    # 直接调用Ollama进行自由对话
    # 可以使用与classify_intent不同的、更开放的提示词
    # response = call_ollama_for_chat(user_input)
    response = f\"这是一个聊天回复(演示)。您说: {user_input}\" # 演示用
    return {"status": "success", "message": "聊天回复", "response": response}

安全警告 handle_write_code handle_create_file 函数中,文件名生成逻辑至关重要。上述示例使用了简单的过滤和替换来创建“安全”的文件名,防止路径遍历攻击(如 ../../../etc/passwd )。在生产环境中,你需要更严格的文件名验证和沙箱机制。 永远不要直接使用用户输入作为文件路径的一部分

3.4 Streamlit状态管理与流程串联

Streamlit的一个特点是脚本从上到下执行,每次交互(如点击按钮)都会导致整个脚本重新运行。这意味着,如果我们不采取措施,上一次的录音、转写结果、意图分析结果都会在点击新按钮后丢失。

解决方案是使用 st.session_state ,这是一个在页面重载间保持数据的字典。

import streamlit as st

# 初始化session state中的关键变量
if 'transcribed_text' not in st.session_state:
    st.session_state.transcribed_text = None
if 'intent_result' not in st.session_state:
    st.session_state.intent_result = None
if 'tool_result' not in st.session_state:
    st.session_state.tool_result = None

# --- 界面布局 ---
col1, col2 = st.columns(2)
with col1:
    st.header("步骤1: 录音")
    audio = audiorecorder("🎤 开始录音", "⏹️ 停止")
    if len(audio) > 0:
        st.audio(audio.tobytes(), format="audio/wav")
        if st.button("转写语音"):
            with st.spinner("正在识别..."):
                text = transcribe_audio(audio.tobytes())
                if text:
                    st.session_state.transcribed_text = text
                    st.rerun() # 触发重载以更新界面

with col2:
    st.header("步骤2: 意图与执行")
    if st.session_state.transcribed_text:
        st.write(f"**识别文本**: {st.session_state.transcribed_text}")
        
        if st.session_state.intent_result is None:
            if st.button("分析意图"):
                with st.spinner("正在分析..."):
                    intent = classify_intent(st.session_state.transcribed_text)
                    st.session_state.intent_result = intent
                    st.rerun()
        else:
            st.write(f"**识别意图**: {st.session_state.intent_result['intent']}")
            st.write(f"*理由*: {st.session_state.intent_result['reason']}")
            
            if st.session_state.tool_result is None:
                if st.button("执行命令"):
                    with st.spinner("正在执行..."):
                        # 这里可以传递更多上下文,例如用于SUMMARIZE的文本
                        result = execute_tool(
                            st.session_state.intent_result['intent'],
                            st.session_state.transcribed_text
                        )
                        st.session_state.tool_result = result
                        st.rerun()
            else:
                st.success("执行完成!")
                st.json(st.session_state.tool_result) # 以JSON格式美观地显示结果
                # 如果是文件操作,可以提供一个下载链接
                if st.session_state.tool_result.get('filepath'):
                    with open(st.session_state.tool_result['filepath'], 'rb') as f:
                        st.download_button(
                            label="下载生成的文件",
                            data=f,
                            file_name=os.path.basename(st.session_state.tool_result['filepath'])
                        )

    # 重置按钮,清空所有状态
    if st.button("重置所有状态"):
        for key in ['transcribed_text', 'intent_result', 'tool_result']:
            st.session_state[key] = None
        st.rerun()

实操心得 st.rerun() 是控制Streamlit流程的关键。当我们需要根据一个按钮的点击来更新 session_state 并立即反映到界面上时,在更新状态后调用 st.rerun() 会强制脚本立即重新执行,从而根据新的状态渲染不同的UI元素(例如,从显示“分析意图”按钮变为显示意图结果)。这种模式是构建交互式Streamlit应用的常用技巧。

4. 环境配置与踩坑实录

纸上得来终觉浅,绝知此事要躬行。下面是我在Windows系统上从零搭建这个项目时遇到的主要挑战和解决方案,希望能帮你绕过这些坑。

4.1 Python环境与依赖管理:虚拟环境是必选项

我的机器上原本装有Python 3.12和3.13。第一个坑就是包管理混乱:用 pip install 给3.12装了个包,然后在3.13的环境下运行脚本,死活 ImportError

解决方案 为每个项目使用独立的虚拟环境 。这是Python开发的金科玉律。

# 在项目根目录下
py -3.12 -m venv venv  # 使用Python 3.12创建虚拟环境
.\venv\Scripts\activate  # 激活虚拟环境(Windows)
# 激活后,命令行提示符前会出现 (venv)
pip install -r requirements.txt  # 安装所有依赖

我的 requirements.txt 文件核心内容如下:

streamlit>=1.28.0
requests>=2.31.0
python-dotenv>=1.0.0
streamlit-audiorecorder>=0.0.3

为什么是Python 3.12? 在项目开发时,3.13可能对一些库的兼容性尚不完全,而3.12是一个广泛支持且稳定的版本。始终明确指定Python版本和依赖项,能保证项目在任何机器上复现的一致性。

4.2 Ollama的安装与模型拉取

Ollama在Windows上的安装异常简单。

  1. 安装 :直接从官网下载 .exe 安装包,运行即可。安装后,Ollama服务会作为后台进程自动启动。
  2. 验证 :打开命令行(CMD或PowerShell),输入 ollama --version ,能看到版本信息即表示安装成功。
  3. 拉取模型 :在命令行运行 ollama pull llama3.2:latest 。这会下载约4GB的模型文件。首次拉取需要一些时间,取决于你的网速。
  4. 运行模型 :可以测试一下 ollama run llama3.2:latest ,然后输入“Hello”,看是否能正常回复。这确认了模型和服务都工作正常。

注意事项 :Ollama默认API服务运行在 http://localhost:11434 。确保你的防火墙没有阻止这个端口的本地连接。如果后续Streamlit应用无法连接到 localhost:11434 ,检查Ollama服务是否在运行(可以在任务管理器的“后台进程”中查找)。

4.3 Streamlit的状态管理陷阱与解决之道

如前所述,Streamlit的“从头执行”模式是双刃剑。除了用 st.session_state 持久化数据,另一个常见陷阱是 回调函数(callback)与状态更新的时机

例如,你有一个按钮,点击后需要执行一个长时间运行的任务(如调用LLM),然后更新界面。如果你直接把耗时操作写在按钮判断的 if st.button: 块里,界面会一直卡住直到操作完成,用户体验很差。

解决方案 :使用 st.spinner 提供加载提示,或者考虑使用 st.form 配合 st.form_submit_button 来组织输入和提交逻辑,有时能更好地管理状态。对于更复杂的异步操作,可以探索 asyncio 或第三方组件。在我的项目中,由于每个步骤(转写、分类、执行)都是顺序且需要用户触发下一步,所以简单的 st.button 配合 st.session_state st.rerun() 已经足够。

4.4 API密钥安全与Git防护

早期我把Groq API密钥直接写死在代码里 GROQ_API_KEY = "sk-..." ,然后顺手 git push 。结果立刻收到了GitHub的邮件警告,触发了 Push Protection ,推送被拒绝。这是GitHub检测到疑似密钥的字符串并自动阻止,是非常棒的安全功能。

正确做法

  1. 安装 python-dotenv pip install python-dotenv
  2. 创建 .env 文件 :在项目根目录创建名为 .env 的文件。
  3. 写入密钥 :在 .env 文件中写入一行: GROQ_API_KEY=your_actual_groq_api_key_here
  4. 忽略文件 :确保 .env .gitignore 文件中(如果不存在,创建它并添加一行 .env )。
  5. 代码中加载 :在Python脚本开头使用 load_dotenv() 加载,然后通过 os.getenv("GROQ_API_KEY") 获取。
# .gitignore 文件内容示例
venv/
.env
*.pyc
__pycache__/
output/  # 也忽略输出目录,避免提交生成的文件

这样,你的密钥只存在于本地,永远不会进入版本库。团队协作时,可以提供一个 .env.example 文件模板,让他人知道需要配置哪些环境变量。

5. 功能扩展与优化思路

这个基础版本已经能跑起来了,但它只是一个起点。你可以从以下几个方向对它进行强化和定制,打造属于你自己的超级助手。

5.1 扩展更多工具意图

目前的四个意图(写代码、创建文件、总结、聊天)只是示例。你可以轻松地添加更多:

  • WEB_SEARCH :集成一个本地运行的搜索工具(如使用 duckduckgo-search 库),让AI能回答实时信息。 (注意:此功能需要网络,且需谨慎处理信息来源)
  • CALCULATE :集成一个安全的数学表达式计算器(如 eval 的替代品 asteval numexpr ),处理“计算2345乘以678”这类指令。
  • CONTROL_SYSTEM (高风险,需极度谨慎) 通过安全的子进程调用,执行预定义的白名单系统命令,如“锁屏”、“调节音量”。 必须严格限制可执行的命令范围,防止任意命令执行漏洞。

添加新意图的步骤:

  1. classify_intent system_prompt 中更新意图列表和描述。
  2. execute_tool 函数中添加新的 elif 分支。
  3. 实现对应的工具函数(如 handle_web_search )。

5.2 提升意图识别的准确性

当前的意图分类依赖于一个通用的LLM。为了提升准确率,可以考虑:

  • 微调小型模型 :收集一批指令-意图的配对数据,对一个小型开源模型(如 bert-base )进行微调,专门用于意图分类。这比调用大模型更快、更便宜、更精准。
  • 少样本提示 :在系统提示词中提供更详细、更多样化的例子(Few-shot Learning)。例如,为每个意图提供3-5个不同表述的示例,能显著提升模型分类的准确性。
  • 输出格式强化 :除了要求JSON,还可以在提示词中强调“如果无法确定,请归为GENERAL_CHAT”,并设置一个较低的 temperature 值。

5.3 实现连续对话与上下文记忆

现在的每次交互都是独立的。要实现多轮对话,需要让LLM记住之前的聊天历史。

  • Ollama的上下文 :Ollama的API在调用时,可以通过 context 参数传递上一轮对话的上下文ID(在响应中返回),从而实现多轮对话。你需要修改 handle_general_chat 和相关函数来维护这个 context 列表。
  • Streamlit状态管理 :将对话历史列表保存在 st.session_state['chat_history'] 中,每次聊天时都将整个历史作为上下文发送给模型。注意,这会消耗更多的Tokens,可能需要设置一个历史长度上限。

5.4 前端界面美化与用户体验

Streamlit虽然方便,但默认样式比较基础。你可以:

  • 使用自定义CSS :通过 st.markdown 注入CSS样式,调整颜色、字体、布局。
  • 分页与布局优化 :利用 st.columns st.expander st.tabs 等容器组件,将录音、历史记录、设置等模块组织得更清晰。
  • 实时语音反馈 :考虑在录音时提供可视化反馈(如音量波动图),这需要更底层的音频处理,但社区可能有相关组件。

5.5 部署与分享

想让别人也能用上你的助手?

  • 本地运行 :最简单,让对方按照你的 README.md 配置环境即可。
  • Docker化 :创建一个Dockerfile,将Python环境、Ollama服务都打包进去。这能解决“在我机器上能跑”的经典问题。注意Docker镜像会比较大(包含模型文件)。
  • 云部署(谨慎) :你可以将Streamlit应用部署到Streamlit Community Cloud、Hugging Face Spaces或任何支持Python的云服务器。但 务必注意 :部署到公网意味着你的本地Ollama API( localhost:11434 )将无法访问。你需要将Ollama也部署到同一个云环境,或者使用云端的LLM API(如Groq的Chat API、OpenAI API等)来替代本地Ollama,但这会引入网络延迟和费用。 绝对不要将本地开发环境中直接访问 localhost 的代码部署到公网

构建这个项目的整个过程,是一次对现有AI工具链的愉快整合。它证明了,利用Groq、Ollama这些优秀的开源和免费服务,一个开发者完全可以在周末的时间里,搭建出一个功能实用、隐私友好的本地AI应用原型。最大的挑战往往不是AI模型本身,而是环境配置、状态管理和安全边界这些“工程问题”。希望我的这些经验和代码,能成为你探索本地AI应用世界的一块跳板。

Logo

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

更多推荐