企业级Voice Agent架构:级联式三明治设计与STT-LLM-TTS编排实战
2026/9/9 11:26:04 网站建设 项目流程

过去两年,很多团队做语音交互项目时都经历过类似的痛苦:单独测 STT,识别率很高;单独测大模型,回答也有模有样;单独测 TTS,音色自然流畅。可一旦把三者串成一条完整的语音对话链路,效果立刻差一个档次。用户说一句话,系统要等两三秒才回;中途说一句“等一下”就被打断逻辑;STT 一旦听错一个字,LLM 会顺着错误继续回答,最后再由 TTS 用非常自然的语气把错误结果播出来。

真正的问题不是某个模型不够强,而是架构没有设计好。到了 2026 年,再做企业级 Voice Agent,最值得投入精力的不是反复更换大模型,而是把 STT、LLM、TTS 当成一个整体系统来编排。本文要讲的,就是目前企业里最常用的“级联式三明治架构”,即上层 STT、下层 TTS、中间由 Agent/LLM 主导的语音智能助手结构。它表面上看起来仍然是级联,但每一层之间的配合方式与传统呼叫中心机器人完全不同。

这个架构真正要解决两大难题:第一是语音交互的延迟与打断控制,第二是级联错误累积带来的不可控。读完本文,你能理解三明治架构的每个环节为什么存在,能跑通一个最小可用的 Voice Agent 服务,并且知道生产环境里哪些地方最容易出问题。

1. 这篇文章真正要解决的问题

很多开发者在接到语音助手项目时,第一反应是“找个 ASR、接个 GPT、再找个 TTS”,把三条 API 拼在一起就能交付。这种思路在小 demo 阶段没问题,一旦进入企业级场景,立刻会遇到一堆与模型效果无关的工程问题。

第一个问题是延迟。语音交互的用户耐心窗口比文字聊天短得多,用户说完一句话后,普遍预期 800 毫秒到 1 秒左右听到回应。但传统做法是:等用户说完一整句,再上传整段音频做非流式识别,识别结果整段进 LLM,等 LLM 完整生成后再整段交给 TTS 合成,最后一次性播出来。这个串行链路各个环节只算一次网络往返,整体延迟就会非常可观。

第二个问题是错误累积。级联架构里,前一级的输出是后一级的输入,误差会沿链路向下放大。STT 的同音字错误、标点缺失,会让 LLM 理解偏差;LLM 即使纠正了部分语义,TTS 在合成时也可能断句错误,导致语音节奏奇怪。最典型的是用户说“帮我查一下天气”,STT 识别成“帮我擦一下天气”,LLM 可能真的理解成某种“天气服务”,最后答得完全不在点上。

因此,本文的核心任务是帮你建立一套可落地的架构认知:理解级联式三明治架构解决了什么,知道它的局限在哪里,能写出一个最小可运行的服务,并具备在生产环境排查问题的方法。适合正在做智能客服、硬件语音助手、呼叫中心机器人、会议记录助手,或者想从纯文本 Agent 转向语音 Agent 的开发者阅读。

2. 基础概念与核心原理

2.1 STT 与 TTS 的定位

STT(Speech-to-Text,语音转写)负责把音频转成文本,是语音助手的“耳朵”。TTS(Text-to-Speech,语音合成)负责把文本转成自然语音,是语音助手的“嘴巴”。这两个概念大家很熟悉,但在 Voice Agent 场景里,它们的角色发生了改变:STT 不再只是“录音转文字工具”,而是需要实时感知用户是否说完、说得是否含糊;TTS 也不只是“文本朗读工具”,而是需要支持流式合成、打断取消和自然停顿。

2.2 LLM 与 Agent 层的含义

LLM(Large Language Model,大语言模型)负责语义理解和生成,是语音助手的“大脑”。这里要注意一个趋势:单纯用 LLM 做问答的时代已经不够了,企业级 Voice Agent 会把 LLM 包装成一个 Agent 层,让它具备调用业务工具、查询订单、获取天气、操作 CRM 的能力。所以在三明治架构里,中间层我统一称为“Agent/LLM 层”。

2.3 什么是级联式三明治架构

把 STT、Agent/LLM、TTS 按顺序串起来,看起来像级联流水线,所以叫“级联式”;但这个架构的关注点不在“串联”本身,而在三层之间的耦合方式。上层的 STT 产出文本后,中间层不仅要做语义回复,还要根据语义决定是否需要调用工具、是否需要追问、是否需要终止对话;下层 TTS 需要响应中间层的指令,而不是机械地读完所有文本。

更直观的理解是汉堡结构:上层面包是 STT,下层面包是 TTS,中间的肉饼是 Agent/LLM。用户的声音先进入上层面包,经过加工变成文本;肉饼层完成所有决策;下层面包负责把决策变成声音。三明治的好坏,不仅取决于每层食材本身,更取决于咬下去时三层能否同时被吃到口中。对应到技术上,就是延迟优化要贯穿三层,而不能只优化某一层。

传统五级级联架构和三明治架构的对比如下:

对比维度传统级联架构级联式三明治架构
处理链路ASR -> NLU -> DM -> NLG -> TTSSTT -> Agent/LLM -> TTS
中间层逻辑多个独立模块分别负责意图、对话状态、话术生成LLM 统一负责语义理解、决策和生成,可用工具扩展
维护成本每个模块都要单独标注数据、训练或调参只需重点维护 LLM 的提示词、工具定义和上下文
延迟优化每个模块一次完整调用,容易累积延迟可按层次并行、流式处理,延迟集中在 LLM 首 token
可扩展性新意图需要更新 NLU 模型新意图通常只需补充工具描述和示例

这个对比说明了一个关键判断:三明治架构并没有消灭级联,而是把原来“五个盒子”简化成“三个盒子”,并让中间的 Agent/LLM 成为唯一需要频繁迭代的决策层。对于团队来说,这意味着更少的模型维护成本和更快的业务迭代速度。

3. 级联式三明治架构解决的两大难题

3.1 难题一:延迟与打断控制

语音助手的延迟由三部分构成:STT 识别所需时间、LLM 生成所需时间、TTS 合成所需时间。传统非流式架构的问题是这三部分串行且必须等各自全部完成,整体延迟等于三者之和。三明治架构的解法是“流式化”。

STT 侧使用增量识别,用户说话过程中就开始返回部分文本,同时通过 VAD(Voice Activity Detection,语音活动检测)判断一句话什么时候结束。LLM 侧使用流式输出,模型生成 token 后可以逐步送给 TTS,而不是等整段回答结束后再合成。TTS 侧也使用流式合成,先拿到一段文本就合成一段音频,播放器边收边播。

打断控制是更隐蔽的难点。用户可能在 TTS 播报过程中突然插话,比如系统正在解释退款政策,用户说“行了知道了”。三明治架构里需要处理两件事:一是立刻停止当前 TTS 播放,二是把用户插话的音频送入 STT,并中断 LLM 正在进行的生成任务。这要求每一层都支持“取消信号”,Agent/LLM 层的生成任务不能再按普通 HTTP 请求来处理,而应该使用可取消的任务编排。

3.2 难题二:错误累积与业务可控性

级联系统的误差会沿链路放大,三明治架构也无法完全避免这一点,但它可以通过“让 LLM 看到更多上下文”来提高容错率。传统架构里,ASR 的输出只是一个字符串,后续 NLU 模块完全不知道“这串文本可能是错的”。三明治架构中,Agent/LLM 层可以利用提示词技术,让模型根据对话历史推断真实意图,甚至在识别结果明显不合理时主动反问。

业务可控性方面的提升更明显。传统级联里,开发者需要为每个对话策略写流程代码,比如先确认意图、再查库、再生成话术。三明治架构中,这套流程被收编为 LLM 的工具调用和上下文管理。开发者的关注点从“控制流程”变成“定义边界”:哪些工具可被调用、哪些参数需要人工确认、哪些回复必须走固定口径。这样反而更容易做权限控制和审计。

不过,架构设计只能降低错误累积的概率,不能消除它。真正让系统“可用”的,是每一层都要暴露可观测性:STT 的识别文本和置信度、LLM 的输入输出和工具调用记录、TTS 的合成状态,都必须通过日志和追踪串联起来。这恰好是三明治架构优于传统黑盒级联的地方,因为层数少,排查链路短。

4. 环境准备与前置条件

学习本文示例不需要很重的硬件,普通的开发机即可。下面环境以 Python 为主,因为当前语音开源生态和 LLM SDK 对 Python 支持最好。版本请以实际项目为准,本文重点演示通用思路。

建议准备以下环境:

  • 操作系统:Linux / macOS / Windows 均可,Linux 生产环境表现最稳定。
  • Python 版本:3.9 或更高。
  • 依赖库:fastapi、uvicorn、websockets,用于搭建 WebSocket 服务和测试客户端。
  • 音频处理:numpy、webrtcvad,用于后续接入真实 STT 时做 VAD 端点检测和音频帧处理。
  • 大模型服务:OpenAI 兼容接口,或本地 Ollama 服务,二选一。
  • 语音服务:可以先用占位接口跑通链路,再替换为 FunASR、Whisper、Edge TTS、开源 TTS 或云厂商 STT/TTS 服务。

如果你希望全部在本地离线运行,一种常见的组合是 FunASR 作为 STT,Ollama 加载 Qwen 或 Llama 系列模型作为 Agent/LLM,Edge TTS 或 VITS 系开源模型作为 TTS。这种组合的好处是网络依赖少、便于内部部署,但对显存和 CPU 有一定的要求。如果只是学习三明治架构,完全可以用云上模型做验证。

安装基础依赖的参考命令如下:

pip install fastapi "uvicorn[standard]" websockets numpy webrtcvad

如果后续要接入本地模型,再根据所选模型框架单独安装依赖。建议先保持最小依赖集,把链路跑通后再逐步加入真实模型,这样排错会容易很多。

5. 核心流程拆解

5.1 音频输入与 VAD 端点检测

语音助手的输入不是一次性音频文件,而是一个持续不断的声音流。如果不对音频流做切分,LLM 永远不知道用户何时说完。VAD 的作用就是对音频流进行端点检测:检测到人声开始后,积累语音帧;检测到连续静音超过设定值,比如 600 毫秒到 800 毫秒,就判定当前这句话结束,把累积的语音片段交给 STT。

VAD 参数非常影响体验。静音阈值设置过长,用户说完话后系统迟迟不响应;设置过短,用户在思考时的短暂停顿就会被误判为“说完”。生产环境一般会结合前端硬件的按键说话模式、能量阈值和模型置信度综合判断。在最小示例里,可以先用“整包音频直接进入 STT”的方式验证链路,后续再把 VAD 挂到音频输入路径上。

5.2 STT 增量识别与文本对齐

STT 分为一次性识别和增量识别。一次性识别简单,但会引入整段等待时间;增量识别在用户说话的同时返回部分文本,可以明显降低用户体验上的延迟。三明治架构中更推荐增量识别,但在实现时要注意:不要在用户还没说完时就拿半句文本去请求 LLM,否则会产生大量无效调用。

一种折中方案是“半句处理”。当 VAD 判定一句话结束后,STT 给出完整文本;如果一句话过长,可以在标点或语义边界处做拆分,把前半句先送给 Agent/LLM,后半句继续识别。这个逻辑看起来复杂,但实际工程中是延迟优化的关键。

5.3 Agent/LLM 调度与工具调用

Agent/LLM 收到 STT 文本后,不能直接回复。它需要先判断用户意图、决定是否调用工具、构造回复内容。企业级场景中,这层通常不是简单 Prompt,而是会携带系统指令、业务工具列表、上下文历史和用户画像。比如用户问“我订单到哪了”,LLM 应该调用订单查询工具,而不是凭记忆乱编物流状态。

实现上,目前主流做法是让 LLM 输出结构化工具调用指令,然后由业务代码执行工具请求,再把工具返回结果回传给 LLM 生成最终话术。这套机制和纯文本 Agent 一致,差别在于它需要和 STT、TTS 的流式状态联动。当用户中途打断时,生成任务要能取消;当用户长时间不说话时,系统要能主动发问。

5.4 TTS 合成与播放控制

TTS 收到 LLM 的最终文本后执行合成。为了减少延迟,这里应优先使用流式合成接口,或者把 LLM 流式输出的片段拼接好后分批送进 TTS。播放控制包括音量、语速、停顿,以及最重要的打断反应。TTS 正在播放时,如果 VAD 检测到用户插话,播放器必须立刻停止,同时向 Agent/LLM 层发送取消信号。如果只是“说完再停”,用户会明显感到系统迟钝。

5.5 会话状态与上下文管理

Voice Agent 的会话比纯文本多一层“语音状态”。除了保存对话历史,还要维护当前播放状态、当前生成任务、用户是否在说话、上下文窗口是否超限。推荐每个连接对应一个 Session 对象,用 session_id 关联。对话历史需要做长度控制,否则一段长时间会议后,LLM 的上下文窗口会被历史塞满,既增加延迟又影响效果。

6. 完整示例代码实现

下面用一个 FastAPI + WebSocket 的最小服务演示三明治架构。注意:示例中的 STT、LLM、TTS 都是打桩实现,只为了让链路先跑通。真实项目中,把stt_recognizellm_replytts_speak三个函数替换为真实模型调用即可。

6.1 项目结构

voice-agent-demo/ ├── main.py ├── requirements.txt └── client.py

6.2 requirements.txt

fastapi uvicorn[standard] websockets numpy webrtcvad

6.3 main.py 完整示例

# 文件路径:voice-agent-demo/main.py import asyncio import base64 import json from dataclasses import dataclass, field from typing import Optional from fastapi import FastAPI, WebSocket, WebSocketDisconnect app = FastAPI() def stt_recognize(audio_b64: str) -> str: """ 语音转写打桩函数。 真实场景:接入 FunASR / Whisper / 云端 STT, 将音频数据转为文本返回。 """ # 这里仅演示链路,真实项目请调用具体的 STT 服务 return "帮我查一下明天的天气" def llm_reply(user_text: str, history: list) -> str: """ Agent/LLM 层打桩函数。 真实场景:接入 OpenAI 兼容接口或本地 Ollama, 可以在这里定义工具调用和业务上下文。 """ # 这里仅演示链路,真实项目请将 user_text 和 history 组装成 Prompt return "好的,明天多云,气温 22 到 28 摄氏度,记得带伞。" def tts_speak(text: str) -> str: """ 语音合成打桩函数。 真实场景:接入开源 TTS 或云 TTS, 将文本合成为 base64 编码的音频数据返回。 """ # 返回一个占位的 base64 字符串,真实项目中替换为真实合成音频 fake_audio = b"fake-wav-bytes" return base64.b64encode(fake_audio).decode("utf-8") @dataclass class SessionState: session_id: str history: list = field(default_factory=list) current_task: Optional[asyncio.Task] = None sessions: dict[str, SessionState] = {} @app.websocket("/ws/{session_id}") async def voice_agent_ws(ws: WebSocket, session_id: str): await ws.accept() if session_id not in sessions: sessions[session_id] = SessionState(session_id=session_id) state = sessions[session_id] try: while True: message = await ws.receive_text() request = json.loads(message) msg_type = request.get("type") if msg_type == "text": user_text = request.get("text", "").strip() elif msg_type == "audio": audio_b64 = request.get("audio", "") user_text = stt_recognize(audio_b64) else: await ws.send_text(json.dumps({ "type": "error", "message": "unknown message type" }, ensure_ascii=False)) continue if not user_text: await ws.send_text(json.dumps({ "type": "error", "message": "empty user text" }, ensure_ascii=False)) continue state.history.append({"role": "user", "content": user_text}) reply = llm_reply(user_text, state.history) state.history.append({"role": "assistant", "content": reply}) audio_b64 = tts_speak(reply) await ws.send_text(json.dumps({ "type": "result", "text": reply, "audio": audio_b64, "session_id": session_id }, ensure_ascii=False)) except WebSocketDisconnect: print(f"session {session_id} disconnected")

这段代码的核心价值是把“接收音频 -> STT -> LLM -> TTS -> 返回音频”的链路完整串起来。WebSocket 天然适合这种长连接场景,每一条用户消息都可以实时互动。

6.4 关键逻辑说明

首先,stt_recognizellm_replytts_speak三个函数是整条链路的三个边界。真实项目里,这三个函数内部会分别连接到独立的模型服务,可能是本地进程,也可能是远程 HTTP 接口。设计成普通函数的好处是便于单元测试和替换。

其次,SessionState保存了 session_id 和对话历史,确保同一个用户的多轮对话能串联起来。示例代码用了内存字典,生产环境应替换为 Redis 等状态存储,支持多实例横向扩展。

最后,WebSocket 端点的消息协议有三种类型:text表示用户直接发文本,audio表示用户发音频,errorresult是服务端返回。这个协议非常简洁,方便后续加入流式音频帧、打断信号和工具调用状态。

6.5 启动服务

在项目目录执行:

uvicorn main:app --host 0.0.0.0 --port 8000 --reload

启动后,FastAPI 会在命令行打印访问地址。打开http://127.0.0.1:8000/docs可以看到自动生成的 API 文档,但 WebSocket 接口无法在 Swagger 页面直接调试,需要写客户端。

6.6 测试客户端代码

# 文件路径:voice-agent-demo/client.py import asyncio import base64 import json import websockets async def main(): uri = "ws://127.0.0.1:8000/ws/demo-session" async with websockets.connect(uri) as ws: # 模拟发送一段音频,真实场景由麦克风采集并编码 fake_audio = base64.b64encode(b"simulate-pcm-audio").decode("utf-8") await ws.send(json.dumps({ "type": "audio", "audio": fake_audio })) response = json.loads(await ws.recv()) print("回复文本:", response["text"]) print("音频长度:", len(response["audio"])) print("session_id:", response["session_id"]) if __name__ == "__main__": asyncio.run(main())

这里发送的是模拟音频数据,所以 STT 函数固定返回“帮我查一下明天的天气”。真实项目中,客户端需要采集麦克风音频,格式通常为 16kHz、16bit、单声道 PCM,或者使用 OPUS 编码以节省带宽。

7. 运行结果与效果验证

启动服务后,先看 uvicorn 的启动日志,确认没有报错。正常情况下,日志会包含类似下面的内容:

INFO: Uvicorn running on http://0.0.0.0:8000 INFO: Application startup complete.

然后另开一个终端运行客户端:

python client.py

预期输出为:

回复文本: 好的,明天多云,气温 22 到 28 摄氏度,记得带伞。 音频长度: 44 session_id: demo-session

音频长度是 base64 编码后的占位字符串长度,不是真实音频文件大小。只要能看到“回复文本”一行,就说明 STT -> Agent/LLM -> TTS 整条链路已经跑通。

判断成功的关键不是 TTS 是否播放真实声音,而是消息是否按预期顺序流转。如果出现连接被拒绝,先检查 uvicorn 是否启动在 8000 端口;如果出现WebSocketDisconnect,检查客户端和服务端的 WebSocket 协议版本是否兼容。大多数问题都能靠观察控制台日志定位。

接入真实模型后,验证标准要升级为:STT 返回文本是否与用户原话一致,LLM 回复是否基于正确上下文,TTS 音频能否正常播放、声音是否自然、被打断时能否及时停止。这个阶段建议每个模型单独验证,再联合联调,避免链路上下层互相干扰导致问题无法定位。

8. 常见问题与排查思路

问题现象可能原因排查方式解决方案
服务启动失败依赖缺失或版本冲突查看 uvicorn 报错信息和 pip 依赖树按 requirements.txt 重新安装,必要时用虚拟环境
WebSocket 连接失败端口被占用或服务未启动检查端口监听状态,确认服务日志关闭占用进程,或更换 8000 为其他端口
客户端收不到回复消息格式错误打印服务端收到的原始 JSON确认客户端发送了正确的 type 字段
STT 识别结果不准音频采样率、编码格式不匹配检查音频格式是否为 16kHz PCM在客户端统一转码后再上传
LLM 回复偏离业务缺少业务上下文或工具定义查看发送给模型的 Prompt 和 history在 llm_reply 中补充系统指令和工具说明
TTS 播放音质差、断句怪TTS 输入缺少标点或文本过长查看传给 TTS 的文本是否有完整标点在文本前后处理阶段补充断句和标点规范化
延迟明显偏高链路串行等待,未使用流式接口在每层打印耗时时间引入流式 STT、LLM 流式输出、TTS 边下边播
用户插话后系统仍在播放缺少打断信号机制查看 TTS 播放逻辑是否检查取消标志实现 cancel 信号,播放器立即停止

这个表格里的每个问题都来自实际工程常见的坑。尤其是最后两行,往往不在模型效果上出问题,而是在架构配合上出问题。调试时建议先跑通最小链路,再一层层加入真实模型,每加一层就重新评估一次延迟和准确率。

9. 最佳实践与工程建议

9.1 先打桩,后接真实模型

很多团队失败是因为一开始就把最强的 STT 和 TTS 接入代码,然后链路一跑就发现延迟高、效果差,却不知道是哪个模块的问题。更稳妥的做法是先用固定文本打桩跑通链路,确认 WebSocket、状态管理、消息编排都正常,再逐个替换真实模型。每个模型替换后都要单独验证输入输出格式。

9.2 用 session_id 和 trace_id 打通全链路日志

语音链路的排查比普通 HTTP 接口难得多,因为同一个请求会经过 STT、LLM、TTS 三个系统。建议从客户端连接开始就生成一个 trace_id,这个 ID 贯穿所有模块,每条日志都带上 session_id 和 trace_id。这样用户反馈“回答奇怪”时,你能快速定位是 STT 转写错、LLM 理解错,还是 TTS 合成节奏不对。

9.3 注意 VAD 参数的线上调优

VAD 是延迟体验的核心。建议把静音判定阈值、最大语音段时长、最短语音段时长都做成可配置项,并针对不同使用场景设置不同参数。在嘈杂环境下,适当提高能量阈值;在安静环境下,可以降低阈值来捕捉轻声说话。这个参数没有通用最优值,需要结合真实录音样本回归验证。

9.4 给 LLM 调用设置超时与降级

企业级系统不能因为模型服务变慢就影响整个语音助手。LLM 调用应该设置超时时间,比如 3 到 5 秒。超时后可以回复固定话术,比如“抱歉,我需要再找找资料”,同时把异常信息记录到日志。如果 TTS 服务不可用,也应该有降级方案,比如只返回文本给前端显示。生产环境变更前一定要有备份和回滚方案,涉及数据库或外部系统时遵循最小权限原则。

9.5 把 TTS 合成结果做缓存

相同话术在客服场景中出现频率很高,比如“请提供您的订单号”。这类固定话术如果每次都重新调用 TTS 服务,既浪费算力又增加延迟。建议以文本哈希为 key,把合成音频缓存到 Redis 或本地文件系统。命中缓存时直接播放,能明显减少重复合成成本。

9.6 做好对话历史截断和摘要

LLM 上下文窗口有限,长会话中历史记录会不断累积。最简单的策略是只保留最近 N 轮完整对话;更高级的做法是当历史超长时,用一次小型 LLM 调用把历史摘要成一段话,再把摘要和最近对话一起送入模型。语音场景里还要考虑用户来回打断导致的重复内容,尽量在送入模型前清理冗余。

10. 总结与后续学习方向

把本文内容收拢来看:级联式三明治架构不是新瓶装旧酒,它真正改变了 Voice Agent 的设计方式。STT、Agent/LLM、TTS 三层各司其职,中间层成为唯一的决策核心,上下两层通过流式化和打断机制与用户高频互动。它解决的延迟问题和错误累积问题,都是传统纯文本 Agent 不太会遇到、但在语音场景里必须面对的关键挑战。

下一步,建议你先基于本文代码跑通一个最小服务,然后把三个打桩函数逐个替换成真实模型,再做一轮 VAD 参数调优和延迟压测。接下去值得深入的方向包括:流式 TTS 的增量播放、LLM 工具调用的语音交互设计、多轮打断下的状态一致性,以及 Voice Agent 的自动化评测体系。最后提醒一句:不要一上来就追求最聪明的模型,先把链路做到稳定、可控、可观测,否则模型越强,翻车时用户越失望。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询