核心课程 / 自然语言交互应用 / EXP-04
EXP-04

端到端语音对话:谁决定它什么时候听、什么时候说

前三页分别解决了「叫醒」「听清」「会说」。这一页把它们串成一轮对话: 状态机决定秩序,模型决定内容。实测还会看到小模型的两个真实缺陷 —— 答不出刚说过的名字、把时间编成三年前。

⭐ 4 学时VoiceLoop 状态机Qwen3.5-0.8B流式回复打断

01目标:跑通一轮「叫醒—说话—回应」,并看懂它为什么卡

给你
键盘输入:小飞小飞 → 我叫王大爷 → 我叫什么名字?
拿到
> [4.8s] 你好!我是小飞…
> [3.7s] 那您就是王大爷了,那小飞呢?
> [4.2s] 那您就是王大爷了,那小飞呢?

上面三行是板上真跑的。注意第三行 —— 它没回答“你叫王大爷”,而是把上一句又重复了一遍。 这不是打字错误,是这一页最重要的现象,第 04 节会解释它为什么发生。

学完你该能做出这三件事

  • 跑通一轮对话:先说唤醒词、再提问、看到带耗时的回复。
  • 说清五个状态谁转到谁、什么条件转 —— 以及“为什么必须先唤醒”。
  • 判断一次回答变差该怪状态机还是怪模型,并给出验证方法。
⚠ 重难点 直觉是“回答不好就是模型不行”。实际是要分两层看:状态机管“该不该听、该不该说”, 模型只管“说什么”。这次实测里,第三句复读是模型的问题(上下文没被利用), 而“对麦克风说话没反应”是状态机的问题(还在 IDLE 等唤醒)。

🔑 一句话:对话 = 状态机保证秩序 + 模型负责内容,两者的故障现象完全不同。

02问题:能听会说,为什么还是接不上一句话

把 ASR 和 TTS 各自跑通之后,你会遇到一串新问题:它一边播报一边把你的话听进去、 你打断它它还在念、问第二遍它忘了第一遍。这些都不是识别或合成的问题。

疑问 1
它怎么知道什么时候该听?

麦克风一直在采,但只有被唤醒之后的话才算指令。这个“算不算”由谁判定?

疑问 2
播报时听见自己怎么办?

TTS 正在响,麦克风也在收音,它会不会把自己的话当成用户指令?

疑问 3
为什么第二问它记不住第一句?

配置里明明有历史,为什么还是不回答“你叫王大爷”?

先猜一下 三个疑问里,哪一个的修复点在状态机而不在模型?记住你的选择,第 04 节步骤 6 后面有答案。

🔑 一句话:这些问题的共同点是“时序”,不是“内容”。

03现象:真机上跑出的三轮对话

🎬 现象速览 · 30 秒先看效果

启动时会先拉起本地大模型服务(首次加载 30~60 秒),就绪后每输入一句回一行 > [耗时] 回复。 下面是板上的完整输出,包含启动那两行。

[llm_mgr] 拉起 LLM 服务: …/llm/llm_server.py --rkllm_model_path …/Qwen3.5-0.8B_w4a16_g128_rk3576.rkllm --port 8080(首次加载 30-60s)
[llm_mgr] LLM 服务就绪: http://127.0.0.1:8080
LLM 就绪: http://127.0.0.1:8080(输入 exit 退出)
> [4.8s] 你好!我是小飞,一个由人工智能构成的智能助手。👋
> [3.7s] 那您就是王大爷了,那小飞呢?
> [4.2s] 那您就是王大爷了,那小飞呢?

另一次单发提问(第一句,含模型加载),耗时 25.4 秒,回答里还带了一句编造的日期:

> [25.4s] 你好!我是 Qwen3.5… 现在大约是 2024 年 10 月 27 日,星期四,凌晨 3:45 左右。…😊

怎么跑出来

先说唤醒词,再说话。这类脚本的日志写文件,屏幕只看得到回复行:

终端一 · 起对话

cd ~/ros2_ws/src/xflz_eai_car1/app/elderly_companion
python3 apps/llm_chat_test.py --config config/fall_config.yaml
LLM 就绪: http://127.0.0.1:8080(输入 exit 退出) ← 然后依次输入:小飞小飞 / 我叫王大爷 / 我叫什么名字?

🔍 看什么:三处。

  • 看方括号里的耗时 —— 首句 25.4 s 是因为现场加载模型;就绪后稳定在 4~5 s。
  • 看第三行与第二行是否相同 —— 相同就说明它没利用历史,这是模型层缺陷。
  • 看有没有回答“时间” —— 编造 2024 年,这是幻觉,模型没有任何时间来源。

🔑 一句话:现象里有两条故障线 —— 一条是响应慢(工程),一条是答得不对(模型)。

04原理:五个状态与六个格子

CORE IDEA · 一句话原理

把对话想成一条流水线:只有“轮到谁”是固定的,说了什么每次都不同。 状态机负责“现在该听还是该说”,模型负责“这一轮说什么”。像客服接电话 —— 话术不同,但“接听、记录、回应”的顺序永远一样。

② 的三个疑问在 04 的哪几格回答
疑问 1 什么时候该听步骤 1~2(IDLE → LISTENING)
疑问 2 播报时听见自己步骤 5~6(SPEAKING 里不喂 ASR + 播完 drain)
疑问 3 记不住上一句步骤 3(历史拼进 messages,但模型可能不用)
三个状态 + 六个处理格:状态决定格子的执行顺序 IDLE 等唤醒 LISTENING 听你说 SPEAKING 播报中 唤醒词 回复就绪 播完 + 双次 drain 防回声 → 回 IDLE 1 IDLE:只喂 KWS,说的话不算指令 形态:一帧帧音频 → 是/否唤醒 2 LISTENING:音频同时喂 ASR 形态:音频 → 一行文本(feed_text 与音频同一路由) 3 拼 messages:system + 最近 6 条历史 + 本句 形态:消息数组(max_history: 6、max_tokens: 200) 4 模型流式吐 token,屏幕边收边打 形态:token 流(实测一句 3.7~4.8 秒) 5~6 逐句切分送 TTS 播报;被打断则 epoch+1 丢弃迟到回复 形态:声音 → 回 IDLE
图 4-1|状态机管前三格与最后一格,模型只管第 4 格;epoch 是打断计数,用来丢掉过期回复。

1为什么没说唤醒词,它就完全不理我?

吃什么:每一帧音频。做什么:IDLE 状态下只把它喂给唤醒词检测,不进 ASR。 吐什么:“是/否唤醒”。动参数会怎样:把 kws.enabled 关掉,就必须靠硬件唤醒事件,纯文本调试则要先输入唤醒词。

实测就是这条规则的直接后果:不加“小飞小飞”那一行,后面两句一条回复都没有。

2唤醒之后,文本和音频走同一条路?

吃什么:唤醒后的音频。做什么:音频走 ASR;调试时用 feed_text 直接塞文本 —— 两者进入同一个处理函数。 吐什么:一行文本。动参数会怎样:所以纯文本调试能完整验证后面的逻辑,不用麦克风。

3“有历史”和“会用历史”是一回事吗?

吃什么:本句 + 历史消息。做什么:按 max_history: 6 截断后拼成 messages 发给模型。 吐什么:请求体。动参数会怎样:改小 → 更快但更容易忘;改大 → 上下文更长、更慢。

关键:历史被送进模型,不等于模型使用了它。实测第三句复读,就是历史在上下文里但模型没提取。

4为什么每句要 4 秒,第一句要 25 秒?

吃什么:请求体。做什么:本地 RKLLM 推理并流式返回 token。 吐什么:token 流。动参数会怎样:首句慢是加载模型(实测 25.4 s 含启动);就绪后 3.7~4.8 s 是纯推理。

5播报时它会不会把自己听进去?

吃什么:模型回复。做什么:按句切分,交给 TTS 播报,状态切到 SPEAKING。 吐什么:声音。动参数会怎样:SPEAKING 期间不把音频喂 ASR,并在播完做双次 drain 清掉回声尾巴。

6我说“停”它还在念,怎么做到的?

吃什么:打断信号。做什么:epoch 加一,正在等待的回复带着旧 epoch 回来时被丢弃,TTS 停止。 吐什么:当前回复作废,回 IDLE 重新听。

揭晓答案(疑问 3 的前半段) 第 02 节里「修复点在状态机」的是疑问 2:播报时听见自己的声音,靠状态机(不喂 ASR + drain)解决, 换模型没有用。

每格答全的五列表

格吃什么做什么用什么吐出动参数会怎样
1音频帧判定是否唤醒WakeWordDetector / 硬件 XFM 事件是/否关 KWS → 只能靠唤醒词输入
2音频或文本识别成句并路由feed_text / SpeechRecognizer一行文本切 DIALOG/ASK 模式路由不同
3文本 + 历史拼 messagesLLMChat(max_history)请求体历史长度影响速度与记忆
4请求体本地推理RKLLM Qwen3.5-0.8B(:8080)token 流max_tokens 限长
5回复逐句合成播放Speaker.say_stream_chunk声音语速影响播报时长
6打断信号作废旧回复epoch 比对回 IDLE不清 epoch → 迟到的回复会插话
术语一句话什么时候失效
状态机规定“现在该听还是该说”的规则表多线程下未加锁会两个状态同时成立
历史窗口送进模型的最近 N 条消息模型忽略历史时,窗口再大也没用
epoch打断计数器,用来识别过期回复不比对就会把旧回复播出来

🔑 一句话:状态机管“该不该”,模型管“说什么” —— 故障先分这两层,再谈修。

05实践:三步跑通一条对话链

  1. 确认配置与模型 约 5 分钟 看 llm.enabled 是否为 true、model_path 指向 ~/models/rkllm/…rkllm。
  2. 起对话并问第一句 约 10 分钟 启动后等“LLM 服务就绪”,先发唤醒词再提问,记录方括号里的耗时。
  3. 做一次记忆实验 约 15 分钟 先报名字、再问名字,看它能不能答上;答不上,再换更直白的问法验证是模型问题。

对账表:命令 → 函数 → 文件

格命令 / 配置函数或变量文件
1kws.enabledWakeWordDetectorspeech/kws.py
2feed_text()VoiceLoop.feed_textspeech/voice_loop.py
3llm.chat.max_history: 6LLMChatllm/chat.py
4llm.server.port: 8080 / model_pathLLMServerManager.ensure_runningllm/server_manager.py
5speech.tts.model_dirSpeaker.say_stream_chunkspeech/tts.py
6(代码内)self._epochspeech/voice_loop.py:106

踩坑速查

学员原话原因怎么办
“输入了话,一句回复都没有”没先说唤醒词,状态还在 IDLE先输入 小飞小飞 或 小飞同学
“等了半分钟才出第一句”首次要现场加载 rkllm 模型(30~60 s)正常;服务起来后单句 4 s 左右
“它答的时间是错的”模型没有时间来源,prompt 里也没给要准就把当前时间拼进 system_prompt
“它把我上一句重复了一遍”0.8B 模型没利用历史换更直白的问法;或把关键信息复述进本句
“端口 8080 被占”上次的 llm_server 没退先关占用进程,或把 llm.server.port 改空自动选
🔁 现象呼应 回到第 03 节那三行:第二行与第三行完全相同,正是「历史被送进模型、但模型没用」的现场。 第 04 节步骤 3 讲的就是这条界线。

🧪 Vibe Coding 复盘

想要提示词验收判据
模块职责「这个文件里状态迁移写在哪几个方法?列出每个方法读写的状态变量」点出 self.state 与 self._epoch
控制流「画出 IDLE → LISTENING → SPEAKING 的判定分支,标出哪些分支会提前 return」三条边 + 打断分支
时序「画出一次对话的时序:用户、状态机、ASR/LLM/TTS 各自的顺序与阻塞点」要说清 SPEAKING 期间不喂 ASR
AI 常见说法源码实际
“识别到唤醒词就开始录音”是状态切换 + 音频同时喂多个消费者
“历史越长记得越牢”历史有 6 条上限,且模型可能忽略
“打断就是把 TTS 关掉”还要 epoch+1,否则迟到回复会插话

🔑 一句话:先看“有没有进到 LISTENING”,再看“模型答得对不对” —— 顺序反过来会白查半小时。

06交互:走一遍状态机

📌 掌握检查:点“打断”之后,那条正在生成的回复会怎样?先说出你的答案,再点。

互动 6-1 · 对话状态机步进台
初始 IDLE:此时麦克风在采,但说的话不算指令。

按这个顺序点,三个疑问都会被演一遍:

  • 直接点「说一句话」→ 提示被忽略(还在 IDLE,对应疑问 1)。
  • 「说唤醒词」→「说一句话」→ 进入 SPEAKING,此时再点「说一句话」不会打断播报(对应疑问 2)。
  • 播报中「打断它」→ epoch 从 0 变 1,日志出现“旧回复已作废”(对应疑问 3 的机制面)。

🔑 一句话:IDLE 只认唤醒词,SPEAKING 只认打断,其余输入一律丢掉。

07验证:三道题

Q1(原理)没先说唤醒词就提问,为什么没有回复?

实测加一行唤醒词后同样的问法立刻有回复。

Q2(实践)第一句要 25 秒、后面每句 4 秒,差别在哪?

日志里“拉起 LLM 服务…首次加载 30-60s”那行就是证据。

Q3(边界)它把上一句重复了一遍,说明什么?

配置里 max_history: 6 生效,问题在模型侧,不在链路。

✅ 目标对账

① 的目标对应题号
跑通一轮对话Q1(唤醒前置)
说清五个状态谁转谁Q1 + 互动 6-1
判断该怪状态机还是怪模型Q2(工程)/ Q3(模型)

🔑 一句话:三题分别考「为什么不听 / 为什么慢 / 为什么答错」。

08总结:状态机管秩序,模型管内容

知识点关系表

概念它解决了什么和别的关系
IDLE挡住无关语音是 ASR 的前置闸门
LISTENING把音频变成一句文本接 EXP-02 的识别链
SPEAKING避免自问自答与 TTS 播报时长耦合
历史窗口让复述、追问成为可能受模型能力限制
epoch丢弃过期回复打断功能的实现基础

两个反直觉

  • 直觉是“有历史就会记得”,实际是历史只是被送进模型,用不用取决于模型能力。
  • 直觉是“说话就能打断”,实际是SPEAKING 期间音频不喂 ASR,只有专门的打断通道才生效。

注意事项

  • 数值陷阱:25.4 s 与 4.8 s 不是同一个量 —— 前者含加载,后者是纯推理,不能拿来算平均。
  • 要让它报时,必须把时间拼进 prompt;模型自身没有时钟。
  • max_history 改大不会让 0.8B 变聪明,只会变慢。
  • 端口冲突会让服务起不来,日志里是“端口已被占用”,不是模型错误。

前置依赖

类别具体缺了会怎样
硬件放音声卡(本板 card 3);说话要麦克风阵列能回文字但听不见
模型~/models/rkllm/Qwen3.5-0.8B_w4a16_g128_rk3576.rkllm服务起不来,一直等待就绪
库rkllm 运行时 + sherpa-onnx(ASR/TTS)导入失败或推理报错
配置llm.enabled: true、port: 8080、max_history: 6关着则自由文本被忽略
环境能起本地端口的权限、有写日志目录的权限服务无法监听或日志写入失败

这套东西用在哪儿

  • 陪伴机器人日常对话:老人问天气、问药名,先唤醒再答。
  • 展厅讲解:观众插话即打断,播报立刻停,重新听下一个问题。
  • 跌倒告警的问询环节:播报“您还好吗”,进入 ASK 窗口等“救命/没事”两类回答。

🔑 记忆点:先问“它有没有在听”,再问“它答得对不对” —— 这两件事分别对应状态机与模型。