端侧语音意图解析:Embedding 召回 + LLM,还是纯 LLM?——一次真实的工程取舍

本文记录我在受限 NPU 硬件上落地"语音 → 意图 → 可执行指令"全链路时,对意图解析模块的两套方案做的取舍。结论先放这:在端侧,Embedding 召回的价值不是"找工具",而是把喂给 LLM 的 token 压下来——而 token 数恰恰是 NPU 上 LLM 延迟的主要瓶颈。


0. 这篇文章在解决什么问题

现在"端侧 AI Agent / on-device function calling"很热。大家都想要这样一个东西:

用户对着设备说一句话 → 设备本地识别意图、填好参数 → 生成一条可执行指令(function call / MCP 指令)去驱动应用。

云端做这件事很容易,丢给 GPT/Claude 一把工具 + 一段话,它直接给你 function call。但端侧不一样:你的算力是一块 NPU(BM1684X / MTK NeuroPilot / RK3576 这类),内存有限,LLM 只能跑 0.6B~4B 的小模型,而且延迟对体验极其敏感——用户说完话到设备响应,超过 1.5 秒就开始"感觉卡"。

意图解析模块,就是这条链路里最吃 LLM、最容易成为瓶颈的一环。我对它设计了两套方案,实测后选了其中一套。下面讲清楚为什么。


1. 整条链路长什么样

在这里插入图片描述先交代上下文。完整的语音助手是一个状态机:

启动 → 初始化(KWS / VAD / ASR)
   → 【监听态】KWS 热词唤醒("小朗小朗")→ VAD 检测语音起止
   → 【转写态】ASR 出文本(NPU/CPU)
   → 【执行态】意图解析 → 指令执行
   → 7s 内无语音/语音不完整/解析失败 → 回退监听态

本文聚焦的是"执行态"里的第一步:ASR 文本 → 可执行指令

输入是一段 ASR 文本,比如:

“帮我打开白板,我想用马克笔写字,笔迹大小调整到 12”

期望输出是一组可执行的结构化指令:

[
  {"tool": "open_whiteboard"},
  {"tool": "set_pen", "params": {"type": "marker", "size": 12}}
]

注意这里有两个动作、还有参数(笔类型、笔迹大小),这正是难点。


在这里插入图片描述

2. 方案二:纯 LLM 一步到位(看着最香,其实有隐藏成本)

最直觉的做法:把"意图列表"和 ASR 文本一起塞给 LLM,让它一步出意图 + 参数。

ASR Text + 意图列表集合 ──► Qwen LLM (NPU) ──► 意图 + 参数

输出类似:

{"action": "打开白板"}
{"action": "设置画笔", "params": {"type": "马克笔", "size": 12}}

看起来很干净。但我落地时发现一个绕不过去的 Gap:

从"意图 + 动作"到"完整可执行的 MCP 指令"之间,还差一个桥接转换器。

LLM 吐出来的 {"action": "设置画笔"} 不是系统能直接执行的东西。系统认的是 set_pen 这个 tool 名、认的是 type: "marker" 这个枚举值——而不是中文的"马克笔"。于是你还得在 LLM 后面再写一层映射:中文动作名 → tool 名、中文参数值 → 系统枚举值。

这层映射本身不难,但它意味着:

  • LLM 的输出空间是"自由文本",不可控。今天它说"设置画笔",明天可能说"调整笔",你的映射表永远在追着补。
  • 想让它稳定输出,就得在 prompt 里把所有工具、所有参数枚举都写进去 → prompt 变长 → token 变多

这个 token 问题,就是端侧的命门。


3. 方案一:Embedding 召回 + LLM(我最终选的)

方案一把意图解析拆成两步:

第一步:工具召回(Embedding 模型,跑 CPU)

ASR Text + FunctionTools 列表集合 ──► Embedding 模型 ──► 召回 Top-K 相关 FunctionTools

我这里用的是中文场景常见的轻量 embedding(m3e / text2vec 这一类),模型小、可以放在 CPU 上跑,不占 NPU——而 NPU 要留给后面的 LLM。Embedding 模型在这里干两件事:

  1. 输入 ASR 文本,通过向量相似度从完整工具库里召回最相关的几个 FunctionTools。
  2. 减少输入给 LLM 的 FunctionTools(token),从而提升 LLM 部分的推理速度。

第二步:参数槽填充 + 指令生成(Qwen LLM,NPU)

ASR Text + 召回的 FunctionTools ──► Qwen LLM (NPU) ──► 完整可执行 MCP 指令

这一步的 LLM 我用的是 LoRA 微调过的 Qwen2.5-0.5B,跑在一颗 9 TOPS 的 MTK NPU 上,实测 prefill ~600 token/s、decode ~45 token/s。选 0.5B 而不是更大的模型,正是因为它配合上一步的 token 压缩,延迟刚好能压进预算;而 LoRA 微调补回了小模型在"工具选择 + 参数槽填充"这个窄任务上的准确率——窄任务正是小模型 + 微调最划算的地方。

LLM 在这里只做两件被严格约束的事:

  1. 从召回的(已经很少的)几个 FunctionTool 里,选出和 ASR 文本最相关的。
  2. 参数槽填充——根据 ASR 文本把这个 FunctionTool 的参数填完整。

因为喂进去的工具定义本身就携带了精确的 tool 名和参数 schema,LLM 直接输出:

{"tool": "open_whiteboard"}
{"tool": "set_pen", "params": {"type": "marker", "size": 12}}

不再需要那层中文→枚举的桥接转换器——Gap 消失了。


4. 核心权衡:为什么端侧值得多加一个 Embedding 模型?

表面看,方案一比方案二多了一个模型(Embedding),更复杂。在云端我大概率会选方案二,因为云端 token 几乎不要钱、上下文窗口巨大。

端侧的成本结构完全不同:

维度 端侧的真实约束
LLM 延迟 由 prefill 的 token 数 + decode 的 token 数共同决定,prefill 几乎和输入 token 数成正比
工具数量 一个真实产品的工具库可能有几十上百个,全塞进 prompt → 输入 token 爆炸
模型大小 端侧 LLM 只有 0.6B~4B,长上下文下既慢又容易乱
输出可控性 工具越多、prompt 越杂,小模型越容易输出错工具名/错参数

关键洞察:Embedding 召回的真正价值,不是"帮 LLM 找工具",而是"把喂给 LLM 的 token 数从’整个工具库’压到’Top-K 几个’"。

而 token 数,正是 NPU 上 LLM 延迟的主要瓶颈。

举个直观的例子:我的工具库是几十个工具的量级,每个工具的定义(名字 + 描述 + 参数 schema)平均下来几十个 token,全部塞进 prompt 就是数千 token 的输入。Embedding 召回 Top-K(我取个位数)之后,实际喂给 LLM 的工具定义只剩几百 token。对一个跑在 NPU 上的 0.6B~4B 小模型,prefill 阶段处理几千 token 和几百 token,延迟差距是肉眼可见的。

而且这个差距会随工具库增长而放大:工具越多,纯 LLM 方案的 prompt 越长、越慢、小模型越容易选错;而 Embedding 召回方案喂给 LLM 的 token 数几乎不变(始终是 Top-K)。工具库越大,方案一的优势越明显。

代价是多加一个 Embedding 模型。但它小、快、跑在 CPU 上不抢 NPU,而且它自己的延迟远小于它为 LLM 省下来的延迟。这是一笔稳赚的交易。


5. 实测:整套链路在 9 TOPS NPU 上跑到 ~1s

光讲道理不够,上数字。下面是方案一在一颗 9 TOPS 的 MTK NPU 上的实测端到端延迟拆解(ASR 用流式中英双语 zipformer,LLM 用 LoRA 微调的 Qwen2.5-0.5B):

阶段 模型 实测延迟
ASR(语音转文本) zipformer 流式中英双语 ~300 ms
Embedding 召回 m3e/text2vec 系(CPU) ~100 ms
LLM 意图解析 + 槽填充 Qwen2.5-0.5B LoRA(NPU) ~600 ms
MCP 指令生成 桥接/模板 10~50 ms
整体系统延迟 ~1 s

对比方案二(纯 LLM):它省掉了 Embedding 那 100ms,但省不掉 LLM 的延迟,反而会拉长它——因为没有召回压 token,整个工具库都要塞进 prompt,prefill 阶段要处理的 token 多出一截。在我的工具库规模下,方案二的 ASR + LLM 至少也是 900ms 起步(300 + 600),而且这个 600ms 会随工具库增大而上涨。方案一多花的那 100ms embedding,买来的是 LLM 延迟的可控和稳定。

几个值得说的点:

  • prefill 600 token/s、decode 45 token/s 是这颗 9 TOPS NPU 上 0.5B 模型的实测吞吐。正因为 decode 不快,我才要在输入端把 token 压到极致——能省的延迟主要在 prefill 和"少生成几个 token"上。
  • ~1s 的整体延迟,是端侧语音交互"无感"的关键门槛。超过 1.5s,用户就会觉得设备"反应慢"。方案一压进 1s,体验上是过线的。

6. 一个容易被忽略的坑:别用 LLM 自报的 confidence 去 gate 动作

链路里有个细节我想单独拎出来提醒:有些设计会让 LLM 在输出里附一个置信度,比如:

{"action": "open", "target": "whiteboard", "confidence": 0.95, "original_text": "..."}

然后用这个 confidence 去决定要不要执行动作。

慎用。 LLM 自评的置信度通常没有经过校准(uncalibrated)——它说 0.95 不代表真有 95% 的概率对。拿一个没校准的数去 gate “要不要真的打开白板/执行操作”,风险不小。

更可靠的 gate 信号是:

  • ASR 的声学置信度 / VAD 是否检测到完整语音段;
  • Embedding 召回的 Top-1 相似度分数(这个是有物理意义的几何距离);
  • 参数是否填全(槽位完整性校验);
  • 真不确定时,走一轮"你是想…吗?"的确认反问。

把"是否执行"的决策权交给这些更可信的信号,而不是 LLM 的一句自我感觉良好。


7. 小结:端侧不是把云端方案缩小,而是换一套成本模型

如果只记一句话:

云端选纯 LLM,因为 token 不要钱;端侧选 Embedding 召回 + LLM,因为 token 就是延迟。

意图解析这一环的设计,本质上是在回答一个问题:你的瓶颈资源是什么? 云端是开发效率,端侧是每一个 token 的推理时间。同一个功能,瓶颈不同,最优架构就完全不同。

这也是我做端侧语音 Agent 这段时间最大的体会:端侧工程的难点很少在"模型能不能跑",而在"在给定的硬件预算下,怎么把整条链路的延迟、内存、可控性配平。"


我在 BM1684X / MTK NeuroPilot / RK 等端侧 NPU 上落地过 Whisper、SenseVoice、ChatTTS、Qwen 等模型的移植与全链路语音 Agent。这个系列会陆续把这些工程实践写出来,如果你也在受限硬件上做端侧 AI,欢迎在评论区交流。

GitHub:https://github.com/superLin006

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐