我从去年开始陆续做了几个智能体相关的小项目,其中一个让我印象很深:让Agent去执行"查竞品价格并生成对比报告"这个任务。最开始的版本处理得相当糟糕——模型倒是知道该调用什么,但参数总是填错,工具函数越来越多之后连选哪个都开始飘。后来我把整个思路从"工具函数"切换成"技能模块"来组织,也就是给Agent建立一套可描述、可注册、可编排的agent-skills体系,问题才真正解决。这篇文章就把我这段时间的完整实践整理出来,从技能数据结构设计、注册中心、运行时链路到质量迭代和踩坑记录,希望给做Agent应用的朋友一些可复用的参考。
1. 从"会说不会做"到"Agent Skills":为什么工具函数不够用了
1.1 一个典型场景:让智能体帮我查竞品价格
先还原一下当时的场景。我在做一个市场情报类的辅助工具,核心需求是:用户丢一句"帮我看看某型号产品的市场价区间",Agent需要自己去几个公开渠道抓取价格、做归一化处理,最后输出一张对比表。
最开始我按常规套路走:写了一批get_price()、parse_webpage()、generate_report()之类的工具函数,把函数列表塞进系统提示词里,让模型按需调用。十几个函数时效果尚可,一旦超过二十个,模型开始出现明显的选择困难——它经常把get_price和parse_webpage的入参搞混,或者在不需要抓网页的时候也去调抓取接口。这其实是很多Agent项目都会撞上的墙:工具是"死"的,只有名字和参数,没有语义边界;模型调用工具像是在猜谜。
1.2 工具(Tools)与技能(Skills)的本质区别
工具和技能之间真正的区别,不是叫法变了,而是封装层级变了。工具是做一个动作,技能是完成一件事。
以"获取竞品价格"为例。工具视角下,我需要拆出fetch_html、extract_price、remove_outliers、format_table这四个函数,然后祈祷模型能按正确顺序调用它们。技能视角下,我只需要注册一个叫"获取竞品价格"的技能,内部把网页抓取、价格提取、异常值过滤、结果格式化全部封装好,对外只暴露必要参数(比如产品名称、目标站点),模型只需要做一次决策:这个技能适不适用于当前任务。
从模型的角度看,技能相当于把一个多步骤流程压缩成了一次"意图映射"。决策次数越少,出错的概率就越低。这也是技能化最底层的价值:不是提升模型能力,而是减少模型需要做的决策量。
1.3 什么时机引入技能体系
并不是所有Agent项目都得第一天上技能体系。我的经验是出现下面三个信号中的任意两个,就值得切换:
- 工具函数数量超过15个,模型选择准确率开始下滑
- 同一个任务需要连续调用3个以上工具,且顺序有依赖关系
- 多个业务场景之间存在明显的公共步骤,比如都要做网页抓取、都要做数据清洗
如果只是三五个小函数,直接工具调用反而更轻量。技能体系有它的引入成本,最明显的就是描述文件维护和注册中心建设,小项目扛这个不划算。我自己的判断标准是:先跑通Demo,再观察函数调用失败日志,当"工具选错"或"传参错误"在失败原因里占比超过30%时,就是切换到Agent Skills的时机。
2. 技能定义与注册:先解决"模型看得懂"的问题
2.1 技能描述文件:语义描述比代码实现更关键
技能化改造的第一步,不是写执行函数,而是写技能描述文件。这个文件是给模型看的说明书,它决定了模型在什么场景下会想起这个技能、会不会正确使用。我最初犯的错误是把描述写得又短又抽象,比如"获取价格信息",结果模型在需要价格归一化的时候也想不起来调它,因为描述里没提"归一化"相关语义。
后来我参考了几个成熟开源方案的写法,结合自己的实践,总结出一套比较稳定的YAML结构:
name: query_competitor_price version: 1.2.0 category: market_intelligence description: > 根据产品名称查询各公开渠道的在售价格,并输出归一化后的 价格区间对比。适用于电商比价、竞品定价分析、行情监控等场景。 verbose描述: 1. 输入产品名,系统自动匹配已知渠道; 2. 抓取成功后再做异常值剔除; 3. 返回结果始终包含最低价、最高价、中位数和样本量。 when_to_use: - 用户询问竞品/同行/对手的价格 - 用户要求对比多个渠道的价格 - 用户需要一个价格区间而非精确单点值 when_not_to_use: - 用户只是询问自家商品的建议售价 - 用户没有提供产品名称或型号 parameters: product_name: type: string required: true description: 标准化后的产品名称或型号,如 AC-2000 无线吸尘器 channels: type: array required: false default: [] items: string description: 指定的渠道列表,为空时自动使用默认渠道池 permissions: network: true local_fs: false shell: false returns: on_success: - min_price - max_price - median_price - sample_count - channel_detail on_error: - error_code - message - partial_result这个文件里description字段是最关键的。我一般会写两层:第一层一句概括,方便模型快速匹配;第二层用verbose描述写完整的输入输出逻辑。模型在路由阶段会先看一句话概要,拿不准再读详细描述,这样既能保证响应速度,也能提高匹配精度。
2.2 参数声明:从JSON Schema到类型校验
参数声明这块,我最终选择了JSON Schema作为底层格式。理由很简单:生态成熟,无论什么语言都能解析,而且模型对JSON Schema的理解普遍比对自定义结构更准确。
一个实用的参数声明示例:
{ "type": "object", "properties": { "product_name": { "type": "string", "description": "标准化后的产品名称或型号", "minLength": 2 }, "channels": { "type": "array", "items": { "type": "string", "enum": ["site_a", "site_b", "site_c"] }, "uniqueItems": true } }, "additionalProperties": false, "required": ["product_name"] }这里我有几个经验。一是additionalProperties一定要设为false,否则模型偶尔会自由发挥添加一些不存在的参数名,导致运行时解析错误。二是枚举值(enum)尽量给全,比如渠道列表就明确写出支持的站点名,模型就不会去猜。三是每个字段的description不能省,哪怕是required为false的字段,模型也需要知道什么时候该填、什么时候省略。字段描述本质上是在帮模型做"填表决策"。
2.3 技能注册表:一个集中式Registry的完整设计
技能描述写完之后,需要一个地方把它们管起来。我搭的是一个集中式的Skill Registry,用数据库表存储元数据,同时为每个技能维护一份可独立部署的执行代码入口。
注册表的核心结构包括四块:基础元数据(name、version、category)、语义索引(description的分词向量)、参数Schema(JSON)、运行信息(执行入口地址、权限标签、依赖关系)。这里面比较有技术含量的是语义索引——它服务于后面要讲的技能路由,大致的做法是:技能注册时,把description和when_to_use拼成一段文本,做embedding之后存入向量字段;运行时拿用户请求做同样的embedding,然后做向量相似度检索。
注册API我设计成幂等的方式:同一个name加version组合存在就覆盖,不存在就插入。每次注册都会生成一个新的版本号,但只有被标记为active的版本会被运行时路由命中。这样支持新旧版本并行,也方便随时回滚。
提示:不要在注册阶段做过多的业务校验,Registry只负责"收编"和"索引"。真正严格的Schema校验放在运行时执行阶段做,这样可以避免注册流程过重。
3. 技能运行时:命中、调度、执行的关键链路
3.1 技能选择策略:关键词匹配之外的语义路由
技能运行时第一个环节是路由,也就是从几百个技能里选出当前请求最可能用到的那一个。我之前只试过关键词匹配,准确率最低大概到60%,每四个请求就有一次选错技能,后来上了语义检索,单技能命中率能从62%拉到88%左右,但还是不够稳定。最终我采用的是三层策略混合:
- 规则层:提前把高频场景写成规则表,比如
包含"竞品"且包含"价格"则强制路由到query_competitor_price。这层支持人工指定,是最高优先级。 - 语义层:对用户请求和技能描述做向量检索,取Top-K候选,这层负责泛化,处理没见过的表达方式。
- 决策层:如果存在多个高相似度候选,把候选技能的完整描述塞给模型,让它做一次轻量级二选一或三选一。
这个三层结构的思路是"先硬规则兜底,再向量召回,最后模型仲裁"。实现成本不高,但准确率比单层语义检索稳定得多。基准测试里,三层混合的路由准确率能稳定在95%以上。
3.2 执行沙箱与权限边界:技能不能被模型"带偏"
技能执行环节最大的风险不是技术问题,而是安全边界。我遇到过的最典型的情况是:模型在某个技能执行失败后,"自作聪明"地改动了参数继续重试,结果把重试请求发到了别的站点上。所以执行沙箱的设计目标很明确:技能代码在受限环境里跑,参数解析和重试逻辑由运行时统一控制,模型不能直接干预执行细节。
我的做法是每个技能跑在独立的容器/沙箱进程里,通过stdin传入参数JSON,执行结果通过stdout返回,限制网络白名单和文件系统访问范围。这样即使某个技能实现写得不够严谨,也只会影响它自己的进程,不会波及整个Agent主流程。
还需要注意一个细节:执行上下文里不要注入完整的历史会话。我给技能只传当前请求的摘要、必要参数和少量业务上下文,避免模型在技能内部"发挥"出一些和主任务无关的行为。上下文越多,不可控性越大,这个在我的实测里非常明显。
3.3 错误处理与重试机制:一次失败技能调用的完整拆解
有一次线上跑出了这样的日志:query_competitor_price执行失败,错误码是NO_PARSEABLE_PRICE,原因是抓取到的页面是反爬验证页,没有实际价格。当时的重试逻辑是"失败就原样重试三次",结果三次全失败,白白消耗了配额。
后面的改进把错误处理分成了两种类型:可恢复错误和不可恢复错误。
- 可恢复错误包括:网络超时、站点临时不可达、验证码页面;这类错误可以换渠道重试,或延迟后重试。
- 不可恢复错误包括:参数校验失败、产品名无法匹配任何渠道、权限不足;这类错误应该放弃重试,直接返回业务错误信息。
同时针对NO_PARSEABLE_PRICE这类情况,我加了降级策略:如果主渠道解析不到价格,就尝试渠道池里下一个站点;主渠道全面失败但有部分数据时,返回partial_result并标记置信度降级。这样用户至少能看到部分结果,而不是一个冰冷异常。
4. 技能质量迭代:从"能跑通"到"稳定好用"
4.1 三个评估指标:命中率、完成率、参数合规率
技能数量到了一定规模之后,光靠人工验证业务功能已经不够了,需要一套量化评估机制。我现在每周跑一次自动化评估,核心指标有三个:
- 命中率(Hit Rate):目标技能是否被正确路由出的比例,低命中率说明描述文本写得不到位,或者路由策略参数有问题。
- 完成率(Completion Rate):技能被调用后是否成功返回预期结果,低完成率说明技能内部实现有问题,如页面结构变了、接口改了。
- 参数合规率(Parameter Compliance Rate):技能的调用参数是否完全符合Schema定义,低合规率说明参数描述有歧义,模型不知道该填什么。
评估的方式是维护一批测试请求样本集,比如100条历史真实请求(脱敏),每周跑一遍,对比本周和上周的指标。如果命中率下降超过3个百分点,我就优先查最近一周有没有改过技能描述或路由规则;如果完成率下降,就查目标站点的页面结构是否更新了。
这三个指标分开看各有意义,合在一起能快速定位问题方向:命中率低是"选得不对",完成率低是"做不出来",参数合规率低是"说不清楚"。
4.2 技能粒度:拆成一个一个的小动作,还是一个大而全的技能
这是一个特别容易纠结的问题。拿"生成价格对比报告"这个需求来说,可以拆成"查价格"、"清洗数据"、"生成表格"三个技能,也可以直接做一个"获取竞品价格报告"的大技能一次性完成。
我的实践结论是:可独立评估、可独立复用的步骤拆成独立技能;流程编排性质的逻辑不要拆,直接做成一个复合技能。
"查价格"本身可以被很多场景复用,所以拆出来是合理的;"清洗数据"如果只是服务于价格报告,拆出来意义不大,反而增加模型决策负担。粒度判断的标准就一条:这个技能除了当前场景,还有没有第二个场景会用到它。有,就拆;没有,就不拆。
4.3 测试用例驱动的技能回归
技能体系的迭代节奏通常很快,技能描述微调、参数调整、路由权重更新都有可能影响已有能力。我专门建了一个回归测试集,每个技能对应一组用例,每条用例包含输入请求、期望路由技能、期望参数、期望结果形态。
每当一个技能改完描述或实现,我跑一遍它自己的用例集;每周再把全部用例集整体跑一遍。跑回归用的是离线模拟方案:不真实调用外部接口,而是给每个技能配一个mock响应器,这样执行结果稳定可复现,不会因为外部网站波动导致误判。
注意:回归测试集里的期望结果不要写死具体值,比如价格数字会变,要写结果形态层面的断言,如"返回了min_price字段且数值大于0"。
5. 落地过程中的踩坑记录与实用建议
5.1 技能描述写太长反而拉低命中率
技能描述需要做到信息量与模型注意力之间的平衡。给技能写"百科全书式"描述并不会让模型理解更透彻,反而会稀释关键词权重。我自己踩过这个坑:某次把query_competitor_price的详细描述扩充到了600多字,结果命中率不升反降,因为模型在面对大量文字时更倾向于"觉得不像就跳过"。
后续总结出的经验是:一句话概要控制在80字以内,主要说明"这个技能做什么"和"什么时候用";详细描述控制在200字以内,只补充参数边界、输出格式和典型场景。多余的内容放到一个examples字段里,作为可选的补充信息,不被路由阶段读取。
5.2 技能之间的隐式依赖:最容易被忽视的坑
技能A(获取竞品价格)和技能B(生成对比报告)如果分开注册,B内部其实依赖A的输出格式——这就是隐式依赖。第一次上线时我没处理这个关系,结果B单独被模型调用时缺少A的标准化数据,日志报错非常难排查,因为错误信息出现在B内部,而根因在A的参数约定里。
解决方法是:在技能元数据里增加depends_on和contract两个字段。depends_on声明前置技能,contract声明技能之间的数据契约(比如输出JSON里必须包含某字段)。注册时强制校验契约一致性;运行时如果发现前置技能未执行,自动拒绝调用并提示Agent先执行A。这样处理之后,隐式依赖问题基本从根上解决了。
5.3 技能版本管理与回滚:不要活在单机时代
技能上线到生产之后,版本管理是一个出现频率不高但一旦出问题就很痛的事。我遇到过某次更新了"价格抓取"技能的解析逻辑,结果把一段正常页面误判为验证码页,导致所有竞品查询功能挂了半个多小时。当时最尴尬的是没有快速的回滚手段,只能手忙脚乱改代码重发。
现在的做法是:所有技能必须带版本号,注册表里保留每个版本的历史记录和active标记。每次变更走"注册新版本→灰度验证→切换active状态"的流程。验证通过后把新版本切为active;发现问题时,把active状态切回旧版本即可,整个过程可以在管理端一键完成。灰度验证我通常跑的是4.3那个回归集,全绿之后再切换。
5.4 给描述文件加"验收句"的小技巧
最后分享一个我觉得很实用的小技巧:给每个技能描述文件的末尾加一句"验收句"。比如query_competitor_price的验收句就是——"当用户问我某产品多少钱时,我应该想到用这个技能,并且只问产品名称和渠道范围,不去问其他无关信息"。这句话不是给程序看的,是给自己做路由回归测试时用的判断基准。每次改完描述文件,我先用这句话自测一版对话,如果模型的表现和验收句描述的一致,才提交注册。
写在最后
整理这套agent-skills实践时,我自己最大的体会是:技能化改造的核心价值,不是让Agent看起来更智能,而是让系统的行为边界更清晰、更可控。每个技能都是一块边界明确的积木,模型负责选积木、拼积木,而积木本身的质量和稳定性由我们这些工程人来保证。随着技能库的膨胀,注册、路由、评估、回滚这些基础设施的重要性会越来越高。如果你也正在做Agent应用开发,希望这篇内容能在技能体系搭建路径上帮你少踩几个坑,尤其是描述文件设计和版本管理这两块,值得尽早布局。