蒲牢善鸣,未必会聊:一次语音 Agent 的打断与时延治理
五个组件都换成更快的,用户还是说:它卡、它不听我说话。
[读者] 正在做实时语音 Agent 的后端与架构同学 [痛点] 链路跑通了,但打断失灵、自说自话、体感发卡 [现在读] 因为你已经撞上「能说」到「会聊」那道坎 [读完] 能给语音链路定延迟预算、轮次策略与降级话术[旧方案] ASR → LLM → TTS 串行管道,一问一答的回合制 | v [新需求] 用户要随时插话、随时打断、随叫随到 | v [冲突] 轮次判定最慢、TTS 队列不清空、回声回灌进上行 | v [后果] 用户说「卡」、说「它不听我」、最后直接挂断转人工我是老李,这几年一直在跟实时语音和客服系统打交道。传说龙生九子,蒲牢好鸣,声传百里——声音大、能出声,可它只是在鸣,不是在聊。做语音 Agent 做得越久,我越觉得蒲牢是个很准的比喻:让机器出声只要三天,让它会聊要三个月。
我手上这套系统不复杂:浏览器或手机通过 WebRTC 进房,上行音频先进 VAD 判轮次,再走流式 ASR,转写交给 LLM(我们用 LangChain 侧的模型,通过 LiveKit Agents 的 LLMAdapter 挂进 AgentSession),回复的流式 token 分句后送 TTS,音频回推给用户。五个模块全是现成的,接起来那天我们都觉得活儿干完了。
但真实用户的第一句反馈不是「答得准不准」,而是「它老抢话」「我说到一半它就开始讲」「我让它停它还在讲」。V1 的答案是准的,节奏是错的——能说,不会聊。
为什么把 ASR、LLM、TTS 全换成更快的一档,用户体感还是卡?
用户插话的那一瞬间,是等 ASR 出 final 再停,还是听到声音就停?
如果打断做错、降级没做,用户最终听到的会是什么——是长久的沉默、是不停的抢话,还是一段没人负责的机器独白?
01、故事
这套系统服务的是一个售后咨询场景:用户打电话进来,问退款进度、问物流、问怎么换货。
这一版的任务是:让来电用户不用按任何键,直接开口说需求,系统听清、答对、并用语音播出来。听着简单,但它把五个模块的时序强行绑到了一条时间轴上。
V1 的做法是最直白的串行管道:VAD 判定一句话说完了,ASR 出 final 文本,把 final 追加进对话历史,交给 LLM,等第一个 token 回来,攒成一句完整的话,再送 TTS,等首个音频帧,推流给用户。
上线第一天,不看准确率、只看日志,我们心里是踏实的:识别有字、回答有内容、播报有声音。蒲牢确实会叫了。
塌方发生在第二周。用户开始用「卡」这个词,而不是「不准」。我们抓了几通真实会话听回放,问题全在节奏上:用户「喂」了一声想插话,助手还在念第二句;助手念到一半,自己的声音被上行麦克风收回去,又被当成用户说话,于是它自己打断自己;用户明确说「等一下」,助手礼貌地回一句「好的,那我继续为您说明」,然后真的继续说明。
能说,是五个模块各自的及格线;会聊,是这五个模块在同一条时间轴上的配合。这两件事不是一个工程量级。
蒲牢声震天
只鸣不解意
拼得五段链
未必成一聊
02、问题
旧方案失效得很具体:V1 是一套回合制管道,它的隐含假设是「用户说完 → 系统想完 → 系统说完 → 用户再说」,四步严格串行。
业务上的影响不是答错,而是没人愿意等:语音场景里用户没有耐心排队,插话是人类对话的默认行为,不是异常行为。系统不接插话,用户就会用挂断来表达意见。
技术上的表现可以归成三类:一是打断失灵,取消动作排在 ASR final 之后,用户在等的其实是「系统什么时候认定我说完了」;二是自说自话,播放出来的声音回灌进上行,被 VAD/ASR 当成用户输入;三是体感发卡,用户感知到的是首音延迟,而我们优化的是整段回答的总耗时。
这三类问题需要一套可验证的完成标准,否则永远说不清有没有变好。我们最后定的是四条口径,全部基于同一时钟轴打点,而不是拍脑袋:
- 打断链路:从 VAD 检测到用户发声,到 TTS 停止出声,中间只允许经过「取消」这一个动作,不允许依赖 ASR final。
- 首音延迟:从 ASR final 到首个下行音频包,必须能拆成「LLM 首 token」+「分句」+「TTS 首帧」三段,每段可单独归因。
- 回合状态:任一时刻,系统状态只能是 listening / thinking / speaking 之一,并且状态迁移必须有日志,杜绝「一边听一边说」。
- 降级覆盖:ASR 超时、LLM 超时、TTS 失败三条路径都必须有明确话术,不允许静默。
注意这里写的是口径与预算,不是实测结果。所有数值都必须由你自己环境里的打点得出,本文不提供任何跑分。
旧链一问答
新客随口插
断在静音窗
痛在体感上
03、原理
先讲这一篇真正需要的三个原理,其他都可以先放下。
第一,语音链路上「快」不是加法,是尾部决定论。用户感知的卡不卡,几乎只取决于首音延迟;而首音延迟不是五段平均值的体现,它由最靠后的那一段的尾部决定——你今天把 ASR 换快了一倍,只要 TTS 的首帧还在等一整句文本,用户听到的第一个字依然不会提前。
反直觉判断:想改善语音体感,优先做的不是「换更快的模型」,而是「让第一句话更短、让第一句话更早开始合成」。分句策略对首音延迟的影响,通常大于模型换代。
第二,VAD 是双刃剑,它就是延迟预算里那个隐形项。VAD 判定「你说完了」的方式是等静音窗口,这个窗口本身就是一笔固定支出:窗口短,打断灵敏但容易被环境音误触发;窗口长,判定稳但用户会感到「我说完了它还在等我闭嘴」。所以延迟预算里必须显式写上「等你闭嘴的时间」这一行,它不属于任何一个模型,却常常是最大的一项。
反直觉判断:在实时对话里,最贵的不是算力,是确定性——用户不知道系统什么时候会响应,比系统慢一点更让人焦虑。
第三,打断是一次级联回滚,不是一个开关。用户插话时,系统要做的是:停止下行音频、清空 TTS 已合成但未播的缓冲、取消 LLM 的流式生成、把当前回合标记为被打断。这四件事里,越靠后的越难撤回,因为 LLM 可能已经发起了工具调用。所以打断的设计原则是:先停最靠近用户的(声音),再停最靠近后端的(生成),并且要保证被中断的回合不会污染对话历史。
至于 LangChain 与 LiveKit Agents 的关系,只需要建立一个正确的心智模型:AgentSession 是编排层,它管的是轮次、打断、音频进出;LLM 只是它插进去的一个可替换部件,通过 LLMAdapter 把 LangChain 侧的模型(以及 LangGraph 编排出来的可调用对象)包成 AgentSession 认识的接口。也就是说,模型再聪明,也救不了编排层没做的打断。版本与适配细节需按当前官方文档核验。
快非加带宽
慢在尾一段
先说短半句
后补长全文
04、架构
[输入] 麦克风音频(WebRTC 上行) | v [模块] VAD + 轮次判定 → 流式 ASR | v [数据/状态] 对话历史 + 当前回合状态(listening / thinking / speaking) | v [处理] AgentSession 编排 → LLMAdapter → LangChain / LangGraph 流式 token → 分句 → TTS 流 | v [输出] 下行音频 + 可取消的控制信号(interrupt)这张图的边界要说清楚:AgentSession 负责的是「谁在说、什么时候停、下一个回合从哪开始」,模型侧只负责「说什么」。把这两个职责混在一起,就会出现我们 V1 那种情况——模型很快,但系统不知道该停了。
收益是显而易见的:所有状态集中在一个编排层,打断、降级、打点都只有一个入口,不需要在五个模块里各改一遍。代价同样明显:编排层成了单点,一旦它的状态机写错,故障表现会非常隐蔽——比如状态卡在 speaking,用户的输入就会被静默丢弃,日志里什么都看不到。
适用条件是:单向播报、通知类场景不需要这套编排,直接串行管道更省事;只要涉及真人实时插话,就必须有显式的回合状态机。这一点和模型选型无关,和场景有关。
链从麦端起
断在轮次中
边有边之限
代有价值偿
05、实战一次
先跑通一个最小但完整的 V1,把它该有的毛病都留着,后面才好归因。
环境与版本:Python 3.11+;livekit-agents 1.x(本文以 1.x 为基线,具体小版本与 API 名称请按当前官方文档核验);VAD 用 Silero 插件;ASR/TTS 用任一官方插件,本文以 OpenAI 插件示意;LLM 走 LangChain 适配。
依赖:
pipinstall"livekit-agents[silero,openai]"langchain langchain-openai配置(.env):
LIVEKIT_URL=ws://127.0.0.1:7880LIVEKIT_API_KEY=devkeyLIVEKIT_API_SECRET=secretOPENAI_API_KEY=sk-xxx本地起一个开发服务器(仅用于联调,不要用于生产):
livekit-server--dev核心实现:V1 刻意采用最朴素的串行编排,不做任何打断前置。
# agent_v1.pyfromlivekit.agentsimportAgent,AgentSession,JobContext,WorkerOptions,clifromlivekit.pluginsimportsilero,openai,langchainfromlangchain_openaiimportChatOpenAIclassVoiceAgent(Agent):def__init__(self)->None:super().__init__(instructions='你是一名售后语音客服。每次回答不超过两句,先给结论。')asyncdefentrypoint(ctx:JobContext)->None:awaitctx.connect()session=AgentSession(vad=silero.VAD.load(),stt=openai.STT(),llm=langchain.LLMAdapter(ChatOpenAI(model='gpt-4o-mini',streaming=True,temperature=0.3)),tts=openai.TTS(voice='alloy'),)awaitsession.start(room=ctx.room,agent=VoiceAgent())if__name__=='__main__':cli.run_app(WorkerOptions(entrypoint_fnc=entrypoint))这里langchain.LLMAdapter的导入路径与构造参数请以 LiveKit 官方的 LangChain 适配文档为准;它不是把 LangChain 塞进 AgentSession,而是把 LangChain 的模型包装成 AgentSession 能消费的 LLM 接口。
打点:没有打点,后面所有排查都是猜。所以第一版就要把五个关键时刻记到同一时钟轴上。
importtime t0=time.monotonic()defmark(stage:str)->None:print(f'{time.monotonic()-t0:.3f}\t{stage}')# 在以下时刻调用 mark(),具体回调名以 livekit-agents 当前版本文档为准:# vad.speech_started / asr.final / llm.first_token / tts.first_frame / audio.first_out启动与请求:
python agent_v1.py dev然后在浏览器打开官方的房间示例页,用同一个房间号进房,对着麦克风说话。
验证:不做任何实测性能断言,只确认三件事成立——能听到回答;日志里五个打点按顺序出现;在助手播报途中插话,观察它会有什么反应。
示例输出(仅示意打点格式,非实测性能数据) 0.000 vad.speech_started 0.780 asr.final 1.240 llm.first_token 1.610 tts.first_frame 1.690 audio.first_out跑通这一刻,V1 就完成了。它必然会暴露出后面要处理的问题:播报期间的插话没有让声音停下来,而被自己回灌的语音又触发了新回合。
先跑最小链
再留已知坑
通不求完美
证不求玄学
06、排查
诊断链一:助手自己打断自己
现象:助手播报途中频繁断句,日志显示同一时间出现「用户说话」。
怀疑:用户网络抖动导致 VAD 误判,或者 ASR 幻听。
检查:把这一轮的 ASR final 文本和刚播出去的 TTS 文本做比对,同时确认它落在播报窗口内还是窗口外。
证据:ASR final 的文本与 TTS 文本高度重合,并且全部落在播报窗口内——不是用户说的,是它自己说的。
根因:上行没有做回声消除,也没有在播报期间做输入门控,扬声器的声音绕一圈回到了麦克风,系统把自己识别成了用户。
错误尝试:先把 VAD 阈值调高、把静音窗口调长。为什么错:这不是灵敏度问题,而是同一段音频绕回来了,回声能量本来就大,调高阈值拦不住它,只会把真正的人类插话一起拦掉——打断从「乱打断」变成「完全打不断」,体感更糟。
修复:优先用耳机或开启回声消除;播报期间对上行做门控,或者干脆半双工;同时在打断判定里加一条抑制规则——如果 ASR 结果与当前播报内容高度相似,视为回声而非用户输入。
诊断链二:打断之后还在播
现象:用户说「等一下」,助手把剩下两句念完才停。
怀疑:LLM 太慢,取消不掉生成。
检查:拿同一时钟轴上的 speech_started、asr.final、tts.stop 三个点,看谁离谁远。
证据:从 speech_started 到 tts.stop 的间隔最长,而 llm.first_token 只占其中一小段;且 stop 动作一定发生在 asr.final 之后。
根因:打断被实现成了「听懂之后再停」,而取消 TTS 队列、取消 LLM 流的动作排在了 final 之后,等于让用户等系统把话听完才允许自己闭嘴。
错误尝试:换一个更小的模型,指望生成更快让取消更早发生。为什么错:时间轴证明瓶颈在 final 之前的等待和未清空的播放队列,换模型只是优化了其中最短的一段,打断依旧不会提前。
修复:把 interrupt 从 final 之后前置到 speech_started,先清空 TTS 缓冲并取消 LLM 流,再等 final 决定新回合的上下文。
疑在模型慢
证在时间轴
错改先换脑
真凶在队尾
07、优化
V2 的每一处改动都来自第 06 章的证据,没有一处是「听说这样更好」。
改动一:打断前置。根因是取消动作排在 final 之后。修改为在 VAD 检测到发声的那一瞬就发出 interrupt,先停音频、清缓冲、取消 LLM 流。原因是用户要的是「你闭嘴」,不是「你听懂」。新行为是用户开口即静音,代价是可能被咳嗽、关门声误触发,需要用「最短发声时长」做一层过滤。
改动二:分句策略改为首句强制短。根因是首音延迟被「等一整句」拖住。修改为按标点断句,并且第一句强制短于某个字数上限,先合第一句送 TTS。原因是人在电话里对前半句的容忍度远低于后半句。新行为是更早出声,代价是韵律略显零碎,需要用后续句子补足语调。
改动三:把 LangGraph 挪到不阻塞首句的位置。根因是工具调用会占住生成。修改为第一句用短承接话术先播(例如「我先帮你查一下」),把 LangGraph 编排的查单动作放到这段音频播放期间并行进行。原因是等待需要被填充,而不是被隐藏。新行为是首音更早,代价是出现「查完了但话还没说完」的错位,需要编排层做一次对齐。
改动四:把降级写进编排层。三条路径分别给确定的话术:ASR 迟迟没有 final,就提示「我没听清,麻烦再说一遍」;LLM 超时,就先播兜底短句再重试一次;TTS 失败,就退化为文字并明确告知转人工。原则只有一条——任何失败都不允许表现为沉默。
验证方式不是看数字,而是看顺序:在打点日志上断言五件事——interrupt 早于 asr.final;audio 停止早于 llm 取消完成;首句文本长度在阈值内;三条降级路径各自触发过一次话术;任一时刻状态机只有一个状态。顺序对了,链路就是对的;数值再漂亮,顺序错了也是假象。
打断前置停
分句短先出
不改链之骨
只调链之序
08、演进
[同一句用户输入] | +--[V1] 等 ASR final 再决定 → 回合制、打断失灵 / 代价:体感卡、用户挂断 | +--[V2] speech_started 即取消 → 可插话、可降级 / 代价:误触发与状态回滚复杂度 | [Trade-off] 得到:可打断的对话体感与可预期的失败提示 失去:一部分确定性与实现上的简单性 适用:真人实时语音交互;不适用:单向播报、通知类场景正确性上,V1 和 V2 的答案质量其实差不多,差的是打断后的上下文正确性:V2 必须保证被打断的回合不进入对话历史,否则模型会把没说完的话当成说过的话,这一点比 V1 更难写对。
稳定性上,V1 更稳,因为它只有一条路径;V2 引入了可取消状态,任何没有回滚干净的取消都会让状态机卡死,这类 bug 不会报错,只会表现为「用户说什么都没反应」。
复杂度与成本上,V2 多了一套状态机、一层回声抑制和三条降级分支,代码量和维护成本明显上升,收益是用户愿意继续说话。
适用范围上,这套改动只在真人实时场景里划算。如果只是把一段文本念给用户听,V1 的串行管道反而是正确选择。
遗留问题也要说清楚:V2 的打断前置会让「嗯」「啊」这类语气词打断播报,是否需要忽略需要按业务实测;回声抑制在免提外放场景依然不可靠,最终还是要靠设备侧 AEC 兜底。
同入两条路
各付各的账
得在快与稳
失在简与省
09、洞见
9.1 能说是组件能力,会聊是时序能力
把五个模块接通,证明的是每个组件都能用;让用户觉得「在聊天」,证明的是它们在同一条时间轴上的配合。这两件事的验收标准完全不同,前者看输出,后者看顺序。反直觉判断:语音助手的「快」,第一优先级不是换更快的模型,而是把打断从「听懂之后」提前到「听到声音」。模型换代通常只能优化预算里最短的那一段。
9.2 打断是一次减法,不是一个开关
很多人把打断理解成「加一个 barge-in 功能」,实际上它是对已有工作的一次回滚:停音频、清缓冲、取消生成、修正历史。反直觉判断:最好的打断体验不是「取消得更快」,而是「可取消的范围更小」——只要一开始就少生成一点、早合成一点,需要回滚的东西自然就少,打断也就自然变快。
9.3 时延预算必须显式写上「等你闭嘴的时间」
VAD 的静音窗口不属于任何一个模型,却是延迟预算里最容易被漏掉的一项。做预算时如果只统计 ASR、LLM、TTS 三段耗时,你会发现纸面上很快,用户依然觉得慢——因为用户是从「我说完最后一个字」开始计时的,而系统是从「我确认你说完了」开始计时的。这两个时刻之间的差,就是体感落差。
9.4 降级是对话的一部分,不是错误页
在文字界面里,失败可以显示一个红字;在语音界面里,失败就是沉默,而沉默在对话里意味着「对方走了」。所以降级必须有话术、必须分级、必须比正常回复更短更确定。用户能接受「我没听清,再说一遍」,不能接受三秒钟的空白。
能说靠组件
会聊靠时序
减法造打断
预算算闭嘴
10、系统落地
原来我们有的是一条串行管道:VAD 判停、ASR 转写、LLM 生成、TTS 播报,每一段都能跑,合起来不会聊。
这一篇新增的是编排层上的三件东西:一个显式的回合状态机(listening / thinking / speaking),一个前置到 speech_started 的取消链路,以及一套覆盖 ASR、LLM、TTS 三条路径的降级话术。模型侧的接入方式没有变,仍然是 AgentSession 通过 LLMAdapter 消费 LangChain 侧的模型,LangGraph 负责需要多步编排的那部分,只是它的位置从「挡在首句前面」挪到了「与首句并行」。
现在能做的是:用户可以随时插话,系统可以在开口的瞬间闭嘴,链路某一段挂掉时用户能听到一句人话,而且这一切都能在同一张打点日志上复现和归因。
还缺的是:多说话人场景下的轮次判定(现在只处理单人对话)、免提外放下的回声抑制(设备侧差异太大)、以及打断后上下文的自动修正(目前靠人工规则)。
下一步的演进方向很清楚:把回合状态机从「按静音窗口判定」推进到「按语义完整度判定」,也就是引入更细的轮次检测模型;同时把降级话术从硬编码文案升级成可配置的策略表,让运营能改。
原有串行链
今添轮次门
能接真插话
缺在多场景
11、小结
Q1 → 体感卡不是模型慢,是轮次判定 + TTS 首包这两个尾部项在拖 Q2 → 打断发生在 VAD speech_started,不发生在 ASR final Q3 → 降级要有分级话术,宁可短而确定,也不能留 silence 状态 → 一条可打断、可打点、可降级的语音 Agent 链路回到最开始那个比喻:蒲牢善鸣,是因为它有一副好嗓子;会聊,是因为它知道什么时候不该出声。语音 Agent 的工程难点,几乎全部集中在「什么时候不该出声」这一侧——而这恰恰是模型再强也替代不了的部分。
问一答时延
问二答时机
问三答降级
状态可上线
12、作业
12.1 理解题:为什么「把 ASR、LLM、TTS 都换成更快的一档」往往改善不了用户体感?
参考答案:用户感知的是首音延迟,而首音延迟由时间轴上最靠后的尾部项决定。如果 TTS 还在等一整句文本、打断还在等 ASR final,那么优化中间某一段只是缩短了非瓶颈部分。先把分句策略和取消时机调对,收益通常比换模型更直接。
12.2 实战题:给一条语音链路加上五点在一条时间轴上的打点,并写一个顺序断言。
参考答案:在 VAD 发声、ASR final、LLM 首 token、TTS 首帧、首个下行音频包五处记录同一时钟的时间戳;断言只校验顺序——打断信号的发出早于 ASR final,音频停止早于 LLM 取消完成。顺序断言不依赖任何性能数值,因此在任何机器上都可复现。
12.3 排障题:用户反馈「助手会自己打断自己」,你的排查顺序是什么?
参考答案:先比对这一轮 ASR final 文本与刚播出的 TTS 文本,再看它是否落在播报窗口内。若高度重合且全在窗口内,根因是回声回灌,不是 VAD 太灵敏。修复方向是设备侧回声消除、播报期间输入门控、以及对与播报内容高度相似的转写做抑制。切忌先调高 VAD 阈值,那会把真人插话一起拦掉。
12.4 架构判断题:为什么说 AgentSession 这类编排层不能省,直接用串行管道不行?
参考答案:串行管道没有显式的回合状态,因此无法表达「现在在播报,用户说话了,应该先停再听」。编排层的价值是把轮次、打断、降级、打点收在一个入口,模型只是它插进去的可替换部件。反过来,单向播报类场景不需要这套编排,串行管道更简单也更稳——这是场景判断,不是技术先进性问题。
四题自成环
理解再实战
排障见证据
架构辨取舍
13、思考
这篇的核心冲突从来不是「模型够不够聪明」,而是「系统知不知道什么时候该闭嘴」。蒲牢的问题不是声音不够大,是它没有对话的概念。
可以复用的工程判断有三条。第一,实时系统里,先找尾部项,再谈优化——任何平均值都会骗人,用户只感知最慢的那一跳。第二,打断、降级这类「负向能力」要按减法设计:可取消的范围越小,体验越自然,实现也越简单。第三,一切体感问题都要落到同一时钟轴上,没有打点的排查都是猜,猜出来的修复通常只是把问题挪了个位置。
声大不算聊
会停才算懂
链断有话说
此役见真章