端到端语音对话:谁决定它什么时候听、什么时候说
前三页分别解决了「叫醒」「听清」「会说」。这一页把它们串成一轮对话: 状态机决定秩序,模型决定内容。实测还会看到小模型的两个真实缺陷 —— 答不出刚说过的名字、把时间编成三年前。
01目标:跑通一轮「叫醒—说话—回应」,并看懂它为什么卡
> [3.7s] 那您就是王大爷了,那小飞呢?
> [4.2s] 那您就是王大爷了,那小飞呢?
上面三行是板上真跑的。注意第三行 —— 它没回答“你叫王大爷”,而是把上一句又重复了一遍。 这不是打字错误,是这一页最重要的现象,第 04 节会解释它为什么发生。
学完你该能做出这三件事
- 跑通一轮对话:先说唤醒词、再提问、看到带耗时的回复。
- 说清五个状态谁转到谁、什么条件转 —— 以及“为什么必须先唤醒”。
- 判断一次回答变差该怪状态机还是怪模型,并给出验证方法。
🔑 一句话:对话 = 状态机保证秩序 + 模型负责内容,两者的故障现象完全不同。
02问题:能听会说,为什么还是接不上一句话
把 ASR 和 TTS 各自跑通之后,你会遇到一串新问题:它一边播报一边把你的话听进去、 你打断它它还在念、问第二遍它忘了第一遍。这些都不是识别或合成的问题。
麦克风一直在采,但只有被唤醒之后的话才算指令。这个“算不算”由谁判定?
TTS 正在响,麦克风也在收音,它会不会把自己的话当成用户指令?
配置里明明有历史,为什么还是不回答“你叫王大爷”?
🔑 一句话:这些问题的共同点是“时序”,不是“内容”。
03现象:真机上跑出的三轮对话
启动时会先拉起本地大模型服务(首次加载 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
🔍 看什么:三处。
- 看方括号里的耗时 —— 首句 25.4 s 是因为现场加载模型;就绪后稳定在 4~5 s。
- 看第三行与第二行是否相同 —— 相同就说明它没利用历史,这是模型层缺陷。
- 看有没有回答“时间” —— 编造 2024 年,这是幻觉,模型没有任何时间来源。
🔑 一句话:现象里有两条故障线 —— 一条是响应慢(工程),一条是答得不对(模型)。
04原理:五个状态与六个格子
把对话想成一条流水线:只有“轮到谁”是固定的,说了什么每次都不同。 状态机负责“现在该听还是该说”,模型负责“这一轮说什么”。像客服接电话 —— 话术不同,但“接听、记录、回应”的顺序永远一样。
| ② 的三个疑问 | 在 04 的哪几格回答 |
|---|---|
| 疑问 1 什么时候该听 | 步骤 1~2(IDLE → LISTENING) |
| 疑问 2 播报时听见自己 | 步骤 5~6(SPEAKING 里不喂 ASR + 播完 drain) |
| 疑问 3 记不住上一句 | 步骤 3(历史拼进 messages,但模型可能不用) |
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 重新听。
每格答全的五列表
| 格 | 吃什么 | 做什么 | 用什么 | 吐出 | 动参数会怎样 |
|---|---|---|---|---|---|
| 1 | 音频帧 | 判定是否唤醒 | WakeWordDetector / 硬件 XFM 事件 | 是/否 | 关 KWS → 只能靠唤醒词输入 |
| 2 | 音频或文本 | 识别成句并路由 | feed_text / SpeechRecognizer | 一行文本 | 切 DIALOG/ASK 模式路由不同 |
| 3 | 文本 + 历史 | 拼 messages | LLMChat(max_history) | 请求体 | 历史长度影响速度与记忆 |
| 4 | 请求体 | 本地推理 | RKLLM Qwen3.5-0.8B(:8080) | token 流 | max_tokens 限长 |
| 5 | 回复 | 逐句合成播放 | Speaker.say_stream_chunk | 声音 | 语速影响播报时长 |
| 6 | 打断信号 | 作废旧回复 | epoch 比对 | 回 IDLE | 不清 epoch → 迟到的回复会插话 |
| 术语 | 一句话 | 什么时候失效 |
|---|---|---|
| 状态机 | 规定“现在该听还是该说”的规则表 | 多线程下未加锁会两个状态同时成立 |
| 历史窗口 | 送进模型的最近 N 条消息 | 模型忽略历史时,窗口再大也没用 |
| epoch | 打断计数器,用来识别过期回复 | 不比对就会把旧回复播出来 |
🔑 一句话:状态机管“该不该”,模型管“说什么” —— 故障先分这两层,再谈修。
05实践:三步跑通一条对话链
- 确认配置与模型 约 5 分钟
看
llm.enabled是否为true、model_path指向~/models/rkllm/…rkllm。 - 起对话并问第一句 约 10 分钟 启动后等“LLM 服务就绪”,先发唤醒词再提问,记录方括号里的耗时。
- 做一次记忆实验 约 15 分钟 先报名字、再问名字,看它能不能答上;答不上,再换更直白的问法验证是模型问题。
对账表:命令 → 函数 → 文件
| 格 | 命令 / 配置 | 函数或变量 | 文件 |
|---|---|---|---|
| 1 | kws.enabled | WakeWordDetector | speech/kws.py |
| 2 | feed_text() | VoiceLoop.feed_text | speech/voice_loop.py |
| 3 | llm.chat.max_history: 6 | LLMChat | llm/chat.py |
| 4 | llm.server.port: 8080 / model_path | LLMServerManager.ensure_running | llm/server_manager.py |
| 5 | speech.tts.model_dir | Speaker.say_stream_chunk | speech/tts.py |
| 6 | (代码内) | self._epoch | speech/voice_loop.py:106 |
踩坑速查
| 学员原话 | 原因 | 怎么办 |
|---|---|---|
| “输入了话,一句回复都没有” | 没先说唤醒词,状态还在 IDLE | 先输入 小飞小飞 或 小飞同学 |
| “等了半分钟才出第一句” | 首次要现场加载 rkllm 模型(30~60 s) | 正常;服务起来后单句 4 s 左右 |
| “它答的时间是错的” | 模型没有时间来源,prompt 里也没给 | 要准就把当前时间拼进 system_prompt |
| “它把我上一句重复了一遍” | 0.8B 模型没利用历史 | 换更直白的问法;或把关键信息复述进本句 |
| “端口 8080 被占” | 上次的 llm_server 没退 | 先关占用进程,或把 llm.server.port 改空自动选 |
🧪 Vibe Coding 复盘
| 想要 | 提示词 | 验收判据 |
|---|---|---|
| 模块职责 | 「这个文件里状态迁移写在哪几个方法?列出每个方法读写的状态变量」 | 点出 self.state 与 self._epoch |
| 控制流 | 「画出 IDLE → LISTENING → SPEAKING 的判定分支,标出哪些分支会提前 return」 | 三条边 + 打断分支 |
| 时序 | 「画出一次对话的时序:用户、状态机、ASR/LLM/TTS 各自的顺序与阻塞点」 | 要说清 SPEAKING 期间不喂 ASR |
| AI 常见说法 | 源码实际 |
|---|---|
| “识别到唤醒词就开始录音” | 是状态切换 + 音频同时喂多个消费者 |
| “历史越长记得越牢” | 历史有 6 条上限,且模型可能忽略 |
| “打断就是把 TTS 关掉” | 还要 epoch+1,否则迟到回复会插话 |
🔑 一句话:先看“有没有进到 LISTENING”,再看“模型答得对不对” —— 顺序反过来会白查半小时。
06交互:走一遍状态机
📌 掌握检查:点“打断”之后,那条正在生成的回复会怎样?先说出你的答案,再点。
按这个顺序点,三个疑问都会被演一遍:
- 直接点「说一句话」→ 提示被忽略(还在 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 窗口等“救命/没事”两类回答。
🔑 记忆点:先问“它有没有在听”,再问“它答得对不对” —— 这两件事分别对应状态机与模型。