前言
这个系列前面几篇分别把「零件」跑通了:
- 板载麦克风录音与蓝牙音箱播放
- 中文语音唤醒(KWS)
- RKLLM 在 6 TOPS NPU 上跑本地大模型
- 离线 TTS 从 11 秒压到 0.4 秒
零件能跑,不等于能组成产品。这一篇讲最后一步:把它们串成一个完整的闭环------唤醒、录音、转写、意图、工具、播报、界面,以及在这个过程中踩到的几个真正耗时间的坑。
为避免重复,KWS 原理、RKLLM 部署、TTS 方案选型、麦克风/蓝牙排查都不再展开,只引用结论。本文重点是四件事:
- 主循环怎么编排,延迟花在哪;
- 意图层怎么设计------规则、LLM、以及为什么最后留了一个开关;
- RKLLM 最坑的一个问题:KV 缓存污染;
- 工具层、界面、部署和性能账本。
一、整机结构:一条链路,两类进程
最终形态是「一块板 + 一个麦克风 + 一个音箱 + 一块屏」,全程不联网。
| 模块 | 实现 | 运行位置 | 关键指标 |
|---|---|---|---|
| 唤醒 KWS | sherpa-onnx Zipformer 3.3M | CPU | 阈值 0.25,词表自定义「小智小智」 |
| VAD | Silero VAD | CPU | 端点检测 + 录音后二次切片 |
| ASR | Paraformer-zh int8 | CPU | 装载 9.2s,解码 RTF≈0.26 |
| LLM | Qwen2.5-1.5B(RKLLM W4A16) | NPU(core 4-7) | 装载 2.3~6.5s,约 1100MB |
| TTS | Matcha + Vocos(RKNN) | NPU | 装载 1.5s,首声约 0.4s |
| 界面 | GTK4 + Cairo + Pango | CPU(独立进程) | 1024×600 全屏,40ms 轮询 |
进程结构刻意做成两个:
assistant.py(主进程)
├── 主线程:唤醒 → 录音 → ASR → 意图 → 播报
├── 合成线程:PipelinedSpeaker 后台逐句合成
├── 提醒线程:定时到点播报(每个提醒一个 daemon 线程)
└── gui.py(子进程):只读 data/ui_state.json,不碰音频
data/
├── ui_state.json ← 主进程写,界面读
├── todos.json ← 工具层持久化
└── reminders.json
这样做的好处是:界面卡住不影响语音,语音卡住不影响界面。GUI 崩溃也不会把主链路带下去。
二、主循环:一条指令是怎么走完的
assistant.py 的核心循环压掉日志和容错后大致是这样:
python
while True:
# 1) 唤醒:KWS 流式喂音频,命中就退出
hit = wait_for_keyword(kws, mic)
# 2) 回应「我在」,并丢掉提示音期间的麦克风数据
play_wake(args)
drain_mic(mic, 0.20)
# 3) 录音:VAD 端点检测
audio, spoken = record_until_silence(mic)
if not spoken:
continue
# 4) 录音后再切一遍:去掉首尾静音,拼回纯语音
speech, nseg = vp.vad_trim(audio)
# 5) 转写
text = vp.transcribe(rec, speech)
if not text.strip():
continue
# 6) 播报期间必须关麦,否则会把回复声当成新指令
mic.close()
mic = None
# 7) 意图 + 执行 + TTS
answer_once(llm, tts, text, args)
三个时间参数决定了交互手感:
| 参数 | 值 | 作用 |
|---|---|---|
WAKE_WAIT |
5.0s | 唤醒后等你开口的时间,超时自动取消 |
MAX_CMD |
15.0s | 单条指令最长录音,防止一直说 |
END_SILENCE |
0.7s | 尾部静音超过 0.7s 判定说完 |
两个容易忽略的细节:
- 唤醒音期间要丢数据。播「我在」的 0.8 秒里,麦克风仍在采集,不丢会把这句播放声录进指令开头。
- 播报时必须关麦 。否则 TTS 的声音会被当成下一轮指令,形成自激。主循环里
mic.close()就是干这个的。
三、录音与 ASR:全放 CPU,给 NPU 让路
板子的 NPU 只有一块,LLM 和 TTS 都要用。所以 KWS、VAD、ASR 全部走 CPU,NPU 只留给「贵」的部分。
ASR 用的是 sherpa-onnx-paraformer-zh 的 int8 版本,16k 单声道、greedy search。两个实测数字:
输入: cmd_20261025_172917.wav (1.98s)
VAD: 1 段语音, 去静音后 1.41s, 耗时 0.35s
ASR: 耗时 0.37s (RTF=0.261)
转写结果: 之后提醒我
也就是说,说完一句话到拿到文本,大约 0.7 秒(VAD 复切 0.35s + 解码 0.37s)。ASR 装载要 9.2 秒,是冷启动里最大的一项,所以常驻进程、不要每轮重启。
vad_trim 值得单独说一句:录音时已经用 VAD 判断「说完了」,但拿到整段音频后再切一遍,把首尾静音和中间的停顿丢掉,再拼起来送 ASR。这样不仅快,转写也更稳------Paraformer 对长静音开头比较敏感。
转写错字不靠重训模型解决,而是在工具层做同音字归一,下一节讲。
四、意图层:规则、LLM,和中间的取舍
这是整个项目返工最多的地方。
4.1 先说结论:Tool Call 不一定非要用大模型
很多人一提「工具调用」就默认是 function calling:让 LLM 输出一段 JSON 选工具、填参数。但工具调用本质上只是「把一句话映射成一个本地函数」,映射方式有两种:
| 方式 | 谁来决定 | 优点 | 缺点 |
|---|---|---|---|
| Function Calling | LLM 输出 JSON | 说法泛化强 | 慢、会编参数、要处理 KV |
| 确定性分发 | 正则/规则命中 | 0ms、可预期、可测 | 说法要枚举 |
这块板上的结论很直接:1.5B W4A16 做路由太慢,也太不准。所以默认架构是「规则命中就直接调本地函数,规则不认识才交给 LLM 闲聊」。工具本身一直都在,只是不再由模型来挑。
后来按需求又加回了 LLM 路由,但做成开关,见 4.4。
4.2 规则路由:0ms 的确定性
入口是 tools.fast_path(),顺序有讲究:
python
def fast_path(text):
t = normalize(text) # 同音字/空格归一
m = _CALC_RE.search(t) # 1) 算术
if m:
a, b = cn2int(m.group(1)), cn2int(m.group(3))
if a is not None and b is not None:
return ("calc", {"a": str(a), "op": m.group(2), "b": str(b)})
if any(k in t for k in ("几点", "几号", "星期", "日期", "时间")):
return ("time", {}) # 2) 时间日期
tl = t.lower()
if any(k in tl for k in ("温度", "内存", "负载", "ip", "cpu", "npu")):
return ("sysinfo", {"q": t}) # 3) 系统信息
if "提醒" in t: # 4) 提醒
md = _DUR_RE.search(t) or _find_abs(t)
...
if "待办" in t or re.search(r"记一下|记一条|记个|记下来|帮我记", t):
... # 5) 待办
return None # 都不认识 -> 闲聊
为什么算术放最前?因为「算十二乘以八」里也可能出现「时间」这类词,先算术后判断日期,能避免误判。规则路由没有语义理解,顺序本身就是优先级设计。
4.3 语音转写带来的两个坑
坑一:同音字。 ASR 把「待办」写成「代办」、「记一下」写成「寄一下/计一下/继一下」都很常见。工具层统一归一,不动模型:
python
_HOMO = (("代办事", "待办事"), ("代办", "待办"),
("寄一下", "记一下"), ("计一下", "记一下"), ("继一下", "记一下"),
("寄一条", "记一条"), ("计一条", "记一条"))
def normalize(text):
t = text.strip()
for a, b in _HOMO:
t = t.replace(a, b)
# 「n p u 温度」这种字母被拆开的情况也要合回去
return re.sub(r"(?<=[A-Za-z])\s+(?=[A-Za-z])", "", t)
坑二:中文数字。 语音转写出来的往往是汉字数字------「十二乘以八」「十五加五」「三千八百」。直接丢给 eval 必然失败,所以加了一个 cn2int():支持「十二」「三千八百」「两」这类写法,返回 None 表示非法。
python
def cn2int(s):
if re.fullmatch(r"-?\d+", s):
return int(s)
total = section = num = 0
for ch in s:
if ch in _CN_VAL: num = _CN_VAL[ch]
elif ch == "十": section += (num or 1) * 10; num = 0
elif ch == "百": section += (num or 1) * 100; num = 0
elif ch == "千": section += (num or 1) * 1000; num = 0
elif ch == "万": total += (section + num) * 10000; section = num = 0
else: return None
return total + section + num
实测:
| 你说 | 路由结果 | 播报 |
|---|---|---|
| 算十二乘以八 | calc 12×8 | 十二 乘以 八 等于 九十六 |
| 十五加五 | calc 15+5 | 十五 加 五 等于 二十 |
4.4 提醒:从「多久后」到「明天早上八点」
最早的提醒只支持相对时长:「十分钟后提醒我喝水」。正则抽时长、再抽「提醒我」后面的内容。第一版把整句都当成 when,结果播成「好的,十秒钟之后提醒我喝水后会提醒你那件事」------这类文案 bug 只有真机跑才会暴露。
后来补了绝对时间,纯规则解析,不经过模型:
| 支持的说法 | 解析结果 |
|---|---|
| 明天早上八点提醒我开会 | 明天 08:00 |
| 后天下午三点半提醒我交材料 | 后天 15:30 |
| 晚上八点提醒我吃药 | 当天 20:00 |
| 明天八点提醒我开会 | 明天 08:00(没说时段,默认早上) |
| 明天早上提醒我 | 明天 08:00(只给时段,按默认时刻) |
| 八点提醒我开会(当前 12:19) | 今天 20:00(取最近的下一个八点) |
| 五点提醒我下班(当前 12:19) | 今天 17:00 |
| 今天早上八点提醒我(已过 8 点) | 「这个时间已经过了,换个时间说吧」 |
只给时段时的默认时刻表:凌晨 5、早上 8、上午 9、中午 12、下午 15、傍晚 18、晚上 19。
两个设计决定值得说一下:
- 「八点」不带时段时,取最近的下一个整点(中午说八点=今晚 20:00,清早说八点=今早 08:00),比一律按早上更符合直觉;
- 「今天八点」已经过了,不偷偷挪到明天,而是明确告诉用户时间已过。静默改期比报错更糟。
4.5 LLM 判意图:加回来了,但必须校验
按需求最终加了一个开关:
bash
./py312/bin/python3.12 -u assistant.py # 默认:LLM 判意图
./py312/bin/python3.12 -u assistant.py --intent rules # 纯规则,0ms
LLM 路由的流程是:分类 → 严格校验 → 不合格退回规则。分类提示词要求只输出一行 JSON,用少量示例约束:
你是意图分类器。只输出一行 JSON,不要解释,不要 Markdown。
可选意图:time / calc / sysinfo / reminder / todo_add / todo_list / todo_done / chat
...
明天早上八点提醒我开会 -> {"intent":"reminder","args":{"when":"明天早上八点","what":"开会"}}
拿到输出后不能直接信,必须逐项校验:
- JSON 能不能解析(模型偶尔会输出裸字符串);
- 意图名是否在白名单里;
calc的 a/b 能否转成数字、op 是否合法;reminder的 when 能否被时长解析器或绝对时间解析器认出来;todo_add的 text 是否为空。
任何一项不通过,就退回规则;规则也没有,才当闲聊。
实测 12 条常用指令,11 条与规则一致:
| 你说 | LLM 模式结果 | 分类耗时 |
|---|---|---|
| 现在几点了 | time | 3.1s |
| 算十二乘以八 | calc 12×8 | 3.9~4.3s |
| 明天早上八点提醒我开会 | reminder(补示例后修正) | 4.1s |
| 我有哪些待办 | todo_list | 3.5s |
| npu温度多少 | sysinfo | 3.8s |
| 你叫什么名字 | chat | 3.6s |
| 今天天气怎么样 | chat | 3.6s |
| 订明天下午的会议室 | todo_add(误判) | 4.1s |
唯一稳定误判是「订会议室」------模型倾向于把它记成待办,往提示词里加反例也没纠正。这也说明:1.5B 做封闭分类勉强够用,做开放式理解还差得远。
所以实际建议是:想把延迟压到最低、行为完全可预期,就用 --intent rules;想体验模型判意图,就用默认模式,接受每条指令多 3~4 秒。
4.6 两个失败的实验
为了不误导,把走过的弯路也记下来。
实验一:让 LLM 做兜底分类(旧提示词)。 单次 4.1~4.5 秒,而且意图和参数都是编的:
- 「现在是什么日子」→
sysinfo {date: 今天}(sysinfo根本没有 date 参数) - 「帮我订明天下午的会议室」→
calc {date: 明天, time: 下午}(calc缺 a/b)
分发时直接 KeyError。结论:分类结果必须校验,不能直接灌进工具层。
实验二:让 LLM 只做提醒槽位填充。 想法是降级任务能稳一点,结果:
- JSON 版:原样把模板吐回来------
{"when":"N小时后","what":"要提醒的事"} - 改短问版:把「明天早上八点」算成「两个小时」(实际约 19.5 小时),what 直接复述整句
降级到「只抽两个槽位」依然不可用,于是回退,改用确定性时间解析。
五、最坑的 bug:KV 缓存污染
这是整个项目花时间最多的一个问题,值得单独一节。
5.1 现象
LLM 路由做完分类之后,紧接着的闲聊会「坏掉」:
[你说] 你叫什么名字
[LLM ] {"intent":"chat","args":{}}
或者干脆吐出空串。看起来像模型太弱,但同样的模型单独跑闲聊完全正常。
5.2 定位
写了一个最小探针,四个步骤:
| 步骤 | 操作 | 结果 |
|---|---|---|
| A | 直接闲聊 | 正常 |
| B | 先做一次分类(keep_history=0),再闲聊 |
念出 JSON 残片/空串 |
| C | 分类后调 reset_history(),再闲聊 |
正常 |
| D | 连续多轮闲聊 | 正常 |
根因清楚了:keep_history=0 只是「不保留本轮」,分类提示词那一段 KV 仍然留在缓存里,下一轮闲聊会把它当成上下文的一部分。
5.3 修复与代价
修复只有一行:
python
# llm.py
def reset_history(self):
_lib.rkllm_clear_kv_cache(self.handle, 0, None, None)
分类用完立刻清:
python
def _llm_route(llm, text):
keep = llm.keep_history
try:
llm.keep_history = 0
raw = llm.ask(_ROUTE_PROMPT + text)
finally:
llm.keep_history = keep
llm.reset_history() # 关键:不清就会污染下一轮
return _parse_route(raw)
代价是:reset 会把之前的多轮闲聊上下文也一起清掉。单实例、1.1GB 内存,没法再开一个模型实例做分类,所以只能接受这个代价,并在应用层补回来。
5.4 多轮上下文的手工重建
既然 KV 靠不住,就把最近两轮对话拼回 prompt 里:
python
hist = "".join("%s:%s\n" % ("用户" if h["role"] == "user" else "助手", h["text"])
for h in _ui["history"][-4:])
prompt = "之前的对话:\n%s现在用户说:%s" % (hist, text)
界面本来就在维护最近 6 条对话,直接复用。实测:
你说:我最喜欢的颜色是蓝色
助手:那真是太美了!蓝色象征着宁静和广阔。
你说:我最喜欢的颜色是什么
助手:那就是蓝色,代表宁静与广阔。
第二轮能答对,说明重建生效。代价是每轮 prompt 多几十个 token,但 1024 的上下文完全够用。
六、工具层:只做确定性的事
tools.py 里目前有 7 个意图:
| 意图 | 触发 | 返回示例 |
|---|---|---|
time |
几点/几号/星期/日期 | 现在是 二零二六年十月二十五日 星期日,下午五点十八分 |
calc |
数字 + 运算符 + 数字 | 十二 乘以 八 等于 九十六 |
sysinfo |
温度/内存/负载/IP/CPU/NPU | NPU 温度 四十五点二 度 |
todo_add |
记一下/帮我记/待办 | 已记下待办:买牛奶 |
todo_list |
我有哪些待办 | 你有 二 条待办:一 ...、二 ... |
todo_done |
完成第 N 条 | 已完成待办:买牛奶 |
reminder |
提醒 + 时间 | 好的,明天早上八点提醒你开会 |
四条设计原则:
- 工具直接返回要播报的中文 。TTS 前端会把 ASCII 数字按英文音素念(
22:05→ "twenty two oh five"),所以温度、内存、条数、时刻全部在工具层转成汉字,不给 TTS 留坑。 - 工具层不调 LLM。所有工具都是 0ms 的本地函数,稳定、可测。
- 不抛异常 。
dispatch外面包一层兜底,参数不对就回「这个我还做不到,换个说法试试?」,绝不让主循环崩。 - 状态落盘 。待办写
todos.json,重启不丢;写文件用临时文件 +os.replace,避免半截 JSON。
还有一个细节:dispatch 返回 None 表示「这个意图我没实现」,主循环会回退到闲聊,而不是硬报错。
七、TTS 播报:流水线掩盖合成延迟
TTS 选型和优化在前一篇已经写透,这里只说落地方式。
用的是 PipelinedSpeaker:一个后台线程逐句合成,主线程顺序播放。合成和播放重叠,首声只等第一块。
python
class PipelinedSpeaker:
def _synth_worker(self): # 后台:合成 -> 写 wav -> 入队
...
def say(self, text): # 主线程:出队 -> aplay -> 返回首声延迟
...
分块参数按后端区分:
- Matcha(NPU):首块 24 字、后续 40 字;
- VITS(CPU):首块 10 字、后续 18 字。
实测首声约 0.4 秒,整段播报不卡顿。唤醒回应音「我在」也是用 Matcha 合成的(0.82 秒、16k 单声道),不再依赖真人素材------之前试过剪切真人音频,听感反而更差。
八、界面:一个 JSON 文件解耦
界面没有做成「主程序画图」,而是独立进程轮询 data/ui_state.json(40ms 一次)。主程序只负责写状态,GUI 只负责画。
五个状态:




说话中会把用户问题和助手回复都显示成气泡:

几个实现细节:
- GTK4 + Cairo + Pango,
WenQuanYi Zen Hei,1024×600 全屏; - 状态文件用「临时文件 +
os.replace」原子写,GUI 不会读到半截 JSON; - GUI 用系统
python3(带 GTK4),语音主程序用自带的 Python 3.12,两者互不干扰; gui.py --shot <dir>可以离屏把五个状态各渲染成一张 PNG,不依赖显示器------本文的图就是这么来的,也方便做视觉回归。
九、部署与运维
板端目录(/userdata/rkllm):
| 文件 | 作用 |
|---|---|
assistant.py |
主循环、TTS 适配、GUI 拉起 |
voice_pipeline.py |
KWS / VAD / ASR 复用模块 |
tools.py |
意图规则 + 工具实现 |
llm.py |
RKLLM ctypes 封装 |
gui.py |
卡通全屏界面 |
audio_up.sh |
播放前复位 ES8389 输出级 |
data/*.json |
待办、提醒、界面状态 |
启动:
bash
cd /userdata/rkllm
./py312/bin/python3.12 -u assistant.py # 默认 LLM 判意图
./py312/bin/python3.12 -u assistant.py --intent rules # 规则判意图
./py312/bin/python3.12 -u assistant.py --no-gui --no-tts --text "现在几点" # 不开口快速回归
电脑侧用一个几十行的 SSH 小工具(paramiko)做上传和远程执行,避免反复手敲 scp:
bash
python bd.py --put assistant.py /userdata/rkllm/assistant.py
python bd.py "cd /userdata/rkllm && ./py312/bin/python3.12 -u assistant.py --no-gui --no-tts --text 现在几点"
日志按阶段打标签,排查时一眼能看出走到哪:
[ROUTE] LLM 3.7s raw={"intent":"calc","args":{...}}
[ROUTE] LLM -> calc {'a': '12', 'op': '*', 'b': '8'}
[TOOL] calc 0.00s -> 十二 乘以 八 等于 九十六
[TTS ] 首声 0.42s | 合成 0.68s / 音频 2.10s (RTF=0.32) | 播报完毕
十、性能账本
10.1 冷启动
| 阶段 | 实测 |
|---|---|
| ASR 装载 | 9.2s |
| LLM 装载(1.5B) | 2.3~6.5s |
| TTS 装载(Matcha) | 1.5s |
| GUI 启动 | < 1s |
ASR 是最大头。所以建议常驻运行,而不是每次说完就退出。
10.2 单轮延迟(说完到出声)
| 环节 | 规则模式 | LLM 模式 |
|---|---|---|
| VAD 复切(2s 音频) | 0.35s | 0.35s |
| ASR 解码 | 0.37s | 0.37s |
| 意图判断 | 0ms | 3.1~4.5s |
| 工具执行 | ≈0ms | ≈0ms |
| TTS 首声 | 0.4s | 0.4s |
| 合计 | ≈1.1s | ≈4.2~5.6s |
如果是闲聊而不是工具指令,还要加上生成时间:首 token 0.6~0.9s,整段 1.5~2.5s。
10.3 内存
- Qwen2.5-1.5B:约 1109MB;
- Qwen2.5-0.5B:约 589MB;
- 板子总内存 3.8GB,空载可用约 1.2GB。
1.5B 装下后余量不多,这也是没法同时开两个模型实例(一个分类、一个闲聊)的原因。
十一、踩坑清单
| 现象 | 根因 | 解法 |
|---|---|---|
| 播报中助手自己触发自己 | TTS 声音被麦克风录进去 | 播报前关麦,播完重开 |
| 指令开头总有杂音 | 唤醒音播放期间的麦克风数据被录进去 | 播「我在」后丢弃 0.2s 数据 |
| 数字念成英文 | TTS 前端把 ASCII 数字按英文音素化 | 工具层统一转汉字 |
| 闲聊念出 JSON 残片/空串 | 分类调用的 KV 污染 | 分类后 reset_history() |
LLM 分类时报 KeyError |
模型编造参数 | 意图/参数白名单校验 + 规则兜底 |
| 「代办」识别不到 | ASR 同音字 | normalize() 归一 |
| 「n p u 温度」答成三路温度 | 字母被空格拆开 | 去掉字母间空格 |
| 提醒播成「...后会提醒你那件事」 | 整句被当成时长 | 正则分别抽 when 和 what |
| IP 念出 127.0.0.1 | 没过滤回环地址 | 过滤 127. 前缀 |
十二、还能做什么
- 绝对时间再扩:目前覆盖「今天/明天/后天 + 时段 + N点」,还没有「下周一」「每月五号」这类表达;
- 流式 ASR:现在是整段解码,边说边出字可以再省几百毫秒;
- 播报打断:目前播报期间必须等它说完,加 barge-in 才能像真人对话;
- 更强模型:如果硬件升级到能跑 7B,function calling 才真正有意义;
- 系统服务化:现在靠 SSH 手工起进程,做成 systemd 服务才算产品。
十三、小结
把零件串起来的过程中,真正难的不是「让某个模型跑起来」,而是这些事:
- 延迟预算。每个环节都要知道花多少毫秒,才能决定什么放 CPU、什么放 NPU。
- 确定性优先。能用规则 0ms 解决的,不要交给 1.5B 去猜;模型的输出必须校验。
- 小模型的边界。分类可以做,开放式槽位抽取不行;不行就换确定性解析。
- 状态与副作用。KV、麦克风、播放、文件,任何一个没管好,都会以「模型好像变笨了」的形式表现出来。
现在这台板子可以做到:说「小智小智」,它回「我在」;说完指令,一秒左右给出结果;断网、重启、放一晚上,第二天照常工作。
对一个完全离线的语音助手来说,这大概就是「能用」和「好玩」的分界线。