#移远产品体验官 Quectel Pi M2 实战:完全离线的中文语音助手

前言

这个系列前面几篇分别把「零件」跑通了:

  • 板载麦克风录音与蓝牙音箱播放
  • 中文语音唤醒(KWS)
  • RKLLM 在 6 TOPS NPU 上跑本地大模型
  • 离线 TTS 从 11 秒压到 0.4 秒

零件能跑,不等于能组成产品。这一篇讲最后一步:把它们串成一个完整的闭环------唤醒、录音、转写、意图、工具、播报、界面,以及在这个过程中踩到的几个真正耗时间的坑。

为避免重复,KWS 原理、RKLLM 部署、TTS 方案选型、麦克风/蓝牙排查都不再展开,只引用结论。本文重点是四件事:

  1. 主循环怎么编排,延迟花在哪;
  2. 意图层怎么设计------规则、LLM、以及为什么最后留了一个开关;
  3. RKLLM 最坑的一个问题:KV 缓存污染;
  4. 工具层、界面、部署和性能账本。

一、整机结构:一条链路,两类进程

最终形态是「一块板 + 一个麦克风 + 一个音箱 + 一块屏」,全程不联网。

模块 实现 运行位置 关键指标
唤醒 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 提醒 + 时间 好的,明天早上八点提醒你开会

四条设计原则:

  1. 工具直接返回要播报的中文 。TTS 前端会把 ASCII 数字按英文音素念(22:05 → "twenty two oh five"),所以温度、内存、条数、时刻全部在工具层转成汉字,不给 TTS 留坑。
  2. 工具层不调 LLM。所有工具都是 0ms 的本地函数,稳定、可测。
  3. 不抛异常 。dispatch 外面包一层兜底,参数不对就回「这个我还做不到,换个说法试试?」,绝不让主循环崩。
  4. 状态落盘 。待办写 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 服务才算产品。

十三、小结

把零件串起来的过程中,真正难的不是「让某个模型跑起来」,而是这些事:

  1. 延迟预算。每个环节都要知道花多少毫秒,才能决定什么放 CPU、什么放 NPU。
  2. 确定性优先。能用规则 0ms 解决的,不要交给 1.5B 去猜;模型的输出必须校验。
  3. 小模型的边界。分类可以做,开放式槽位抽取不行;不行就换确定性解析。
  4. 状态与副作用。KV、麦克风、播放、文件,任何一个没管好,都会以「模型好像变笨了」的形式表现出来。

现在这台板子可以做到:说「小智小智」,它回「我在」;说完指令,一秒左右给出结果;断网、重启、放一晚上,第二天照常工作。

对一个完全离线的语音助手来说,这大概就是「能用」和「好玩」的分界线。

相关推荐
lucas_AI1 小时前
别再迷信 OCR 专用小模型:8 个 7B 以下 VLM 实测,抽取 JSON 结构还得靠通用选手
人工智能
EnCi Zheng1 小时前
AI Agent工具误调用怎么解决-从参数校验到高危二次确认的四层工程防线
大数据·数据库·人工智能
一级新生1 小时前
多智能体协作:常用协作模式详解
人工智能·状态模式·agent·多智能体·智能体
Bruce_Liuxiaowei1 小时前
AI 红队实践:用 garak 扫描本地 qwen3:8b 模型
人工智能·大模型安全
szxinmai主板定制专家1 小时前
基于 AM62x + Artix-7 的低功耗 ARM+FPGA 平台在轨道交通车门控制中的应用
arm开发·人工智能·fpga开发·zynq·am62x
愤怒的代码1 小时前
KV Cache 压缩技术:把大模型的“记忆”塞进一个小盒子
人工智能·agent
Ai-_Man1 小时前
Microsoft Copilot对话数据怎么导入到另外一个Microsoft Copilot账号里去?AI导出鸭
开发语言·javascript·人工智能·microsoft·chatgpt·ecmascript·copilot
aneasystone本尊1 小时前
学习大模型训练的显存管理与混合精度
人工智能