1. 为什么“能对话”和“像活人”是两码事
做过语音助手的人大概都有这种体验:把 ASR、LLM、TTS 三个模块串起来,跑通了,延迟也压到了两秒以内,但用户用了一次就不想再用第二次。问题出在哪?出在这条链路从设计之初就是“回合制”的——你说完,它识别,它思考,它合成,然后播放。整个过程像在跟一个反应迟钝但礼貌过头的客服打交道,每个环节都正确,合在一起就是没有“活人感”。
“活人感”这个词听起来玄,拆开看其实很具体。真人对话里,对方会在你还没说完的时候发出“嗯”“对”这样的反馈音;会在思考的时候拖长音或者加个“那个……”;会在你说到一半时预判你的意思提前接话;会在被打断时自然停下而不是硬把话说完。这些行为在传统的 ASR→LLM→TTS 串行链路里几乎无法实现,因为每个模块都是独立工作的,模块之间没有“正在发生什么”的共享状态。
我最早做语音交互是在智能音箱那波,当时用的是一个很典型的串行方案:VAD 检测到静音就切断,把音频送给 ASR,ASR 返回文本后拼上历史对话送给 LLM,LLM 流式返回文本,按标点切句送给 TTS,TTS 合成完一段推一段。这套方案在“问答”场景下能用,但一旦用户说话稍微长一点、带点犹豫、或者中途改口,体验就崩了。最典型的问题是:用户说“帮我查一下明天……算了还是查后天吧”,ASR 把整句识别完,LLM 收到的是完整文本,它得自己判断用户到底要查哪天。而真人对话里,对方在你说到“算了”的时候就已经知道你要改口了。
所以这篇文章想聊的不是“怎么把三个模块接起来”,而是“为什么接起来之后不像人”,以及如果要往“活人感”方向走,需要在哪些地方动刀。适合已经跑通过基础语音链路、正在被体验问题困扰的开发者,也适合刚接触语音交互、想少走弯路的朋友。
2. 串行链路的三个结构性缺陷
2.1 回合制交互与真人对话的根本冲突
串行链路最核心的假设是“用户说完→系统响应”这个回合制模型。这个假设在命令式场景下成立,比如“打开客厅灯”,但在自然对话里几乎不成立。真人对话是重叠的、增量的、可打断的。你说话的时候我在听,但我不是等你说完才开始处理,而是一边听一边理解,一边给出反馈信号。
串行链路里,ASR 必须等到 VAD 判定一句话结束才输出最终结果。VAD 的判定逻辑通常是“静音超过 300ms 到 800ms 认为说完”,这个阈值本身就是个两难:设短了,用户稍微停顿就被切断;设长了,响应延迟明显。更麻烦的是,用户说“嗯……让我想想”这种填充语时,VAD 可能把它当成一句话结束,系统就开始响应了,而用户其实还在组织语言。
我实测过几个主流方案的 VAD 静音阈值,在安静环境下 500ms 左右比较平衡,但在嘈杂环境或者用户说话带犹豫时,这个值需要动态调整。问题是,串行链路里 VAD 是独立模块,它不知道 LLM 那边在等什么,也不知道 TTS 那边准备好了没有,它只能按固定规则切分。
2.2 模块间信息孤岛导致的“机械感”
ASR 输出的是文本,LLM 收到的是文本,TTS 收到的是文本。文本这个中间表示丢掉了太多信息。ASR 在识别时其实知道很多额外信息:每个词的置信度、音频的能量变化、语速、停顿位置。这些信息在串行链路里全被扔掉了,因为下游模块只接收文本。
举个例子,用户说“我要买一张去北京的票”,ASR 可能对“北京”这个词的置信度是 0.95,对“票”的置信度是 0.7。如果 LLM 知道“票”的置信度不高,它可以在回复里主动确认“您是要买票对吗”,而不是直接假设用户说的是“票”。但串行链路里 LLM 看不到置信度,它只能基于文本做判断。
TTS 这边也一样。LLM 生成的回复文本里,哪些词应该重读、哪里应该停顿、语速应该快还是慢,这些在纯文本里是没有的。TTS 只能按默认韵律合成,听起来就是“机器念稿”。真人说话时,重音和停顿是跟着语义走的,而语义信息在 LLM 那边,TTS 拿不到。
2.3 延迟叠加与“抢话”难题
串行链路的延迟是累加的:VAD 等待静音(300-800ms)+ ASR 识别(200-500ms)+ LLM 首 token 生成(300-800ms)+ TTS 首帧合成(100-300ms)。加起来轻松超过 1 秒,用户感知到的就是“我说完它愣了一会儿才回”。
更麻烦的是“抢话”问题。真人对话里,对方经常在你还没说完的时候就接话了,这叫“反馈性接话”。串行链路做不到这一点,因为 ASR 还没输出最终结果,LLM 根本没东西可处理。有些方案尝试用“部分 ASR 结果”提前送给 LLM,但部分结果不稳定,LLM 可能基于错误文本生成错误回复,然后用户听到一半发现系统理解错了,体验更差。
我试过一个折中方案:ASR 流式输出中间结果,LLM 同时开始处理,但只生成“确认性”的短回复,比如“嗯”“好的”“我看看”,等 ASR 最终结果出来再生成正式回复。这个方案能缓解“抢话”问题,但需要 LLM 支持两种模式的切换,实现复杂度不低。
3. 让语音有“活人感”的四个关键改造点
3.1 从“回合制”到“增量式”的架构调整
要解决回合制的问题,核心思路是让 ASR、LLM、TTS 不再是串行的三个独立模块,而是共享一个“对话状态”的协作系统。具体来说,ASR 流式输出中间结果,LLM 持续接收这些中间结果并维护一个“当前理解”的状态,TTS 则根据 LLM 的状态决定什么时候开始合成、合成什么。
这个架构里,VAD 的角色变了。它不再是一个“切分器”,而是一个“信号源”,持续输出“当前是否有语音活动”“语音能量变化”等信息。LLM 根据这些信息决定是否要给出反馈音、是否要开始生成回复。
我画过一个简单的状态机来描述这个过程:用户开始说话时,系统进入“倾听”状态,ASR 流式输出,LLM 维护一个“部分理解”;用户停顿超过阈值时,系统进入“思考”状态,LLM 基于当前理解生成回复;用户重新开始说话时,系统回到“倾听”状态,如果 LLM 已经在生成回复,则根据新输入决定是继续、修正还是放弃。
这个状态机的关键在于“部分理解”的维护。LLM 需要能够处理不完整的输入,并且知道哪些部分是确定的、哪些部分是推测的。这需要 LLM 具备一定的“增量理解”能力,或者在外层加一个专门的模块来处理。
3.2 用“反馈音”和“填充词”制造对话节奏
真人对话里,听的一方不是沉默的。你会听到“嗯”“对”“然后呢”这样的反馈音,这些声音告诉说话的人“我在听”“你继续”。语音系统要有人感,必须加入这些反馈音。
实现上,可以在 ASR 检测到用户持续说话时,让 TTS 合成一个短促的“嗯”或“好的”插入到对话中。这个反馈音不需要等 LLM 生成,可以是一个预置的音频片段,根据用户说话的时长和语调选择不同的反馈音。
填充词则是另一回事。当 LLM 需要较长时间思考时,可以先让 TTS 说“让我想想”“那个……”来填补空白。这些填充词的选择也有讲究:如果用户问的是一个简单问题,用“嗯”就够了;如果是一个复杂问题,用“让我想想”更自然。
我实测下来,反馈音和填充词对“活人感”的提升非常明显。用户调研里,加了反馈音的版本在“自然度”评分上比不加的版本高了将近 40%。但要注意,反馈音不能太频繁,否则会显得敷衍。我的经验是,用户连续说话超过 3 秒时给一个反馈音,之后每隔 5-8 秒再给一个,比较自然。
3.3 韵律和情感信息的跨模块传递
TTS 要听起来像人,不能只靠文本。LLM 在生成回复时,其实知道哪些词重要、哪里应该停顿、语气应该是什么。这些信息需要传递给 TTS。
一个可行的方案是让 LLM 输出带标注的文本,比如用 SSML 或者自定义的标记语言。但 SSML 太笨重,LLM 生成起来容易出错。更轻量的做法是在文本里插入简单的控制符,比如用“|”表示短停顿,“||”表示长停顿,用“*”包裹需要重读的词。
TTS 这边需要支持这些控制符。现在很多神经网络 TTS 方案都支持韵律控制,比如通过调整音素时长、基频曲线来实现。如果 TTS 不支持,也可以在文本预处理阶段把这些控制符转换成 SSML 或者直接调整音频。
另一个思路是让 LLM 输出一个“情感标签”,比如“平静”“兴奋”“抱歉”,TTS 根据这个标签选择不同的音色或韵律模板。这个方案实现简单,但粒度比较粗,适合对情感要求不高的场景。
3.4 打断处理与对话修复
真人对话里,打断是常态。你说话的时候我可能插话,我说话的时候你可能打断我。语音系统要有人感,必须能处理打断。
打断处理的核心是“快速停止”和“状态保存”。当 ASR 检测到用户在系统说话时开始说话,TTS 需要立即停止播放,LLM 需要保存当前生成的状态,然后根据用户的新输入决定是继续之前的回复还是重新生成。
这里有个细节:TTS 停止播放时,不能硬切,否则会有“咔”的一声。需要在音频缓冲区里做一个淡出,大概 50-100ms 就够了。LLM 保存状态时,需要记录已经生成了哪些内容、当前在哪个位置,以便后续继续。
对话修复则是另一个层面。如果用户打断后说“不是这个意思”,系统需要能够回退到之前的某个状态,重新理解用户意图。这需要 LLM 维护一个对话历史,并且能够根据用户的修正指令回退。
我踩过的一个坑是:打断后 LLM 直接重新生成,结果用户听到的是完全不同的回复,之前说的半句话被丢弃了。后来改成“保存状态+增量修正”,用户打断后系统会说“抱歉,您是说……”,然后基于修正后的理解继续,体验好很多。
4. 实操:从零搭建一个“有活人感”的语音对话原型
4.1 环境准备与工具选型
先说工具选型。ASR 我用的是流式方案,支持中间结果输出,延迟在 200ms 左右。LLM 用的是支持流式生成的开源模型,7B 参数级别,在消费级显卡上能跑。TTS 用的是神经网络方案,支持韵律控制,首帧合成延迟控制在 150ms 以内。
具体配置如下:
| 模块 | 选型 | 关键参数 | 延迟目标 |
|---|---|---|---|
| ASR | 流式识别模型 | 中间结果间隔 200ms | <300ms |
| LLM | 7B 指令微调模型 | 流式输出,首 token <500ms | <800ms |
| TTS | 神经网络 TTS | 支持 SSML 韵律控制 | <200ms |
| VAD | 能量+过零率 | 动态阈值,静音 400ms | <50ms |
硬件方面,我用了一张 12G 显存的显卡,ASR 和 TTS 跑在 CPU 上,LLM 跑在 GPU 上。如果显存不够,LLM 可以量化到 4bit,性能损失大概 10% 左右,但显存占用能降到 6G 以下。
4.2 核心代码:流式 ASR 与 LLM 的对接
流式 ASR 的输出是一个生成器,每次 yield 一个中间结果。LLM 需要持续消费这些中间结果,并维护一个“当前理解”的状态。下面是一个简化的实现:
class DialogueState: def __init__(self): self.partial_text = "" self.final_text = "" self.is_user_speaking = False self.last_update_time = 0 def update_partial(self, text): self.partial_text = text self.last_update_time = time.time() def finalize(self, text): self.final_text = text self.partial_text = "" self.is_user_speaking = False async def asr_to_llm(asr_stream, llm_client, state): async for result in asr_stream: if result.is_final: state.finalize(result.text) response = await llm_client.generate(state.final_text) yield response else: state.update_partial(result.text) if should_give_feedback(state): yield FeedbackSignal("嗯")这段代码的关键在于should_give_feedback函数。它根据用户说话的时长、上次反馈的时间间隔来决定是否插入反馈音。我的实现是:用户连续说话超过 3 秒,且距离上次反馈超过 5 秒,就触发一次反馈。
4.3 反馈音与填充词的触发逻辑
反馈音的触发逻辑需要结合 VAD 的输出。VAD 持续输出“当前是否有语音”“语音能量”“静音时长”等信息。当检测到用户持续说话时,启动一个计时器,超过阈值就触发反馈音。
class FeedbackController: def __init__(self, min_speech_duration=3.0, min_interval=5.0): self.min_speech_duration = min_speech_duration self.min_interval = min_interval self.speech_start_time = None self.last_feedback_time = 0 def on_speech_start(self): self.speech_start_time = time.time() def on_speech_end(self): self.speech_start_time = None def should_feedback(self): if self.speech_start_time is None: return False duration = time.time() - self.speech_start_time if duration < self.min_speech_duration: return False if time.time() - self.last_feedback_time < self.min_interval: return False self.last_feedback_time = time.time() return True填充词的触发则是在 LLM 开始生成但首 token 还没出来的时候。可以设置一个超时,比如 500ms 内没有首 token,就触发一个填充词。
4.4 韵律标注的生成与 TTS 对接
LLM 生成回复时,需要同时生成韵律标注。我用的方案是在 prompt 里要求 LLM 在文本中插入控制符。比如:
请生成回复,并在适当位置插入韵律标记: - 用 | 表示短停顿(200ms) - 用 || 表示长停顿(500ms) - 用 *词* 表示重读LLM 输出的文本可能长这样:“好的,我|帮您查一下||明天下午的天气。”
TTS 这边需要解析这些控制符。如果 TTS 支持 SSML,可以转换成 SSML;如果不支持,可以在文本预处理阶段把控制符转换成音素级别的时长调整。
def parse_prosody(text): # 将控制符转换为 SSML text = text.replace("||", '<break time="500ms"/>') text = text.replace("|", '<break time="200ms"/>') # 处理重读 import re text = re.sub(r'\*(.+?)\*', r'<emphasis level="strong">\1</emphasis>', text) return text实测下来,加了韵律标注的 TTS 输出在自然度上明显好于不加的版本。特别是长句子里,停顿的位置对了,听起来就不像机器在念稿。
5. 常见问题与排查技巧实录
5.1 反馈音太频繁或太稀疏怎么办
反馈音的频率是个需要调参的事情。太频繁了显得敷衍,太稀疏了又显得冷漠。我的经验是,初始值设成“用户连续说话 3 秒触发第一次,之后每 5 秒触发一次”,然后根据用户调研微调。
如果用户反馈“太吵”,可以把间隔拉长到 8 秒,或者只在用户说话超过 5 秒时才触发。如果用户反馈“没反应”,可以把首次触发时间缩短到 2 秒。
还有一个技巧是根据用户说话的语速动态调整。语速快的用户,反馈间隔可以短一点;语速慢的用户,间隔长一点。语速可以通过 ASR 输出的词间时间间隔来估算。
5.2 LLM 基于部分 ASR 结果生成错误回复
这是增量式架构里最常见的问题。ASR 的中间结果不稳定,LLM 可能基于错误文本生成回复,然后用户听到一半发现理解错了。
解决方案有几个。一是让 LLM 只基于“稳定”的部分结果生成,比如只处理已经确认的词,不确定的词用占位符代替。二是让 LLM 生成“确认性”回复而不是“正式”回复,比如“您是说……对吗”,等最终结果出来再生成正式回复。三是在 LLM 外层加一个“一致性检查”,如果最终结果和部分结果差异太大,就丢弃之前的生成重新来。
我用的方案是第二种加第三种:LLM 先基于部分结果生成一个“草稿”,同时启动一个计时器,如果 500ms 内 ASR 输出了最终结果,就基于最终结果重新生成;如果超时了,就把草稿作为正式回复输出。
5.3 TTS 打断后的音频残留问题
TTS 打断时,如果直接停止播放,音频缓冲区里可能还有未播放的数据,导致“咔”的一声或者残留几个音节。解决方案是在停止前做一个淡出。
具体实现是在音频缓冲区里,从当前位置往后取 100ms 的数据,乘以一个线性衰减的窗函数,然后播放这 100ms 的淡出音频,再停止。这样听起来就是自然停止,没有突兀感。
def fade_out(audio_buffer, fade_duration_ms=100): fade_samples = int(sample_rate * fade_duration_ms / 1000) fade_window = np.linspace(1, 0, fade_samples) audio_buffer[-fade_samples:] *= fade_window return audio_buffer5.4 对话状态丢失与修复
打断后对话状态丢失是另一个常见问题。用户打断后说“不是这个意思”,系统需要知道“之前的意思”是什么,才能理解“这个意思”指的是什么。
我的做法是在 LLM 的对话历史里维护一个“状态栈”。每次 LLM 生成回复时,把当前的对话状态压栈。用户打断并修正时,从栈里弹出最近的状态,基于修正后的输入重新生成。
这个方案的问题是栈会越来越深,需要设置一个上限,比如保留最近 5 个状态。另外,状态的表示也需要设计,不能只是简单的文本历史,还要包括“当前意图”“已确认信息”“待确认信息”等结构化数据。
5.5 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 反馈音太频繁 | 触发间隔太短 | 检查 FeedbackController 参数 | 拉长 min_interval |
| LLM 回复与用户意图不符 | 部分 ASR 结果不稳定 | 对比部分结果与最终结果 | 加一致性检查或延迟生成 |
| TTS 打断有杂音 | 音频缓冲区未淡出 | 检查 fade_out 是否生效 | 加 100ms 淡出 |
| 对话状态丢失 | 状态栈未维护 | 检查状态保存逻辑 | 加状态栈,限制深度 |
| 响应延迟高 | 各模块串行等待 | 打点测量各模块耗时 | 改增量式,并行处理 |
| 韵律标注不生效 | TTS 不支持控制符 | 检查 TTS 输入解析 | 转 SSML 或预处理 |
6. 一些实操心得和后续可以折腾的方向
这套增量式架构我跑了大概三个月,迭代了七八个版本,最大的体会是:“活人感”不是靠某个模块做出来的,而是靠模块之间的协作“涌现”出来的。单独看 ASR、LLM、TTS,每个模块都很成熟,但串在一起就是没有人的感觉。问题不在模块本身,而在模块之间的“缝隙”里。
另一个体会是,反馈音和填充词的效果远超预期。我一开始觉得这些是“花架子”,但用户调研里,加了反馈音的版本在“自然度”和“愿意继续使用”两个指标上都有显著提升。后来想明白了:真人对话里,这些声音占了很大比例,它们传递的是“我在听”“我在想”这样的社交信号,没有这些信号,对话就变成了“命令-响应”,而不是“交流”。
后续可以折腾的方向有几个。一是把情感识别加进来,根据用户的语气调整回复的情感。二是把视觉信息加进来,比如用户的表情、手势,这些在视频通话场景下能大幅提升“活人感”。三是把个性化做深,不同用户喜欢不同的反馈风格,有的喜欢“嗯嗯”这种简短的,有的喜欢“好的我明白了”这种完整的,可以根据用户历史行为自适应。
最后分享一个小技巧:反馈音的音色最好和 TTS 的主音色一致,否则听起来像两个人。我一开始用了一个预置的“嗯”音频,和 TTS 音色不匹配,听起来很怪。后来改成用 TTS 实时合成“嗯”,虽然多了一点延迟,但自然度好很多。如果延迟敏感,可以预生成一批不同音色的反馈音,根据 TTS 音色选择匹配的。