给 Agent 装上耳朵和嘴巴:STT + LangGraph + TTS 三明治架构的生产级落地实战关键词:流式 STT、VAD、LangGraph、StateGraph、Interrupt、Durable Execution、流式 TTS、打断控制、背压治理、会话路由、Kafka、Kubernetes、语音 Agent前言大模型进入业务系统之后,文本 Agent 已经不再稀奇,真正难的是把 Agent 做成一个能实时“听”和“说”的生产系统。很多团队在第一版语音 Agent 中都会采用最直接的链路:用户说话 - ASR 识别 - LLM 回复 - TTS 合成 - 播放这条链路在 Demo 阶段没有问题,但一旦进入真实业务,问题会集中爆发:语音输入是持续流,不是一次性请求用户会打断,系统必须支持中途停说、停播、改问LLM 的回复是增量文本流,不是最终整段文本TTS 的消费节奏和 LLM 的生成节奏并不一致会话状态、工具调用、消息顺序、幂等恢复都不能靠“一个 async 函数”硬扛因此,语音 Agent 不是把三个模型简单串起来,而是要把它设计成一个带有状态机、流控、事件总线和中断恢复能力的分层系统。本文讨论的“三明治架构”指的是:上层:STT / VAD,负责把连续音频流转换为可消费的文本事件流中层:LangGraph Agent,负责状态编排、工具调用、上下文管理与中断恢复下层:TTS,负责把增量文本流转换为低延迟可播放的语音流三层之间不直接耦死,而是通过事件协议、状态模型和背压机制连接。这样做的目标不是“写起来优雅”,而是让系统在高并发、长会话、频繁打断、偶发故障下依然可控。1. 为什么语音 Agent 不能做成简单流水线1.1 文本 Agent 与语音 Agent 的本质差异文本 Agent 的典型交互是离散回合:用户发送一段文本Agent 执行推理和工具调用返回文本结果语音 Agent 则是连续时序系统:用户音频按 20ms~60ms 分片持续进入VAD 判断什么时候开始说、什么时候结束说STT 持续输出 partial/final 文本Agent 在“文本未完全结束”前就要准备状态转换TTS 在 Agent 尚未生成完整段落前就可能开始首句合成播放期间用户随时可能插话,导致当前会话被打断所以语音 Agent 不是单纯的“模型调用问题”,而是一个典型的实时流式编排问题。1.2 生产系统真正约束的是延迟预算用户是否觉得“像人在对话”,核心不在模型参数规模,而在端到端时延是否被控制在心理可接受区间内。一个典型延迟预算可以拆成:阶段目标时延VAD 检测到起说点50ms ~ 150msSTT partial 首字返回150ms ~ 350msSTT final 句尾确认300ms ~ 800msAgent 首 token100ms ~ 400msTTS 首包返回150ms ~ 350ms播放首音节400ms ~ 900ms真正的优化目标不是“单点最低延迟”,而是“首响应尽快出现,后续流稳定推进,不因任一环节抖动拖垮整条链路”。1.3 简单串联为什么在高并发场景下会失效在实际业务里,简单串联方案通常有四类致命问题:问题一:输入和输出的时间粒度不一致STT 处理的是音频帧,LLM 处理的是 token,TTS 处理的是句段或文本块,三者天然不是同一个节奏。如果没有中间状态层:STT 会不断吐 partial 文本LLM 可能因为 final 文本迟迟未到而无法稳定触发TTS 又会因为上游句段边界不清而频繁重合成问题二:用户打断时无法有序回滚如果 TTS 已经在播,LLM 还在继续生成,而用户这时说“不是,我是问昨天的订单”,系统必须同时完成:停止当前 TTS 播放终止当前 Agent 生成标记当前响应为 interrupted打开新一轮音频窗口保留必要上下文,丢弃无效执行分支这不是一个函数 cancel 就能解决的问题,而是一次状态迁移。问题三:长会话容易把状态污染成一锅粥很多 Demo 把会话状态存成一个 Python dict 或一个 WebSocket 上下文对象。前 5 轮看起来没问题,到了第 30 轮就会出现:partial 和 final 文本混杂工具结果污染下一轮上下文TTS 播放序号错乱断线重连后恢复不到正确节点问题四:没有背压就没有稳定性高峰时不是所有模块一起变慢,而是其中一个先变慢,进而把整个系统拖垮。典型链路如下:STT 正常 - Agent 稍慢 - TTS 排队增加 - 播放积压 - 网关连接数升高 - 内存上涨 - 尾延迟恶化如果没有队列水位、限速、丢弃策略和隔离策略,系统会进入雪崩。2. 三明治架构的核心思想2.1 所谓“三明治”,本质是三层解耦我们把语音 Agent 划成三层:┌────────────────────────────────────────────────────────────┐ │ Layer 1: STT / VAD │ │ 负责音频接入、分片、语音起止检测、partial/final 文本输出 │ └────────────────────────────────────────────────────────────┘ │ ▼ ┌────────────────────────────────────────────────────────────┐ │ Layer 2: LangGraph Agent │ │ 负责状态图、工具调用、对话记忆、打断恢复、分支与回退 │ └────────────────────────────────────────────────────────────┘ │ ▼ ┌────────────────────────────────────────────────────────────┐ │ Layer 3: TTS │ │ 负责句段切分、流式合成、音频缓存、播放控制与 stop 信号 │ └────────────────────────────────────────────────────────────┘这三层不是简单上下游关系,而是各自对不同“流”负责:STT 负责音频流到文本流LangGraph 负责文本流到状态流TTS 负责状态流到音频流2.2 为什么中间层必须是状态图而不是普通链式调用语音 Agent 有三个天然特征:会话是长生命周期的执行会被外部打断工具调用会形成循环这恰好对应状态图的三个能力:显式状态建模节点级暂停与恢复条件分支与循环边LangGraph 在工程上的价值不只是“更方便写 Agent”,而是它把这些能力放进了运行时语义里:可持久化执行状态可通过 checkpointer 进行 durable execution可在节点前后或节点内部执行 interrupt可用 thread/session 维度恢复执行这类语义对语音场景很关键,因为语音对话不是一次性 RPC,而是持续交互流程。2.3 三明治架构的四条设计原则原则一:先把事件流建稳,再谈模型效果模型效果只能提升“回答质量”,不能解决“顺序错乱、重复播报、断线丢状态”。原则二:一切外部副作用都要可幂等包括:工具调用TTS 推送Kafka 投递状态落库WebSocket 下行因为中断恢复本质上意味着执行可能重放,不能让重放带来副作用重复。原则三:把“打断”当成一等公民不是给播放器加一个stop()就完了,而是从协议、状态机、节点编排、缓存和回放逻辑上全面支持 interruption。原则四:把“最终一致的语义正确”放在“瞬时绝对实时”前面在生产系统里,极少数 100ms 的额外等待,往往能换来明显更稳的语义边界、句段边界和状态一致性,这笔账大多数场景都值得。3. 生产级架构设计:从单连接到分布式集群3.1 全局组件视图客户端(Web / App / IVR) │ ▼ ┌────────────────────────────┐ │ Access Gateway │ │ WebSocket / WebRTC 接入 │ │ 鉴权、限流、租户隔离、路由 │ └────────────────────────────┘ │ ▼ ┌────────────────────────────┐ │ Session Router │ │ 会话归属、粘性路由、心跳 │ └────────────────────────────┘ │ ├──────────────► STT Worker Pool │ │ │ └── 输出 asr.partial / asr.final │ ├──────────────► Agent Worker Pool │ │ │ └── 输出 agent.delta / tool.call / agent.final │ └──────────────► TTS Worker Pool │ └── 输出 tts.chunk / tts.end / tts.stop 共享基础设施 - Kafka / Redis Streams:事件总线 - Redis:会话游标、短期水位、去重标记 - PostgreSQL / MySQL:会话存档、业务工具数据 - Object Storage:录音分片、回放与审计 - Prometheus + Loki + Tempo:指标、日志、链路追踪3.2 关键不是微服务多,而是边界清晰建议至少拆成四类职责:1. 接入层负责:WebSocket / WebRTC 接入用户鉴权租户级限流会话初始化断线重连握手2. 语音理解层负责:PCM 标准化VAD 检测STT 流式识别partial/final 文本输出3. 对话编排层负责:LangGraph 图执行工具调用上下文摘要中断与恢复安全策略和回复裁剪4. 语音生成层负责:句段切分TTS 流式合成音频块排序和缓冲stop/cancel 协议3.3 语音 Agent 的核心状态机语音系统如果不先设计状态机,后面所有优化都只是在修补症状。一个推荐的会话状态机如下:IDLE └── 用户开始说话 ──► LISTENING LISTENING ├── partial 文本持续到达 ──► LISTENING ├── final 文本确认 ───────► THINKING └── 超时/断连 ───────────► CLOSED THINKING ├── 调用工具 ────────────► TOOL_EXECUTING ├── 首句文本可下发 ───────► SPEAKING ├── 用户打断 ────────────► INTERRUPTING └── 异常恢复 ────────────► RETRYING TOOL_EXECUTING ├── 工具成功 ────────────► THINKING ├── 工具降级 ────────────► THINKING └── 用户打断 ────────────► INTERRUPTING SPEAKING ├── TTS chunk 持续发送 ───► SPEAKING ├── 播放结束 ────────────► IDLE ├── 用户打断 ────────────► INTERRUPTING └── TTS 失败转文本 ───────► IDLE INTERRUPTING ├── 清理当前播放与生成 ───► LISTENING └── 清理失败 ────────────► RECOVERING这里最重要的不是状态名字,而是每次迁移都要带上:session_idturn_idsequence_nocausation_idcorrelation_ididempotency_key否则分布式链路一复杂,就很难追踪和恢复。4. 为什么 LangGraph 适合做中间层4.1 不是为了“更像 Agent”,而是为了“更像工作流”语音 Agent 中间层既像 Agent,又像工作流引擎。它既要做:LLM 推理工具调用短期记忆和长期记忆也要做:失败重试节点中断状态持久化人工接管重放恢复LangGraph 的优势就在于它把这些编排能力放在同一套模型里,而不是让我们自己拼接“聊天 SDK + 状态缓存 + 重试器 + 自定义 DAG”。4.2 StateGraph 在语音场景里的直接价值StateGraph 很适合承载这类会话状态:from typing import TypedDict, Literal, Optional, List class VoiceAgentState(TypedDict, total=False): sessi
给 Agent 装上耳朵和嘴巴:STT + LangGraph + TTS 三明治架构的生产级落地实战
给 Agent 装上耳朵和嘴巴:STT + LangGraph + TTS 三明治架构的生产级落地实战关键词:流式 STT、VAD、LangGraph、StateGraph、Interrupt、Durable Execution、流式 TTS、打断控制、背压治理、会话路由、Kafka、Kubernetes、语音 Agent前言大模型进入业务系统之后,文本 Agent 已经不再稀奇,真正难的是把 Agent 做成一个能实时“听”和“说”的生产系统。很多团队在第一版语音 Agent 中都会采用最直接的链路:用户说话 - ASR 识别 - LLM 回复 - TTS 合成 - 播放这条链路在 Demo 阶段没有问题,但一旦进入真实业务,问题会集中爆发:语音输入是持续流,不是一次性请求用户会打断,系统必须支持中途停说、停播、改问LLM 的回复是增量文本流,不是最终整段文本TTS 的消费节奏和 LLM 的生成节奏并不一致会话状态、工具调用、消息顺序、幂等恢复都不能靠“一个 async 函数”硬扛因此,语音 Agent 不是把三个模型简单串起来,而是要把它设计成一个带有状态机、流控、事件总线和中断恢复能力的分层系统。本文讨论的“三明治架构”指的是:上层:STT / VAD,负责把连续音频流转换为可消费的文本事件流中层:LangGraph Agent,负责状态编排、工具调用、上下文管理与中断恢复下层:TTS,负责把增量文本流转换为低延迟可播放的语音流三层之间不直接耦死,而是通过事件协议、状态模型和背压机制连接。这样做的目标不是“写起来优雅”,而是让系统在高并发、长会话、频繁打断、偶发故障下依然可控。1. 为什么语音 Agent 不能做成简单流水线1.1 文本 Agent 与语音 Agent 的本质差异文本 Agent 的典型交互是离散回合:用户发送一段文本Agent 执行推理和工具调用返回文本结果语音 Agent 则是连续时序系统:用户音频按 20ms~60ms 分片持续进入VAD 判断什么时候开始说、什么时候结束说STT 持续输出 partial/final 文本Agent 在“文本未完全结束”前就要准备状态转换TTS 在 Agent 尚未生成完整段落前就可能开始首句合成播放期间用户随时可能插话,导致当前会话被打断所以语音 Agent 不是单纯的“模型调用问题”,而是一个典型的实时流式编排问题。1.2 生产系统真正约束的是延迟预算用户是否觉得“像人在对话”,核心不在模型参数规模,而在端到端时延是否被控制在心理可接受区间内。一个典型延迟预算可以拆成:阶段目标时延VAD 检测到起说点50ms ~ 150msSTT partial 首字返回150ms ~ 350msSTT final 句尾确认300ms ~ 800msAgent 首 token100ms ~ 400msTTS 首包返回150ms ~ 350ms播放首音节400ms ~ 900ms真正的优化目标不是“单点最低延迟”,而是“首响应尽快出现,后续流稳定推进,不因任一环节抖动拖垮整条链路”。1.3 简单串联为什么在高并发场景下会失效在实际业务里,简单串联方案通常有四类致命问题:问题一:输入和输出的时间粒度不一致STT 处理的是音频帧,LLM 处理的是 token,TTS 处理的是句段或文本块,三者天然不是同一个节奏。如果没有中间状态层:STT 会不断吐 partial 文本LLM 可能因为 final 文本迟迟未到而无法稳定触发TTS 又会因为上游句段边界不清而频繁重合成问题二:用户打断时无法有序回滚如果 TTS 已经在播,LLM 还在继续生成,而用户这时说“不是,我是问昨天的订单”,系统必须同时完成:停止当前 TTS 播放终止当前 Agent 生成标记当前响应为 interrupted打开新一轮音频窗口保留必要上下文,丢弃无效执行分支这不是一个函数 cancel 就能解决的问题,而是一次状态迁移。问题三:长会话容易把状态污染成一锅粥很多 Demo 把会话状态存成一个 Python dict 或一个 WebSocket 上下文对象。前 5 轮看起来没问题,到了第 30 轮就会出现:partial 和 final 文本混杂工具结果污染下一轮上下文TTS 播放序号错乱断线重连后恢复不到正确节点问题四:没有背压就没有稳定性高峰时不是所有模块一起变慢,而是其中一个先变慢,进而把整个系统拖垮。典型链路如下:STT 正常 - Agent 稍慢 - TTS 排队增加 - 播放积压 - 网关连接数升高 - 内存上涨 - 尾延迟恶化如果没有队列水位、限速、丢弃策略和隔离策略,系统会进入雪崩。2. 三明治架构的核心思想2.1 所谓“三明治”,本质是三层解耦我们把语音 Agent 划成三层:┌────────────────────────────────────────────────────────────┐ │ Layer 1: STT / VAD │ │ 负责音频接入、分片、语音起止检测、partial/final 文本输出 │ └────────────────────────────────────────────────────────────┘ │ ▼ ┌────────────────────────────────────────────────────────────┐ │ Layer 2: LangGraph Agent │ │ 负责状态图、工具调用、对话记忆、打断恢复、分支与回退 │ └────────────────────────────────────────────────────────────┘ │ ▼ ┌────────────────────────────────────────────────────────────┐ │ Layer 3: TTS │ │ 负责句段切分、流式合成、音频缓存、播放控制与 stop 信号 │ └────────────────────────────────────────────────────────────┘这三层不是简单上下游关系,而是各自对不同“流”负责:STT 负责音频流到文本流LangGraph 负责文本流到状态流TTS 负责状态流到音频流2.2 为什么中间层必须是状态图而不是普通链式调用语音 Agent 有三个天然特征:会话是长生命周期的执行会被外部打断工具调用会形成循环这恰好对应状态图的三个能力:显式状态建模节点级暂停与恢复条件分支与循环边LangGraph 在工程上的价值不只是“更方便写 Agent”,而是它把这些能力放进了运行时语义里:可持久化执行状态可通过 checkpointer 进行 durable execution可在节点前后或节点内部执行 interrupt可用 thread/session 维度恢复执行这类语义对语音场景很关键,因为语音对话不是一次性 RPC,而是持续交互流程。2.3 三明治架构的四条设计原则原则一:先把事件流建稳,再谈模型效果模型效果只能提升“回答质量”,不能解决“顺序错乱、重复播报、断线丢状态”。原则二:一切外部副作用都要可幂等包括:工具调用TTS 推送Kafka 投递状态落库WebSocket 下行因为中断恢复本质上意味着执行可能重放,不能让重放带来副作用重复。原则三:把“打断”当成一等公民不是给播放器加一个stop()就完了,而是从协议、状态机、节点编排、缓存和回放逻辑上全面支持 interruption。原则四:把“最终一致的语义正确”放在“瞬时绝对实时”前面在生产系统里,极少数 100ms 的额外等待,往往能换来明显更稳的语义边界、句段边界和状态一致性,这笔账大多数场景都值得。3. 生产级架构设计:从单连接到分布式集群3.1 全局组件视图客户端(Web / App / IVR) │ ▼ ┌────────────────────────────┐ │ Access Gateway │ │ WebSocket / WebRTC 接入 │ │ 鉴权、限流、租户隔离、路由 │ └────────────────────────────┘ │ ▼ ┌────────────────────────────┐ │ Session Router │ │ 会话归属、粘性路由、心跳 │ └────────────────────────────┘ │ ├──────────────► STT Worker Pool │ │ │ └── 输出 asr.partial / asr.final │ ├──────────────► Agent Worker Pool │ │ │ └── 输出 agent.delta / tool.call / agent.final │ └──────────────► TTS Worker Pool │ └── 输出 tts.chunk / tts.end / tts.stop 共享基础设施 - Kafka / Redis Streams:事件总线 - Redis:会话游标、短期水位、去重标记 - PostgreSQL / MySQL:会话存档、业务工具数据 - Object Storage:录音分片、回放与审计 - Prometheus + Loki + Tempo:指标、日志、链路追踪3.2 关键不是微服务多,而是边界清晰建议至少拆成四类职责:1. 接入层负责:WebSocket / WebRTC 接入用户鉴权租户级限流会话初始化断线重连握手2. 语音理解层负责:PCM 标准化VAD 检测STT 流式识别partial/final 文本输出3. 对话编排层负责:LangGraph 图执行工具调用上下文摘要中断与恢复安全策略和回复裁剪4. 语音生成层负责:句段切分TTS 流式合成音频块排序和缓冲stop/cancel 协议3.3 语音 Agent 的核心状态机语音系统如果不先设计状态机,后面所有优化都只是在修补症状。一个推荐的会话状态机如下:IDLE └── 用户开始说话 ──► LISTENING LISTENING ├── partial 文本持续到达 ──► LISTENING ├── final 文本确认 ───────► THINKING └── 超时/断连 ───────────► CLOSED THINKING ├── 调用工具 ────────────► TOOL_EXECUTING ├── 首句文本可下发 ───────► SPEAKING ├── 用户打断 ────────────► INTERRUPTING └── 异常恢复 ────────────► RETRYING TOOL_EXECUTING ├── 工具成功 ────────────► THINKING ├── 工具降级 ────────────► THINKING └── 用户打断 ────────────► INTERRUPTING SPEAKING ├── TTS chunk 持续发送 ───► SPEAKING ├── 播放结束 ────────────► IDLE ├── 用户打断 ────────────► INTERRUPTING └── TTS 失败转文本 ───────► IDLE INTERRUPTING ├── 清理当前播放与生成 ───► LISTENING └── 清理失败 ────────────► RECOVERING这里最重要的不是状态名字,而是每次迁移都要带上:session_idturn_idsequence_nocausation_idcorrelation_ididempotency_key否则分布式链路一复杂,就很难追踪和恢复。4. 为什么 LangGraph 适合做中间层4.1 不是为了“更像 Agent”,而是为了“更像工作流”语音 Agent 中间层既像 Agent,又像工作流引擎。它既要做:LLM 推理工具调用短期记忆和长期记忆也要做:失败重试节点中断状态持久化人工接管重放恢复LangGraph 的优势就在于它把这些编排能力放在同一套模型里,而不是让我们自己拼接“聊天 SDK + 状态缓存 + 重试器 + 自定义 DAG”。4.2 StateGraph 在语音场景里的直接价值StateGraph 很适合承载这类会话状态:from typing import TypedDict, Literal, Optional, List class VoiceAgentState(TypedDict, total=False): sessi