在语音交互项目里,真正难的不是单独跑通一个 ASR 模型或接上一个对话大模型,而是把“听到声音、听懂语义、做出决策、返回语音”这条完整链路串起来。这篇教程围绕 VoiceAgent 的代码实战展开,核心是级联式三明治架构:上下两层是音频相关的输入输出能力,中间夹着大模型与智能体决策层。全文不依赖某个具体的云平台,以本地可复现的方式实现一个最小可运行的语音智能体,从环境准备、代码实现、运行验证到问题排查逐步展开。

适用人群是已经了解 Python 基础、想动手做语音 Agent 项目的开发者。学完后,你会得到一套包含录音、语音识别、大模型决策、工具调用、语音合成与播放的完整项目骨架,并且知道每一层失败时应该从哪里查起。

1. 先理解 VoiceAgent 的级联式三明治架构

很多教程一上来就贴代码,结果读者改完配置依然跑不通。原因是只关注了某个模型,没有理解整个系统如何分层、数据和错误如何流动。VoiceAgent 这个名字听起来复杂,拆开之后其实只有三层。

1.1 三明治架构的三层划分

所谓“三明治架构”,指的是语音交互系统由三层构成,中间一层是决策核心,上下两层是语音能力层。用一张通俗的图来记忆:两片面包是音频相关技术,中间的肉和蔬菜是大模型智能体。

第一层是感知输入层,负责把物理声音变成文本。这个阶段主要涉及麦克风录音、音频降噪、端点检测、语音识别 ASR。它的输出是一段用户文本。

第二层是智能决策层,也就是三明治中间的“馅”。它接收文本,通过大模型理解用户意图,判断是否需要调用工具,结合上下文生成最终回复文本。这一层是 VoiceAgent 的价值所在,因为单纯的语音识别和文本问答已经被解决得很成熟,难的是把“语音识别出来的话”变成“系统能执行的动作”。

第三层是执行输出层,负责把回复文本变成声音并播放出来,同时执行工具侧的真实动作。TTS 合成、音频播放、调用业务系统 API、下发控制指令都在这一层完成。

层位置 职责 典型技术组件 输出
上片:感知输入层 录音、降噪、识别 麦克风、faster-whisper、VAD 用户文本
中间:智能决策层 意图理解、任务规划、工具调用 大模型、Agent Core、工具注册表 回复文本、动作指令
下片:执行输出层 语音合成、播放、业务执行 edge-tts、pyttsx3、业务 API 音频、动作结果

1.2 为什么用级联而不是端到端

端到端语音大模型是理想形态:声音进去,声音出来,中间不做文本中转。但现阶段在工程落地中,级联式架构仍然更可控。

级联的意思是处理流程严格自上而下单向传递:录音完成后做 ASR,ASR 输出文本交给大模型,大模型输出文本交给 TTS。每一级的输出是下一级的输入。

这种设计的几个实际好处:

  • 每一层可以独立测试和替换。ASR 效果不好只需要换 ASR,大模型决策有问题只需要改提示词和工具定义,不需要重写整条链路。
  • 每一层都能留日志。文本中间结果可以直接打印,出问题时很快能判断是识别错、理解错还是合成错。
  • 工具调用更容易落地。大模型输出结构化 JSON 后,由 Agent 层负责解析和执行,不需要让语音层感知业务细节。
  • 延迟虽然比端到端高,但对于大多数语音助手、工单录入、智能客服辅助场景,几百毫秒到一两秒的延迟是可接受的。

1.3 级联链路中的关键数据对象

级联式架构要稳定运行,必须定义几个统一的数据对象,避免每一层各写各的格式。

项目中主要使用三个数据对象:

  • AudioChunk :录音产生的音频数据,携带采样率、声道数、时长。
  • UserMessage :经过 ASR 后的用户文本,附带置信度、识别耗时。
  • AgentReply :智能体最终的回复文本,携带是否调用过工具、链路耗时、日志标识。

代码中不一定要定义成复杂的类,可以用简单字典,但字段名必须统一。下面给出一个建议的字段约定:

# 全链路统一的数据结构约定
audio_input = {
    "data": bytes,          # PCM 音频数据
    "sample_rate": 16000,   # 采样率
    "duration": 3.2,        # 音频时长,单位秒
}

user_message = {
    "text": "今天深圳天气怎么样",
    "asr_cost_ms": 1200,
    "confidence": 0.98,
}

agent_reply = {
    "text": "深圳今天多云,气温 28 度,出门建议带伞。",
    "action": [
        {"tool": "get_weather", "args": {"city": "深圳"}}
    ],
    "agent_cost_ms": 800,
}

有了这套约定,pipeline 层只需要按照顺序把它们串起来,每一层内部怎么实现不会被外部感知。

注意:级联架构最怕“中间结果不可见”。建议从项目第一天就把每一层的中间文本和耗时打印到日志里,否则后期排查问题时只能靠猜。

2. 环境准备:先把依赖和模型准备到能跑通的状态

不少项目写完了代码却跑不起来,原因往往不是代码本身,而是环境版本不一致、模型没有下载、服务没有启动。这一节先把环境固定下来。

2.1 运行环境和依赖清单

推荐在 Linux 或 macOS 上运行,Windows 也可以,但需要注意麦克风设备和 sounddevice 的驱动兼容性。Python 版本建议 3.10 或 3.11,避免部分音频库在 3.12 下出现编译问题。

创建虚拟环境:

python3 -m venv .venv
source .venv/bin/activate
pip install --upgrade pip

依赖文件 requirements.txt 如下:

sounddevice==0.4.6
numpy>=1.24,<2.0
faster-whisper>=1.0.0
openai>=1.30.0
edge-tts>=6.1.0
python-dotenv>=1.0.0
soundfile>=0.12.1
requests>=2.31.0

安装命令:

pip install -r requirements.txt

这里说明一下每个依赖的用途:

  • sounddevice :负责读麦克风音频和播放音频,底层基于 PortAudio。
  • faster-whisper :使用 CTranslate2 加速的 Whisper 版本,在 CPU 上也能跑。
  • openai :Python SDK 用来调用 OpenAI 兼容的大模型接口,本地 Ollama 和云端服务都兼容。
  • edge-tts :微软 Edge 的在线 TTS 服务,合成中文语音效果好,且不需要申请额外密钥。
  • python-dotenv :读取 .env 文件管理密钥和配置。

2.2 准备 ASR 模型

faster-whisper 在第一次调用时自动下载模型。为了减少运行时的不确定性,建议提前手动下载一次。

from faster_whisper import WhisperModel

# 下载并加载 small 模型到内存
model = WhisperModel("small", device="cpu", compute_type="int8")
print("ASR model loaded")

# 测试一段中文转写
segments, info = model.transcribe("test.wav", language="zh")
print(info.language, info.language_probability)

如果你本机没有 test.wav 文件,可以先随便录制一段音频再跑。 small 模型在 CPU 上的速度基本可用,显存足够时可以换 medium ,识别准确率更高,但速度会变慢。

2.3 准备大模型推理服务

在本地环境,推荐使用 Ollama 部署一个 OpenAI 兼容接口。这种方式不需要申请云上密钥,也不涉及网络策略问题,适合作为学习环境。

安装 Ollama 后,先拉取模型:

ollama pull qwen2.5:7b

启动服务:

ollama serve

确认接口可用:

curl http://localhost:11434/v1/models

看到返回模型列表,说明大模型服务已经就绪。Ollama 提供的 API 兼容 OpenAI 格式,因此项目中直接使用 OpenAI 客户端,把 base_url 指向 http://localhost:11434/v1 即可。

from openai import OpenAI

client = OpenAI(
    base_url="http://localhost:11434/v1",
    api_key="ollama",  # 本地服务不校验密钥,但字段不能为空
)

resp = client.chat.completions.create(
    model="qwen2.5:7b",
    messages=[{"role": "user", "content": "你好"}],
)
print(resp.choices[0].message.content)

如果你使用的是云端模型服务,把 base_url 和 api_key 换成你自己的配置即可。教程后面所有代码不依赖具体模型厂商。

2.4 确认麦克风和音频设备

录音环节最常遇到的问题是“程序跑起来但没有声音进去”。运行下面这段脚本,查看默认输入设备:

python -c "import sounddevice as sd; print(sd.query_devices()); print(sd.default.device)"

重点确认默认输入设备不是 None ,采样率支持 16000 Hz。如果系统有多个麦克风,需要在后续 Recorder 类中显式指定设备编号。

3. 项目结构与配置

环境准备好之后,开始搭建项目目录。代码量不大,但结构要清晰,方便后续扩展成多轮对话或接入新的 TTS 引擎。

3.1 目录结构

voice_agent/
├── .env.example
├── requirements.txt
├── config.py
├── pipeline.py
├── main.py
├── audio/
│   ├── __init__.py
│   ├── recorder.py
│   └── player.py
├── asr/
│   ├── __init__.py
│   └── transcriber.py
├── agent/
│   ├── __init__.py
│   ├── llm_client.py
│   ├── tools.py
│   └── core.py
├── tts/
│   ├── __init__.py
│   └── synthesizer.py
├── temp/
└── output/

temp 目录存放每次录音的临时音频文件, output 目录存放合成后的语音文件,二者都可以在运行时自动创建。

3.2 全局配置 config.py

配置文件集中管理音频参数、模型参数和接口地址。把参数抽出来而不是散落在各个模块里,后期调参时只需要改一个文件。

# config.py
import os
from pathlib import Path
from dotenv import load_dotenv

load_dotenv()

BASE_DIR = Path(__file__).resolve().parent
TEMP_DIR = BASE_DIR / "temp"
OUTPUT_DIR = BASE_DIR / "output"
TEMP_DIR.mkdir(exist_ok=True)
OUTPUT_DIR.mkdir(exist_ok=True)

# 音频采集参数
SAMPLE_RATE = 16000
CHANNELS = 1
CHUNK = 1024
DEFAULT_RECORD_SECONDS = 5

# ASR 参数
ASR_MODEL_SIZE = "small"
ASR_DEVICE = "cpu"
ASR_COMPUTE_TYPE = "int8"

# 大模型参数
LLM_BASE_URL = os.getenv("LLM_BASE_URL", "http://localhost:11434/v1")
LLM_API_KEY = os.getenv("LLM_API_KEY", "ollama")
LLM_MODEL = os.getenv("LLM_MODEL", "qwen2.5:7b")
LLM_TEMPERATURE = 0.2
LLM_TIMEOUT = 30

# TTS 参数
TTS_ENGINE = "edge"
TTS_VOICE = "zh-CN-XiaoxiaoNeural"
TTS_RATE = 0
TTS_VOLUME = 100

3.3 环境变量 .env.example

.env.example 提供给团队其他成员参考,实际运行时复制一份为 .env 再填入自己的值。

# .env.example
LLM_BASE_URL=http://localhost:11434/v1
LLM_API_KEY=ollama
LLM_MODEL=qwen2.5:7b

如果是远程大模型服务,只需要修改这些值,其余业务代码不用动。这正是配置外置化的价值,也是从学习环境过渡到生产环境的第一步。

4. 逐层实现代码

下面按三层架构逐层写代码。每段代码都会说明解决的问题和需要注意的关键点。

4.1 语音采集层:Recorder

Recorder 的核心任务是把麦克风输入保存为 WAV 文件,并且尽量控制时长。学习阶段不引入复杂的 VAD 模型,采用“固定时长 + 静音判断”的简化方案。

# audio/recorder.py
import time
import wave
import numpy as np
import sounddevice as sd
from config import SAMPLE_RATE, CHANNELS, CHUNK


class Recorder:
    def __init__(self, sample_rate=SAMPLE_RATE, channels=CHANNELS):
        self.sample_rate = sample_rate
        self.channels = channels

    def record(self, max_seconds=5, silence_threshold=0.02, silence_seconds=1.0):
        """录音:在 max_seconds 内如果持续静音超过 silence_seconds 则提前结束。
        返回音频文件路径和实际录制时长。"""
        frames = []
        start = time.time()
        last_voice_time = time.time()

        def callback(indata, frame_count, time_info, status):
            frames.append(indata.copy())

        with sd.InputStream(
            samplerate=self.sample_rate,
            channels=self.channels,
            dtype="float32",
            callback=callback,
        ):
            while time.time() - start < max_seconds:
                if frames:
                    last_frame = frames[-1]
                    volume = np.sqrt(np.mean(last_frame ** 2))
                    if volume > silence_threshold:
                        last_voice_time = time.time()
                if time.time() - last_voice_time > silence_seconds and frames:
                    break
                time.sleep(0.05)

        if not frames:
            raise RuntimeError("没有采集到音频数据")

        audio_data = np.concatenate(frames, axis=0)
        duration = len(audio_data) / self.sample_rate
        path = f"temp/record_{int(time.time() * 1000)}.wav"

        with wave.open(path, "wb") as wf:
            wf.setnchannels(self.channels)
            wf.setsampwidth(2)
            wf.setframerate(self.sample_rate)
            wf.writeframes((audio_data * 32767).astype(np.int16).tobytes())

        return path, duration

这个 Recorder 采用流式回调收集音频,在每次回调中计算音量,用音量判断用户是否还在说话。注意这里把 float32 转成了 int16 再写入 WAV,因为 faster-whisper 对 16 位 PCM 支持最稳定。

4.2 ASR 层:语音转文本

ASR 层封装 faster-whisper,外部只需要传入音频路径,返回文本结果。加了一层轻量容错,识别为空时直接抛出异常,让上层走兜底逻辑。

# asr/transcriber.py
from faster_whisper import WhisperModel
from config import ASR_MODEL_SIZE, ASR_DEVICE, ASR_COMPUTE_TYPE


class Transcriber:
    def __init__(self, model_size=ASR_MODEL_SIZE, device=ASR_DEVICE, compute_type=ASR_COMPUTE_TYPE):
        self.model = WhisperModel(model_size, device=device, compute_type=compute_type)

    def transcribe(self, audio_path, language="zh"):
        segments, info = self.model.transcribe(
            audio_path,
            language=language,
            vad_filter=True,
            beam_size=5,
        )
        text = "".join(seg.text for seg in segments).strip()
        if not text:
            raise RuntimeError("ASR 识别结果为空")
        return text, info.language_probability

这里打开 vad_filter 后,Whisper 会自动过滤掉较长的静音片段,对录音首尾的环境噪声不敏感。 language="zh" 固定中文,避免中英文混输时识别混乱。

4.3 中间决策层:大模型客户端

大模型客户端是对 OpenAI SDK 的薄封装。为什么要再包一层?因为后续可能需要在请求里统一注入超时、重试、日志,放在一个类里管理最方便。

# agent/llm_client.py
from openai import OpenAI
from config import LLM_BASE_URL, LLM_API_KEY, LLM_MODEL, LLM_TIMEOUT


class LLMClient:
    def __init__(self, base_url=LLM_BASE_URL, api_key=LLM_API_KEY, model=LLM_MODEL, timeout=LLM_TIMEOUT):
        self.client = OpenAI(base_url=base_url, api_key=api_key, timeout=timeout)
        self.model = model

    def chat(self, messages, tools=None):
        kwargs = {
            "model": self.model,
            "messages": messages,
            "temperature": 0.2,
        }
        if tools:
            kwargs["tools"] = tools
            kwargs["tool_choice"] = "auto"
        resp = self.client.chat.completions.create(**kwargs)
        return resp.choices[0].message

temperature 设为 0.2 是因为智能体场景希望输出稳定,不希望同一个问题每次回复都不同。

4.4 工具注册与执行

工具是智能体真正落地的关键。这里定义两个示例工具:获取时间、查询天气。查询天气用模拟数据,实际项目中可以替换成真实天气 API。

# agent/tools.py
import datetime
import json

WEATHER_MOCK = {
    "深圳": {"temperature": 28, "condition": "多云", "wind": "东南风3级"},
    "广州": {"temperature": 30, "condition": "阵雨", "wind": "南风2级"},
    "北京": {"temperature": 22, "condition": "晴", "wind": "北风3级"},
}


def get_current_time():
    return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")


def get_weather(city: str):
    if city in WEATHER_MOCK:
        data = WEATHER_MOCK[city]
        return json.dumps({"city": city, **data}, ensure_ascii=False)
    return json.dumps({"city": city, "error": "暂不支持该城市"}, ensure_ascii=False)


TOOL_SCHEMAS = [
    {
        "type": "function",
        "function": {
            "name": "get_current_time",
            "description": "获取当前日期和时间",
            "parameters": {"type": "object", "properties": {}},
        },
    },
    {
        "type": "function",
        "function": {
            "name": "get_weather",
            "description": "查询指定城市的实时天气",
            "parameters": {
                "type": "object",
                "properties": {
                    "city": {"type": "string", "description": "城市名称,例如 深圳"},
                },
                "required": ["city"],
            },
        },
    },
]


def execute_tool(name: str, arguments: str):
    args = json.loads(arguments) if arguments else {}
    if name == "get_current_time":
        return get_current_time()
    if name == "get_weather":
        return get_weather(args.get("city", ""))
    return json.dumps({"error": f"unknown tool: {name}"})

工具的 schema 必须严格写清楚参数类型和必填项,否则大模型在生成 tool call 时容易漏参数。实际项目中工具函数应该做参数校验,不能直接相信大模型生成的 JSON。

4.5 智能体核心:Agent Core

Agent Core 的职责是维护消息列表、判断是否调用工具、把工具结果回填给模型。

# agent/core.py
import json
from agent.llm_client import LLMClient
from agent.tools import TOOL_SCHEMAS, execute_tool

SYSTEM_PROMPT = """你是 VoiceAgent 的决策核心,运行在一个语音助手系统中。
用户说话会先经过语音识别变成文本,你需要根据用户意图执行工具或直接回答。
规则:
1. 用户询问时间、天气等可以通过工具获取的信息时,必须先调用工具。
2. 工具返回结果后,用一句简洁自然的中文回答用户。
3. 不要编造工具返回结果。
4. 如果不能确定,请直接说明不知道。
"""


class AgentCore:
    def __init__(self):
        self.llm = LLMClient()
        self.messages = [{"role": "system", "content": SYSTEM_PROMPT}]

    def run(self, user_text: str) -> str:
        self.messages.append({"role": "user", "content": user_text})

        for _ in range(3):
            msg = self.llm.chat(self.messages, tools=TOOL_SCHEMAS)

            if msg.tool_calls:
                self.messages.append(msg)
                for tool_call in msg.tool_calls:
                    result = execute_tool(
                        tool_call.function.name,
                        tool_call.function.arguments,
                    )
                    self.messages.append({
                        "role": "tool",
                        "tool_call_id": tool_call.id,
                        "content": result,
                    })
            else:
                reply = msg.content or "抱歉,我没有理解你的意思。"
                self.messages.append({"role": "assistant", "content": reply})
                return reply

        return "抱歉,多次尝试后仍然无法处理你的请求。"

循环上限设为 3,是为了防止大模型陷入“调用工具-再调用工具”的死循环。每一次循环要么执行工具,要么返回最终回复。多轮对话中消息列表会不断增长,后续可以考虑裁剪历史消息。

注意: self.messages.append(msg) 这一行不能漏。如果模型返回了 tool_calls,但没有把原始消息回填到对话列表,下一次请求就会丢失工具调用上下文,导致模型重新生成相同的工具调用,形成死循环。

4.6 TTS 层:文本转语音

TTS 层优先使用 edge-tts,原因是它合成的中文语音自然度高,不需要申请密钥,接口简单。

# tts/synthesizer.py
import edge_tts
import asyncio
from config import TTS_VOICE, TTS_RATE, TTS_VOLUME


class Synthesizer:
    def __init__(self, voice=TTS_VOICE):
        self.voice = voice

    def synthesize(self, text: str, output_path: str) -> str:
        rate = f"{TTS_RATE:+d}%"
        volume = f"{TTS_VOLUME:+d}%"

        async def _run():
            communicate = edge_tts.Communicate(
                text,
                self.voice,
                rate=rate,
                volume=volume,
            )
            await communicate.save(output_path)

        asyncio.run(_run())
        return output_path

这里 asyncio.run 每次调用都会创建新的事件循环。在简单脚本中够用,但如果后续集成 Web 框架或流式服务,需要改成复用循环。

播放环节使用 sounddevice 播放 WAV 文件:

# audio/player.py
import soundfile as sf
import sounddevice as sd


def play_wav(path: str):
    data, sr = sf.read(path)
    sd.play(data, sr)
    sd.wait()

4.7 串联 pipeline:把三层级联起来

pipeline 是整条链路的编排者,负责把录音、识别、决策、合成、播放串起来,同时打印每一层的耗时。

# pipeline.py
import time
from audio.recorder import Recorder
from audio.player import play_wav
from asr.transcriber import Transcriber
from agent.core import AgentCore
from tts.synthesizer import Synthesizer
from config import OUTPUT_DIR


class VoicePipeline:
    def __init__(self):
        self.recorder = Recorder()
        self.transcriber = Transcriber()
        self.agent = AgentCore()
        self.synthesizer = Synthesizer()

    def run_once(self, max_seconds=5):
        t0 = time.time()

        print("[VOICE] 开始录音,请说话...")
        audio_path, duration = self.recorder.record(max_seconds)
        print(f"[VOICE] 录音结束,实际时长 {duration:.1f} 秒")

        t1 = time.time()
        text, confidence = self.transcriber.transcribe(audio_path)
        print(f"[ASR] 识别结果: {text} (置信度 {confidence:.2f})")

        t2 = time.time()
        reply = self.agent.run(text)
        print(f"[AGENT] 最终回复: {reply}")

        t3 = time.time()
        output_path = str(OUTPUT_DIR / f"reply_{int(time.time() * 1000)}.mp3")
        self.synthesizer.synthesize(reply, output_path)
        print(f"[TTS] 语音合成完成: {output_path}")

        t4 = time.time()
        play_wav(output_path)

        print(f"[STATS] 录音={t1 - t0:.2f}s ASR={t2 - t1:.2f}s "
              f"AGENT={t3 - t2:.2f}s TTS+播放={t4 - t3:.2f}s")

4.8 入口 main.py

入口脚本采用回车触发的形式,每按一次回车开始一轮录音,输入 exit 退出。这样在终端里操作最直观。

# main.py
from pipeline import VoicePipeline


def main():
    pipeline = VoicePipeline()
    print("VoiceAgent 已启动。按回车开始录音,输入 exit 退出。")

    while True:
        cmd = input("> ").strip().lower()
        if cmd == "exit":
            break
        try:
            pipeline.run_once(max_seconds=5)
        except Exception as e:
            print(f"[ERROR] {e}")


if __name__ == "__main__":
    main()

到这里,最小的 VoiceAgent 已经完整了。接下来需要实际运行并验证。

5. 运行与验证

代码写完后不要直接开始加功能,先把链路跑通,确认每一层都能输出预期结果。

5.1 端到端运行的预期日志

启动程序后,按回车并说一句话,例如“深圳今天天气怎么样”。正常情况下会看到类似下面的日志:

VoiceAgent 已启动。按回车开始录音,输入 exit 退出。
> 
[VOICE] 开始录音,请说话...
[VOICE] 录音结束,实际时长 2.8 秒
[ASR] 识别结果: 深圳今天天气怎么样 (置信度 0.98)
[AGENT] 最终回复: 深圳今天多云,气温 28 度,东南风 3 级。
[TTS] 语音合成完成: output/reply_1750000000000.mp3
[STATS] 录音=0.80s ASR=1.50s AGENT=0.60s TTS+播放=0.70s

先看 ASR 一行的文本是否准确,再看 Agent 的回复是否合理,最后确认能听到合成的语音。三步都通过,说明链路正常。

5.2 用文本直接测试 Agent,减少变量

如果端到端跑不通,先绕过录音和 TTS,单独测试 Agent。这种方式能快速判断问题在哪一层。

# test_agent.py
from agent.core import AgentCore

agent = AgentCore()
while True:
    text = input("请输入文本: ")
    if text == "exit":
        break
    reply = agent.run(text)
    print("回复:", reply)

用这个脚本测试以下输入:

  • “现在几点了”
  • “北京天气怎么样”
  • “你好,你是谁”
  • “帮我查一下上海的天气”

5.3 衡量链路耗时和各层开销

通过 [STATS] 行可以判断系统瓶颈:

层 耗时范围 主要影响因素
录音 0.5 - 1.5 秒 静音检测触发时机、用户语速
ASR 0.5 - 3 秒 模型大小、CPU 还是 GPU
Agent 0.5 - 5 秒 模型推理速度、工具调用轮数
TTS + 播放 0.3 - 1 秒 网络延迟(edge-tts 在线合成)

如果 ASR 耗时过高,把 ASR_MODEL_SIZE 从 small 改成 tiny 测试效果。如果 Agent 耗时过高,换更小的模型,或者把 temperature 调低以减少采样波动。

6. 常见问题排查

级联链路出错时,遵循“从输入到输出”的顺序排查:先确认音频录上了,再确认识别文本,再确认大模型回复,最后看 TTS。不要一开始就去改模型参数。

6.1 ASR 层问题

问题现象 可能原因 检查方式 处理建议
安装 faster-whisper 失败 Python 版本过高或缺少 C++ 编译环境 python --version ,查看报错日志 使用 Python 3.10 或 3.11,重新创建虚拟环境
识别结果为空 麦克风没声音、录音太短、说话音量低 打印录音文件时长和音量 延长录音时间,提高说话音量,检查麦克风设备
中文识别出英文或乱码 没有指定 language="zh" 查看 transcribe 调用参数 显式传入 language="zh"
CPU 推理非常慢 模型过大或未启用 int8 查看 compute_type 改用 small + int8 ,或换 GPU

6.2 Agent 层问题

问题现象 可能原因 检查方式 处理建议
连接大模型失败 Ollama 服务未启动或端口不对 curl http://localhost:11434/v1/models 执行 ollama serve
提示 model not found 模型名字与本地不一致 ollama list 查看模型名 修改 LLM_MODEL
工具调用参数一直是空 工具 schema 缺少必填字段或描述不清晰 打印 tool_call.function.arguments 在 schema 中把必填字段标注为 required
回复内容与工具结果无关 系统提示词约束不足 查看完整 messages 日志 在提示词中明确“必须根据工具返回结果回答”
循环调用工具不退出 消息列表缺少 assistant 原始回填 检查 self.messages.append(msg) 调用工具后立即回填

最危险的问题是“吞异常”。不要在 Agent 层写一个空 except 然后返回固定文案,那会让问题被掩盖。至少要打印 traceback ,或者把异常信息组装到回复里。

6.3 TTS 层问题

问题现象 可能原因 检查方式 处理建议
edge-tts 合成失败 网络无法访问在线服务 检查网络、查看报错信息 切换到 pyttsx3 离线引擎
合成成功但播放无声音 播放设备错误或音量过低 用播放器直接打开 mp3 文件 检查系统输出设备
播放报错 samplerate mismatch WAV 采样率与播放设备不匹配 打印音频文件参数 统一用 16000 Hz 或让播放器自动重采样

6.4 麦克风问题

录音没有声音前,先确认系统级麦克风是可用的。可以用系统录音工具录一段,再播放确认。然后在 Python 里运行:

import sounddevice as sd
print(sd.query_devices())
print(sd.default.device)

如果默认输入设备不对,在 Recorder 构造时传入 device 参数,或者直接在 sounddevice.InputStream 中指定 device 。

排查顺序建议:系统麦克风 → Python 设备识别 → 录音文件波形 → 静音阈值 → ASR 输入。不要跳过任何一步,否则容易在错误层级浪费时间。

7. 生产环境最佳实践与扩展方向

学习环境跑通只是开始。要把 VoiceAgent 应用到真实业务中,还需要考虑多轮对话、工具真实化、延迟优化、可观测性和安全边界。

7.1 从单轮到多轮对话

当前 AgentCore 的消息列表只存在于一次进程内,且没有做历史裁剪。生产环境要支持多轮,需要:

  • 在消息列表中加入 session_id 维度,按照会话区分上下文。
  • 设置最大历史轮数,例如保留最近 10 轮,超出后丢弃最老的非系统消息。
  • 对话持久化到 Redis 或数据库,服务重启后可以恢复上下文。
  • 增加敏感信息过滤,避免把身份证号、手机号等明文发送给模型服务。

7.2 把模拟工具升级为真实系统调用

工具函数在前端表现为一个 Python 函数,但在生产环境中它可能就是一次 API 调用、一条数据库查询或一个控制指令。升级时要重点考虑:

  • 工具调用超时和重试策略,避免大模型等待业务接口太久。
  • 工具权限校验,不是每个用户都能调用所有工具。
  • 返回值长度控制,工具返回内容过多会占用大模型上下文。
  • 幂等性,防止语音误触发导致重复下单。

7.3 延迟优化和流式交互

当前实现是完整的“录音结束后再处理”,用户体验是说完话要等两三秒。优化方向包括:

  • 使用 VAD 检测说话起始点,用户开口就开始识别,而不是等整句结束。
  • ASR 开启流式识别,边说话边出文本。
  • TTS 改为流式播放,第一段语音合成完成后立即播放,不用等整句合成结束。
  • 如果使用远程大模型,可以启用流式输出,首 token 延迟会明显缩短。

这些优化要以“链路可观测”为前提。建议全链路加入 trace_id ,每次请求都生成一个唯一 ID,日志中带上这个 ID,这样每一层耗时一目了然。

7.4 安全与可观测性

语音智能体涉及音频数据和个人信息,生产环境不能按学习环境的方式处理。

  • 音频文件不能长期保留,处理完应及时删除或脱敏保存。
  • 大模型提示词要约束“不能泄露系统提示词”“不能执行非授权工具”。
  • 对工具调用结果做 JSON Schema 校验,遇到非法结构要重试或降级,而不是直接拼接到回复里。
  • 记录每个用户会话的识别文本和工具调用记录,便于审计和问题追溯。
  • 设置全链路超时,避免录音、ASR、Agent、TTS 某一步卡死后整个服务不可用。

7.5 学习路线建议

如果你是新手,建议按下面这个顺序推进:

  1. 跑通本文的单轮闭环,不要加额外功能。
  2. 替换 ASR 模型大小,观察识别准确率和耗时变化。
  3. 新增一个自定义工具,例如“查询今日待办”“计算器”,理解工具 schema 和大模型输出的关系。
  4. 加入多轮对话和上下文裁剪。
  5. 把 TTS 替换成离线引擎,对比在线和离线的效果差异。
  6. 引入流式识别和流式合成,优化交互体验。

每一步都做一次“日志验证”,而不是只验证“能启动”。

本文的核心技术判断是:VoiceAgent 的落地点不是大模型本身,而是级联链路里每一层之间的数据契约和异常处理机制。把录音、ASR、Agent、TTS 解耦成独立模块,每层都能独立测试和替换,整个项目才具备从 demo 演进到生产系统的可能。下一步,你可以先加一个真实业务工具,再逐步引入流式交互,让语音智能体真正解决实际问题。

Logo

邀请您加入社区

更多推荐