意图调了没反应,回传函数摘出来就变成了空壳
一个很消耗人的场景。你照着模板写好了 InsightIntentExecutor,insight_intent.json 也配了,用 hdc shell aa start 在本地模拟,onNewWant 稳稳触发,executeParam 里的 JSON 打印得清清楚楚。然后你接上小艺智能体,对着手机说话,应用安安静静,onCreate、onNewWant、执行器,一个都没进来。日志干干净净,连一行拒绝的痕迹都没有。
本地全绿,云端全静默。你开始怀疑人生:是签名不对?能力没申请?还是 foreground 模式被前台死锁拦了?这些确实都可能,但在把锅甩给云端之前,有一个端侧的问题更值得先看——你用来回传执行结果的那个函数,可能从一开始就没真正生效过。它不抛异常,不打印错误,只是安静地返回一个 undefined,然后调用方一直等到超时。
把 sendExecuteResult 存进变量,它就不是原来那个函数了
insightIntentProvider 下面只有两个回传口:sendExecuteResult 和 sendIntentResult。签名看起来平平无奇,一个 id,一个结果对象,返回 Promise<void>。但真机跑过就会发现一件事:这两个函数只能通过完整的点号链调用。一旦你把它们从命名空间上摘下来,放进一个类型完全对齐的变量里,再调用,行为就变了。
typescript
// 危险写法:函数被摘出宿主
type Sender = (id: number, r: insightIntent.ExecuteResult) => Promise<void>;
const send: Sender = insightIntentProvider.sendExecuteResult;
send(0, { code: 0 }); // 返回 undefined,不抛错,没有 Promise
send(...) 这一行执行完,什么都不会发生。没有异常,没有日志,.catch() 无处可挂,await 一个 undefined 会立刻兑现成 undefined,而不是给你一个“调用失败”的信号。业务上的表现就是:执行器该回传的时候静默跳过,系统侧等到超时,用户看到的是“无响应”。
这个坑的危险之处在于它长得太像正常代码了。把回调传下去、把函数存进配置对象、把发送逻辑抽到一个工具函数里——这些都是日常写法。但 insightIntentProvider 的成员不是普通对象上的方法,它的实现要回到宿主命名空间上去找,摘出来之后那条路就断了。编译器不拦,运行时不报,只有行为悄悄消失。
安全的写法只有一种:把点号链留在原地,用一层转发函数包住它。
typescript
function finishWith(id: number, code: number): Promise<void> {
return insightIntentProvider.sendExecuteResult(id, { code });
}
finishWith 内部仍然是完整的 insightIntentProvider.sendExecuteResult(...),宿主没有丢,Promise 正常建立,.catch() 和 await 才有意义。如果你需要一个可传递的“发送器”,传 finishWith 这个函数本身,不要传 insightIntentProvider.sendExecuteResult 的引用。
intentName 差一个下划线,本地能拉、云端就静默
端侧还有一个对齐成本很低、但排查时很耗时间的点:intentName 的命名。端侧校验规则要求大驼峰,正则大致是 ^[A-Z][a-zA-Z0-9]+$,不允许下划线。CreateCard 能过,create_card 过不了。而小艺开放平台上创建端插件工具时,如果你的命名习惯和端侧不一致,两边对不上,云端投递就会在进入你的应用之前被静默丢弃,本地 hdc 模拟根本模拟不到这一层。
排查顺序可以很简单:打开 insight_intent.json,看 intentName 是不是大驼峰、有没有下划线;然后去小艺开放平台,确认端插件工具名称和它逐字符一致,大小写、数字、一个符号都不能差。intentVersion 同样要对上,端侧 insight_intent.json 里的版本号和平台定义的版本号不一致,也会导致云端不投递。本地 hdc 能拉起只证明 Ability、metadata 和 Want 解析链路是通的,它不证明云端链路也通。
completeArkTSScriptInApp 是唯一完成信号,漏一条分支就是“执行报错”
如果你走的是 ArkTS 脚本方式,完成信号只有一个:scriptManager.completeArkTSScriptInApp。系统侧判断这次执行结没结束,不看你函数有没有 return,不看你异常有没有被捕获,只看你有没有调用这个口,并把 requestCode 原样传回去。
这意味着每一条代码路径都必须走到它:正常完成、参数校验失败、业务异常、超时分支。少一条,系统侧就一直等,最终表现为“执行报错”。很多“本地测都正常、审核必现”的问题,根子就在某条只在审核侧触发的分支里漏了这个调用。
typescript
import { scriptManager } from '@kit.AbilityKit';
async function run(ctx: Context, requestCode: string): Promise<void> {
try {
const params = parseParams(argv);
if (!params.ok) {
// 参数校验失败也必须走完成信号,不能静默 return
await scriptManager.completeArkTSScriptInApp(ctx, requestCode, {
code: ERR_INVALID_PARAMS,
result: { message: params.reason }
});
return;
}
const data = await doBusiness(params);
await scriptManager.completeArkTSScriptInApp(ctx, requestCode, {
code: 0,
result: data
});
} catch (err) {
// 异常路径同样要回包,不能把错误吞掉等超时
await scriptManager.completeArkTSScriptInApp(ctx, requestCode, {
code: ERR_INTERNAL,
result: { message: (err as Error).message }
});
}
}
requestCode 必须原样透传,不能自己生成一个新的;ExecuteResult 的结构要和 SKILL.md 里声明的分支对得上,不能这边回一个 code: 0,那边期望的是另一个字段名。把完成信号当成“业务成功才需要调的东西”,是这类问题最常见的误解。
参数从外部进来,不要先 Number 再判断
意图参数来自语音、智能体或系统入口,它是不可信输入。一个很自然的写法是拿到 count 之后 Number(raw.count),再判断范围。空字符串会变成 0,1.5 会被当成合法数字,"abc" 变成 NaN 之后如果判断写得不严,还可能溜进默认分支。
更稳的边界是白名单加严格类型校验,在进入业务之前把参数收敛成一个冻结的规范化对象。count 必须是 Number.isSafeInteger 为真的整数,action 必须在允许的枚举里,未知字段直接拒绝,不做“先转换再判断”。
typescript
function normalize(raw: unknown): { action: string; count: number } {
if (typeof raw !== 'object' || raw === null) throw new Error('invalid_payload');
const r = raw as Record<string, unknown>;
if (r.action !== 'preview' && r.action !== 'export') throw new Error('unknown_action');
if (typeof r.count !== 'number' ||
!Number.isSafeInteger(r.count) ||
r.count < 1 || r.count > 20) {
throw new Error('invalid_count');
}
return Object.freeze({ action: r.action, count: r.count });
}
Object.freeze 不是为了性能,是为了防止后续代码拿到这个对象之后又把它改了,导致“校验时是合法的,执行时已经不是那个值”。
多步骤操作没有补偿,重试一次就多一份脏数据
意图执行经常不是单步。先创建草稿,再加入索引,两步都成功才算完成。如果第一步成功、第二步失败,你的代码只回了一个 code: 非零,界面提示“执行失败”,但草稿已经真实存在了。用户重试一次,又创建一份新草稿,旧的那份永远没人清理。
补偿逻辑不需要做成通用事务框架,但至少要满足三条:每一步有明确的回执或可查询的 ID;失败时按逆序执行补偿,且补偿本身是幂等的;补偿动作要持久化,不能只活在一次调用的内存里。创建草稿返回的 draftId 要交给补偿函数,索引失败时用它去删草稿;删除接口要能接受“已经不存在”作为成功,而不是抛一个“找不到”把补偿也卡住。
日志要能回答“走到哪一步停了”
意图链路的静默失败,最后往往靠日志定位。不要只在入口打一行“收到意图”,在回传口打一行“回传成功”。中间的参数规范化、业务分支、补偿步骤、完成信号调用,每一步都应该有一个带 requestCode 或 instanceId 的日志锚点。排查时先看 hilog -T Intent 过滤意图相关日志,确认 Want 有没有进来;如果 Want 进来了但执行器没动静,往执行模式、路由和参数上查;如果执行器进了但调用方超时,先检查回传函数是不是被摘出了宿主,再看 completeArkTSScriptInApp 是不是漏了某条分支。
综上所述,意图框架的“静默”不是一处藏起来的 bug,而是一组分布在回传、命名、完成信号、参数和补偿上的隐性契约。insightIntentProvider 的函数必须留在点号链上,摘出来就是空壳;intentName 端侧大驼峰、平台逐字符一致,差一个符号云端就不投;完成信号是唯一出口,每条路径都得走到;外部参数先校验再进业务,不要先转换;多步骤操作失败先补偿再重试,别让脏数据跟着重试一起堆。下一次意图“调了没反应”的时候,先别改 onNewWant,打开执行器看一眼回传那一行——它很可能正在安静地返回 undefined。
更多推荐


所有评论(0)