实时语音 AI 系统开发中,最大的难点往往不在模型本身,而在音频链路和响应速度。过去六个月,我们团队从零搭建了一套面向实时语音对话的 AI 系统,目标是让用户在停止说话后尽快听到 AI 回复,而不是像传统轮询接口那样等待十几秒。这篇文章会复盘这套系统的设计判断、核心实现、调优过程和踩坑路径,适合正在做语音助手、实时客服、智能体交互的开发者参考。
从头到尾,我们最关心的问题只有一个:从用户结束说话开始,到 AI 语音回复的第一个字节到达用户设备,这条链路能不能做到一秒以内。围绕这个目标,团队经历了三次大的架构调整、多次延迟定位和大量的稳定性修补。下面按主题拆开讲。
1. 实时语音 AI 系统到底难在哪里
1.1 “响应式”不只是模型快
很多人以为语音 AI 响应快就等于大模型推理快。实际不是这样。完整链路至少包含采集音频、传输、VAD 端点检测、语音识别(STT)、语义生成(LLM)、语音合成(TTS)、音频传输、播放这几个环节。任何一个环节出现几毫秒级抖动,最终体验都会明显变差。
所谓“响应式”,可以拆成三个可量化的目标:
- 用户停顿时能快速识别出“话说完了”。
- 识别结果能以流式方式逐步交给大模型,而不是等整句结束再处理。
- 生成的文本能边生成边合成、边合成边播放,而不是等全部文本出来后再合成。
这三个目标共同决定了语音 AI 的交互体感。因为人耳对延迟非常敏感,超过 1.5 秒就会觉得明显卡顿。我们内部把“用户说最后一句话结束到听到 AI 首包语音”称为端到端响应延迟,核心预算定在 1 秒内。
1.2 实时语音链路的延迟预算
音频链路是连续流,不像普通 HTTP 请求有明确的起止边界。延迟预算必须分配到每个环节。
| 环节 | 目标延迟 | 说明 |
|---|---|---|
| 音频上行 | 50ms 内 | 客户端切帧发送,用 WebSocket 或 RTP 传输 |
| VAD 端点检测 | 300ms 内 | 判断用户停止说话,不能太快也不能太后 |
| STT 首段结果 | 200ms 内 | 流式识别,边给边出词 |
| LLM 首 Token | 400ms 内 | 使用流式生成接口 |
| TTS 首包合成 | 300ms 内 | 文本流到音频流 |
| 音频下行 | 50ms 内 | 回到客户端并播放 |
这些目标单独看都不夸张,串联起来就很容易超时。比如 STT 延迟 500ms,LLM 延迟 800ms,TTS 延迟 500ms,再加上网络抖动,端到端超过两秒非常正常。所以设计系统时不能只看“平均延迟”,要看“尾延迟”,同时要把流水线改成流式并行,而不是串行。
1.3 与普通 API 服务的区别
普通后端服务是请求-响应模型,收到完整请求后处理,再返回完整响应。语音 AI 系统更像数据管道,客户端会持续发送音频帧,服务端也要持续返回文本或音频片段,任何一方都在持续产生数据。
这就带来几个普通服务不常见的问题:
- 会话生命周期长,一个用户可能连续对话半小时,连接状态需要一直被维护。
- 音频帧有顺序和时间戳,处理时不能乱序,否则播放会卡顿。
- 系统必须支持打断,用户正在听 AI 回复时可能直接插话,需要及时停止合成和播放。
- 并发模型不能沿用“一个请求一个线程”的思路,要改成异步流式模型。
在动手写代码前,我们花了一周时间把这些问题想清楚,否则后面很难回退。
2. 六个月时间线:从验证到上线的节奏控制
2.1 前六周:用最小闭环验证可行性
我们没有一开始就设计庞大架构,而是用最快的速度搭了一个最小闭环:浏览器录音,通过 WebSocket 发送音频帧到 Python 后端,后端调用一个流式 STT 接口,把识别结果拼成文本后发给一个流式 LLM 接口,再把 LLM 输出文本交给一个流式 TTS 接口,最后把音频帧推回浏览器播放。
这个阶段只有一个目标:验证端到端延迟是否有可能小于 1 秒。按顺序处理会导致延迟很高,于是我们直接实现了最简单的流式转发:STT 边识别边出词,LLM 拖入中间结果,TTS 拿到文本后立刻合成第一包。第一次跑通时,端到端首包大约在 1.3 秒到 1.8 秒之间。虽然距离目标还有差距,但证明了链路可行。
关键结论是:
- STT 流式输出确实能大幅降低首包延迟。
- LLM 是否流式输出,直接决定 TTS 能不能提前启动。
- 瓶颈往往在 TTS,因为很多语音合成服务需要较多文本才开始合成。
2.2 中间三个月:架构拆分、并发模型和会话管理
最小闭环跑通后,我们开始考虑多个并发用户使用时的稳定性。最初的单体脚本把音频处理、会话状态、AI 调用全放在一起,两个用户同时说话就出现互相干扰。
这段时间主要做了三件事:
- 拆分音频网关、会话管理、AI 编排三个模块,分别独立部署。
- 引入异步任务队列,所有 AI 调用都改造成可等待的流式任务,避免阻塞事件循环。
- 设计会话状态机,覆盖“等待唤醒”“语音输入中”“等待 AI 响应”“AI 播放中”“用户打断”等状态。
到第三个月结束时,系统已经可以稳定承载数十路并发语音对话,但延迟还没有完全达到目标。
2.3 最后六周:稳定性、成本和可观测性
最后阶段的重心不在功能,而在生产环境存活能力。我们补充了延迟追踪、错误日志、音频质量监控,优化了 TTS 合成的缓存策略,并把部分高频模块从单点进程改成多副本部署。
这个阶段的典型产出包括:
- 全链路 Trace:从音频帧进入网关开始,到 TTS 音频帧返回客户端,每个环节都有时间戳和 ID。
- 自动降级:LLM 服务超时时,返回固定话术并再次尝试;TTS 失败时改用文本返回,不阻塞整个通话。
- 成本控制:对 STT 和 TTS 的关键参数做了仔细调优,减少无效调用。
六个月结束时,我们在测试环境中的端到端首包延迟已经能稳定控制在 1 秒左右,少数长文本场景会到 1.2 秒到 1.5 秒,还在继续优化。
3. 系统架构与关键组件选型
3.1 端到端链路与数据流
整体链路可以简化为下面这条路径:
客户端录音 -> 音频帧(Opus) -> WebSocket 网关 -> 音频缓冲区 -> VAD -> 流式 STT -> 流式 LLM -> 流式 TTS -> 音频帧 -> WebSocket 网关 -> 客户端播放中间还有一个会话管理器,负责保存用户 ID、对话历史、上下文状态和打断标志。数据流不是一次性的,而是每时每刻都在移动。STT 输出的是增量文本,LLM 输出的也是增量 token,TTS 输出的是小块音频帧。只有把三段流串起来,才可能把延迟压进目标范围。
Input: 用户语音片段 -> 增量文本列表 LLM: 增量文本拼装 -> 流式 token TTS: 流式 token 拼装 -> 增量音频帧 Output: 增量音频帧 -> 客户端播放3.2 音频接入层:WebRTC 与 WebSocket 的选择
实时语音传输有两个常见选择:WebRTC 和 WebSocket。WebRTC 适合双向低延迟音视频通话,带回声消除、降噪、自动增益等音频处理能力,但实现复杂度高。WebSocket 简单、兼容性好,能直接承载二进制音频帧,但在弱网下的丢包重传和音频前处理需要自己做。
我们的场景以对话为主,客户端和服务器之间不是对等音视频会议,所以最终选择 WebSocket 作为主要传输层,同时保留 WebRTC 接入的扩展接口。
| 对比项 | WebRTC | WebSocket |
|---|---|---|
| 延迟 | 较低,适合实时音视频 | 依赖上层协议,通常也可接受 |
| 音频处理 | 自带 AEC、降噪、AGC | 需要自己实现或集成 |
| 开发复杂度 | 高,信令和 NAT 穿透复杂 | 低,二进制传输简单 |
| 适用场景 | 视频通话、双向实时通信 | 语音助手、客服机器人、命令式交互 |
如果原始音频质量较差,我们会在网关侧做降噪和 AGC 处理,避免 STT 识别率下降。
3.3 STT、LLM 与 TTS 的编排方式
编排层的核心任务是管理数据流的状态。我们为每路会话维护一个轻量级状态机:
{ "session_id": "a1b2c3d4", "state": "listening", "user_text": "", "assistant_text": "", "history": [], "interrupt": false, "audio_queue_size": 0 }sate可以取listening、thinking、speaking、interrupted等。每个阶段会向不同的 AI 服务发送流式请求,同时收集增量结果。
这里有一个容易被忽略的问题:LLM 输入什么?
我们不让 LLM 等完整 STT 结果,而是把增量文本不断拼入 prompt。为了让模型不因“半句话”产生无效回复,我们会在 prompt 中加入约束,让它只根据完整句意回答,如果用户没说完就等待。实际效果比较难调,后来我们在 STT 给出的“端点”信号之后再发送最终完整文本给 LLM,但流式 token 已经提前开始预热。
3.4 基础设施依赖
整个系统依赖以下基础设施:
- Kubernetes 作为部署平台,AI 三个服务独立扩容。
- Kafka 或 Redis Streams 作为异步任务队列,用于将不要求极低延迟的日志、事件、对话存储任务异步化。
- OpenTelemetry 采集全链路 Trace,Prometheus 存储指标,Grafana 展示。
- 对象存储保存原始音频和对话记录,用于审计和数据回放。
这些组件不是一开始就全部引入的。我们在第二个月才加入 Kafka,第五个月才引入完整的 Trace 体系。过早引入反而会拖慢迭代速度。
4. 让音频流“听得到、回得快”的实现细节
4.1 音频帧分片、缓冲和超时控制
客户端发送音频帧时,我们约定每帧 20ms,采样率 16kHz,单声道 PCM 数据。如果是 Opus 编码,则边解码边使用。网关收到帧后,按时间戳顺序放入会话缓冲区。
缓冲区不能无限增长。我们需要在 VAD 中检测用户停止说话:
- 连续语音超过 30 秒,强制截断并触发识别。
- 尾部静音超过 500ms,自动判定为“说完了”。
- 如果一直没有声音,超过 10 秒关闭会话。
这些参数都需要针对不同人群和场景调整。语速快的用户可能需要更短的静音阈值,而安静环境下可以适当延长。
VAD_CONFIG = { "sample_rate": 16000, "frame_ms": 20, "silence_threshold_ms": 500, "max_voice_duration_sec": 30, "idle_timeout_sec": 10, }4.2 会话状态与打断处理
打断是实时语音系统最麻烦的问题之一。用户正在听 AI 回复时开始说话,系统需要立刻完成三件事:
- 停止当前 TTS 合成,丢弃尚未播放的音频帧。
- 清空 STT 缓冲区,开始接收用户的下一句输入。
- 把当前对话历史保存,避免上下文丢失。
我们为音频帧增加一个session_id和sequence,在网关层判断是否需要丢弃旧帧。如果收到打断标志,后续 TTS 音频帧不再下发到客户端。
4.3 流式输出:STT -> LLM -> TTS 如何不阻塞
最简单的错误写法是等 STT 完全结束,再一次性调用 LLM,然后等 LLM 全部输出,再一次性调用 TTS。这样虽然编码简单,但延迟极高。
我们要做的是把三个服务都改成可流式消费的接口:
- STT 输入音频流,输出增量文本。
- 我们维护一个
text_buffer缓存已识别的文本。 - 每 100ms 或有新的文本增量时,将当前
text_buffer作为 LLM 的输入。 - LLM 流式返回 token,我们用
token_buffer缓存,并触发 TTS。
为了避免 LLM 对未完成的句子做出错误响应,我们设计了两种触发模式:
auto_flush:当 STT 给出“端点”事件后,把完整文本发给 LLM。preheat:在用户说话过程中,不断用中间文本让 LLM 开始生成,但丢弃或覆盖不完整的回答。
最终我们选择了以auto_flush为主,preheat作为加速手段。
4.4 最小实现示例:基于 asyncio 的实时管道
下面这个例子不是完整生产代码,而是用来展示如何用 asyncio 实现一条简单的流式管道。这里用伪接口替换真实 STT、LLM 和 TTS,方便理解。
import asyncio class RealtimeVoicePipeline: def __init__(self): self.session = { "user_text": "", "assistant_text": "", } async def on_audio_frame(self, frame: bytes): # 1. 送入 STT,这里用 async_speech_to_text 模拟 text_delta = await async_speech_to_text(frame) self.session["user_text"] += text_delta # 2. 当文本增量足够时,触发 LLM 流式生成 if text_delta: await self._stream_llm_and_tts() async def _stream_llm_and_tts(self): text = self.session["user_text"] # 这里可以使用已保存的 token 增量,避免重复生成 async for token in async_llm_stream(text): # 3. 将 token 交给 TTS audio_delta = await async_tts_synthesize(token) # 4. 将音频帧推送到 WebSocket await websocket.send(audio_delta) async def on_vad_endpoint(self): # STT 判定用户已说完时,发送一个完整结束信号 await websocket.send({"type": "endpoint"})真实系统里还需要处理流式 token 与 TTS 句子的切分,不能每来一个 token 就合成一次,否则语音会很破碎。常用的做法是给 TTS 定一个最小文本长度阈值,比如累计 10 到 20 个字符再合成。
5. 延迟测量、调优与验证
5.1 延迟指标定义
我们内部定义了几个关键指标:
| 指标 | 含义 | 目标范围 |
|---|---|---|
| STT First Result | 说话开始到首个识别词返回 | < 300ms |
| STT Endpoint | 语音结束到完整文本确定 | < 500ms |
| LLM TTFB | 发送完整文本到首个 token 返回 | < 500ms |
| TTS FPB | 拿到文本到首个音频帧生成 | < 400ms |
| End-to-End FPB | 用户停止说话到客户端播放首包 | < 1000ms |
这些指标需要埋点统计,不能靠用户反馈。我们用 OpenTelemetry 给每个音频帧和每个会话生成 Trace ID,在网关、STT、LLM、TTS 四处分别记录时间戳。
5.2 端到端延迟测试方法
最可靠的验证方式是录制一段固定音频,模拟客户端发送,并测量服务器返回首个音频帧的时间。下面是一个简化脚本思路:
import asyncio import websockets import time import numpy as np AUDIO_FILE = "test_speech.pcm" async def measure(): frames = read_pcm_frames(AUDIO_FILE, frame_ms=20) start_ts = None first_audio_ts = None async with websockets.connect("ws://localhost:8000/ws") as ws: for frame in frames: await ws.send(frame) # 假设服务端返回类型是二进制音频帧 response = await ws.recv() if isinstance(response, bytes): if start_ts is None: start_ts = time.perf_counter() if first_audio_ts is None: first_audio_ts = time.perf_counter() break # 最后发送一个 endpoint 标记 await ws.send(b"END_OF_SPEECH") while True: response = await ws.recv() if isinstance(response, bytes): if first_audio_ts is None: first_audio_ts = time.perf_counter() break if isinstance(response, str): # 收到结束事件 break if start_ts and first_audio_ts: print(f"End-to-end first byte delay: {(first_audio_ts - start_ts) * 1000:.1f} ms") asyncio.run(measure())测试时要注意网络环境。本地测试只反映代码效率,生产环境还要考虑不同地域的用户。我们会在多个区域部署网关,并在每个区域各放置一台拨测机器人。
5.3 关键参数与调优方向
调优过程中调整最频繁的几个参数如下:
| 参数 | 初始值 | 调优方向 | 错误配置表现 |
|---|---|---|---|
| VAD 静音阈值 | 300ms | 增加到 500ms | 用户正常停顿被强行截断 |
| LLM 温度 | 1.0 | 降到 0.7 | 回复过于发散 |
| LLM max tokens | 512 | 增加到 1024 | 回复被截断,TTS 不完整 |
| TTS 最小文本累计 | 5 字 | 增加到 15 字 | 语音破碎,拼接不自然 |
| WebSocket 消息最大长度 | 64KB | 不做大调整 | 音频帧发送失败 |
| 音频队列长度 | 100 帧 | 增加到 500 帧 | 网络抖动时出现音频卡顿 |
调优逻辑要结合具体业务。如果是客服机器人,回答通常较短,可以把max_tokens调小,换取更稳定延迟。如果做开放聊天,则需要更长上下文和更大输出长度。
5.4 学习环境与生产环境的区别
学习环境下,我们可以把所有服务跑在单机上,用本地 mock 接口测量延迟。但生产环境必须考虑:
- 网络链路和地域。
- 并发用户数和峰值。
- 函数计算的冷启动。
- 依赖服务的限流。
- 日志、Trace、监控带来的额外开销。
我们在生产环境使用多副本部署,并为每个 AI 服务设置超时。STT 超时 3 秒,LLM 超时 5 秒,TTS 超时 3 秒。单个服务超时不会终止整个会话,而是跳到降级分支。
6. 生产环境常见的坑与排查路径
6.1 音频卡顿、丢字和回声
问题现象:用户反馈 AI 声音时断时续,或者出现回声。
常见原因:客户端与服务端音频参数不一致,比如采样率、位深或声道数不同;网络抖动导致音频帧迟到;播放端没有做 jitter buffer。
检查方式:
- 抓取音频链路日志,看是否存在乱序帧。
- 对比客户端的音频参数和服务端解析参数。
- 在服务端统计每一帧到达时间,看是否存在超过 40ms 的间隔。
处理建议:
- 统一使用 16kHz/16bit/mono PCM,编解码层做严格校验。
- 客户端增加 100ms 到 200ms 的 jitter buffer,缓解网络抖动。
- 回声问题优先使用浏览器的回声消除能力,服务端做降噪兜底。
6.2 STT 识别慢或不稳定
问题现象:用户明显停顿后,识别结果迟迟不出来,需要重复说话。
常见原因:VAD 静音阈值设置过长;STT 服务在长文本场景下延迟升高;原始音频信噪比太低。
检查方式:
- 查看 VAD 日志,确认是否在预期时间发出
endpoint。 - 查看 STT 分词时间戳,定位是等待音频还是推理慢。
- 回放原始录音,看是否存在环境噪声。
处理建议:
- 把静音阈值从 500ms 降到 400ms,但要防止误切断。
- 给 STT 增加噪声抑制和自动增益。
- 如果 STT 服务支持热词,加入业务专有名词,能显著提升稳定度。
6.3 LLM 输出过长导致 TTS 延迟
问题现象:用户问一个简短问题,AI 回复很慢,且首包时间明显变长。
常见原因:LLM 输出太长,TTS 需要更多文本才能合成第一包,或者 TTS 排队。
检查方式:
- 查看 LLM 首 token 时间,确认不是模型响应延迟。
- 查看 TTS 等待文本长度,看是否超过预设阈值。
- 检查 TTS 消费速度,看是否存在积压。
处理建议:
- 在 prompt 中要求模型保持简洁。
- 对 LLM 输出做截断处理,超出一定长度后强制结束。
- TTS 合成时增加“首句优先”策略,先合成第一句话,后续句子边生成边合成。
6.4 WebSocket 连接泄漏和队列积压
问题现象:运行一段时间后,新用户无法建立连接,或网关内存持续升高。
常见原因:客户端断开时没有关闭服务端会话;异常分支没有释放会话资源;异步任务在等待 AI 服务时没有设置超时。
检查方式:
- 使用
ss -s或查看网关连接数监控。 - 服务端日志中统计
on_close和session_release数量。 - 检查队列积压量,比如 Kafka lag。
处理建议:
- 给每个 WebSocket 会话设置空闲超时,默认 60 秒无操作自动关闭。
- 在任务入口使用
asyncio.wait_for设置 AI 调用超时。 - 每次会话结束时显式清理状态、取消 pending task,避免泄漏。
6.5 排查顺序与日志关键字
遇到实时语音问题时,我们通常按下面顺序排查:
- 确认用户网络状态:检查 WebSocket 连接是否稳定、是否有大量重连。
- 确认音频帧是否到达:查看网关日志中的
audio_frame_received。 - 确认 VAD 是否触发:搜索
endpoint_detected。 - 确认 STT 是否输出:搜索
stt_text_delta。 - 确认 LLM 是否响应:搜索
llm_token。 - 确认 TTS 是否生成:搜索
tts_audio_frame。 - 确认音频是否下行:搜索
audio_frame_sent。
每一步缺失都代表问题发生在对应环节。我们使用统一的session_id串联所有日志,排查时直接按会话 ID 过滤。
7. 可复用清单与最佳实践
7.1 上线前检查清单
以下内容建议在发布前逐项确认:
- 音频参数是否全链路统一:采样率、位深、声道数、帧大小。
- VAD 静音阈值和最大语音时长是否符合场景。
- STT、LLM、TTS 接口是否都开启流式模式。
- 是否设置了 AI 服务超时和降级策略。
- WebSocket 会话是否有空闲超时和异常清理。
- 是否埋点统计 STT First Result、LLM TTFB、TTS FPB、End-to-End FPB。
- 是否记录全链路 Trace,能否用 session_id 串联日志。
- 客户端是否有 jitter buffer 和播放状态管理。
- 是否在压测环境验证过多路并发。
- 是否准备了断线重连和会话恢复方案。
7.2 低成本验证和成本控制
实时语音 AI 的成本主要来自 STT、LLM 和 TTS 的调用量。控制成本可以从几个方面入手:
- 在本地先用 mock 服务验证流程,避免一次次调用真实 API。
- VAD 判定为非语音的帧不要送入 STT。
- LLM 使用缓存:相似问题可以走缓存回答,减少重复计算。
- TTS 对高频固定话术做预合成,放入本地或 CDN。
- 使用覆盖率和回放测试,而不是无差别压测。
比如欢迎语、转接人工提示这类固定内容,完全可以提前合成并保存。
7.3 团队协作和需求边界
六个月内能完成系统,关键之一是明确需求边界。我们没有追求把所有对话都变成完美语音助手,而是把核心场景压缩为“简洁回答、快速返回、稳定不崩”。对超出边界的能力,比如多轮复杂推理、多说话人识别,放到下一个版本。
实际项目里,产品、算法、后端、客户端四方的协作至关重要。建议每周固定时间做一次延迟和音频质量回顾,用同一段测试音频对比不同版本的表现,避免各自改完配置后互相影响。
7.4 后续扩展方向
这套系统后续值得扩展的方向包括:
- 多语言 STT/TTS 切换。
- 多模态输入:结合摄像头画面或屏幕内容作为 LLM 上下文。
- 个性化音色:TTS 支持用户自定义音色。
- 边缘部署:将 VAD 和简单意图识别放到客户端,降低服务端压力。
- 长上下文记忆:把对话历史做向量化检索,减少 token 开销。
如果是从零开始做,建议先跑通最小闭环,再逐步拆分模块。不要把第一版架构设计得过于复杂,因为六个阶段里每个阶段学到的经验都可能推翻原有设计。
实时语音 AI 系统的核心是流式和状态管理。流式让延迟可控,状态管理让多轮对话和打断可靠。所有优化手段,包括延迟指标、音频参数、服务编排,都应该围绕这两个点展开。对于新手团队,最值得投入的是建立一套完善的日志和 Trace 体系,它能让你在排查问题时少走很多弯路。