1. “skills”不只是个关键词:先把概念门槛跨过去
上个月我在一个模拟项目X里做了一个小实验:让AI助手通过一句日常问话,直接触达内部工具的核心功能,整个过程没有人工点击任何菜单,系统自动完成了意图识别、参数提取、权限校验和动作执行。这个体验背后,就是整套skills体系在工作。我今天想聊的,就是“skills”这套东西从概念落到可用、好用、能上生产环境,中间到底隔着哪些工程细节。
我知道很多人第一次接触“skills”,是因为看了一些平台宣传稿或者智能体演示视频,觉得挺神奇,但真到自己动手做的时候,反而不知道从哪里下手。这个东西不像写一个普通接口,你把路由配好、参数校验做好、返回数据格式化一下就成了。技能(skill)的本质是一套“意图-动作-回执”的完整闭环,系统负责理解用户说的话,你的代码负责把用户想要的结果真正做出来。如果你只把它当成一个API来写,大概率会翻车。
这篇文章没有平台偏好,而是站在开发者视角,把“技能”从设计到上线的全链路拆开讲清楚,包括意图描述怎么写、参数怎么设计、代码怎么组织、调试怎么搞、上线后怎么迭代。无论你是在做移动端App、智能硬件、企业内部工具,还是独立开发者的身份,这套方法论基本都能直接套用。
1.1 一句话讲清技能是什么
你可以把技能理解成“给AI助手装上的一个专业插座”。用户不需要知道你的App内部怎么组织,他只需要说一句人话,比如“帮我定个明天早上8点的闹钟”,系统就会判断这句话该交给哪个技能处理,然后从这句话里抽出时间、动作等关键信息,调起你预先写好的执行逻辑,最后把结果反馈给用户。
整个闭环里,有三个组成部分缺一不可:一是意图描述,也就是你用自然语言告诉系统“什么样的用户请求归我管”;二是参数结构,也就是你希望系统从用户话里提取哪些信息,比如时间、地点、数量;三是动作执行器,也就是真正干活的代码。三者缺一个,技能就跑不起来。
这个类比可以再延伸一下:一个普通插座,谁插上都能用;而“专业插座”意味着只有插头形状匹配的设备才能接上。系统会先做“形状匹配”,也就是意图理解,匹配成功后才会把电流送进去。所以,技能开发的核心工作量,并不全在写代码上,反而有一大半花在“怎么让系统准确识别出该用你”这件事上。
1.2 什么样的能力值得做成技能
我见过不少开发者,恨不得把App里所有功能都包成技能,这种热情可以理解,但上线之后往往效果很差。原因很简单:不是所有功能都适合通过自然语言来触发。
我自己在判断某一个能力是否适合做成技能时,会用三个标准去过一遍:是否高频、是否重复、是否有明确的输入输出。举个正面例子,某个提醒类App里的“定时提醒”功能,用户天天要用,表述方式五花八门但核心语义稳定(“八点叫我”“半个小时后提醒我”“明天上午开会前通知我”),这个就非常适合。再举个反面例子,一个图片编辑App里的“高级滤镜参数微调”,这个功能本身是低频长尾操作,用户自己都不知道想要什么效果,做成技能以后系统识别出来了也不知道该怎么回答,这就是典型的“能做但没必要”。
另外还要考虑一个因素:用户会不会愿意用自然语言去说这件事。如果一句话能说清楚,而且说完以后他明显比手动操作快,那就值得做;如果他要描述半天,你还得反复追问,那这个技能体验就很糟糕。与其硬做,不如先砍掉。
| 适合做成技能 | 不适合做成技能 |
|---|---|
| 高频且重复的操作类需求 | 低频、探索性的长尾功能 |
| 有明确的输入参数,比如时间、地点、数量 | 需要用户先看懂界面才能操作的功能 |
| 结果可以简洁回执给用户 | 结果依赖复杂交互或可视化 |
| 用户有固定说法习惯 | 用户自己都描述不清需求 |
1.3 技能和“App里的一个API”有什么不一样
这是很多人容易混淆的地方。普通API是App主动去调的,调用链路完全由你控制,前端点击某个按钮,后端返回数据,逻辑清晰,调试也方便。但技能不一样,它是“被动等待系统调度”的:用户说的话先经过系统,系统判断意图,再决定要不要调用你。你没有办法预测用户下一句会说什么,也没有办法控制系统什么时候触发你。
这个“被动”属性会带来连锁反应。第一,你的技能描述文件必须标准化,系统要靠它来决定“什么情况派你上场”,写得模糊,系统就会把你漏掉或者误派给其他技能。第二,你的动作执行器必须做成无状态的、可重入的,因为用户可能在一句话里连续触发两次,比如“帮我定一个明早8点的闹钟,再定一个9点30分的提醒”。第三,你的回执信息要尽量简短清晰,因为它是被“插”在系统对话里的,不是展示在你自己的App页面里。
所以,技能本质上是你把业务逻辑的“入口控制权”让渡给了系统。你在享受系统带来的流量和分发能力的同时,也得接受一种新的工程约束:你不再清楚用户是怎么走到这一步的。
2. 动手之前最重要:场景、意图描述与参数设计
很多人一上来就写代码,结果写完了才发现系统压根识别不准。技能开发真正的第一仗,不是在编辑器里,而是在纸上。你需要先把场景锁死、把意图描述写透、把参数边界划清楚,再动手写实现。
2.1 选场景:从“高频、重复、痛点”三个词出发
我上一次做技能,选的是“设置定时器”这个非常不起眼的能力。为什么选它?因为我在整理用户反馈时发现,这个动作在我们的App里被高频执行,而且用户常常在双手没空的时候操作——比如做饭、健身、开车前设提醒。这种场景非常符合自然语言交互的优势区间:单手操作都嫌麻烦,嘴说一句是最快的。
选场景的时候,我会把候选功能列出来,然后逐个问三个问题:用户在这个功能上是不是每天都用?操作路径是不是超过三步?手动操作时用户是不是经常抱怨“太麻烦了”?三个问题里如果至少中两个,那么这个场景就值得做技能。如果只有“高频”没有“痛点”,比如只是每天看一眼天气,那其实是系统级模板就能覆盖的,做成你自己的技能反而多余。
选定之后,还要进一步划清边界。比如“设置定时器”,边界就是“创建一个新的倒计时”,至于“暂停、取消、查看剩余时间”这些操作,我建议先不要塞进同一个技能里。技能的功能边界越清晰,系统做意图分类的时候就越不容易纠结,识别准确率会明显更高。贪多往往是识别率的头号杀手。
2.2 意图描述:写得越像用户说话,识别就越准
一个技能识别准不准,七成功劳压在意图描述上。很多人写描述习惯于“函数式”写法,比如“创建一个新的定时器对象,参数为时长,单位为秒”,这种描述看起来严谨,但用户根本不会这么说话,系统在匹配的时候也容易找不到关联。
我第一次做技能时犯过这个错。我写的是“设置定时器,用于创建倒计时”,结果实测用户说“帮我计个5分钟”“泡面好了叫我”“20分钟后喊我”,系统全都识别不到。后来我把描述改成了:
- “用户想设置一个倒计时,可能会说:定个5分钟、计时10分钟、帮我计个时”
- “用户可能在做饭、运动、等待时使用,语气偏口语,不一定会提到‘定时器’三个字”
- “如果用户提到了具体的时间长度,比如5分钟、半小时、1小时,就属于这个技能”
改完之后,识别率一下子从刚过50%升到了八成以上。原因也不复杂:系统做语义理解时,靠的是文本语义的相似度匹配,它需要看到足够多的“同义表达”和“场景词”才能建立关联。你描述里覆盖的表达越丰富,模型越容易把你的技能“召”出来。
但这里也要注意分寸。意图描述不是把用户话术抄一遍就完事,而是要在描述里说清楚“什么样的语义算这个技能”,以及“什么样的语义不算”。比如说“帮我定个明天早上8点的闹钟”和“定时10分钟”虽然都要用定时能力,但它们对应的是不同的技能,如果你想统一处理,就要在描述里把两边都覆盖到,并且明确区分边界,否则系统会全都派给它,造成误触发。
2.3 参数设计的三个决策点
参数设计直接决定了你这个技能能做到多“聪明”。我通常会从三个维度去决策:类型、必填性、默认值。
类型方面,能用结构化类型就尽量不要用裸字符串。比如时间参数,一定要用日期时间类型,这样系统可以自行解析“明天早上8点”“半小时后”“周五下午”这些自然语言表达,而不用你在代码里写一堆字符串解析逻辑。再比如数量参数,用整数类型,系统可以直接把“5分钟”“半小时”“1小时”解析成对应的数值和单位。
必填性方面,我的原则是“能设默认值就绝不强制用户填写”。比如定时器技能,如果用户只说“帮我计个时”,没说多久,系统可以主动追问一次“要计多久”,也可以直接走默认值策略。我实测下来,追问一次的成功率远高于默认值直接执行——因为用户大概率真的有自己的需求,他只是忘了说。但无论如何,参数设计时一定要考虑“缺失值”的兜底路径,否则用户一句话里漏了一个参数,你的动作执行器直接空指针,体验就是灾难。
枚举约束也很有用。如果你的技能涉及单位选择,比如分钟、小时、还是秒,与其等用户说一个词让你解析,不如给系统提供候选列表,这本身就是一种纠错。系统在抽取参数的时候会更“有底气”,你的执行器也能少做一层防护。
3. 从设计走向代码:技能原型的搭建过程
设计做扎实了,后面的代码反而比较顺。我倾向于把整个技能拆成三个文件来落地:技能声明文件、意图处理器、业务服务层。这里说的文件划分不是某个平台的强制要求,而是我习惯的工程组织方式,目标是让每层各司其职,后续调试定位问题也方便。
3.1 技能声明文件:系统的“自我介绍”
技能声明文件是一段结构化的元信息,系统依靠它来注册这个技能。它通常包含技能名称、唯一标识、意图描述、参数定义和处理入口。很多平台给了可视化的配置界面,但我还是建议你在代码仓库里直接维护声明文件,这样方便做版本管理和代码评审。
一个典型的技能声明文件大致长这样:
skill_id: timer_create name: 设置倒计时 description: | 用户想创建一个倒计时,最长不超过2小时。 常见触发表达:定个5分钟、计时10分钟、帮我计个时。 如果用户想暂停、取消或查询剩余时间,则不属于本技能。 input_schema: - name: duration_value type: integer required: true description: 倒计时时长,单位由 duration_unit 决定 - name: duration_unit type: string enum: [秒, 分钟, 小时] required: false default: 分钟 description: 倒计时的单位 handler: SkillTimerCreate这里面最关键的两个点,一个是description写得够不够生动,另一个是input_schema里的参数约束是否给出了足够清晰的候选范围。描述不是给人看的注释,它是给“语义匹配模型”吃的素材。参数约束则是给“实体抽取模型”用的提示,两边配合好了,系统才能在一个句子里同时完成“该不该调”和“抽什么信息”两步判断。
3.2 意图处理器:保持轻薄的执行层
意图处理器负责接收系统传进来的参数、调用业务逻辑、返回回执文本。我的经验是,处理器要尽量薄,它只做三件事:参数二次校验、调用业务层、组装回执。复杂的业务计算,比如时长换算、排期冲突检测,都不要堆在这个文件里,否则一旦出错,你很难分清是系统参数传错了、还是业务逻辑本身的bug。
写处理器时可以参考这样的伪代码结构:
func handle(intent: TimerCreateIntent) async -> Response { // 1. 参数兜底:防止系统传了异常值 let duration = clamp(intent.durationValue, in: 1...7200) let unit = intent.durationUnit ?? .minute // 2. 调用业务层,真正创建倒计时 let timer = try await timerService.create( duration: duration, unit: unit, source: .assistant ) // 3. 组装回执,尽量简短 let reply = "好,已为你设置 \(duration) \(unit.rawValue) 倒计时" return Response(text: reply, status: .success) }这里有个容易被忽略的细节:业务来源标记。我习惯在业务层保留一个source字段,记录这次操作是用户手动点击产生的,还是通过技能调用产生的。这个字段平时看起来没啥用,但当你做数据分析、排查线上问题、衡量技能使用率的时候,它是第一手的判断依据。没有这个标记,技能上线后再想区分流量来源,就得翻日志硬啃。
回执文本也值得琢磨一下。系统对话里通常不需要你的App给出一大段结果说明,用户要的是一个干净利落的确认。我写回执时,会尽量控制在20个字以内,并且把最关键的信息——时长、动作、状态——放在最前面。如果你的技能需要返回卡片或者富文本,也要把默认文本版本准备好,保证系统在没有渲染条件时也能读出关键信息。
3.3 授权与数据上下文:技能访问资源的正确姿势
技能经常会访问用户的私有数据。比如一个“创建待办”的技能,需要写入用户的任务列表;一个“查天气”的技能,需要读取地理位置。这里的第一原则是权限最小化:技能只用它需要的数据类型,绝不顺带请求多余权限。
很多人会忽略的是,技能的授权和用户主动打开App时的授权,在交互上完全不同。用户手动操作时,系统弹窗出现的位置和时机你还能预判;而技能调用是“用户话音刚落,动作就执行”的节奏,授权弹窗如果处理不好,会让整个对话链路变得很生硬。我建议在技能设计阶段就把“无授权时怎么办”考虑清楚,而不是等到代码里遇到权限被拒才手忙脚乱。
另外,如果技能和你的App需要共享数据,比如App里建了一个列表,技能要在同一个列表上追加一条记录,那么就涉及数据共享容器的问题。我踩过一次坑:技能侧写入的数据,App端读不到,排查了半天发现是用了不同的容器标识。这类问题一旦上线,用户侧感知倒不明显,但你自己分析数据时会非常痛苦。所以,容器标识、数据格式、读写时机,一定要在原型阶段就统一好。
4. 自然语言系统最见真功夫:调试与测试实操
普通的代码调试,你打断点、看堆栈、复现路径,基本就能定位问题。但技能调试多了一个变量,就是自然语言本身。同一个意图,用户可以说出无限多种表达,你根本不可能穷举所有可能,这就导致技能调试很像“在迷雾里开地图”,你只能一点点点亮已知区域,然后祈祷未知区域没有大坑。
4.1 搭一个最小可用的测试闭环
我每做一个技能,都会先搭一个最小测试闭环,而不是等整个App写好再去联调。这个闭环由四部分组成:模拟器环境、日志输出、真实语料集、回放脚本。
模拟器环境负责测试参数解析和动作执行,先在模拟器里确认“系统识别到的参数”是什么,再确认“执行器拿到的参数”是什么。日志输出是重点,我会在每个关键节点打日志:意图被命中时打一条、参数抽取完成时打一条、处理器开始执行时打一条、业务层返回结果时打一条。不要觉得打日志啰嗦,实际排障时你恨不得每个节点都标记一下。
真实语料集是准备一批用户可能说的自然语言句子,逐条喂给系统,看系统是否都能准确匹配到你的技能。回放脚本则是把这些语料串起来,模拟连续对话场景,用于验证上下文衔接是否有问题。
这一套下来,你基本能在不依赖真实用户的情况下,把技能的“确定性”部分摸透。剩下那些“非确定性”的部分,就是自然语言理解本身的天花板,也是后续需要持续投入优化方向的。
4.2 我踩过的三个测试坑
第一个坑是歧义语料。有一次我测试“帮我定个闹钟”,系统抽出来一个时间参数,我心想这不对啊,用户没说具体时间。研究半天才发现,没加required约束的时间参数会让系统自行补一个“当前时间”,这非常反直觉。从那以后,我的每一个参数都会明确标注“是否允许系统填充默认值”。凡是用户没说而业务上又不能确定的,一律设置为缺失,然后走追问逻辑。
第二个坑是空槽位与追问链路。系统抽不出参数时,通常会触发追问,但很多默认追问写得很机械,比如“你想设置多少分钟?”用户回答“10分钟”后,系统重新进入技能。问题是,如果第一次用户说的是“帮我定个闹钟”,第二次说“10分钟”,系统很可能判定这是新一句话,又跑回到技能注册表里去匹配,结果匹配到别的技能。这个问题的根源在于测试时只测了单轮问答,没有测试多轮连续输入。我现在测技能,一定会把追问后的第二轮、第三轮输入也纳入测试用例。
第三个坑是并发与重复调用。有段时间我们App里加了一个新技能,结果用户反馈说“设了两次提醒”,排查之后发现是同一个意图被系统同时分发给技能的两个实例,两个实例各自执行了一次。这其实不是系统故意造成的,而是执行器没有做幂等控制。后来我给关键操作加了一个幂等键,同一句话产生的调用只允许成功一次就算通过。判断药物“相同”,最后只看成分表。
4.3 用“语料库回归”守住技能质量底线
技能识别准确率这件事,最怕的不是一开始低,而是“改了一版描述之后突然掉分”。我建议每个技能都建立一个语料库回归集,大概50到100条就够用了,包含正常请求、口语化表达、边界情况和明显不该触发本技能的反面例句。
每次修改意图描述、调整参数结构以后,不要急着重新跑App全流程,先把这个语料回归集跑一遍。跑完之后你会得到一张准确率对比表,比如从82%涨到87%,或者从90%掉到84%。有了这张表,你就能判断这次修改到底是正向优化还是负向调整,而不是靠感觉“好像识别得更准了”。
这个习惯救过我一次:有一版我为了让某个参数的抽取更准,加了很多同义词,结果回归测试发现一个反面例句突然被误判了。原来是这些同义词让系统把一个完全无关的请求也匹配了进来。如果不是先把回归集跑了一遍,这个误判上线后就会变成真实用户的困惑。
5. 上线只是开始:性能、隐私与迭代节奏
技能做完原型、测试通过,你可能会觉得大功告成,但真正上线之后才是问题集中爆发的时候。这个阶段,我关注的核心不是功能了,而是性能、隐私和版本演进这三件事。
5.1 性能量化:慢半秒,体验差一大截
自然语言交互对延迟的容忍度比手动操作低得多。用户手动点一个按钮,等1秒他可能还忍得住;但他说了一句话,如果等两秒才得到回复,他大概率会觉得“这个AI好笨”。所以我在技能上线前会设定一组可量化的性能目标,并且把这个目标写进测试断言里,而不是靠人工感受去判断。
| 指标 | 目标值 | 说明 |
|---|---|---|
| 意图识别耗时 | 小于300ms | 系统从听到话到识别意图的时间 |
| 动作执行耗时 | 小于700ms | 从系统调起你的执行器到返回回执 |
| 失败重试次数 | 单次请求不超过1次 | 超过即视为失败,走兜底文案 |
| 日志完整率 | 100% | 每个关键节点必须有日志 |
这些目标值不是拍脑袋定的,而是我尝试过不同阈值后总结出来的体验边界。你可以根据自己的场景调整,但一定要有明确数字,否则性能优化无从谈起。
5.2 权限最小化与数据留存策略
技能上线以后,你会拿到大量用户对话日志。这些日志对调优意图识别非常宝贵,但也意味着你要承担不小的隐私责任。我的建议是,所有日志默认只保存业务层面抽出的结构化参数,比如duration_value: 300、unit: 秒,不保存用户说的话原文。
有些平台可能会把用户原始语料放在统一管道里做质量分析,但作为技能开发者,你应该在业务侧明确自己的数据边界:不做超出执行技能的额外分析,不把对话原文用于其他模型训练,不在客户端留存敏感数据。这些边界要写进你的技能配置文件里,不是口头承诺。
权限最小化还有一个隐蔽的好处:一旦权限请求变少,系统对技能的可信度评级也会相应提升,从而获得更稳定的分发流量。这算是合规之外的意外收益。
5.3 灰度发布与版本兼容
技能也是代码,也要灰度发布。我通常用三步走:先是内部开发环境验证,功能完整、回归集通过;然后放给一个小规模用户群体,观察真实识别率、失败率、用户反馈;最后再全量放开。
这里要特别小心意图描述的变更。描述是“排序模型”的输入,你改几个词,识别率可能就会变化。灰度期间要同时对比新旧描述版本在同一批语料上的识别率,差异超过两个百分点就需要慎重,不要因为新描述“感觉更全面”就急着全量。
版本兼容方面,技能声明的参数结构一旦新增或删除字段,会影响所有已安装的客户端。旧版本App可能还在用旧参数结构调用你的执行器,所以新增参数时必须提供默认值,删除参数时要保留一个过渡期兼容逻辑。我见过一些技能上线后出现“用户喊了好几次,App没反应”的情况,最后排查发现是客户端版本太旧,发出的请求里少了新必填字段。这种问题只靠服务端日志很难发现,因为你看不到客户端那边为什么没调起来。
6. 几条可以照抄的实战心得
写到这里,技术链条基本过完了。最后分享几条我自己的实操心得,不一定适用于所有场景,但每一条都是踩过坑之后沉淀下来的,可以直接拿去用。
6.1 三个“改了马上见效”的优化点
第一个优化点是在意图描述里批量加入同义词和场景词。项目上线初期,系统反馈“识别成功率83%”,我看了一下,发现描述里全都是书面语,缺少“泡面”“喊我”“叫醒”“倒计时”这类口语词。于是我把用户真实语料里的高频表达逐条补进描述,跑了三轮,识别率提升到了88%以上。这个动作不需要改任何代码,只是动描述文件,见效极快。
第二个优化点是给参数加默认值而不是一上来就追问。我第一次做技能时,所有参数都设成必填,结果每个用户进来都要被追问三轮,流失率很高。后来我复盘发现,有些参数其实可以给默认值,比如单位默认“分钟”,用户没提到单位也能直接执行。追问只在真正会改变执行结果时才需要做,比如时间没说,就不能默认。
第三个优化点是把日志结构化。早期我的日志都是杂乱的print,出了问题要翻半天。后来我把每条日志统一格式化成[skill_id][event][duration_ms][params],配合一个简单的脚本就能一键回放每次调用的完整链路。这个习惯帮我节省了至少一半的排障时间。
6.2 一个提升识别率的土办法:收集真实口语语料
最后一个建议听起来很简单,但很多人就是不做:上线前找5个完全不了解这个技能的人,让他们用最自然的口语去发起请求,把他们的原话逐条记录下来。
你可能会发现,他们根本不按你设计的路径说话。有人会说“帮我设个倒计时,感觉时间有点紧张”,有人会说“你能不能一会儿提醒我”,甚至有人会说“弄个30秒的”。这些从真实用户嘴里冒出来的话,是你意图描述优化的最佳素材,比你自己坐在那里臆想一百句“标准表达”都有用。
我自己每次看到识别率因为这些真实语料回升,都会觉得这个循环很值得坚持。技能优化本质上不是算法竞赛,而是“你越了解用户怎么说话,你就越能把技能做成他们想要的样子”。这个循环已经变成了我每个技能上线的固定仪式,你也可以试试。