摘要

本文记录在端侧(API 26 设备)用轻量小模型搭建 Agent 编排的完整实践。核心结论是:能跑模型不等于能做事,端侧 Agent 的落地底盘是「意图识别 + 工具闭环 + 受限编排」三件套。文章先给出可复用的 agentSafe 编排骨架(工具注册表、白名单校验、上下文裁剪、异常兜底),再用真机实测的 4 个踩坑案例(意图误判、工具参数缺失、上下文裁剪丢信息、步数上限触发)按「现象 → 原因 → 修复方式」逐一拆解,最后给出实测数据对比与可直接抄的实战清单。优化后意图识别准确率从 72% 提升到 91%,多步任务完整完成率从 63% 提升到 88%。

目录

一、背景与痛点

上一篇讲了"端侧 NPU 轻量大模型推理接入",能把一个小模型跑起来。但能跑模型不等于能做事——用户说"帮我总结这篇、再发给老张",需要模型先识别意图、再拆步骤、最后调本地能力完成。这就是端侧 Agent 编排。

我第一版把模型输出直接当结果,结果模型说"已帮你发送",其实啥也没干——它根本没调用任何能力。本质是只做了生成,没做"意图→工具调用"的闭环。本文讲清端侧意图识别、工具注册与本地编排循环。

「只生成」和「真编排」的差别,用一张图就能看清:

真编排(本文方案)

用户指令

意图识别 intent+slots

白名单校验

调用本地工具

结果回灌模型

基于真实结果回复

只生成(第一版踩坑)

用户指令

模型直接输出文本

回复『已帮你发送』

实际什么都没执行

二、环境与能力规格

维度规格
推理底座@kit.CoreAIKit / MindSpore Lite 端侧推理(API 26)
意图解析小模型输出结构化 intent + slots
工具注册本地能力(分享/收藏/搜索)注册为 tool
编排循环识别意图→调用 tool→回灌结果→再决策
适配建议工具调用上限设 5 步,防死循环

从能力分层看,端侧 Agent 由下往上共四层,越往上越贴近用户,越往下越贴近硬件:

交互层:语音/文本输入、结果播报

编排层:意图识别 + 工具调度 + 上下文管理

能力层:summarize / share / search / 收藏 等注册工具

推理层:CoreAIKit + MindSpore Lite 端侧小模型

硬件层:NPU / CPU,API 26 设备

三、核心代码(可复用骨架)

// 1) 工具注册表
const tools = {
  summarize: (text) => localSummarize(text),
  share:   (target) => localShare(target),
};

// 2) 编排循环:意图 -> 调工具 -> 回灌
async function agent(userSay: string) {
  for (let i = 0; i < 5; i++) {
    const plan = await llm.plan(userSay, ctx); // 输出 intent+slots
    if (plan.intent === 'done') break;
    const res = await tools[plan.intent](plan.slots);
    ctx = ctx + `\n结果:${res}`;               // 回灌模型
  }
}

上面这段骨架刻意写得很短,是为了让结构一眼看清。真正落地时,还需要补上三块"工程化"的细节:工具白名单校验、上下文裁剪、异常兜底。下面给出一个更完整的版本,可以直接替换掉骨架里的 agent 函数。

// 3) 工具白名单:只有注册过的意图才允许执行
const ALLOWED = new Set(Object.keys(tools));

// 4) 上下文裁剪:只保留最近 3 步 + 关键结果,防止越聊越慢
function trimCtx(history: string[], keep = 3): string {
  return history.slice(-keep).join('\n');
}

// 5) 完整编排:白名单 + 裁剪 + 异常兜底
async function agentSafe(userSay: string) {
  const history: string[] = [`用户:${userSay}`];
  for (let step = 0; step < 5; step++) {
    const plan = await llm.plan(userSay, trimCtx(history));

    // 终止条件
    if (plan.intent === 'done') {
      return plan.reply ?? '已完成';
    }

    // 白名单校验:未注册意图直接拒答,绝不硬调
    if (!ALLOWED.has(plan.intent)) {
      return '这个我暂做不到,换个说法试试?';
    }

    // 执行工具,异常不抛出,转为可回灌的结果
    let res: string;
    try {
      res = await tools[plan.intent](plan.slots);
    } catch (e) {
      res = `执行失败:${(e as Error).message}`;
    }

    history.push(`动作:${plan.intent} 结果:${res}`);
  }
  return '步骤太多啦,我先停一下,你可以拆成更小的指令。';
}

整个编排循环的执行时序如下,可以对照代码理解每一步的输入输出:

"本地工具集" "端侧小模型" "编排器 agentSafe" "用户" "本地工具集" "端侧小模型" "编排器 agentSafe" "用户" "帮我总结这篇再发给老张" "plan(指令, 裁剪后上下文)" "intent=summarize, slots={text}" "白名单校验通过" "summarize(text)" "摘要结果" "plan(指令, 上下文+摘要结果)" "intent=share, slots={target:'老张'}" "share('老张')" "分享成功" "plan(指令, 上下文+分享结果)" "intent=done" "已总结并分享给老张"

从时序图能看出两个设计要点:第一,每一轮决策都必须带上上一轮的真实结果,否则模型无法判断下一步该做什么;第二,终止由模型显式给出 done,而不是靠步数耗尽来兜底,步数上限只是最后一道保险。

把 agentSafe 的每一轮循环抽象成状态机,可以更直观地看到「决策—校验—执行—回灌」的闭环:

携带裁剪后上下文

intent = done

intent 为具体工具

未注册意图

校验通过

成功

异常转文本

未达步数上限

达到步数上限

接收指令

模型决策

已完成

白名单校验

兜底拒答

执行工具

结果回灌

触顶汇总

四、运行效果与踩坑

下面这 4 个案例都来自真机(API 26 设备)实测,按「现象 → 原因 → 修复方式」整理,代码片段可直接对照 agentSafe 修改。

案例 1:意图误判——“总结"被识别成"搜索”

现象: 用户说"帮我总结一下这篇笔记",模型输出 intent=search, slots={keyword:"总结"},结果去本地搜索了"总结"两个字,返回空列表,用户一脸问号。真机 200 条指令抽样中,这类串味约占 28%。

原因: 提示词里 summarize 和 search 的意图描述都写了"处理文本内容",触发词高度重叠;且小模型对"总结/概括/提炼"这类近义表达泛化不足,容易退化成关键词匹配。

修复方式: 给每个意图补上触发词 + 反例 + 必填槽位三件套,并在解析后做一次槽位合法性校验,槽位不合法就降级重判。

const INTENT_SPEC = `
intent 只能是以下之一:
- summarize: 触发词「总结/概括/提炼/摘要」,slots 必须含 text(待总结正文)
  反例:用户说"搜一下总结这个词" -> 属于 search,不是 summarize
- search: 触发词「搜索/查找/找一下」,slots 必须含 keyword
  反例:用户说"总结这篇" -> 属于 summarize,不是 search
`;

// 槽位校验:必填槽位缺失或类型不符,视为误判,走一次重判
const REQUIRED: Record<string, string[]> = {
  summarize: ['text'],
  search: ['keyword'],
  share: ['target'],
};

function validPlan(plan: any): boolean {
  const need = REQUIRED[plan.intent];
  if (!need) return false;
  return need.every((k) => typeof plan.slots?.[k] === 'string' && plan.slots[k].length > 0);
}

改完后同一批抽样准确率从 72% 升到 91%,"总结/搜索"这一对混淆基本消失。

案例 2:工具参数缺失——share 拿不到目标人

现象: 用户说"把刚才的总结发出去",模型给出 intent=share, slots={},localShare(undefined) 直接抛异常,链路中断,用户只看到一句"出错了"。

原因: 多步指令里"发出去"的目标是上文隐含的(上一轮用户提过"发给老张"),但裁剪后的上下文只保留了最近 3 步的动作结果,把最初那句用户原话裁掉了,模型无从推断 target。

修复方式: 两层处理。第一层,把用户原始指令固定保留在上下文头部,不参与裁剪;第二层,槽位缺失时不让模型硬猜,改为向用户追问。

// 上下文结构:原始指令常驻 + 最近 N 步动作可裁剪
function buildCtx(userSay: string, history: string[], keep = 3): string {
  const recent = history.slice(-keep).join('\n');
  return `原始指令:${userSay}\n最近动作:\n${recent}`;
}

// 槽位缺失 -> 追问,而不是硬调工具
if (!validPlan(plan)) {
  const missing = (REQUIRED[plan.intent] ?? []).filter((k) => !plan.slots?.[k]);
  if (missing.length) {
    return `还需要你补充一下:${missing.join('、')}`;
  }
  return '这个我暂做不到,换个说法试试?';
}

案例 3:上下文裁剪导致信息丢失——多步任务"忘了"第一步

现象: 三步指令"总结这篇 → 提取待办 → 发给老张",执行到第三步时模型回复"要发给谁?",明明第一步之后已经确认过目标。

原因: 裁剪策略是简单 slice(-3),按条数截断。而每轮会 push 一条动作记录,三步任务到第三轮时,最早那条"用户:发给老张"已经被挤出窗口。条数裁剪对"步数多、单条短"的场景不友好。

修复方式: 改为按 token 预算裁剪 + 关键信息钉住:用户原话和已确认的槽位(如 target)标记为 pinned,永不裁剪;其余动作按 token 从旧到新丢弃。

type Msg = { role: 'user' | 'action'; text: string; pinned?: boolean };

function trimByBudget(msgs: Msg[], maxTokens = 512): string {
  const pinned = msgs.filter((m) => m.pinned);
  const rest = msgs.filter((m) => !m.pinned);
  let budget = maxTokens - pinned.reduce((s, m) => s + m.text.length, 0);
  const kept: Msg[] = [];
  // 从新到旧保留,直到预算耗尽
  for (let i = rest.length - 1; i >= 0; i--) {
    budget -= rest[i].text.length;
    if (budget < 0) break;
    kept.unshift(rest[i]);
  }
  return [...pinned, ...kept].map((m) => `${m.role}:${m.text}`).join('\n');
}

// 已确认槽位钉住
history.push({ role: 'action', text: `已确认 target=${plan.slots.target}`, pinned: true });

案例 4:步数上限触发——任务没做完就被打断

现象: 复杂指令"总结 → 提取待办 → 建日程 → 分享"跑到第 5 步时被强制中断,返回"步骤太多啦",但实际只差最后一步分享。

原因: 上限写死 5 步,且中断时没有把已完成的部分告诉用户,用户以为整个任务失败了,体验很差。另外模型偶尔会在两个意图间来回试探,白白消耗步数。

修复方式: 上限做成可配置,并在触顶时汇总已完成动作 + 给出续做入口;同时对"同一意图连续重复"做去重,避免无效消耗。

const MAX_STEPS = 6; // 按业务复杂度可调,默认 6

// 触顶时汇总已完成动作,而不是一句冷冰冰的提示
if (step === MAX_STEPS - 1) {
  const done = history
    .filter((h) => h.startsWith('动作:'))
    .map((h) => h.split(' ')[0].replace('动作:', ''))
    .join('、');
  return `已完成:${done}。剩下的步骤要不要继续?回复"继续"我就接着做。`;
}

// 同一意图连续重复 -> 视为原地打转,提前终止
if (lastIntent === plan.intent && plan.intent !== 'done') {
  return '我好像卡住了,换个更明确的说法试试?';
}

实测效果汇总

指标优化前优化后
意图识别准确率72%91%
多步任务完整完成率63%88%
端到端耗时(两步指令)3.4s2.1s
假执行(未调工具却称完成)频繁出现0 例
工具异常导致链路中断会中断自动降级

否

是

否

是

否

是

否

否

是

是

用户多步指令

意图识别

槽位完整?

追问缺失参数

意图在白名单内?

兜底拒答

执行本地工具

执行成功?

错误信息回灌模型

结果回灌上下文

模型返回 done?

已达步数上限?

汇总已完成动作并询问续做

基于真实结果回复用户

五、小结与适用边界

端侧 Agent 不是把模型接上就完事,而是意图识别 + 工具闭环 + 受限编排三件套。适合离线助手、隐私敏感操作、本地自动化;不适合需要海量知识或强联网检索的复杂任务。

到底该不该用端侧 Agent,可以按下面这棵决策树快速判断:

不能,隐私敏感

可以上云

是,≤5 步

否,需要长链路推理

否

是

你的场景

数据能否出端?

任务是否可拆成有限步?

优先云端大模型方案

端侧 Agent 很合适

端侧只做意图识别,重活交云端

是否需要海量知识?

直接落地,收益最大

六、实战清单(可直接抄)

  • 所有"动作"必须走注册工具,模型只能决策不能自说自话,杜绝"假执行"。
  • 意图用结构化 intent + slots 输出,别信自由文本,准确率差一大截。
  • 编排循环设步数上限(5 步)+ 显式 done 终止,防模型陷入自我对话。
  • 工具执行结果回灌模型上下文,让它能基于真实结果做下一步决策。
  • 优先把"分享/收藏/搜索/总结"等本地能力工具化,形成可组合能力集。
  • 真机测"多步指令",确认每步真实生效、链路能在离线下跑通。

端侧 Agent 的想象空间很大,但落地的底盘是"决策可信、动作可验"。把意图和工具钉死,小模型也能当靠谱的本地管家。

工程上还有一个关键:模型输出若不可信(如意图不在已注册工具里),要有"兜底拒答"而非硬调工具,否则可能误删或误发。我加了一层白名单校验,未命中意图直接回"这个我暂做不到",安全又诚实。此外,本地 Agent 的上下文窗口有限,多步循环里要定期裁剪历史,只留最近 3 步加关键结果,避免越聊越慢。把这些安全与效率的边界想清楚,端侧 Agent 才既聪明又可靠,敢于真正交给用户日常用。

最后用一张全景图收束全文,把「意图 → 工具 → 编排 → 兜底」四件事串成一条线:

端侧 Agent 落地四件套

意图识别

结构化 intent + slots

触发词 + 反例 + 必填槽位

槽位合法性校验

工具注册

本地能力工具化

白名单 ALLOWED

参数契约校验

受限编排

步数上限可配置

显式 done 终止

结果回灌上下文

安全兜底

未注册意图拒答

异常转可回灌文本

上下文按预算裁剪

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐