“agent-skills”这个词,我第一次被它击中,是在一个开源的Agent项目文档里。当时项目方把几十个工具函数一股脑塞进一个工具文件夹,命名混乱到连作者本人都要翻半天才能找到某个功能,我就意识到,这件事不是“把函数换个目录”这么简单。真正把agent-skills当一回事去设计,是在我自己做一个多轮对话Agent产品、被一堆裸函数和硬编码逻辑折磨到崩溃之后。所谓技能体系,本质上是一套让AI Agent把“会做的事情”模块化、标准化、可复用、可评估的能力组织方式,它不绑定某个具体框架,而是一种实践方法,直接解决的问题是:Agent的能力边界在哪里、什么时候该调用什么能力、能力出错了如何快速定位。
这篇内容适合三类人:正在给Agent疯狂追加工具函数、但发现越来越难维护的开发者;在做企业级Agent产品、需要多人协作管理一大堆能力模块的团队;以及研究提示词工程和工具调用的算法同学。我会从为什么需要技能体系、到技能目录怎么设计、再到一个技能从零到能上线运行的完整路径,最后讲维护和排查的坑,尽量把这条链路讲透。
1. 为什么Agent需要一套“技能体系”而不是一堆工具函数
1.1 裸工具函数到底哪里让人崩溃
先还原一下我在项目里看到的典型“裸工具”状态。你定义了一个get_weather(city),另一个同事写了个query_weather_by_city(city_name),还有一个遗留系统里躺着fetchWeather(cityId)。三个函数做的事情几乎一样,但函数名、参数名、返回结构全不一样。大模型在规划时看到的是工具列表里的函数名和描述,它根本不知道这三个函数是同一个能力,更悲剧的是,它可能会随机选一个。
这带来的连锁反应很现实:工具调用日志里出现大量“无效规划”,模型今天选A明天选B,行为不可复现;出现bug时你得挨个排查三个函数的返回字段差异;想做一个统一的缓存策略或者权限控制,都没地方下手。说句不好听的,这种状态下的工具集根本谈不上“能力”,只是一堆散装函数。
类比一下:裸工具函数就像把一个五金工具箱里的螺丝刀全部混在一起,没有标注规格,也没有按用途分类。你需要一把十字螺丝刀的时候,得把整箱倒出来翻。而技能体系做的事情,就是给每个能力一个明确的“规格标签”,并且把相近能力做归一化处理。
1.2 技能的本质:工具是“能做什么”,技能是“承诺怎么做”
我理解中,工具函数只回答了“我能做什么”,但技能要回答一组完整的问题:这个能力在什么场景下该被使用、输入输出长什么样、失败时怎么处理、边界是什么。它更像一份“能力契约”,而不是一段孤立代码。
拿天气能力举例,一个工具函数版本的描述可能是:get(city) -> dict
一个技能版本的描述会变成:
- 名称:current_weather_snapshot
- 适用场景:用户询问当前天气、出行建议、穿衣建议等场景
- 输入:城市名(中文或拼音均可),可选时间点
- 输出:结构化天气数据 + 一句给用户看的自然语言摘要
- 失败模式:城市不存在时返回明确错误码,不抛出未捕获异常
这带来的价值是:大模型在做工具选择时,不再是“猜”,而是根据技能的“适用场景”字段做一次精准匹配。调用方也不再需要关心技能内部怎么实现,只要按照契约传参和解析结果。技能内部是翻第三方API、查数据库、还是读本地缓存,对外部完全透明。
1.3 技能粒度怎么拆才合理
技能粒度是这套体系里最容易翻车的设计点。拆太粗,一个技能干太多事,比如“处理订单”这种技能,内部可能是分页、统计、修改状态、退款杂在一起,模型调用时不知道具体触发哪个分支,结果完全不可控;拆太细,技能数量爆炸,一个场景要串五六个技能,模型规划链路变长,Token开销和出错率一起涨。
我在实践中总结了一套“可再分性 + 复用频率”的衡量标准,体感比拍脑袋定规则靠谱得多:
| 粒度 | 判定标准 | 示例 | 使用频率预期 |
|---|---|---|---|
| 原子技能 | 单个业务行为,不可再拆分 | 查库存、查订单状态、发验证码 | 高频 |
| 组合技能 | 固定顺序的多个原子技能串联 | 下单前检查库存+锁定库存 | 中频 |
| 工作流技能 | 含有条件分支的完整业务流程 | 售后退款全流程 | 低频但复杂 |
判断一个技能是否该独立出来,我建议问三个问题:这个行为会在多少个场景里被复用?这个行为是否只有一个清晰的业务意图?它的输入输出是否可以独立于其他技能定义?三个问题答案都是“是”,就值得拆成一个独立技能;有一个“否”,再往上一层考虑。
2. 技能目录怎么设计:从命名、结构到文档规范
2.1 一个能跑起来的技能目录长什么样
先给一个我目前比较满意的目录结构,这个结构经历了三轮迭代,现在基本稳定:
skills/ current_weather/ SKILL.md metadata.yaml main.py tests/ test_main.py test_snapshots.py examples/ case_01.json query_order/ SKILL.md metadata.yaml main.py tests/ test_main.py examples/ case_01.json每个技能一个独立目录,目录名就是技能名,里面必须有SKILL.md、metadata.yaml和主实现文件。SKILL.md是给大模型看的“操作手册”,metadata.yaml是给框架看的“注册信息”,主实现文件是给运行环境看的“执行代码”。这样三个角色各自看各自该看的东西,互不干扰。
有人会问,为什么不直接用一个大tools.py把所有技能塞进去?我的体会是,当技能数量超过30个后,单文件模式的协作冲突会急剧上升,两个人同时改同一个文件,合并代码都能耗掉半天。独立目录带来的隔离性,短期看是多了一些文件,长期看在多人协作时收益非常明显。
2.2 SKILL.md 里的核心字段,少一个都容易翻车
SKILL.md是整个技能体系里最容易被低估的文件,我见过太多人把它当成一个“简介”来写,结果模型根本不知道怎么用这个技能。一个合格的SKILL.md至少要包含以下字段:
| 字段 | 作用 | 填写建议 |
|---|---|---|
| name | 技能唯一标识 | 与目录名一致,禁止重名 |
| description | 一句话说明能力 | 写明“做什么”,不要写“怎么做” |
| when_to_use | 触发场景描述 | 列出明确的业务场景,越具体越好 |
| when_not_to_use | 负向排除 | 防止模型在错误场景误调用 |
| input_schema | 输入参数定义 | 字段类型、是否必填、枚举值范围 |
| output_schema | 返回结构定义 | 顶层字段、嵌套结构、空值策略 |
| examples | 调用示例 | 2-3个不同场景下的输入输出样例 |
| error_handling | 错误处理说明 | 每种错误码的含义、调用方该怎么做 |
description和when_to_use是最影响模型决策的两个字段。我见过一个写得极差的描述:“此技能用于查询订单相关信息。”这个描述几乎没有任何区分度,因为订单相关的技能可能有十个。后来改成:“此技能用于查询订单当前状态、物流信息、支付状态,适用于用户咨询‘我的订单到哪了’‘发货了没’‘什么时候到货’等场景。”效果立竿见影,误调用率下降了一半以上。
2.3 技能命名与版本管理,这两件小事别忽略
技能命名看似小事,实际影响着模型的理解和团队协作效率。我推荐的命名规则是“动词 + 名词”结构,比如query_order、send_email、convert_pdf。名字里不要带版本号,不要带环境名,不要用拼音缩写。convert_pdf_v2_final这种名字在第一版上线那天就注定是灾难。
版本管理上,每个技能目录内部维护自己的版本号,用语义化版本规则(主版本号.次版本号.修订号)。主版本号在输入输出结构不兼容时升级,次版本号在功能扩展但保持兼容时升级,修订号只改内部实现和bug修复。metadata.yaml里记录版本号、作者、最后修改时间、依赖项,这些信息在定位问题时能省大量时间。
3. 从零写一个可复用的Agent技能:以“仓库库存快照”为例
3.1 场景设定与输入输出定义
下面我用一个具体的技能“仓库库存快照”完整走一遍从设计到落地的过程,这是我在一个电商客服Agent里实际做过的技能。
业务背景是:客服Agent被问到“这个商品还有货吗”“现在下单什么时候能发”,需要在回复前先查库存。原先是直接读数据库,后来发现库存表在高峰期有大量读写,而客服Agent查询频率高,直接查库经常超时,另外返回一整张表的几十个字段,模型反而不知道该怎么组织回复。
于是我决定做成一个“快照式”技能:底层维护一个定期刷新的库存快照缓存,对外只暴露精简结果。输入参数定为商品SKU列表,输出为每个SKU的库存状态、剩余数量、预估发货时长。定义输入输出时,我优先考虑三个原则:参数尽量少、类型尽量明确、输出带一个面向用户的话术摘要。
3.2 实现代码:从数据模型到主函数
用pydantic定义输入输出结构,是我目前体感最稳的方案,类型校验、错误提示、序列化都省心:
from typing import List, Optional from pydantic import BaseModel, Field class InventoryInput(BaseModel): sku_list: List[str] = Field( description="需要查询库存的SKU列表,最多支持20个", min_length=1, max_length=20 ) check_time: Optional[str] = Field( default=None, description="查询时间点,格式YYYY-MM-DD HH:MM:SS,默认当前时间" ) class SKUInventory(BaseModel): sku: str stock_status: str = Field(description="in_stock/low_stock/out_of_stock") remaining: int = Field(description="剩余可售数量") estimated_delivery_days: int = Field(description="预估发货天数") class InventoryOutput(BaseModel): items: List[SKUInventory] summary: str = Field(description="给用户看的一句库存摘要") generated_at: str = Field(description="快照生成时间")主函数的核心逻辑是先从缓存读快照,再标记已过期或缺失的SKU,异步回源数据库刷新一次:
def run(sku_list: List[str]) -> InventoryOutput: cache_hits = [] miss_list = [] for sku in sku_list: item = snapshot_cache.get(sku) if item and not item.is_stale(): cache_hits.append(item) else: miss_list.append(sku) if miss_list: fresh_items = query_and_rebuild(miss_list) cache_hits.extend(fresh_items) items = [SKUInventory( sku=x.sku, stock_status=classify_status(x.remaining), remaining=x.remaining, estimated_delivery_days=estimate_delivery(x.remaining) ) for x in cache_hits] summary = build_summary(items) return InventoryOutput(items=items, summary=summary, generated_at=now())这里有几个在真实业务里踩过的细节:classify_status不能只看“有没有货”,要结合安全库存水位判断;estimate_delivery要考虑仓配区域;build_summary要生成自然语言摘要而不是只给结构化数据,否则模型每次都要自己“翻译”一遍数据,既费Token又容易出错。
3.3 技能注册与Agent调用链路
实现完主函数后,需要把技能注册到框架里。我用一个简单的注册表来管理,避免各技能之间互相感知:
from typing import Dict, Callable skill_registry: Dict[str, Dict] = {} def register_skill(name: str, description: str, when_to_use: str, handler: Callable, input_model, output_model): skill_registry[name] = { "name": name, "description": description, "when_to_use": when_to_use, "handler": handler, "input_model": input_model, "output_model": output_model } register_skill( name="inventory_snapshot", description="查询商品库存状态、剩余数量和预估发货时长", when_to_use="用户询问是否有货、是否能发货、预计发货时间等场景", handler=run, input_model=InventoryInput, output_model=InventoryOutput )在Agent运行时,框架根据LLM生成的“技能名+参数”去注册表里找handler,执行后再把结构化结果拼回对话上下文。这里有个关键点:技能的执行结果要“翻译”成适合放回上下文的格式,我的做法是把summary字段直接拼进去,同时附上结构化items供模型按需取用。
4. 技能的测试、评估与迭代
4.1 单元测试是底线,不是可选项
技能一旦被模型调用,它的输出质量直接影响用户体验,因此单元测试必须覆盖核心逻辑。对每个技能,我要求至少写三组测试:正常输入、边界输入、异常输入。
正常输入用典型业务场景,比如库存技能查询5个SKU;边界输入要覆盖空列表、单个SKU、超过20个SKU;异常输入要覆盖不存在的SKU、数据库超时、缓存为空等。断言不只验证返回结构,还要验证summary里的关键信息是否与结构化数据一致,比如remaining=0时摘要里不能出现“现货”字样。
测试框架用pytest,再配合快照测试保护输出结构。快照测试的作用是:当某次改动无意中改了返回字段名或类型时,测试会直接失败,逼着你审视这次改动是否合理。
4.2 场景回放,比十组手写测试更能暴露问题
单元测试能保护“代码逻辑”,但保护不了“模型调用技能的决策”。我后来建立了一套场景回放机制,思路是把真实对话历史里“Agent成功调用技能”的样本收集起来,形成回归集,每次技能改动后自动跑一遍。
具体做法:从生产日志里抽200条包含技能调用的完整对话,把用户原始提问、模型规划时的工具列表、最终选择的技能、传入参数、技能返回结果、用户反馈都存成JSON。回归脚本会重放这200条记录,输出“技能选择一致性”和“参数生成有效性”两个指标。如果某次改动导致80%的用例选择了不同的技能,那八成是SKILL.md里的描述被改坏了。
场景回放还有个额外好处:它帮你积累了一批高质量的“调用示例”,这些示例可以反过来补充到SKILL.md的examples字段里,形成正向循环。
4.3 技能调优:从“能跑”到“好用”的三板斧
技能做完能跑只是第一步,真正好用的技能要经过几轮调优。我的经验集中在三个方向。
第一,调描述。如果日志里模型经常在“该用技能A时偏偏选了技能B”,先检查两个技能的when_to_use是否重叠过多,再把高频场景词写进描述里。我见过最有效的一次调整,是往描述里加了三个用户原话的例子,误选率直接从30%降到8%。
第二,容错输入。模型生成的参数经常不按套路来,日期可能是“明天”而不是具体日期,城市名可能是“帝都”而不是“北京”。技能内部做一层宽松表达式解析,把常见口语别名映射到标准值,能极大减少“技能会写但不会用”的尴尬。
第三,控Token。SKILL.md会随系统提示一起发给模型,它越长,每次调用消耗的Token就越多。一个技能文档超过800字时,我开始警觉,优先精简描述性废话,把关键触发词前置到开头200字内。实测下来,在不影响调用准确率的前提下,能省掉约20%的Token开销。
5. 多人协作维护一套技能库的坑
5.1 接口评审是维护技能库的“安全带”
当技能数量多起来,尤其是团队超过三个人之后,接口评审就变得非常重要。一个常见事故是:客服系统的人改库存技能时,为了让管理后台也能用,给InventoryOutput增加了一个warehouse_detail字段,结果客服Agent的上下文被塞进一堆用不到的详情数据,Token消耗飙升,模型开始犯糊涂。
我后来规定:凡涉及输入输出结构变动、新增技能、删除技能、修改触发场景,必须走一次接口评审。评审不看实现代码,只看三样东西:SKILL.md的when_to_use、输入输出schema、旧版本兼容方案。这个过程其实很快,但能挡掉90%的隐性事故。
5.2 文件管理、私有包、中心平台怎么选
多人协作还牵涉到一个工具选型问题:技能库到底存在哪、怎么分发。
最简单的是“git仓库 + 文件目录 + PR评审”,适合10人以内的小团队,每个技能一个目录,串行评审合并。缺点是技能达到上百个之后,检索和依赖管理会比较痛苦。中等规模团队我推荐“私有包管理”,比如用内网的pip源或npm源发布每个技能为独立包,调用方按版本安装,可以在服务间复用技能,但打包发布流程会增加一定维护成本。大型组织会倾向于建设中心化的技能管理平台,统一存储、统一权限、可视化调试,但搭建成本高,团队规模不够大时容易变成负担。
我的建议是:团队人数在5人以下,先别急着造平台,git仓库 + 目录约定完全够用;等技能量级上来了再迁移,不要一上来就追求“平台化”。
5.3 几个真实踩过的协作坑
说几个我在实际协作中踩过的坑,都是血泪教训。
第一个坑是“技能目录名改动引发连环失败”。有个技能原来叫query_stock,后来我觉得不够语义化,改成了inventory_snapshot,结果没同步更新历史会话里的技能调用记录,导致场景回放时大量用例失败。现在所有技能改名都必须走评审,并且要批量同步更新历史样本里的名字。
第二个坑是“共享数据结构被悄悄修改”。两个技能都用OrderInfo结构,有人觉得加个字段很安全,结果另一个技能的测试全部挂了。后来我把公共schema抽出来单独管理,并规定:任何人改公共结构必须全量跑一遍所有技能的测试集。
第三个坑是“文档和实现脱节”。有同事改了技能内部逻辑,但忘了更新SKILL.md,模型按照旧描述传参数,接口不兼容直接报错。现在我在CI里加了一个检查:如果SKILL.md和metadata.yaml的最后修改时间戳相差超过某段时间,就触发提醒,逼着作者同步更新文档。
6. 常见故障与排查技巧
6.1 技能在Agent运行时总是不被调用
这是最让人头疼的问题之一:技能明明存在,测试也能跑通,但模型就是不用它。排查思路首先看描述和触发场景,把技能名和when_to_use打印在日志里,对比模型实际生成了什么,容易发现问题。
更多时候问题出在“技能描述与其他技能太相似”,模型在两个相似技能之间纠结,最终选了错误的那一个。解决办法是在描述里增加负向排除,比如when_not_to_use: 本技能不适用于售后改单场景,改单请使用order_modify_skill。另一个常见原因是技能列表太长,模型上下文窗口被塞满,有些技能压根没被“看到”。这时需要做技能分片或摘要机制,而不是一味堆技能。
6.2 参数解析报错与模型“乱传参”
模型传参不按input_schema来,是技能上线初期的高频问题。常见表现是:日期传了“后天”而不是具体日期;枚举值传了“快点发货”而不是express/normal;数组类型传了逗号分隔字符串。
我的应对策略不是让模型变得严谨,而是让技能变得宽容:在runner层统一加一层参数清洗,这层根据字段注释做类型转换和别名映射。比如日期字段支持natural_language_date()解析,把“后天”转成具体日期;枚举字段做模糊匹配,容忍同义词。经过这层清洗后,参数解析报错率下降非常明显。
6.3 技能返回结果太复杂,Agent反而看不懂
有些技能返回的数据结构设计得极其“完备”,嵌套四五层、字段几十个,模型拿到后一脸懵,不知道该用哪个字段组织回答,最后给用户一段含糊其辞的话。
解决办法是设计“双层输出”:顶层是给模型直接使用的摘要和核心判断,底层是给需要深度处理场景的详细数据。比如库存技能的summary是一句“该商品上海仓现货充足,预计明天可发货”,底层是各仓库存明细。模型大多数场景只用顶层摘要,复杂度高的场景再取底层数据。这个设计从根本上减少了模型“读不懂输出”的问题。
排查这类问题时,我建议开起技能调用日志的“输出长度统计”,如果某个技能的平均输出Token显著高于其他技能,就优先考虑精简它的返回结构。
技能体系不是一次搭建就一劳永逸的东西,它更像是一个需要持续打理的花园。每次看到模型准确命中一个技能、顺利生成用户满意的回复时,你会觉得前面那些设计、评审、测试的功夫都值了。如果你正在被一堆散装工具函数折磨,不妨先从一个小技能开始尝试,把SKILL.md写详细,把输入输出结构钉死,再慢慢铺开,这套实践的收益会随着技能数量的增长越来越明显。