基于Whisper与Streamlit构建语音控制AI代理:从语音识别到智能工具调用
语音识别(STT)技术是实现人机自然交互的关键基础,其核心原理是将音频信号转化为机器可理解的文本。随着深度学习的发展,以Whisper为代表的端到端模型显著提升了识别准确率与鲁棒性,为构建免提、高效的智能应用提供了技术支撑。这项技术的工程价值在于,它能够作为智能代理(AI Agent)的感知入口,将用户的语音指令无缝转化为结构化任务。在实际应用中,结合大语言模型(LLM)的函数调用(Functio
1. 项目概述:当语音指令遇见AI代理
最近在捣鼓一个挺有意思的东西:一个完全由语音控制的AI智能体。想象一下,你只需要对着麦克风说句话,比如“帮我查一下明天北京的天气”或者“总结一下我上周的工作报告要点”,它就能理解你的意图,调用相应的工具去执行任务,并把结果用语音或文字反馈给你。整个过程,你的双手是解放的,交互自然得就像在跟一个聪明的助手对话。这个项目的核心,就是把前沿的语音识别模型和轻量级的Web应用框架结合起来,打造一个可交互、可扩展的AI代理原型。
我选择的技术栈非常明确: OpenAI的Whisper 负责“听懂”人话, Streamlit 负责构建一个极其简单、快速的交互界面,而AI代理的“大脑”则可以用各种大语言模型(LLM)的API来实现,比如OpenAI的GPT系列或开源的Llama等。Whisper在语音转文本(STT)上的表现有目共睹,尤其是在中英文混合、带口音或背景噪声的场景下,其鲁棒性远超许多传统方案。Streamlit更是快速原型开发的利器,几行代码就能拉起一个带有按钮、输入框和实时显示区域的应用,特别适合这种需要前端交互但又不想写复杂HTML/JS的场景。
这个项目适合谁呢?如果你是对AI应用开发感兴趣的开发者,想亲手实践如何将多个AI模块(语音、语言模型、工具调用)串联成一个完整工作流,这是一个绝佳的练手项目。它也适合那些想为自己的智能家居、个人知识库或者工作效率工具添加“语音入口”的极客。即使你之前没有深入接触过语音识别或Web开发,只要对Python有基本了解,跟着下面的步骤走,也能在几个小时内看到成果。关键在于理解整个数据流的拆解与组装逻辑。
2. 核心架构与工作流设计
2.1 系统组件拆解与选型理由
整个系统的运行可以看作一条清晰的流水线: 语音输入 → 语音转文本 → 文本理解与任务规划 → 工具执行 → 结果生成与反馈 。每个环节的技术选型都经过了权衡。
首先是 语音捕获与转写模块 。这里我坚定地选择了Whisper。为什么不选用云服务商的STT API(如Azure、Google Cloud)呢?核心原因在于 隐私、成本和离线能力 。Whisper模型可以完全本地部署,你的语音数据无需离开本地环境,这对处理敏感信息或在内网部署至关重要。其次,一旦模型部署好,调用几乎没有额外成本,适合高频次使用。Whisper提供了从 tiny 到 large 多种规模的模型,在精度和速度上可以权衡。对于实时性要求高的语音控制, base 或 small 模型在普通CPU上也能达到可接受的延迟;如果追求更高的转写准确率,尤其是专业术语或复杂语境,则可以使用 medium 或 large 模型,并配合GPU加速。
接下来是 交互界面与逻辑控制模块 ,Streamlit是不二之选。它的核心优势是 开发效率 。传统上,为这样一个应用构建Web界面,需要前后端分离,涉及HTML、CSS、JavaScript以及后端框架(如Flask、Django)。Streamlit允许你用纯Python脚本描述整个UI和交互逻辑。一个录音按钮、一个实时显示转录结果的文本框、一个展示AI回复的区域,这些都用简单的 st.button 、 st.text_area 、 st.write 就能实现。其会话状态(Session State)管理能力,能很好地处理用户多次交互的上下文。这对于我们构建一个多轮对话的AI代理至关重要。
最后是 AI代理核心(大脑) 。这部分的选择最灵活。你可以直接调用OpenAI的ChatCompletions API,利用其强大的函数调用(Function Calling)能力来让模型决定何时、如何调用我们定义的工具(如查询天气、搜索网络、计算器等)。你也可以使用LangChain、LlamaIndex这类框架来组装链(Chain),它们提供了更丰富的工具集成和记忆管理模块。如果追求完全开源和本地化,可以用Ollama本地运行一个Llama 3或Mistral模型,并通过其API进行交互。在这个原型中,我会以OpenAI API为例进行讲解,因为它最成熟、文档最全,便于理解核心概念。你可以轻松地替换成其他LLM提供商。
2.2 端到端工作流与数据流转
让我们把上述组件串联起来,看看一次完整的交互是如何发生的:
- 用户发起交互 :用户在Streamlit页面上点击“开始录音”按钮。Streamlit调用浏览器的
MediaRecorderAPI(通过streamlit-webrtc等组件或原生JavaScript)捕获麦克风音频流,并编码为片段(例如WAV格式的Blob数据)。 - 音频预处理与传输 :前端将音频Blob发送到Streamlit后端(Python服务器)。后端接收到音频数据后,可能需要进行一些预处理,如格式转换(确保是Whisper支持的格式,如
.wav)、采样率重采样(统一到16kHz)、以及可能的降噪(可选,Whisper本身抗噪能力不错)。 - 语音转文本 :预处理后的音频数据被送入本地加载的Whisper模型。模型输出转写结果,即用户说的原始文本。 这里有一个关键点 :需要处理语音的端点检测(VAD)问题。是用户说一句就转写一句(流式),还是用户停止说话一段时间后整体转写?对于控制型应用,通常采用“按句”模式,这需要结合静音检测来判断一句话的结束。
- 文本理解与代理决策 :转写得到的文本被发送给LLM(例如GPT-4)。我们会在系统提示词(System Prompt)中清晰地定义AI代理的角色、可用的工具列表以及调用工具的格式。LLM分析用户意图,如果判断需要调用工具,它会以指定的格式(如JSON)输出一个“工具调用请求”。
- 工具执行 :后端解析LLM的输出,识别出要调用的工具和参数。然后,执行相应的Python函数(例如,调用一个天气API,传入城市参数“北京”)。
- 结果合成与反馈 :工具执行的结果返回给LLM。LLM结合之前的对话历史和工具执行结果,生成一段面向用户的自然语言回复。
- 多模态输出 :最后,这段回复文本会显示在Streamlit界面上。 为了体验更完整,我们还可以加入文本转语音(TTS)模块 ,比如使用
pyttsx3(本地离线)或gTTS(在线),将回复读出来,形成一个闭环的语音交互。
整个数据流的核心在于 异步和非阻塞 。录音、转写、LLM调用、工具执行都可能耗时,需要妥善设计UI状态(如加载指示器),避免界面卡死。Streamlit的 st.spinner 和缓存机制( @st.cache_data )在这里能派上用场。
3. 环境搭建与核心模块实现
3.1 项目环境与依赖配置
工欲善其事,必先利其器。我们先来创建一个干净的项目环境。我强烈建议使用 conda 或 venv 来管理Python环境,避免包冲突。
# 创建并激活一个新的conda环境(推荐)
conda create -n voice-agent python=3.10
conda activate voice-agent
# 或者使用venv
python -m venv venv
# Windows: venv\Scripts\activate
# Linux/Mac: source venv/bin/activate
接下来,安装核心依赖。我们将依赖分为几组:语音处理、Web框架、AI模型接口和工具库。
# 语音处理核心
pip install openai-whisper # OpenAI官方Whisper库
pip install sounddevice soundfile # 用于音频录制和播放(备用方案)
# 注意:Whisper依赖ffmpeg,请确保系统已安装。Ubuntu/Debian: `sudo apt install ffmpeg`, macOS: `brew install ffmpeg`, Windows可从官网下载并添加至PATH。
# Web应用框架
pip install streamlit
# 可选但强烈推荐,用于更优雅的音频流处理
pip install streamlit-webrtc
# AI代理核心 (以OpenAI为例)
pip install openai
# 如果想用LangChain来构建更复杂的代理逻辑
# pip install langchain langchain-openai
# 工具库与其他
pip install python-dotenv # 管理环境变量(如API密钥)
pip install requests # 用于调用外部API工具
# 如果需要TTS
pip install pyttsx3 # 离线TTS
# pip install gtts # 在线Google TTS(需要网络)
注意:Whisper模型下载 。第一次运行
whisper相关代码时,它会自动从Hugging Face Hub下载指定模型(如base)。如果网络不畅,可以手动下载模型文件(.pt格式)并放置到本地缓存目录(通常为~/.cache/whisper/),然后在代码中指定本地路径。
创建一个 .env 文件来安全地存储你的OpenAI API密钥(或其他LLM服务的密钥):
OPENAI_API_KEY=your_api_key_here
3.2 Whisper语音识别模块深度集成
Whisper的使用看似简单,但集成到实时应用中有不少细节要注意。我们不会仅仅满足于调用 whisper.transcribe(audio_path) ,而是要处理音频流、控制延迟、并处理可能的错误。
首先,我们封装一个Whisper服务类。考虑到实时性,我们可能不需要每次都加载模型。可以使用单例模式或Streamlit的缓存来避免重复加载。
import whisper
import numpy as np
import io
import soundfile as sf
from typing import Optional, Tuple
class WhisperTranscriber:
def __init__(self, model_size: str = "base", device: str = "cpu"):
"""
初始化Whisper转录器。
:param model_size: 模型大小,可选 "tiny", "base", "small", "medium", "large"
:param device: 运行设备,"cpu" 或 "cuda"
"""
self.model_size = model_size
self.device = device
self.model = None
self._load_model()
def _load_model(self):
"""加载Whisper模型,利用缓存避免重复加载。"""
# 在实际Streamlit应用中,可以将此函数用@st.cache_resource装饰,全局缓存模型
if self.model is None:
print(f"Loading Whisper {self.model_size} model on {self.device}...")
self.model = whisper.load_model(self.model_size, device=self.device)
print("Model loaded.")
def transcribe_audio_bytes(self, audio_bytes: bytes, sr: int = 16000) -> Tuple[str, dict]:
"""
将内存中的音频字节数据转录为文本。
:param audio_bytes: 音频字节数据(如WAV格式)
:param sr: 音频采样率,Whisper期望16kHz
:return: 转录文本和包含详细信息的字典
"""
# 将字节数据转换为numpy数组
# 这里假设传入的是WAV格式。如果是其他格式,需要相应处理。
try:
# 使用soundfile从内存中读取字节
audio_io = io.BytesIO(audio_bytes)
audio_array, original_sr = sf.read(audio_io, dtype='float32')
# 重采样到16kHz(如果必要)
if original_sr != sr:
import librosa
audio_array = librosa.resample(audio_array, orig_sr=original_sr, target_sr=sr)
# 确保是单声道
if len(audio_array.shape) > 1:
audio_array = np.mean(audio_array, axis=1)
# 调用Whisper转录
result = self.model.transcribe(audio_array, fp16=False) # CPU上fp16=False
text = result["text"].strip()
return text, result
except Exception as e:
print(f"Transcription failed: {e}")
return "", {"error": str(e)}
def transcribe_audio_file(self, file_path: str) -> Tuple[str, dict]:
"""转录本地音频文件。"""
try:
result = self.model.transcribe(file_path)
return result["text"].strip(), result
except Exception as e:
print(f"Transcription failed: {e}")
return "", {"error": str(e)}
关键细节与避坑指南 :
- 音频格式与采样率 :Whisper模型内部固定使用16000Hz采样率。无论你从何处获得音频,最终送入模型的数组必须是16kHz、单声道、浮点格式。
soundfile和librosa是处理格式转换和重采样的好帮手。 - 内存管理 :长时间运行的Streamlit应用,如果反复加载大模型(如
large),会消耗大量内存。务必使用@st.cache_resource来缓存模型对象。@st.cache_resource def load_whisper_model(model_size="base"): return whisper.load_model(model_size) - 实时性与流式转录 :上面的代码是“一段式”转录,即用户说完一整段话后一次性处理。对于更实时的体验,Whisper本身支持流式处理,但需要更复杂的音频流管理和句子级的分割。一个折中方案是使用 语音活动检测(VAD) 来判定用户何时开始和结束说话,然后将检测到的单句音频片段送入Whisper。
silero-vad是一个优秀的轻量级VAD库,可以集成进来。 - 多语言与任务指定 :
transcribe方法支持language和task参数。如果你明确知道用户会说中文,可以设置language="zh"以提高准确率和速度。task可以是"transcribe"(转录)或"translate"(翻译成英文)。
3.3 Streamlit前端界面与音频捕获
Streamlit界面是我们的控制中心和展示窗口。我们需要设计一个直观的UI,包含录音控制、状态显示、对话历史和设置区域。
import streamlit as st
import asyncio
import queue
import threading
from datetime import datetime
# 假设我们已经有了上面的WhisperTranscriber类和OpenAI的客户端
from openai import OpenAI
import json
import os
from dotenv import load_dotenv
load_dotenv()
# 页面配置
st.set_page_config(page_title="语音控制AI助手", layout="wide")
st.title("🎤 语音控制AI助手")
st.markdown("点击下方按钮开始录音,用自然语言向我下达指令吧!")
# 初始化会话状态,用于存储对话历史和各类状态
if 'messages' not in st.session_state:
st.session_state.messages = [] # 存储对话历史,格式:[{"role": "user", "content": "..."}, ...]
if 'transcript' not in st.session_state:
st.session_state.transcript = ""
if 'is_recording' not in st.session_state:
st.session_state.is_recording = False
if 'audio_queue' not in st.session_state:
st.session_state.audio_queue = queue.Queue()
# 侧边栏 - 设置
with st.sidebar:
st.header("设置")
model_size = st.selectbox("Whisper模型", ["tiny", "base", "small", "medium"], index=1)
llm_model = st.selectbox("AI模型", ["gpt-3.5-turbo", "gpt-4", "gpt-4-turbo"], index=0)
enable_tts = st.checkbox("启用语音回复 (TTS)", value=False)
if st.button("清空对话历史"):
st.session_state.messages = []
st.rerun()
# 主界面分为两列
col1, col2 = st.columns([1, 2])
with col1:
st.subheader("录音控制")
# 这里是一个简化的录音按钮示例。实际生产环境推荐使用streamlit-webrtc进行更稳定的音频流处理。
if st.button("🎤 开始录音", key="start_rec", disabled=st.session_state.is_recording):
st.session_state.is_recording = True
st.session_state.transcript = "正在聆听..."
# 在实际实现中,这里会启动一个后台线程或异步任务来捕获音频
# 并将音频数据块放入 st.session_state.audio_queue
st.info("录音已开始,请说话...")
if st.button("⏹️ 停止录音", key="stop_rec", disabled=not st.session_state.is_recording):
st.session_state.is_recording = False
st.success("录音已停止,正在处理...")
# 触发音频处理流程:从队列中取出完整音频,调用Whisper转写
# process_audio_background()
st.subheader("实时转写")
transcript_placeholder = st.empty()
transcript_placeholder.text_area("转写结果", value=st.session_state.transcript, height=150, disabled=True)
if st.session_state.transcript and not st.session_state.is_recording:
if st.button("发送给AI助手"):
# 将转写文本添加到对话历史,并调用AI代理
user_message = st.session_state.transcript
st.session_state.messages.append({"role": "user", "content": user_message})
# 调用AI处理函数(后续实现)
# response = call_ai_agent(user_message)
# st.session_state.messages.append({"role": "assistant", "content": response})
st.session_state.transcript = "" # 清空当前转写
st.rerun()
with col2:
st.subheader("对话历史")
chat_placeholder = st.container()
with chat_placeholder:
for msg in st.session_state.messages:
with st.chat_message(msg["role"]):
st.write(msg["content"])
# 如果正在等待AI回复,显示一个加载指示器
# if st.session_state.waiting_for_ai:
# with st.chat_message("assistant"):
# with st.spinner("思考中..."):
# pass
关于音频捕获的深入探讨 : 上面的代码中,录音按钮只是一个示意。在浏览器中直接、稳定地捕获音频流, streamlit-webrtc 组件是目前最优雅的解决方案 。它基于WebRTC,提供了低延迟的音频/视频流处理能力。
# 使用streamlit-webrtc的简化示例
from streamlit_webrtc import webrtc_streamer, WebRtcMode, ClientSettings
import av
def audio_frame_callback(frame: av.AudioFrame) -> av.AudioFrame:
# 这里可以实时处理每一帧音频数据,例如放入队列供VAD和Whisper使用
audio_data = frame.to_ndarray()
# 将audio_data放入一个全局队列中
st.session_state.audio_queue.put(audio_data)
# 如果需要原样播放,可以返回frame,否则返回None
return frame
# 在Streamlit页面中
webrtc_ctx = webrtc_streamer(
key="audio-recorder",
mode=WebRtcMode.SENDRECV,
client_settings=ClientSettings(
rtc_configuration={"iceServers": [{"urls": ["stun:stun.l.google.com:19302"]}]},
media_stream_constraints={"audio": True, "video": False},
),
audio_frame_callback=audio_frame_callback,
source_audio_track=True, # 允许选择音频输入设备
)
使用 streamlit-webrtc ,音频捕获变得非常稳定,并且可以跨平台工作。你需要处理的是从队列中消费音频数据,进行VAD判断,并将有效的语音片段拼接起来,最终送给Whisper。
4. AI代理核心与工具调用实现
4.1 基于LLM的函数调用(Function Calling)设计
AI代理的“智能”体现在它能理解用户意图并决定执行什么操作。OpenAI的Chat Completions API提供的“函数调用”功能完美契合这个需求。我们首先需要定义代理可以使用的“工具”,也就是一系列函数及其描述。
假设我们的助手有三个能力:查询天气、计算器、获取当前时间。
import openai
from datetime import datetime
import requests
import json
# 初始化OpenAI客户端
client = openai.OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
# 1. 定义工具(函数)列表
tools = [
{
"type": "function",
"function": {
"name": "get_current_weather",
"description": "获取指定城市的当前天气情况",
"parameters": {
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "城市名称,例如:北京,上海",
},
"unit": {
"type": "string",
"enum": ["celsius", "fahrenheit"],
"description": "温度单位,摄氏度或华氏度",
},
},
"required": ["location"],
},
},
},
{
"type": "function",
"function": {
"name": "calculator",
"description": "执行数学计算",
"parameters": {
"type": "object",
"properties": {
"expression": {
"type": "string",
"description": "数学表达式,例如:3 + 5 * 2, sqrt(16)",
},
},
"required": ["expression"],
},
},
},
{
"type": "function",
"function": {
"name": "get_current_time",
"description": "获取当前的日期和时间",
"parameters": {
"type": "object",
"properties": {
"timezone": {
"type": "string",
"description": "时区,例如:Asia/Shanghai, UTC",
},
},
"required": [],
},
},
},
]
# 2. 实现具体的工具函数
def get_current_weather(location: str, unit: str = "celsius") -> str:
"""模拟天气查询,实际应调用真实API如OpenWeatherMap"""
# 这里模拟返回
weather_info = {
"北京": {"temperature": 22, "condition": "晴朗", "humidity": 40},
"上海": {"temperature": 25, "condition": "多云", "humidity": 65},
}
data = weather_info.get(location, {"temperature": 20, "condition": "未知", "humidity": 50})
temp = data["temperature"]
if unit == "fahrenheit":
temp = temp * 9/5 + 32
return f"{location}的天气:{data['condition']},温度{temp}度{'摄氏度' if unit=='celsius' else '华氏度'},湿度{data['humidity']}%。"
def calculator(expression: str) -> str:
"""安全地计算数学表达式"""
try:
# 警告:直接使用eval有安全风险,仅用于演示。生产环境应使用更安全的库如`asteval`或限制表达式。
# 这里做简单演示,实际需严格过滤。
allowed_chars = set("0123456789+-*/(). sqrt")
if not all(c in allowed_chars for c in expression.replace(" ", "")):
return "错误:表达式包含不安全字符。"
result = eval(expression, {"__builtins__": {}}, {"sqrt": __import__('math').sqrt})
return f"{expression} = {result}"
except Exception as e:
return f"计算错误:{e}"
def get_current_time(timezone: str = "Asia/Shanghai") -> str:
"""获取指定时区的当前时间"""
from datetime import datetime
import pytz
try:
tz = pytz.timezone(timezone)
current_time = datetime.now(tz)
return current_time.strftime(f"%Y年%m月%d日 %H:%M:%S ({timezone})")
except pytz.exceptions.UnknownTimeZoneError:
return f"未知时区:{timezone},请使用有效的时区名称(如Asia/Shanghai, UTC)。"
# 工具名称到函数的映射
available_functions = {
"get_current_weather": get_current_weather,
"calculator": calculator,
"get_current_time": get_current_time,
}
4.2 代理决策循环与对话管理
有了工具定义,接下来是核心的代理逻辑:让LLM根据对话历史和当前用户输入,决定是直接回答还是调用工具,并处理多轮工具调用。
def call_ai_agent(user_input: str, conversation_history: list) -> str:
"""
调用AI代理处理用户输入。
:param user_input: 用户当前输入文本
:param conversation_history: 之前的对话消息列表
:return: AI助手的最终回复文本
"""
# 构建消息列表,包含历史对话和当前用户输入
messages = conversation_history + [{"role": "user", "content": user_input}]
# 第一轮调用:让LLM判断是否需要调用函数,以及调用哪个
first_response = client.chat.completions.create(
model=st.session_state.get("llm_model", "gpt-3.5-turbo"),
messages=messages,
tools=tools,
tool_choice="auto", # 让模型自行决定是否调用工具
)
response_message = first_response.choices[0].message
tool_calls = response_message.tool_calls
# 将模型的回复添加到消息历史中,这一步很重要,用于维持对话上下文
messages.append(response_message)
# 检查是否有工具调用
if tool_calls:
# 遍历所有被调用的工具(可能同时调用多个)
for tool_call in tool_calls:
function_name = tool_call.function.name
function_to_call = available_functions.get(function_name)
if function_to_call:
# 解析工具调用参数
function_args = json.loads(tool_call.function.arguments)
# 执行工具函数
function_response = function_to_call(**function_args)
# 将工具执行结果作为一条新消息追加到对话历史
messages.append({
"tool_call_id": tool_call.id,
"role": "tool",
"name": function_name,
"content": function_response, # 工具执行结果必须是字符串
})
else:
# 如果请求了未定义的函数,返回错误信息
messages.append({
"tool_call_id": tool_call.id,
"role": "tool",
"name": function_name,
"content": f"错误:未知工具 '{function_name}'。",
})
# 第二轮调用:将工具执行结果返回给LLM,让它生成面向用户的总结性回复
second_response = client.chat.completions.create(
model=st.session_state.get("llm_model", "gpt-3.5-turbo"),
messages=messages,
)
final_reply = second_response.choices[0].message.content
else:
# 如果没有工具调用,直接使用第一轮的回复
final_reply = response_message.content
return final_reply
这个循环的精髓在于 :
- 上下文维护 :每次调用LLM,都必须传入完整的
messages历史,包括用户消息、AI的回复(含工具调用请求)、以及工具执行的结果。这保证了模型能理解整个对话的来龙去脉。 - 工具调用格式 :当模型决定调用工具时,它会在
tool_calls字段中返回一个结构化的请求。我们必须按照这个结构,执行对应的函数,并将结果以特定格式(role: “tool”)返回给模型。 - 多轮工具调用 :一个复杂的用户请求可能涉及多个工具。例如,“先查一下北京天气,再计算一下比上海高多少度”。上述循环可以处理单个请求内的多个
tool_calls。对于需要多轮对话才能完成的任务(如先确认城市再查询),则依靠外层的对话历史(st.session_state.messages)来管理。
现在,我们可以将之前Streamlit界面中的“发送给AI助手”按钮逻辑补全:
if st.session_state.transcript and not st.session_state.is_recording:
if st.button("发送给AI助手"):
user_message = st.session_state.transcript
st.session_state.messages.append({"role": "user", "content": user_message})
with st.spinner("AI助手正在思考..."):
# 调用代理函数
ai_response = call_ai_agent(user_message, st.session_state.messages[:-1]) # 传入历史,不包括刚添加的这条
st.session_state.messages.append({"role": "assistant", "content": ai_response})
# 如果需要TTS,在这里触发语音合成
if enable_tts:
# tts_speak(ai_response)
pass
st.session_state.transcript = ""
st.rerun() # 刷新界面以显示新消息
5. 高级功能扩展与性能优化
5.1 流式响应与实时反馈
上面的实现中,用户需要等待整个“转录→LLM思考→工具执行→LLM总结”流程完成才能看到回复。为了体验更佳,可以实现 流式响应 。OpenAI API支持以流(stream)的形式返回内容,我们可以边生成边显示在Streamlit界面上。
def call_ai_agent_streaming(user_input: str, conversation_history: list):
"""
流式调用AI代理,生成器函数,yield返回回复的每个片段。
"""
messages = conversation_history + [{"role": "user", "content": user_input}]
# 第一轮调用,判断工具调用
response = client.chat.completions.create(
model="gpt-3.5-turbo",
messages=messages,
tools=tools,
tool_choice="auto",
stream=False # 工具调用阶段不建议流式,因为需要完整解析工具调用请求
)
response_message = response.choices[0].message
tool_calls = response_message.tool_calls
messages.append(response_message)
if tool_calls:
# 处理工具调用(非流式)
for tool_call in tool_calls:
function_name = tool_call.function.name
function_to_call = available_functions.get(function_name)
if function_to_call:
function_args = json.loads(tool_call.function.arguments)
function_response = function_to_call(**function_args)
messages.append({
"tool_call_id": tool_call.id,
"role": "tool",
"name": function_name,
"content": function_response,
})
# 第二轮调用,流式返回最终回复
stream = client.chat.completions.create(
model="gpt-3.5-turbo",
messages=messages,
stream=True # 开启流式
)
for chunk in stream:
if chunk.choices[0].delta.content is not None:
yield chunk.choices[0].delta.content
else:
# 如果没有工具调用,直接流式返回第一轮的回复(但第一轮我们没开stream,所以这里简化处理)
# 更严谨的做法是,第一轮也开启stream,并解析流中的tool_calls,这更复杂。
# 此处为简化,直接返回完整内容。
yield response_message.content
在Streamlit界面中,可以使用 st.write_stream 来消费这个生成器,实现打字机效果:
# 在按钮点击事件中
if st.button("发送给AI助手"):
user_message = st.session_state.transcript
st.session_state.messages.append({"role": "user", "content": user_message})
# 为助手的回复创建一个占位符
with st.chat_message("assistant"):
message_placeholder = st.empty()
full_response = ""
# 调用流式函数
for chunk in call_ai_agent_streaming(user_message, st.session_state.messages[:-1]):
full_response += chunk
message_placeholder.markdown(full_response + "▌") # 光标效果
message_placeholder.markdown(full_response) # 最终显示
st.session_state.messages.append({"role": "assistant", "content": full_response})
st.session_state.transcript = ""
# 注意:使用流式时,rerun可能不合适,需要更精细的状态管理。
5.2 语音合成(TTS)与完整闭环
为了让交互体验从“语音输入,文本输出”升级到“全语音对话”,我们需要添加文本转语音(TTS)功能。这里介绍两个方案:
方案一:离线方案( pyttsx3 ) 优点:无需网络,隐私好,速度快。 缺点:声音机械,可调节参数有限,跨平台可能有问题。
import pyttsx3
def speak_text_offline(text: str):
"""使用pyttsx3离线语音合成"""
try:
engine = pyttsx3.init()
# 设置语速、音量等(可选)
rate = engine.getProperty('rate')
engine.setProperty('rate', rate - 20) # 稍慢一点
engine.say(text)
engine.runAndWait()
except Exception as e:
st.error(f"语音合成失败: {e}")
方案二:在线方案( gTTS + playsound /音频播放) 优点:声音自然(Google TTS),支持多语言。 缺点:需要网络,有延迟,有调用限制。
from gtts import gTTS
import io
import base64
from pathlib import Path
def speak_text_online(text: str, lang='zh-cn'):
"""使用gTTS在线合成语音,并在Streamlit中播放"""
try:
tts = gTTS(text=text, lang=lang, slow=False)
# 保存到临时文件或字节流
audio_bytes = io.BytesIO()
tts.write_to_fp(audio_bytes)
audio_bytes.seek(0)
# 在Streamlit中播放音频
# 方法1:保存为临时文件后使用st.audio
# with tempfile.NamedTemporaryFile(delete=False, suffix='.mp3') as fp:
# tts.save(fp.name)
# st.audio(fp.name, format='audio/mp3')
# 方法2:直接使用字节数据(更优雅)
audio_base64 = base64.b64encode(audio_bytes.read()).decode()
audio_html = f'<audio autoplay controls><source src="data:audio/mp3;base64,{audio_base64}" type="audio/mp3"></audio>'
st.components.v1.html(audio_html, height=100)
except Exception as e:
st.error(f"在线语音合成失败: {e}")
将TTS集成到主流程中,只需在得到AI的最终回复 full_response 后,调用 speak_text_online(full_response) 即可。注意,在Streamlit中自动播放音频可能受到浏览器策略限制,通常需要一次用户交互(如点击按钮)后才能触发。因此,可以添加一个“播放回复”按钮,或者确保TTS调用是在用户点击“发送”按钮的事件回调中。
5.3 性能优化与部署考量
当应用从原型走向实际使用时,性能优化至关重要。
-
模型加载与缓存 :Whisper和大型语言模型加载耗时。务必使用
@st.cache_resource进行缓存。对于Whisper,甚至可以预加载不同尺寸的模型,让用户在设置中切换时无需重新下载。@st.cache_resource def get_whisper_model(size="base"): return whisper.load_model(size) @st.cache_resource def get_llm_client(): return openai.OpenAI(api_key=os.getenv("OPENAI_API_KEY")) -
异步处理 :音频录制、转写、LLM调用、TTS都是I/O密集型或计算密集型任务。使用异步(
asyncio)或多线程可以防止Streamlit界面阻塞。例如,可以将耗时的Whisper转写和LLM调用放入线程池中执行。import concurrent.futures executor = concurrent.futures.ThreadPoolExecutor(max_workers=2) # 在按钮回调中 future = executor.submit(call_ai_agent, user_message, history) # 使用st.session_state来跟踪任务状态和结果 -
会话状态管理 :Streamlit是“从头到尾”执行脚本的。任何需要跨“重运行”保持的变量(如对话历史、音频队列、任务状态)都必须存入
st.session_state。管理好这些状态是构建复杂应用的关键。 -
部署 :使用Streamlit Cloud、Hugging Face Spaces或自己的服务器部署。注意:
- 环境变量 :将
OPENAI_API_KEY等敏感信息设置为部署平台的环境变量。 - 依赖管理 :提供完整的
requirements.txt。 - 资源限制 :Streamlit Cloud有内存和CPU限制。如果使用较大的Whisper模型(如
medium或large),可能超出免费额度。考虑使用base或small模型,或者寻求更高配置的托管方案。 - 网络与延迟 :如果使用在线TTS或LLM API,网络延迟会影响体验。考虑添加超时处理和加载动画。
- 环境变量 :将
6. 常见问题排查与实战心得
6.1 音频处理相关难题
问题1:录音没有声音或Whisper转写总是空。
- 排查步骤 :
- 检查麦克风权限 :浏览器是否已授权网站使用麦克风?Streamlit本地运行时通常是
http://localhost:8501,确保浏览器没有阻止。 - 检查音频格式 :确保传递给Whisper的音频数据是16kHz、单声道、浮点数的NumPy数组。使用
librosa或soundfile库进行加载和重采样是最可靠的方式。 - 检查音频数据 :在调用Whisper前,可以先简单保存音频片段到文件,用播放器听听看是否正常。这能快速定位是录音问题还是转写问题。
- 尝试不同的Whisper模型 :
tiny模型速度最快但精度最低。如果base不行,试试small。同时,可以指定语言参数language=“zh”来提升中文识别率。
- 检查麦克风权限 :浏览器是否已授权网站使用麦克风?Streamlit本地运行时通常是
- 心得 :音频处理管道中,数据格式是万恶之源。建立一个简单的调试函数,将每个环节的音频数据形状、采样率、最大值最小值打印出来,能省去大量猜测时间。
问题2:实时录音延迟高,句末切断或等待时间过长。
- 解决方案 :集成一个轻量级的VAD(语音活动检测)。
silero-vad是一个不错的选择。它的原理是判断音频帧是否包含人声。你可以设置一个阈值和静音持续时间,当检测到静音超过设定时间(如500ms)时,就认为一句话结束,将之前的音频片段送去转写。 - 代码片段示意 :
# 伪代码,需安装 silero-vad from silero_vad import load_silero_vad, get_speech_timestamps vad_model = load_silero_vad() # 在音频帧回调中 speech_timestamps = get_speech_timestamps(audio_frame, vad_model, sampling_rate=16000) if speech_timestamps: # 有语音,累积到缓冲区 audio_buffer.extend(audio_frame) else: # 静音超过阈值,处理缓冲区内的音频 if len(audio_buffer) > SILENCE_DURATION_THRESHOLD: process_audio_chunk(concatenate(audio_buffer)) audio_buffer.clear()
6.2 Streamlit应用与状态管理
问题3:点击按钮后应用刷新,状态丢失。
- 原因 :Streamlit脚本每次交互都会从头执行。所有变量都会重新初始化。
- 解决 : 所有需要持久化的数据,必须存入
st.session_state。这是Streamlit开发的铁律。从对话历史、用户设置到临时计算中间量,只要需要跨“重运行”存在,就放进去。 - 示例 :
# 初始化 if 'chat_history' not in st.session_state: st.session_state.chat_history = [] # 读取和写入 st.session_state.chat_history.append(new_message)
问题4:使用 st.rerun() 或流式输出时,界面行为异常或重复执行。
- 建议 :谨慎使用
st.rerun(),它会导致整个脚本重新执行。在流式输出场景下,更好的模式是利用st.empty()占位符动态更新内容,并避免在回调中触发rerun。对于复杂的多步骤交互,考虑使用st.form来封装输入和提交按钮,它可以防止部分脚本的重复执行。
6.3 AI代理与工具调用
问题5:LLM不调用工具,总是直接回答。
- 排查 :
- 检查工具描述 :
description和parameters的描述是否清晰、准确?LLM依赖这些描述来判断是否调用。描述要具体,说明工具的用途和适用场景。 - 检查系统提示词(System Prompt) :在
messages列表的开头,加入一条role: “system”的消息,明确指示AI扮演一个“可以调用工具来获取信息的助手”。例如:“你是一个有帮助的助手,可以调用工具来查询天气、时间或进行计算。如果用户的问题涉及这些方面,请调用相应的工具。” - 示例的力量 :在对话历史中提供一两个用户调用工具的例子(Few-shot Learning),能显著提升模型调用工具的倾向性。
- 检查工具描述 :
- 心得 :函数调用(Function Calling)的可靠性很大程度上依赖于提示工程。清晰的指令、详细的工具描述和少量的示例,比调整模型参数更有效。
问题6:工具调用参数解析错误。
- 原因 :LLM输出的参数JSON可能格式不正确或缺少必需字段。
- 解决 :在解析
tool_call.function.arguments时,一定要用try-except包裹json.loads。对于缺失的必需参数,可以提供默认值或返回一个错误信息要求用户澄清。try: function_args = json.loads(tool_call.function.arguments) except json.JSONDecodeError: # 处理JSON解析错误,例如让模型重试或直接回复用户 return “我理解您的要求,但在处理时遇到了技术问题,请稍后再试或换种方式提问。”
问题7:处理复杂、多轮的工具调用场景。
- 场景 :用户说“帮我订一张明天从北京到上海的机票,要早上的航班”。这可能需要先调用“查询航班”工具,用户选择航班后,再调用“填写乘客信息”、“支付”等工具。这是一个多轮、有状态的流程。
- 进阶设计 :这超出了简单的单次函数调用,需要引入 代理(Agent)工作流 或 状态机 。你可以使用LangChain的AgentExecutor,它内置了处理多步工具调用的循环逻辑。或者,自己管理一个更复杂的会话状态,记录当前任务所处的阶段和已收集的信息,分步引导用户。
6.4 部署与生产化建议
- 日志记录 :在生产环境中,务必添加详细的日志,记录用户的输入、转写结果、工具调用请求和结果、LLM的回复以及任何错误。这对于调试和监控至关重要。
- 错误处理与降级 :网络可能不稳定,API可能超限。对Whisper调用、LLM API调用、外部工具API调用都要设置超时和重试机制。当某个服务失败时,应有降级方案(例如,TTS失败就只显示文字)。
- 成本控制 :OpenAI API按Token收费。对于语音应用,Whisper转写的文本和LLM的输入输出都可能很长。考虑设置对话轮次上限,或对输入文本进行摘要。监控API使用量,避免意外开销。
- 安全性 :
- API密钥 :绝对不要硬编码在代码中。使用环境变量或安全的密钥管理服务。
- 用户输入净化 :如果工具调用涉及系统命令或文件操作(如我们的计算器示例使用了
eval),必须对输入进行极其严格的过滤和沙箱化,防止代码注入攻击。生产环境中应使用安全的数学表达式解析库。 - 音频数据 :如果涉及用户隐私,确保音频数据在传输和存储过程中加密,并在处理后及时删除。
这个项目从技术上看是多个流行组件的巧妙拼接,但真正让它“活”起来,需要你在细节处反复打磨:音频处理的稳定性、对话逻辑的流畅性、异常情况的鲁棒性。我自己的体会是,最难的不是让第一个原型跑起来,而是在各种边缘case和真实用户五花八门的输入下,依然能稳定、可靠、自然地工作。每一次调试和优化,都是对“如何构建一个可用AI产品”的更深理解。不妨从这个最小可行产品(MVP)开始,逐步添加你需要的工具,比如连接你的日历、邮件、智能家居设备,让它真正成为你的私人语音助手。
更多推荐

所有评论(0)