1. 从"会聊天"到"会干活":agent-skills到底在解决什么问题
今年我大部分时间都在跟"让大模型真正干成一件事"较劲。聊过天的人都知道,GPT、Claude这类模型文本生成能力很强,可你真让它"帮我把上个月华东区的订单按金额排个序,再算一下退款率",它就露馅了——它既没有订单库的访问权限,也没有执行查询的能力,只能给你编一份看着像样、实际对不上的数据。这就是agent-skill要解决的核心矛盾:大模型是推理引擎,不是执行引擎。它知道"怎么做"的路径,但缺一双"能动手"的手。
所谓skill,其实就是把模型之外的真实能力封装成一个个可描述、可调用、可验证的最小单元。一个查库存的skill、一个发邮件的skill、一个操作数据库的skill,背后对应的是真实系统的接口、函数或脚本,而大模型负责三件事:判断该用哪个skill、把用户的话转成skill需要的参数、把skill的返回结果组织成人话。这个分工一旦清晰,agent就从"嘴替"变成了"助理"。
这个抽象不是某个框架发明的,而是大家做了一阵子之后自然收敛出来的共识。早期做agent的人各有各的叫法,有人叫tool、有人叫plugin、有人叫action,后来"skill"这个词越来越常见,因为它比tool强调"能力"而非"工具",比plugin强调"单体可插拔"而非"整套扩展"。名字不重要,关键是这样几个特征:
- 可描述:能被模型理解它的功能、适用范围、触发条件。
- 可调用:有明确的函数签名、入参出参,能被程序真正执行。
- 可验证:执行结果可以检查,出错能定位,不会稀里糊涂。
- 可组合:一个复杂任务可以由多个skill按顺序或条件拼装完成。
过去两年我见过太多demo级agent的失败模式:模型选错工具、参数传错、结果没人校验、错误直接暴露给用户。问题不出在模型聪明不聪明,而在于底层skill层的设计太粗糙。所以这篇想把agent-skills从"概念"讲到"落地",重点放在我实际做过的技能调度系统、踩过的坑、以及一套可以照搬的评测思路上。内容主要面向正在做agent应用、或准备把LLM接进业务系统的工程师,也适合产品经理理解为什么"给agent加个功能"不是一个prompt能搞定的事。
2. 一次真实的技能接入:从需求拆解到函数签名落地
先说一个我反复讲给团队听的例子:给电商运营agent加一个"查库存"技能。看起来很简单对吧?但真正动手拆解时,会发现至少冒出一堆问题:按SKU查还是按品类查?要不要带仓库维度?过期库存算不算?返回格式是列表还是汇总?模型怎么知道用户说的"那批货"对应哪个SKU?
2.1 把一个业务需求拆成模型能用的函数签名
我习惯先写一段"能力说明",用大白话描述这个skill到底能干什么,再倒推函数签名。比如查库存的需求,运营实际会说的话包括:"A1001还剩多少""华东仓的iPhone 15库存怎么样""那个蓝色的SKU还能发几天"。从这三句话能看出,一个"查库存"接口至少需要支持两种入参风格:精确SKU查询、按条件过滤查询。
所以接口设计成:
def query_inventory( sku_id: str | None = None, category: str | None = None, warehouse: str | None = None, include_zero: bool = False, ) -> list[InventoryItem]: """查询商品库存。 sku_id: 精确的SKU编码,格式如A1001。 category: 品类名称,支持模糊匹配,如"iPhone"。 warehouse: 仓库名称,如"华东仓";不传表示全部仓库。 include_zero: 是否包含零库存记录,默认False。 """模型侧看到的不是Python函数,而是对应生成的JSON Schema。这一步极其关键,因为模型的参数抽取完全依赖schema里的类型和description。我在生产里见过最离谱的一次,模型把用户说的"库存别太少"理解成了include_zero=True,理由竟然是description里出现了"零"字。所以description要写清楚语义边界,而不是罗列字段含义。
2.2 JSON Schema描述里的"措辞陷阱"
给skill写schema描述,我有一条铁律:描述的是"做什么",而不是"是什么"。举例:
{ "name": "query_inventory", "description": "查询商品在指定仓库的实时库存数量。当用户询问现货量、剩余量、可发量、SKU库存时使用。不适用于查询历史库存或成本价。", "parameters": { "type": "object", "properties": { "sku_id": { "type": "string", "description": "精确的SKU编码,如A1001。用户说出完整编码时填写。" }, "category": { "type": "string", "description": "品类或商品名称,支持模糊匹配,如'iPhone'、'蓝色卫衣'。" }, "warehouse": { "type": "string", "description": "仓库名称,如'华东仓'。不传表示查询全量仓库。" } } } }这么做的好处是,把"什么情况下触发"写进了description,模型在做技能选择时就有了明确指引。实际效果上,带触发条件描述的skill,召回率能比只写函数功能的高出将近二十个百分点——这个数字不是我编的,是我在内部评测集上跑出来的对比结果。
2.3 返回值设计:给模型留好"表达素材"
很多人只关注入参,忽略了出参结构对模型回答质量的影响。比如query_inventory返回一个列表,每项含sku_id、name、warehouse、quantity、status。模型拿到这些数据后,才能组织出"A1001在华东仓还有320件,状态正常"这类回答。反过来,如果接口返回的是已经格式化好的字符串,模型反而失去了解释和推理的空间,遇到"哪些SKU低于安全库存"这类追问就答不上来。
所以我现在的做法是:出参尽量结构化,字段粒度尽可能细,让模型自己决定怎么汇总、怎么展示。这也方便后续做结果校验——结构化数据才能程序化检查,纯文本没法断言。
3. 技能调度的核心链路:意图识别、参数抽取和路由分发
skill本身只是"能力",真正让agent像样的是它背后的调度逻辑。一个典型的技能调用周期是这样的:用户输入进来,先判断有没有命中的skill,再抽取参数,然后执行,最后把结果交给模型组织回答。看起来就四步,但每一步都有不少门道。
3.1 原生function calling和自建路由器的取舍
大多数情况下,我直接用模型的function calling能力做"意图识别+参数抽取"两步。以OpenAI系为例,你把所有skill的schema传给模型,模型会返回该调用哪个函数、参数是什么,这是最省事也最稳妥的路径。但有一个前提条件:skill总量别太多。我实际测试下来,一次性暴露超过20个schema时,模型的选择准确率会开始下滑,到50个以上时下滑非常明显。
如果你要管理几十上百个skill,自建路由器是更好的选择。我的做法是先做一层"预筛":用模型把所有skill按语义聚类到一个目录树里,比如订单类、库存类、营销类、售后类,然后外部请求先经一个轻量分类模型或关键词倒排索引定位到子目录,只把子目录里的几个schema丢给模型做精确选择。这相当于给技能的查找做了一层索引,成本和效果都划算。
3.2 参数抽取:这是幻觉重灾区
参数抽取阶段我踩过最多的坑,归纳起来有三类:
- 编造参数:用户没提仓库,模型擅自填了一个"华东仓"。这类最危险,因为接口能查到数据,结果也"正常",用户根本发现不了被误导了。
- 过度泛化:用户说"看看还有多少货",模型不知道该填什么就给
sku_id编个A1001。严格来说这不算模型错,是skill设计不够好,缺一个"无明确标识时按条件查询"的入口。 - 参数类型错误:schema里写了integer,模型传了字符串"10",后端一校验就报错。
针对编造和过度泛化,我的方案是双管齐下。一方面在description里明确写"如果用户未提及,不要猜测,保持空值",另一方面在后端加一层参数合理性校验:仓库名必须存在于仓库字典、SKU编码必须匹配格式,校验不过就返回"参数校验失败"给模型,让它重新问用户。这比让模型事后自行纠正可靠得多。
3.3 无命中时的降级策略
用户的表达不可能永远落在skill覆盖范围内。无命中时,如果直接对用户说"抱歉我做不到",体验很差。我现在的降级链路是这样:
- 尝试同义改写后重新匹配一轮;
- 如果还没有命中,进入"知识性回答"模式,用模型自身能力给出通用回答,但明确标注这不是实时数据;
- 同时记录这次未命中query,沉淀到日志里,定期分析补skill。
这套链路跑下来,用户的"无效提问"比例从最初的30%以上降到了个位数。背后逻辑很简单:agent的价值是把不确定性转成确定性,而不是把所有不确定性都推给用户。
4. 技能仓库的组织方式:单体注册表、分层目录与动态装载
skill少的时候,写个if-else或者dict把函数名映射一下就行。但skill数量过了50个,还在用同一个注册表硬撑,迟早会因为命名冲突、加载顺序、版本不一致等问题崩溃。我经历过一次线上事故,起因就是两个skill都注册了get_order,后加载的把先加载的覆盖了,用户问订单状态,返回的却是统计口径完全不同的接口数据。那次之后我彻底重构了技能仓库。
4.1 用一个类实现技能注册和发现
我的核心抽象是SkillRegistry,每个skill都是一个Skill类的实例,元信息包括名称、版本、描述、schema、执行函数、权限标记。注册表负责三件事:注册、查找、生命周期管理。
class Skill: def __init__(self, name, version, description, parameters, handler, required_role=None): self.name = name self.version = version self.description = description self.parameters = parameters self.handler = handler self.required_role = required_role @property def schema(self): return { "type": "function", "function": { "name": f"{self.name}_{self.version}", "description": self.description, "parameters": self.parameters, } }注册表的查找逻辑用了个很朴素的思路:按名称精确查找 + 按描述关键词倒排索引。任何技能注册时都会被解析出一组关键词,存进一个内存里的倒排表。这样外部请求进来,先用关键词粗筛缩小范围,再交给模型精排。这个设计让模型的schema输入窗口始终保持在20个以内,命中率稳定。
4.2 用目录结构管理技能和配置
代码组织上,我按领域把skill拆成独立目录,每个目录一个包:
skills/ inventory/ __init__.py query.py adjust.py order/ __init__.py create.py cancel.py marketing/ coupon.py这种组织方式的好处是边界清晰,每个人只维护自己负责的目录,互不干扰。每个skill文件里除了实现函数,还会声明自己的元信息,用一个register装饰器挂到全局注册表:
@register class QueryInventorySkill(Skill): name = "query_inventory" version = "1.2.0" description = "..."4.3 动态装载和热更新
skill上线不可能每次都重启主服务,所以热更新是刚需。我用importlib实现了一个简单的动态装载器:监听技能目录的变更,发现新文件或版本号变化就重新加载对应模块,老版本继续保留一小段时间供运行中的请求收尾。
import importlib.util def load_skill_module(module_path, module_name): spec = importlib.util.spec_from_file_location(module_name, module_path) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) return module这里有个容易忽略的坑:动态加载的模块在Python里不会自动卸载,重复加载同一个模块会覆盖旧对象,但旧对象可能还持有连接池或全局变量。我后来强制要求每个skill模块不允许持有全局状态,所有连接都通过上下文传入,才彻底解决这个问题。技能模块越无状态,动态装载越安全,这条经验值五个星。
5. 实站排障:技能冲突、幻觉参数和响应超时
喊了这么久的"踩坑",这部分是重点中的重点。我把自己真实碰到过的三个经典问题完整复盘一遍,每个问题的排查链路都比答案本身有价值。
5.1 技能命名空间的"幽灵覆盖"
现象:用户问"帮我查一下订单A1001的状态",返回的数据一直是老接口的格式,同事新上线的get_order似乎完全不生效。
排查过程:首先怀疑注册表没有加载新模块,检查日志发现新模块确实被加载了,但顺序排在老模块之前。然后怀疑是缓存,清理后发现依旧。最后打开注册表的内存视图,发现同名skill存在两条记录,而查找逻辑返回的是最后一次匹配到的那个——但我代码里明明写的是返回第一个。
这个坑的根源是我对dict遍历顺序的假设出了问题:Python 3.7以后dict保序,但我在注册时用了register装饰器,模块加载顺序受文件系统扫描顺序影响,新文件排在前面,于是dict里出现了同键覆盖。看似是注册顺序问题,本质是命名空间没有唯一性约束。修复方案很简单:注册时检测重名直接抛异常,强制开发者显式指定版本或改名。我建议所有做技能系统的团队都把这一点做成硬约束,而不是靠编码规范提醒。
5.2 幻觉参数:模型"脑补"了一个合法值
现象:用户问"上海仓还有多少手机壳",模型调用query_inventory时传了warehouse="上海仓",但系统里根本没有"上海仓",只有"华东仓"。
这个看起来不算严重,因为返回结果必然是空的,用户会意识到不对。真正严重的是它不报错——接口返回[],模型对着空列表还能组织出一句"上海仓暂无手机壳库存",用户以为真的没货,可能转头就让运营补货,这就闹出事了。
排查链路:
- 先在日志里找到这次的完整调用链,确认模型确实传了"上海仓";
- 对比历史数据发现,之前有一次成功调用传的是"华东仓",但因为仓库字典里有别名映射,那次成功了;
- 进一步查发现,模型是在参考用户历史消息时"学习"到了"仓库应该填地名"这个模式,但把别名映射规则误用到了新query上。
解决方案分三层:第一层,在schema的description里明确写"warehouse必须是仓库字典中的标准名称,不可使用别名或城市名";第二层,在参数校验时做标准名称映射,把"上海"映射到"华东仓",同时返回映射提醒;第三层,在评测集里加入这类"别名误导"场景,防止回归。三层叠加之后,这类错误基本清零了。
5.3 响应超时:模型等不起,用户更等不起
现象:某个售后skill偶尔会卡住,日志显示函数执行耗时高达45秒,最终抛出超时异常,用户收到的是"系统繁忙"。
原因其实很朴素:这个skill后端调了一个慢的外部ERP接口,平时1秒返回,但赶上ERP批量任务时就会拖到几十秒。模型在function calling模式下有自己的超时上限,等不到结果就直接放弃了。
排查链路:先抓请求耗时分布,发现超时集中在每月底和每天下午三点——正好是ERP批量跑数的时间。再去看代码,发现调用外部接口时没有设置超时时间,用的是requests默认行为,会一直等。
修复:所有外部调用统一加timeout,读操作10秒、写操作30秒,超时后先返回给模型"服务暂时繁忙,请稍后重试",同时触发一次Redis里的降级标记,后续同类请求在五分钟内走缓存或者直接提示用户稍后再试。从那之后,这个skill的超时率从大约18%降到了1%以下。给所有外部依赖设超时,是agent工程化最便宜的一笔投资。
6. 技能评测体系:没有度量就没法迭代
很多团队做skill是"写一个算一个",上线后靠用户反馈来发现问题。这个方式在demo阶段没问题,但skill数量到了几十个、改动频率起来之后,没有评测体系就是盲人摸象。我甚至见过因为改了一个共享参数的schema描述,导致另一条链路选错技能的情况——这种回归,肉眼几乎发现不了。
6.1 三套评测集和它们的建立方法
我维护三套测试集,分别管不同的事:
| 评测集 | 样本规模 | 覆盖内容 | 用途 |
|---|---|---|---|
| 技能选择集 | 300条左右 | 用户query → 期望命中技能 | 验证路由和选择准确率 |
| 参数抽取集 | 200条左右 | 用户query → 期望参数JSON | 验证参数抽取和类型转换 |
| 端到端集 | 100条左右 | 完整对话 → 期望动作序列 | 验证多技能组合和整体效果 |
技能选择集的建立方式是"从日志里捞失败样本 + 人工补充边界case"。我会把每个技能配上至少5条正例和3条反例。正例是"应该选这个技能"的典型说法,反例是"看似相关但不应触发"的干扰说法。比如query_inventory的反例就包括"库存报表怎么导出"——这是导出报表技能的事,不是查询库存的事。反例的价值在防误触发,很多团队只做正例不做反例,效果差很多。
6.2 三个核心指标的算与看
评测跑完,我只看三个数值:选择准确率、参数抽取合格率、端到端任务成功率。
- 选择准确率=正确选择技能的次数/总请求次数,衡量路由是否可靠。
- 参数抽取合格率要求参数完全正确且没有编造,才算合格。部分正确按不合格计,这个标准很苛刻,但能逼着团队把description写清楚。
- 端到端任务成功率看的是最终结果是否满足用户意图,由评测人员打标,衡量的是整个链路的最终价值。
把三个指标拉通看能发现很多有意思的关系。比如有一次我发现选择准确率提升到了97%,但端到端成功率没动,一查原因:很多query技能选对了、参数也对了,但用户真正要的是"把结果下载下来",而skill只返回了表格数据没有下载入口。这就是典型的"局部正确、整体无效",每个skill的边界设计必须对着用户完整意图来。
6.3 回归测试:改动技能必须过一遍旧样本
我有一条硬规定:任何skill的schema、description、路由逻辑改动,都必须跑完全量评测集才能合入。理由是改动description里的一个词,表面上是改善A场景,但可能因为语义偏移让B场景的模型召唤错误技能。这类回归不靠评测集基本发现不了,而评测集本身就是为这种场景设计的。
跑评测的方式也简单粗暴:离线构造一批与评测集同分布的请求,发给带新配置的agent,然后自动对比结果与预期。对比不通过就进人工复核,复核出问题就回滚。整个过程写成一个CI任务,每次改动自动触发,大概十几分钟跑完。现在团队改起skill来心里有底,再也不用靠"上线试两天看看"赌运气。
7. 关于skill设计,我的几条偏执经验
聊聊我在无数项目里沉淀下来的几条私货,不算标准答案,但非常管用。
第一条,一个skill只干一件事,宁可多写几个小而专的skill,也不要写一个大而全的。大skill看起来省事,但description一复杂,模型的技能选择准确率就会掉。我之前把一个"订单全流程处理"技能拆成查询、修改、取消、导出四个独立skill后,端到端成功率从84%提到了92%。拆开的代价是维护多几个文件,收益却立竿见影。
第二条,description要写"why触发"而不是"how执行"。skill描述是写给模型看的,不是写给程序员看的。它应该告诉模型"什么场景下调用这个能力",而不是"这个能力内部怎么实现的"。我第一次把一个数据库查询skill的description从接口说明改成触发场景说明后,误召回率几乎砍半。
第三条,任何skill都必须有显式的权限声明。哪怕是内部demo,我也要求每个skill标记required_role,比如查库存只需要登录态,但改价格必须管理员。agent没权限时宁可拒绝也不要"尝试执行后再失败",因为后者的代价可能是数据被改错。这个教训来自一次真实事故——测试环境的某个删除接口因为没有权限校验,被模型连续调用了十几次,开发库的测试数据被清了。从那以后权限检查是skill注册的第一道门槛。
最后一条,给skill留一个"观察模式"。每个skill我都默认实现一个dry_run参数,不真正执行业务逻辑,只返回"将要做的操作和参数"。这在调试和演示时非常好用,能让用户和开发者都看清楚模型到底打算干什么,提前发现参数编造的问题。成本很低,收益极高。
agent-skill这个方向远没到定型的时候,我最近在折腾的是给skill加"自描述"能力——让每个skill能回答"你需要什么信息才能执行"以及"你执行完会发生什么",这相当于给模型配备了一个能对话的API。目前踩出了一些有意思的现象,等跑稳了再整理一篇具体展开。