做 AI Agent 最头疼的,不是模型选型,也不是提示词怎么写,而是技能怎么组织。我前前后后折腾了三个多月,踩过无数坑,最后沉淀下来的核心思路就是一句话:把"技能"当成一等公民来设计,而不是继续堆函数、堆工具列表。所谓 agent-skills,就是一套负责技能定义、装载、组合和演进的完整方案,它直接把"Agent 能干什么"这件事从代码层面提升到了资产层面,让大模型在合适的场景下调用合适的能力,也让技能本身可以被单独测试、单独升级、单独复用。这篇文章我会把这套设计思路、落地步骤和实际踩坑记录完整写出来,适合正在做 AI Agent 应用、对多工具协作有困惑,或者想把底层能力沉淀成可复用资产的人。
1. 为什么要单独搞一套"技能库"来管 Agent
1.1 我最初是怎么把 Agent 玩坏的
开始做 Agent 应用的时候,我走的是大多数人都走过的路:写一堆 Python 函数,把函数名和描述拼进 system prompt,然后让大模型自己选着调。一开始只有三五个函数,效果还行,模型基本能分清什么时候该调天气查询、什么时候该调日程管理。
等函数数量到了二十几个之后,问题就出来了。
模型开始"张冠李戴"。明明是查询天气的场景,它调用了闹钟设置;明明是搜索文档接口,它去调了数据库查询。我第一反应是描述写得不够清楚,于是把每个函数的描述从一句话扩写成了三句话,甚至五句话,结果 prompt 越来越长,模型的调用准确率却没有明显提升,反而因为上下文被大量描述占满,回答本身的质量变差了。
我又试了按业务模块分组,把工具按"天气类""日程类""搜索类"分别装进不同的子 Agent,然后由主 Agent 做意图路由。这条路走通了一部分,但新的麻烦随之而来:子 Agent 之间共享状态很痛苦,技能逻辑分散到多个地方,改一个参数要同时改好几个模块,维护成本直线上升。
真正让我下定决心重构的,是一次线上事故。某个技能升级后,函数签名改了,但旧的 prompt 描述没同步更新,模型拿着旧参数去调用新函数,返回了一堆异常,用户侧看到的就是"机器人开始胡言乱语"。那个时刻我意识到,工具函数只是最底层的零件,零件可以随意更换,但让大模型稳定使用这些零件,需要一整套围绕"技能"的规范。
1.2 技能、工具、插件:先把概念边界理清楚
在继续往下讲之前,我觉得有必要把三个很容易混淆的概念理清楚,因为后面所有的设计都基于这个边界。
工具是最小可执行单元。它通常是一个函数、一个 API 端点,只做一件事,比如"查询某个城市的当前温度""向某张表插入一条记录"。工具本身不关心业务场景,也没有任何决策逻辑。
技能是面向完整任务的封装。一个技能可能包含多个工具的调用、参数校验、默认值、错误降级、返回格式标准化。更重要的是,技能必须携带一份专门给大模型看的自然语言说明,包括触发条件、参数含义、使用示例。技能的核心特征是"可交付":它可以单独交给另一个 Agent 使用,可以在不同项目间复用,可以独立测试和升级。
插件是偏生态层面的打包分发单位。一个插件可以封装多个技能,解决的是"如何把能力节点交付给一个 Agent 环境"的问题,比如安装、卸载、权限控制。
| 层级 | 关注点 | 变换频率 | 谁负责定义 |
|---|---|---|---|
| 工具 | 单一操作实现 | 高,经常改逻辑 | 后端工程师 |
| 技能 | 任务能力封装 | 中,改描述或参数 | 产品 + 工程一起 |
| 插件 | 分发、安装、权限 | 低,整体上下架 | 平台或生态方 |
agent-skills 管的位置很明确:技能层。它不管最底层工具怎么写,也不碰插件分发,中层这一块是以前最容易被忽略、恰恰又是决定 Agent 效果好坏的关键地带。
2. agent-skills 的核心设计:一个技能到底包含什么
2.1 技能包的标准目录结构
我把每个技能定义成一个目录,这个目录就是一份标准技能包,内部文件职责严格分离。下面这个结构是目前项目里每个技能都遵循的模板。
skills/ weather/ SKILL.md skill.yaml code/ weather.py __init__.py tests/ test_weather.py assets/ default_city.jsonSKILL.md 是给大模型看的说明书,用 Markdown 写成,包含技能的用途、触发条件、参数说明、示例调用。这份文件最终会被注入到 Agent 的上下文里,直接决定模型会不会调用这个技能。
skill.yaml 是给程序看的元数据,用机器可读的格式记录技能名称、命名空间、版本号、入口函数、依赖关系。装载器启动时扫描这些字段,完成注册。
code/ 是技能的实际实现代码。每个技能一个目录,内部逻辑互不干扰,不共享全局变量,这是后面能独立测试、独立升级的前提。
tests/ 是回归测试。以前工具函数写测试,但技能层的测试往往被忽视。实际上技能层更容易出错,因为同一个输入交给不同模型版本,可能触发不同的调用路径,必须有固定用例兜底。
assets/ 放一些静态资源,比如默认城市列表、地区编码映射表,避免把大段数据写死在代码里。
提示:目录设计的核心原则是"一个技能只做一件事"。如果某个技能目录里出现了多个不相干的函数,比如既有天气查询又有股票价格,就应该拆成两个技能包。技能粒度越小,模型理解越容易,复用性也越高。
2.2 SKILL.md 为什么那么关键,怎么写才有效
在 agent-skills 里,我花最多精力调的不是 Python 代码,而是 SKILL.md 这份纯文本说明书。"触发条件是整个描述的骨架,参数是血肉,示例是灵魂"——这句话基本概括了我的写作原则。
先看一个简化版示例。
# 天气查询 ## 用途 查询指定城市未来若干天的天气状况,包括温度范围、降水概率、风速,并基于这些数据给出穿衣或出行建议。 ## 触发条件 当用户提及某个城市或地点的天气、气温、是否需要带伞、穿什么衣服、是否适合出行时,调用本技能。 示例问题: - "上海明天天气怎么样" - "后天北京适合跑步吗" - "今天出门要不要带伞" ## 参数说明 - city: string,必填,城市中文名,例如"上海" - days: integer,可选,查询未来天数,1到7,默认3 ## 返回值 返回结构包含: - city: 城市名 - forecast: 日期列表,每项包含最高温、最低温、降水概率、天气现象 ## 注意 - 城市名不接受英文,客服服务时应先使用我们的城市映射表转成中文。 - 如果城市不存在,返回错误码 CITY_NOT_FOUND,不要猜测天气。"触发条件"这一节最关键,因为它决定了模型的调用意图边界。我踩过的坑是把触发条件写得过窄,比如只写了"当用户询问天气时调用",结果用户说"明天出门穿什么",模型就不知道要调天气技能了。后来我把穿衣、带伞、出行这类关联场景全部写进触发条件,调用率立刻明显上升。
"参数说明"这一节必须写清楚每个参数是必填还是可选、格式要求、默认值。模型经常因为参数说明不完整而想当然填空。比如 days 如果不写默认值,模型可能传入一个很大的数,导致后端查询超时。
"示例"这一节的作用是让模型把抽象描述和一个具体输入输出关联起来。我建议至少写三个示例:一个标准调用、一个参数省略调用、一个边界情况错误示例。边界错误示例尤其有用,它告诉模型"出现这种情况时不要瞎编,返回约定的错误码即可"。
2.3 技能装载与运行时注册流程
有了标准技能包,接下来是装载。我写了一个统一的装载器,启动时扫描技能根目录,解析每个技能的 SKILL.md 和 skill.yaml,生成该技能对应的工具清单,最终汇总成一个可供调用的能力清单。
import os import yaml SKILL_ROOT = "skills" tool_schemas = [] def load_skills(skill_root=SKILL_ROOT): loaded = {} for root, dirs, files in os.walk(skill_root): if "skill.yaml" not in files: continue skill_dir = os.path.basename(root) meta = yaml.safe_load(open(os.path.join(root, "skill.yaml"), encoding="utf-8")) skill_md = open(os.path.join(root, "SKILL.md"), encoding="utf-8").read() schema = build_tool_schema(meta, skill_md) loaded[skill_dir] = {"meta": meta, "schema": schema, "root": root} return loaded def build_tool_schema(meta, skill_md): # 将 SKILL.md 的描述抽取成结构化的 function schema return { "name": meta["name"], "description": skill_md.split("##")[0].strip(), "parameters": { "type": "object", "properties": meta["parameters"], "required": meta.get("required", []), }, }实际项目中我不会把整个 SKILL.md 塞进 function schema 的 description 字段,因为 token 消耗太厉害。通常的做法是:从 SKILL.md 的"触发条件"和"注意"两个 section 里抽取一句浓缩描述放进 schema,完整的 instructions 单独挂一个引用,只在 Agent 进行复杂任务编排时才加载。
装载器做三件硬校验:一是检查 skill.yaml 里声明的入口函数是否真的存在于 code 目录里;二是校验参数 JSON Schema 是否合法;三是确认版本号格式。这三件事只要有一件不通过,整个技能就不注册进 Agent,避免"带病上线"。
3. 从零落地一个技能:天气查询到穿衣建议
3.1 先实现底层代码,再写说明书
这个章节我拿"实时天气查询"技能来做完整演示。第一步是实现 code/weather.py,为了演示方便,我模拟了一个数据源,实际项目里可以换成天气服务商提供的 API。
import random def demo_weather_data(city: str, days: int = 3): """模拟天气数据源,实际项目中替换为真实 API。""" forecast = [] current_temp = random.randint(15, 30) for offset in range(days): high = current_temp + random.randint(0, 3) low = high - random.randint(5, 10) forecast.append({ "date": f"2025-01-{offset + 1:02d}", "high": high, "low": low, "precipitation": random.choice(["0%", "30%", "70%"]), "condition": random.choice(["晴", "多云", "小雨"]), }) return {"city": city, "forecast": forecast} def get_weather(city: str, days: int = 3): city = city.strip() if not city: return {"error": "CITY_NOT_FOUND", "message": "城市不能为空"} if not 1 <= days <= 7: return {"error": "INVALID_DAYS", "message": "days 必须在 1 到 7 之间"} data = demo_weather_data(city, days) return {"status": "ok", "data": data}这段代码做了两件小事:一是入参校验,city 空值直接返回错误码,days 限制在 1 到 7;二是返回结构统一,成功返回带 status 和数据,失败返回 error 和 message。不要小看这两个设计,后面模型乱传参、传空参的时候,这两段逻辑能帮你拦截掉大量幻觉行为。
配套的 skill.yaml 长这样。
name: weather_query namespace: daily_utils version: 1.2.0 entry: code.weather:get_weather parameters: city: type: string description: 城市中文名,例如"上海" days: type: integer description: 查询未来天数,1到7 default: 3 required: - city3.2 注册到 Agent 里,实测对比效果
技能包写好后,放进 skills 目录,重新启动装载器,Agent 就自动多了一个可调用能力。我在同样的输入下对比了不挂技能和挂技能的效果。
输入:"上海明天需要带伞吗"
不挂技能的 Agent,完全靠模型内部知识回答,它能大致说出上海的气候特点,但给不出明天的精准降水概率,而且不同模型版本给出的答案差异很大。挂载技能后,模型先调用 weather_query,把 city 传成"上海",days 传成 1,拿到返回的降水概率后,再组织回答:"明天上海降水概率 70%,建议带上伞。"
整个过程中,Agent 负责两件事:判断当前问题需要哪个技能,以及把技能返回的结构化数据转化成自然语言回复。这套分工明确以后,效果稳定很多,不会出现答非所问。
注意:为了让大模型优先调用技能而不是直接凭记忆回答,SKILL.md 的触发条件里必须强调"先调用本技能,再组织回复"。我见过不少案例,技能明明挂上了,模型却绕过去直接回答,就是因为说明书里没有强制声明调用顺序。
3.3 把原子技能组合成复合技能
单技能跑通之后,自然要考虑组合。天气查询本身是原子技能,但用户真正想要的往往不是一串数据,而是"明天穿什么""适不适合洗车"这类决策型答案。这时候可以把天气查询技能作为上游能力,再包一层"穿衣建议"技能。
我个人的方案是做一个轻量级 workflow 编排,而不是让大模型完全自由调度。因为一旦技能数量多了,模型自由编排的不确定性会成倍上升。workflow 文件用简单的 YAML 描述执行顺序。
name: dress_advisor steps: - skill: weather_query output_vars: [weather_data] - skill: outfit_analysis input_map: weather_data: ${weather_data} output_vars: [outfit_suggestion]这个 workflow 的意思是:先调用天气查询技能,把结果存进 weather_data,再调用穿衣分析技能,把天气数据作为输入,最终输出具体的穿衣建议。
组合技能给我最大的收益是"可观测性"会好很多。每次用户问"明天穿什么",走的是固定的两步链路,中间哪一步出问题,日志里一目了然,排查成本比黑盒自由编排低得多。
4. 实操中踩过的坑和排查技巧
4.1 技能描述互相打架,模型不知道该调谁
技能多了以后,最典型的故障是模型在两个技能之间反复横跳。我遇到过一次,天气查询和穿衣建议两个技能都把"穿什么衣服"写进了触发条件,结果模型有时调天气、有时调穿衣,输出风格完全不统一。
排查后发现根因是触发场景存在重叠。解决方案有两个方向,我两个都用上了。第一个方向是给技能设置优先级字段,在 skill.yaml 里加一个 priority,主技能优先级高,描述优先级信息在装载时拼进系统提示词,让模型优先选择高优先级技能。第二个方向是重写触发条件,把重合的场景明确划归到某一个技能,比如"穿衣建议"覆盖场景更完整,天气查询的触发条件里就不写"穿什么"这类词,只保留"气温、风力、降水"等数据类问题。
这里有一个值得注意的经验:技能说明的重写不是一次性的,每加一个新技能,都要回头检查已有技能的描述是否和它冲突。技能库是一个活的生态,不是写入后就不动了。
4.2 大模型传参不守规矩,幻觉参数怎么拦截
这是让我最头疼的一个坑。模型在理解用户问题时,经常会自己脑补出一些不存在的参数,比如城市不存在、日期格式错误、days 超出范围。如果不做拦截,技能实现层会拿到垃圾输入,返回一堆无意义数据,而 Agent 还把这些数据当真相回答给用户。
我的做法是在技能包里做三层防御。
第一层是 SKILL.md 里的"注意"字段,明确告诉模型参数边界。第二层是 skill.yaml 的参数 schema,days 限制范围、city 枚举合理值,装载时强制校验。第三层是 code 层的硬校验,不合法直接返回约定错误码。
硬校验的返回格式我有过多次调整,最后定下的规范是:错误码 + 人类可读的 message。比如城市找不到,返回 {"error": "CITY_NOT_FOUND", "message": "没有找到这个城市,请确认城市名"}。这个返回会被模型读到,模型会根据 message 重新向用户确认或直接承认错误。没有这层机制时,模型常常会把错误信息吞掉,凭空编一个合理答案继续回答。
4.3 技能测试与灰度发布,怎么保证不掉链子
技能和普通函数的测试有一个核心区别:技能的效果好坏,取决于模型是否在正确时机调用它、是否正确传参、是否正确组织返回数据。所以我在 tests/ 里除了写单元测试,还会写一组"行为回归用例"。
def test_weather_skill_triggers_correctly(): prompts = [ "上海明天需要带伞吗", "后天北京天气如何", "西安最近三天适合跑步吗", ] for prompt in prompts: result = agent_with_skills.handle(prompt) assert result.tool_calls[0].name == "weather_query", prompt这类用例跑的是端到端流程,每个 prompt 必须触发指定技能,调用顺序、参数、返回格式都符合预期。每当我更新技能描述或者更换底层模型版本时,先跑一遍这组回归用例,能挡住大部分意外回归。
灰度发布方面,我的流程比较轻量:新技能先在配置文件里设置 enabled: false,装载器扫描时不注册;然后挑 5% 流量或内部测试环境开启,观察调用成功率、超时率、用户反馈,稳定一两天再全量放量。如果发现问题,直接把 enabled 改成 false 然后重启装载器,就能完成回滚,不用改任何代码。
5. 关于 agent-skills,我最后想分享的几点体会
整套方案打磨下来,我最大的体会是:决定 Agent 能力上限的,不是模型本身的聪明程度,而是技能资产的沉淀质量。以前改一个函数要全局重新验证,现在只需要单独改一个技能包、跑一遍回归用例就能安全上线;以前接手别人做的 Agent 完全是一团乱麻,现在直接用目录结构就能盘清楚整个系统能做什么。
如果有人想从零开始试行这套方案,我的建议是先找三个最核心的技能,把目录、说明、装载流程完整跑通,验证这套规范确实能提升维护效率,再逐步铺开。三个技能的时候,什么方案都看不出差距,等技能超过三十个,规范和混乱之间的差距会非常明显。
再分享一个小技巧:给每个技能编写者发一份模板化的 SKILL.md 大纲,让"触发条件""参数说明""示例"这三个字段成为必须项,缺一个就不允许提交。这会逼迫作者把技能的使用方式想清楚,也方便之后的其他人直接复用。我自己后来接手别人写的技能时,只要这份说明书齐全,基本不需要再去翻代码逻辑。技能库的最终目标,是让代码成为说明书的附属品,而不是反过来。