这两周我一直在折腾agent-skills,起因特别简单:我搭的智能体越来越像个“会聊天但干不了活”的顾问,让它查个航班信息、改个配置文件,它要么嘴上答应,要么把参数拼得乱七八糟。后来我把大模型的“技能”从代码里拆出来,做成一个个可命名、可安装、可组合的技能包,整个执行链路才稳定下来。这篇文章就把我这套组织方式、设计取舍、踩坑记录和接入思路完整写出来,给同样在搭 Agent、做自动化流程、或者正被“模型老是瞎编工具参数”折磨的朋友一个可以直接参考的落地方案。
我默认你至少写过一点大模型应用,知道 function calling 大概是什么,但不需要你用过任何特定框架。文中所有代码片段都是我实际跑过的简化版本,你可以直接抄进自己的项目里改改。
1. 我为什么放弃“在一个文件里堆技能”
1.1 最初的做法,以及它为什么撑不住
最早我给 Agent 加工具能力的时候,走的是最朴素的路线:写一个tools.py,里面堆了二十多个函数,然后在系统提示词末尾贴一大段说明,告诉模型“你有这些工具可用,格式是 XXX”。一开始只有五六个工具时,效果还能接受。等工具数量涨到二十个以上,问题就开始冒头了。
最明显的是模型“选择性失忆”。明明工具列表里就有search_flight,模型遇到航班查询需求时却跑去调用get_weather,或者自己编一个不存在的参数进去。我调试了很久,发现根子在于:我把工具函数、参数说明、调用规则全混在一起,模型在长上下文里很难快速定位“当前这个任务应该用哪个技能、这个技能到底吃什么参数”。
另一个痛点更现实:每加一个新功能,我都得改tools.py,还得同步改系统提示词,一不留神就出现“代码里有这个函数,但提示词里没写”或者反过来。这种散点维护的方式,在项目早期没问题,一旦技能变多,人脑根本记不住谁依赖谁。
1.2 技能应该是什么:可发现、可校验、可调用
后来我重新想这个问题,把“技能”收敛成三个核心属性:
- 可发现:模型需要知道系统里存在哪些技能,每个技能是干什么的,什么时候该用它。
- 可校验:技能接收什么参数、参数类型是什么、哪些必填,这些必须在调用前就能被检查。
- 可调用:技能背后必须有真正能执行的逻辑,而不是模型嘴上说说。
这三个要求听起来直白,但大部分项目只做到了第三个。可发现靠的是在提示词里写一段自然语言,可校验靠的是模型自觉,结果当然不稳定。
我用一个生活化的类比来理解这件事:模型就像办公室里的职员,技能就是墙上的插座。职员不需要知道插座背后是火电还是水电,只需要看见插孔形状,知道自己这台设备的插头能插进去,插进去就有电。你要做的不是教会职员发电,而是把插座设计得规范、标注清晰、协议统一。agent-skills做的就是这套“插座规范”。
1.3 一个具体场景:让 Agent 学会查航班
举一个我实际遇到的例子。我的智能体需要帮用户查询航班信息,底层对接的是某航司的开放接口。在没有拆分技能之前,我每次都得在提示词里写:
你可以调用航班查询接口,接口地址是 xxx,请求方法是 GET, 参数有 from_city, to_city, date,其中 date 的格式是 YYYY-MM-DD, 返回结果是 JSON,里面包含航班号、起降时间、价格等字段……这段说明不仅占上下文,而且模型经常读歪。有一次它把date理解成“返回当天的日期”,直接把系统当前日期传了进去,导致查询结果永远是当天航班。
拆成技能包之后,提示词里只剩一句话:“你拥有flight_search技能,需要查询航班时使用它。”模型需要知道的所有细节都被封装在skill.json和对应执行函数里。调用时,模型只需要输出意图化的结构化指令,剩下的参数校验和 HTTP 请求都由执行器负责。这样改造后,同样场景下模型的正确调用率从大概六成提升到了九成以上。
2. agent-skills 是怎么组织一个技能包的
2.1 一个技能包的骨架
我用的目录结构非常简单,每个技能是一个文件夹,里面至少包含三个文件:
skills/ └── flight_search/ ├── skill.json ├── main.py └── requirements.txtskill.json:技能的身份证,包括名称、描述、参数声明,是模型发现技能的依据。main.py:技能的真实实现,接收解析好的参数,返回可读结果。requirements.txt:技能的依赖清单,安装时单独处理,避免污染主环境。
一个最简的skill.json长这样:
{ "name": "flight_search", "description": "查询两个城市之间的航班信息,适用于机票预订、行程规划场景。当用户需要坐飞机出行时使用,不要用于查询火车票或酒店。", "parameters": { "type": "object", "properties": { "from_city": {"type": "string", "description": "出发城市名"}, "to_city": {"type": "string", "description": "到达城市名"}, "date": {"type": "string", "description": "出发日期,格式 YYYY-MM-DD"} }, "required": ["from_city", "to_city", "date"] } }main.py里则是普通的 Python 函数,接收字典形式的参数,返回字符串或简单的数据结构:
def run(params: dict) -> str: from_city = params["from_city"] to_city = params["to_city"] date = params["date"] # 实际的 HTTP 请求逻辑省略 result = fetch_flights(from_city, to_city, date) return format_flight_summary(result)这里有一个设计细节:run的返回值我尽量收敛成“模型能直接读懂的文本”,而不是原始 JSON。原因后面会详细讲,简单说就是模型读一长串嵌套 JSON 容易迷失重点,而一段“上海到北京,3月1日,共有5个航班,最早06:30,最晚21:00,经济舱均价850元”这样的摘要,模型可以直接引用回答用户。
2.2 描述与参数声明:为什么必须显式化
很多人在写技能描述时不够重视,觉得“描述嘛,随便写两句就行”。但技能描述其实是模型决策的核心依据。模型本质上是在做“文本匹配”:读你的描述,判断当前用户意图是否命中。描述写得含糊,模型就会在多个技能之间左右横跳。
我后来总结出一套描述写作规范:
- 先说明这个技能“能做什么”,一句话说清。
- 再说明“什么时候该用”,列出典型场景。
- 最后画一条边界“什么时候不该用”,比如航班查询不要用于火车票查询。
- 参数描述里写清楚单位、格式、取值范围,比如日期必须是
YYYY-MM-DD,城市名用中文全称。
参数声明我直接用 JSON Schema 语法。它足够通用,既能喂给训练好的大模型做 function calling 格式转换,也能在运行时做严格校验。没必要自己发明一套 DSL。
2.3 调用约定:从 LLM 到函数执行的桥
既然技能是给大模型用的,就必须定义清楚“模型怎么表达调用意图”。我采用的做法是:模型不直接执行代码,而是输出结构化的调用指令,由执行器负责分发。
整个流程分四步:
- 系统提示词里附上所有已注册技能的清单,清单由
skill.json自动生成。 - 模型根据用户问题,决定调用哪个技能,并生成符合参数声明的 JSON。
- 执行器解析模型输出,先做 JSON 校验,校验通过后调用对应技能的
run。 - 技能返回结果,执行器把结果拼到上下文里,交给模型继续组织回答。
这个“模型只决定、执行器只执行”的桥接方式,核心价值在于职责分离。模型不接触真实的 HTTP 请求,不接触文件系统,也就不容易因为“想当然”而搞出危险操作。执行器这一侧可以做鉴权、限流、超时控制,这些都是模型侧很难做好的事。
3. 关键设计取舍:描述、校验、安全
3.1 技能描述比实现更重要
这句话我是在实际踩坑之后才真正信服的。技能实现写得再漂亮,模型看不到你的代码,它只能看到description字段。换句话说,描述就是模型眼中的技能全貌。
我对比过两种写法。第一种是过度自信型:
"description": "查询所有交通信息"看起来覆盖面很广,但实际上技能只实现了航班查询。结果就是用户问“从上海到杭州的高铁”,模型自信地调用了这个技能,返回了一堆航班航班,用户一脸问号。
第二种是边界明确型:
"description": "查询国内主要城市之间的航班信息。用于机票预订、行程规划。不适用于火车、汽车、轮船等地面交通查询。"这会让模型在遇到高铁需求时,明确知道不该调用它,从而转向其他技能或者直接说明自身能力不足。宁可让模型的技能利用率低一点,也不要让它错误地调用一个看似万能、实则残缺的技能。
我还试过在描述里加上一些“触发词”,比如“当用户提到起飞、航班号、机票时优先考虑”。这种方式对某些模型有效,但也可能造成误伤。需要结合你的实际模型效果持续调优,没有一劳永逸的写法。
3.2 参数校验与失败重试:让 Agent 失败得“体面”
模型生成参数时,即使有了 JSON Schema,也难免出现类型错误、缺字段、枚举值超范围的情况。比如参数声明里date是字符串,模型却给了{"date": 20250301},没有引号。
我在执行器里加了一个统一校验层,所有技能在真正执行前都过一遍:
from jsonschema import validate, ValidationError def dispatch(skill, raw_args): try: validate(instance=raw_args, schema=skill.parameters) except ValidationError as e: return { "status": "invalid_params", "error": str(e), "hint": "请检查参数类型和必填项,重试一次" } return skill.run(raw_args)这样做的妙处是:校验失败不会直接中断对话,而是把结构化的错误信息抛回给模型,模型看到hint后会理解“哦,我少传了参数或者格式不对”,然后自我纠错重新发起调用。
实测下来,很多表面上的“模型乱调参数”问题,其实可以通过这种“校验—反馈—重试”闭环解决一大部分。模型不是故意犯错,它只是看不见自己的输出格式。你给它一次纠错机会,比在提示词里反复强调“必须严格按照格式”有效得多。
3.3 安全边界:技能运行在受限环境里
技能一旦可以执行真实操作,安全问题就绕不过去。我的原则很简单:让技能跑在尽可能小的权限范围内。
具体做了三件事:
- 超时控制:每个技能调用默认最多跑 30 秒,超时直接终止,防止模型陷入死循环式的重试。
- 网络白名单:技能默认不能访问公网,只有显式申请网络权限的技能才会被授予访问特定域名的资格。比如
flight_search只能访问航司接口域名。 - 高危操作确认:涉及写文件、发送消息、支付等操作,执行器不会直接执行,而是先返回一个“待确认意图”,由用户在前端点击确认。
这些机制不复杂,但能避免很多“模型一句话就帮你下了订单”的惊悚场景。agent-skills这类方案本身只解决“技能组织”问题,安全边界必须自己在执行器层面补上。
4. 真实运行记录:从“模型出幻觉”到“稳定执行”
4.1 一次端到端调用:查火车票
我挑一个可复现的简单案例来展示完整链路。技能是train_query,描述是“查询 12306 上指定日期两城之间的车次信息”。用户输入:“帮我看看这周五从北京到南京的高铁。”
模型的思维链大致是这样的:
用户需要查询火车票,这属于 train_query 技能的职责范围。 日期是“这周五”,需要转成具体日期,假设今天是3月1日,本周五是3月3日。 调用 train_query,参数:from_city=北京, to_city=南京, date=2025-03-03, train_type=高铁模型输出结构化调用指令后,执行器解析并校验,然后调用技能的run函数去请求 12306 接口。返回结果被改写成摘要:
北京到南京,2025-03-03,高铁共12个车次。 最早 G101,06:45 出发,09:48 到达,商务座还有少量余票; 最快 G115,07:30 出发,10:12 到达,二等座一等座余票充足……模型拿到这段摘要后再组织自然语言回答,整个过程没有任何人手工干预。
4.2 踩坑:技能名冲突
有段时间我在技能库里加了第二个查询类技能train_search,当时脑子里没多想,以为和train_query是不同名字。结果执行器注册时发现,有两个技能的name字段在网络传播后变成了同一个规范化名字,新技能覆盖了旧技能。
这个坑的直接后果是:模型明明调用了train_query,执行器却路由到了新技能,返回的数据结构完全不兼容,上下文里出现一堆解析错误。
现在的做法是:每个技能包在注册时都会做唯一性校验,发现重名直接报错并拒绝加载。另外我引入了一个简单的命名空间概念,技能名格式统一为domain.action,比如transport.train_query、transport.flight_search。这样即使不同作者写的技能重名,只要域名不同就不冲突。
4.3 踩坑:描述太泛会带偏模型
前面提到过“描述太泛”的问题,我再展开一次具体现象。flight_search早期的描述是“查询航班信息,支持国内外航线”。结果用户问“北京飞纽约要多久”,模型调用了这个技能,但技能底层只对接了一家国内航司的接口,根本没有国际航线。后续重试三次都是同样的错误,浪费了一轮又一轮上下文。
这个案例的教训是:描述不仅要说清“能做什么”,更要说清“能力边界”。后来我干脆在描述里追加了“仅限国内主要机场之间的直飞航班,不包含中转、国际航班”,之后模型再也没有拿它处理过国际航线问题。
4.4 踩坑:技能返回结果太长,撑爆上下文
技能返回结果如果是一大段原始 JSON,模型读取时会非常吃力。我早期某个技能返回的是完整的航班列表,包含舱位代码、税费明细、机场三字码等三十多个字段,总长度接近三千个 token。模型为了组织回答,又把这些 token 原样引用了一部分,对话窗口很快就不够用了。
解决办法有两个:
- 摘要化:技能在返回前,把原始数据浓缩成“人类可读的概要”,只保留用户最关心的字段。
- 分页化:如果概要本身还是很长,就只返回前 5 条,并提供“获取更多结果”的技能入口。想看得更全,就让模型再去调用一次。
现在我对技能返回值有一条硬性标准:默认不超过 500 个 token,超过就要做截断或摘要。这条标准实施之后,整个对话的上下文利用率明显提高了。
5. 如何快速接入现有 Agent 框架
5.1 从零接入的最小执行循环
如果你现在用的是裸的大模型 API,想自己搭一套最小可用的技能执行循环,核心代码并不复杂:
message_history = [{"role": "system", "content": build_skill_list()}] message_history.append({"role": "user", "content": user_input}) while True: response = llm.chat(messages=message_history) if response.function_call is None: break skill_name = response.function_call.name args = response.function_call.arguments result = dispatcher.dispatch(skill_name, args) message_history.append(response.message) message_history.append({ "role": "function", "name": skill_name, "content": result })build_skill_list会遍历技能库目录,把每个技能的name和description拼成一段系统提示词。dispatcher.dispatch就是上面提到过的“校验—执行—返回”流程。
这套循环足够应对大多数场景。如果你用的是 OpenAI 的 function calling 协议,只需要把skill.json转成它要求的 tool 定义格式,其他逻辑完全复用。
5.2 与主流框架对接的思路
我自己临时试验过把agent-skills风格技能接入现有的 LangChain 流程,思路也简单:
- 用
skill.json里的name、description、parameters生成框架要求的Tool。 - 把
main.run包一层,作为 tool 的可执行函数。 - 注册到 Agent 的 tool 列表里。
这种对接方式的好处是:技能库本身不依赖任何框架,你在 LangChain 里能用,换到其他框架也能用。技能包是一个个独立的目录,复制粘贴就能迁移。
5.3 注册自有技能的完整步骤
如果我要新加一个“天气查询”技能,流程大概是:
- 在
skills/下新建weather_query目录。 - 编写
skill.json,注意描述里写清楚适用条件和边界。 - 在
main.py中实现run(params)函数。 - 在本地跑一个测试脚本,模拟模型输出参数,验证执行器能正确返回。
- 把技能目录复制到运行环境,重启执行器。
你可能会觉得这比“直接写个函数挂上去”多了一步。没错,确实多了一步。但多出来的这一步换来了两个好处:一是模型对技能的理解更稳定,二是技能可以独立复用。当你的 Agent 项目不止一个,或者你打算把技能分享给团队其他人时,这个成本完全值得。
6. 后续可以扩展的方向,以及我个人的一点体会
6.1 给技能加版本号和回归测试
技能包现在支持version字段,比如1.2.0。当技能实现更新但参数接口不变时,模型不需要感知;但当参数结构有明显变化时,版本号能帮我在日志里快速定位问题。我还给每个技能配了一个test.json,里面存放几组典型的输入输出样例。每次改动之后跑一遍回归,确保新代码没有破坏旧场景。
这听起来很“工程化”,但对于真实的 Agent 项目来说非常必要。模型应用最怕的就是“昨天还能用,今天突然疯了”,而原因往往出在某个工具函数的细节改动上。
6.2 把技能做成分发式的“插件市场”
如果你把技能包想象成一个个独立目录,那么它们天然适合打包分发。我在内部折腾过一个简单的技能仓库:目录里每个技能都有一个metadata.json,包含作者、版本、依赖说明,别人可以一键安装到自己的 Agent 里。这很像 VS Code 插件市场的思路,其实没有什么新概念,但一旦技能数量超过三十个,统一分发和版本管理能省下大量时间。
6.3 我的真实体会
最后说点私货。agent-skills本质上不是什么高深莫测的技术,它只是把软件开发里早已成熟的“模块化”思想搬到了大模型应用层。真正让项目产生质变的,不是某个炫酷的执行引擎,而是你认认真真把一个技能的描述写清楚,把它该做什么、不该做什么划清楚边界。
如果你想动手试试,我建议不要一上来就设计一个庞大的技能框架。先挑两三个你日常最高频的工具,比如查天气、查地图、查文件,按上面这套结构搭起来,跑通一两个端到端场景,感受一下模型选择技能和纠错的过程。等你熟悉了这套“契约感”,再逐渐扩充技能库,会顺手很多。我自己就是在搭到第六七个技能时才慢慢体会到:技能管理这件事,做减法比做加法更重要。