agent-skills实战:为AI Agent封装可复用技能模块,解决工具调用难题
做AI Agent开发的朋友大概率会撞上同一个问题:模型明明很聪明,落地时却总卡在"使唤工具"这一步。今天想聊聊我最近整理的一个项目——agent-skills,一句话说清楚它是什么:给智能体预先封装好一批"可复用的技能模块",让Agent不再对着几十个零散工具抓瞎,而是像一个有经验的员工一样,按需调用一套成熟的操作流程。
这个项目解决的是Agent从"能推理"到"能干活"之间的关键断层。模型再强,如果没有一套结构化的技能体系去约束工具调用、处理异常、格式化结果,那么写出来的Agent基本都在和工具参数、返回结构、上下文膨胀作斗争。agent-skills的定位不是某个具体业务插件,而是一套技能定义规范 + 运行时加载机制 + 测试方法的组合。适合正在搭Agent框架、做工作流自动化,或者被function calling维护成本逼疯的团队参考。这篇就把我踩过的坑、拆过的设计、以及实测下来能用的方案一次说清楚。
1. 整体设计思路:为什么必须在模型和工具之间加一层"技能"
1.1 工具函数越来越多之后,问题根本不是模型不够聪明
最早我写Agent也是老一套:给模型塞一堆function definition,让它自己选。大概十几个工具的时候一切正常,模型调用得挺准。等工具涨到四十个以上,问题开始变得诡异——模型时常选错工具、参数张冠李戴、同一个操作出现好几种写法(比如"查天气"和"获取天气信息"明明是一个事,模型却当成两个能力),而且每次请求光工具描述就占了上下文一大截。
这不是模型能力退化,而是你给模型的"操作说明"没有结构化。一堆扁平工具扔给模型,等于让一个新员工直接去操作一整面墙的按钮,每个按钮还只有一个笼统标签。agent-skills的核心思路特别简单:把工具调用从"让模型自己拼装"变成"让模型选择一个封装好的动作包"。每个动作包就是一个Skill,里面不仅有底层工具函数,还有参数校验、调用顺序、失败回退、结果规整这些逻辑。模型只需要说"我要用这个技能",剩下的脏活累活由技能内部消化。
1.2 技能、工具、工作流和插件的边界到底怎么划
刚开始我也没有清晰边界,写一段时间后发现混淆严重。后来我用一张表把概念钉死,完整定义如下:
| 概念 | 最小单元 | 主要特征 | 典型场景 |
|---|---|---|---|
| Tool(工具) | 单个原子操作 | 一个函数/接口,完成单一动作,无状态 | 发HTTP请求、读数据库、计算MD5 |
| Skill(技能) | 工具+规则+流程 | 内部可调多个工具,包含校验和容错,有明确输入输出契约 | 把"查询并汇总库存"封装成一个动作包 |
| Workflow(工作流) | 多个Skill的有向编排 | 固定顺序/条件分支,适合稳定流程 | 订单处理流水线 |
| Plugin(插件) | 打包和分发载体 | 一组技能+配置+权限声明,便于安装卸载 | 把办公套件能力发布成插件市场 |
划分完之后很多僵局迎刃而解。工具是"手",技能是"一套动作",工作流是"标准作业程序"。Agent应该面向技能做决策,而不是面向工具做决策。如果设计时发现一个Skill内部还需要让模型实时决定怎么做,那么这个Skill多半拆得还不够细,或者它本来应该被设计成一个Workflow。
1.3 技能的终极优势:可观测、可维护、可共享
工具调用的痛点之一是不可观测——模型今天选A工具,明天选B工具,同样目标,执行路径完全不同,排查都不知道从哪下手。有了技能封装,Agent的决策粒度从"调用某个函数"变成"选择某个Skill",追踪链路一下子就清晰了。技能内部每一次工具调用都有固定日志结构,哪个环节失败一目了然。
维护性上也占便宜。如果底层接口从V1升级到V2,我只需要改对应Skill的内部实现,模型侧的Skill描述完全不需要动。市面上很多Agent框架把工具描述写死在系统提示词里,接口一变提示词也要改,牵一发动全身。技能层相当于给模型和底层工具之间加了一个防腐层,底层怎么变,模型感知不到。
共享方面更直接。Skill清单文件(Manifest)就是一个包含描述、参数Schema、使用范围的文本文件,完全可以在团队内甚至跨团队复用。我们后来把这套技能目录做成了类似内部npm仓库的东西,新人接入项目时不再需要研读几十个工具函数,翻一遍技能清单就能上手。
2. 技能定义与编排:核心细节决定了Agent用不用你的技能
2.1 技能清单(Skill Manifest)是技能的门面
每个Skill配套一份Manifest,它的质量直接决定Agent会不会正确触发这个Skill。这里贴一份我在项目里实际使用的YAML结构:
name: calendar_event_scheduler version: 1.2.0 description: > 用于创建、查询和修改日历事件。当用户提到安排会议、查看日程、 预订会议室、修改时间冲突时使用。不适用于待办事项管理。 trigger: conditions: - 用户明确提及日期时间 - 用户涉及参会人邀请 input_schema: type: object properties: action: type: string enum: [create, query, update, delete] event_title: type: string start_time: type: string format: date-time participants: type: array items: type: string required: [action] execution: mode: tool_composition tools: - calendar_api - address_book_api - notification_api fallback_strategy: degrade_to_query permissions: scopes: [calendar:read, calendar:write] requires_auth: true timeout_ms: 15000这个配置文件里有几个字段值得重点说。
description是最容易被忽略但实际影响最大的字段。早期我写得特别随意,比如"日历管理",然后发现Agent老是把这个技能用在不需要的场景里。后来改成上面这种写法,不仅写明"什么时候用",还写明"什么时候不用"——这种负面排除条件特别管用。模型在模糊场景下会主动放弃调用,而不是硬来。
trigger.conditions是我后来加的一个优化项。它为技能选择器提供了一条快速判断路径。如果Agent的决策模块支持语义匹配,可以先用这些条件做一次粗筛,再走LLM精排,速度和准确率都能提升。
permissions则把权限声明放到了技能包内部。后续不管谁接入这个技能,都能看到它要求哪些权限,而不是让管理员从代码里逐个翻。
2.2 三种技能编排模式:从纯提示词到纯代码
Skill内部具体怎么"干活",我一般分成三种模式。没有一种放之四海皆准,关键看技能内部逻辑的稳定性。
第一种是Prompt-Only 模式。技能内部没有一个工具函数,只有一段精心设计的提示词模板。适合那些"模型本身就能完成,但直接问容易漏细节"的任务。比如生成周报摘要,我给Skill内置了一份包含章节顺序、语言风格、字数约束的指令模板,模型拿到用户输入后用这套模板产出结果。这种模式实现最轻,缺点是结果方差大,适合输出质量要求不极端的场景。
第二种是Tool Composition 模式,也是目前用得最多的。技能内部声明一组依赖工具,并定义它们之间的调用顺序和数据处理规则。比如"组织会议"这个Skill,内部先调日历接口查空闲时段,再调通讯录接口选参会人,最后调日程接口写入,同时还带一层异常回退:如果写入失败就降级为只读查询,把可用时段展示给用户。模型层面看不到这些细节,它只负责给Skill传一个正确触发指令。
第三种是Code 模式,直接把执行逻辑写成代码块固定在技能内部。有些操作不具备让模型"推理后决定参数"的价值,比如数据格式转换、定时触发的批处理任务、调用内部SDK等,就应该用代码写死,只暴露少量受控参数给模型。这种模式稳定性和执行效率最高,但灵活性最低。我的经验法则:如果一个技能连续十次调用后参数都不需要模型发挥,那就改成Code模式,别给模型制造犯错机会。
2.3 input_schema 的松紧度:太松乱来,太紧废掉
输入Schema 是另一个容易做过头的地方。一开始我为了让模型"安全",大量字段都设成required,结果模型频繁报参数缺失,技能调用成功率直线下降。后来我重新梳理,原则是:
- 必填字段只保留那些"没有它技能绝对无法执行"的参数。比如账号ID,没有这个值后续一切无从谈起。
- 其余全部设为可选,技能内部提供默认值或基于上下文推断。
- 允许模型传入冗余字段,但Schema里不声明,执行时忽略即可,不要因为多传参数就报错。
这背后是对模型行为模式的理解。当前这些模型在严格Schema下的表现其实是不稳定的——面对满屏required字段,它倾向于把缺失参数"编"出来而不是承认缺数据。给Schema留出呼吸空间,反而能减少幻觉式填参。
3. 实操过程:从零搭一套Skill运行时
3.1 技能仓库的目录结构怎么摆
技能比代码更讲究"可发现性",所以目录结构一开始就要定清楚。我使用的是这样一套布局:
agent-skills/ ├── registry/ │ ├── index.yaml # 全局技能注册表 │ └── categories/ │ ├── productivity/ │ ├── communication/ │ ├── data_processing/ │ └── system_admin/ ├── skills/ │ ├── calendar_event_scheduler/ │ │ ├── MANIFEST.yaml │ │ ├── code/ │ │ │ └── executor.py │ │ └── tests/ │ │ ├── golden_cases.json │ │ └── edge_cases.json │ ├── email_summarizer/ │ │ ├── MANIFEST.yaml │ │ ├── prompts/ │ │ │ └── summary_template.txt │ │ └── tests/ │ └── ... └── runtime/ ├── loader.py ├── resolver.py ├── runner.py └── observability.pyregistry/index.yaml 只放每个技能的精简元数据,比如技能名、一句话描述、分类、权限级别。这份文件会被加载进Agent的上下文,所以严格控制体量,一个技能只占一行。
skills 目录下每个技能独立成文件夹,包含Manifest、执行代码或提示词模板、以及测试用例。这里有一个经验点:测试用例必须和技能放在一起,而不是丢到中心化测试平台。因为技能边界很清晰,跟着技能走的测试才有复用性和说服力。
runtime 是运行时组件。loader负责加载Manifest并做Schema校验,resolver负责从模型决策或语义匹配中确定调用哪个技能,runner负责实际执行并统一处理异常。下面逐个讲。
3.2 注册中心生成上下文:如何做到"只给Agent看够用的信息"
早期我把所有技能的完整Manifest全塞进系统提示词,技能数量到二十几个时,上下文直接爆了,而且模型被大量相似描述干扰,选择准确率反而下降。后来我改成两层结构:
- 第一层:全量精简索引。index.yaml中每个技能只有name、一句话description、category三列。全部技能加起来大约占2~3K token。这一层常驻上下文。
- 第二层:详细Manifest按需加载。Agent决定调用某个技能后,runtime将完整Manifest和相关执行配置加载进来,再交给内部的执行器处理。
效果非常明显。技能数量从20增加到40时,上下文开销只增加了不到1.5K token,模型决策准确率比原来塞全量描述时还高。原理其实就是减少选择噪声——模型只需要做粗粒度选择,细粒度参数处理由技能内部消化。
3.3 技能选择器:语义粗筛 + LLM精排
技能选择是运行时最核心的一环。我实测下来,纯语义匹配(Embedding + 余弦相似度)速度快但Top1准确率不稳;纯LLM选择准确率高但延迟大、成本高。最后落的方案是混合式:
import json from sentence_transformers import SentenceTransformer import openai class SkillResolver: def __init__(self, registry_index, embedding_model_name="BAAI/bge-small-zh-v1.5", llm_topk=3): self.registry_index = registry_index self.embedder = SentenceTransformer(embedding_model_name) self.llm_topk = llm_topk def prefilter(self, user_intent: str, topk: int = 5): # 先与注册表中每个技能的 description 计算语义相似度 # 获取表面相似度最高的 topk 候选 ... return candidate_skills def resolve(self, user_intent: str, conversation_history: list[str]): candidates = self.prefilter(user_intent, topk=self.llm_topk) # 把这几个候选的 name 与详细 description 发给 LLM,让 LLM 做最终单选 prompt = self._build_selection_prompt(user_intent, candidates, conversation_history) resp = openai.chat.completions.create( model="gpt-4o-mini", messages=[{"role": "user", "content": prompt}], temperature=0 ) return json.loads(resp.choices[0].message.content)粗筛阶段我试过很多模型,在国内网络环境稳定可用的前提下,BGE系列的小模型性价比不错,向量维度也低,内存占用很小。如果你是纯离线环境,也可以换其他的。总之核心思路是:永远不要让LLM从四十个技能里做选择——它一定会晕,而是先缩到3~5个再做精排。
精排的Prompt我格式固定,要求LLM只输出一个JSON,不能有任何解释。温度设0,模型名选推理成本低的型号。实测这一步耗时大约300~600ms,性能瓶颈主要在LLM推理,但比全开放式选择要快得多,准确率也高。
3.4 技能执行器:统一处理超时、重试、幂等
技能执行器就是我说的"干活的地方"。内部会处理三件最烦人的事:超时、重试、幂等。
超时。每个Skill在Manifest里声明timeout_ms,执行器用异步Task定时强制终止。这里有个细节:不能只在工具调用层面配超时,因为技能内部可能是多个工具组成的链路,链路上单次调用不超时,但整体可能被拖死。所以执行器监控的是技能整体deadline。
重试。不是所有失败都值得重试。我们给内部工具调用标了失败类型:可重试(网络抖动、暂时不可用)和不可重试(参数错误、鉴权失败)。执行器只对可重试错误生效,最多重试2次,间隔按指数退避。
幂等。技能执行必须设计成可安全重放的。比如"创建订单"这个技能,内部要先调用库存接口锁库存,再创建订单记录。如果第一步成功第二步失败然后整体重试,就可能重复锁库存。解决方案是给每个技能调用分配一个全局唯一的request_id,内部所有工具调用都携带这个ID,让下游接口按ID做去重。这比在技能内部加分布式锁简单得多,实测效果也稳。
3.5 技能测试:没有黄金用例就不算完成
技能入库前必须通过一套最小测试集——Golden Cases和Edge Cases。
Golden Cases是典型成功路径。针对calendar_event_scheduler,至少要有:创建一个无冲突事件、查询当日事件、修改一个事件时间。每条用例记录输入、预期Skill触发结果、预期内部工具调用链路、预期最终返回值。这是技能质量的最低门槛。
Edge Cases则是容易翻车的边界场景。比如日历技能遇到时间冲突怎么办,邮件技能遇到空收件箱怎么办。这些用例不一定要求技能完美处置,但要求它不能报一个不清不楚的500错误。要么给出明确降级说明,要么返回"无法处理"并解释原因。
测试框架我直接用pytest封装,跑起来也简单。关键在于每次修改技能内部实现后,全部跑一遍Golden + Edge用例,不通过的Skill不允许进入Registry。有了这套机制,技能库的稳定性肉眼可见地提升,打新版本的时候也不慌了。
4. 常见问题与排查技巧实录
4.1 Agent死活不触发技能,或触发错了技能
这是最常遇到的问题。如果是"该触发没触发",第一排查方向是description写得不够精确,正面触发条件没覆盖用户真实表达。比如用户问"明天下午有空吗",如果你在description里只写了"安排会议"而不是"查询日程和可用时间",模型会认为这不是调用场景。
如果是"触发错了技能",比如把"设置提醒"误触发了"创建日历事件",优先排查相似技能之间的description边界。我见过最典型的情况是两段description都提到"时间""安排"这类词,模型根本无法区分。解决办法是给每个技能description里增加一段"区别提示",明确写"本技能不处理XXX,如需XXX请找技能YYY"。
提供一个排查清单:
- 用户的自然语言是否被Manifest中trigger.conditions直接覆盖?
- description里是否包含负面排除条件?
- 候选技能之间是否存在语义重叠?
- 精简索引里的"一句话描述"是否足以区分两个相似技能?
- LLM精排给的是单选还是多选?多选模式下模型倾向于一次选多个技能,造成混乱,最终决策必须是单选。
最后一个问题我尤其要强调——最终一定要让LLM输出单选。多选看起来灵活,实际效果非常糟糕,模型会同时触发两个相似技能,执行结果互相覆盖。
4.2 技能内部工具调用失败,但表层日志看不到原因
技能内部封装层数多了以后,最怕"底层失败,上层报成功"。我加了一条铁律:技能执行器返回给Agent的结果必须是结构化状态码,包括success、degraded、failed三种。
success就是全流程成功;degraded是部分成功,比如查询成功但写入失败,这时候要返回给Agent哪些数据是可用的;failed才是彻底失败。早期我让技能内部工具错误直接冒泡给模型,结果模型被一段堆栈或错误JSON污染,回答全乱套。改成结构化状态码之后,Agent拿到degraded就能向用户解释"我能查到当前状态,但暂不支持修改,因为服务端拒绝了写入请求",体验自然得多。
另外,技能内部每条工具调用都要输出一条日志,包含request_id、工具名、耗时、返回摘要、错误信息。排查时直接按request_id拉出全链路。不要等到出问题再补日志,技能本身就是个调用链路,链路日志应该是默认行为。
4.3 上下文质量下降:技能元数据该瘦身了
技能数量一旦超过三十,你会明显感觉到Agent选择犹豫。这时候别急着换更大的模型,先给技能元数据减脂。
我常用的减脂手段有三个:
- 精简一句话描述。index里每句话不超过20个字,删掉所有形容词,只留动作+对象。
- 分类分组。给技能打category,让Resolver先走分类过滤,再走技能选择。比如用户意图是数据处理类,就不该考虑日历技能。这能把候选空间缩到原来的三分之一。
- 合并重复技能。很多技能其实可以抽公共子流程,比如不同技能里都涉及"解析日期",就将其抽成一个公共组件。
减完之后上下文干净了,选择准确率不说突飞猛进,至少恢复到稳定水平。
4.4 权限和安全:技能被Agent滥用比想象中更常见
技能封装了更高级的操作,如果权限没控制好,Agent会在一次任务里触发多个敏感操作,风险比单个工具调用大多了。比如一个"整理客户信息"的Skill,内部可能同时读取CRM、发短信、修改标签,如果模型理解错用户意图,一个技能就把三个权限全占用了。
我在Manifest里要求强制声明permissions,runtime在执行前根据Agent的授权token校验scope。校验不通过就直接拒绝技能调用,并返回一段可读信息给Agent。这比让开发者在代码里手动判断权限要省心得多。
同时建议对技能做分级审批,尤其是有写操作权限的技能。入库时至少经过一个review,人员有精力就直接看执行代码,没精力至少看Manifest里的execution.tools和permissions声明。技能不是越多越好,高风险技能宁可拆得细一些,让Agent每次只获得最小必要权限。
5. 技能库的持续积累与演化:从脚本到技能的改造路径
5.1 哪些需求值得沉淀成技能
不是所有功能都该做成技能。我自己的判断标准是三句话:是否能被自然语言稳定触发、是否内部有固定执行逻辑、是否会被多个业务场景复用。三者都满足,才值得做成Skill。
比如"定时发送周报"这种,意图明确(用户说"每周五下午把周报发给团队"就能触发),逻辑固定(拼接数据、套模板、走邮件系统),而且市场、运营、研发都可能用,就可以沉淀成技能。
反之,那些"这次任务临时要算个数字"的逻辑,做成普通工具函数放在代码库里就好。盲目把什么都升级成技能,技能库变成大杂烩,Agent的选择准确率和维护成本都会恶化。
5.2 技能版本管理和灰度上线
技能也是代码,也要讲究版本和发布。我们的做法是Manifest带version字段,Registry里每个技能允许存在多个历史版本,但默认只激活一个版本。灰度上线时先让5%流量跑新版本,对比技能成功率、degraded率、耗时三项指标,达标后再全量。
有一点经验很关键:技能执行器要做版本兼容,不能因为Manifest升级就把旧调用全废掉。因为Agent决策时可能已经在上下文里引用了旧版本的description,突然切换会导致选择不一致。做好版本兼容,让执行器按调用时声明的版本号加载对应配置,才能平滑升级。
5.3 团队共建技能库的协作习惯
技能库不需要专人维护,但必须有人对质量负责。我们团队的做法是每周固定一个技能Review时段,每个人把本周新写的或改动过的Skill拿出来过一遍,重点看描述质量和异常处理。Review记录直接挂在技能目录的CHANGELOG里,这样这个技能为什么这么设计、改过什么坑,后人一看便知。
另外一个容易被忽略的协作坑:技能命名需要全局统一且唯一。这看起来是常识,但实际中总会冒出"query_user_info"和"get_user_profile"这种双胞胎。我直接在CI脚本里检查索引中是否有重名技能,有就让流水线直接失败。命名上有共识,后面做权限审计、排查日志都会省很多力气。
写在最后
这套体系我从最开始的十几个工具函数,一路磨到几十个技能稳定运行,最大的体会是:做Agent不是给模型堆更多能力,而是给模型更少但更完整的选项。技能库的维护成本大头不在执行代码,而在描述语义与边界打磨——每次新增技能前都值得先花半小时思考"这个技能和现存技能会不会让模型产生选择困惑",比事后调优省心太多。
最后再分享一个实用小技巧:定期用线上真实调用日志里的低置信度样本反哺技能优化。每当Agent选错了技能或者该选没选,把样本存下来,积攒一批评后集中分析,你会发现自己对"模型眼中的技能语义"理解的偏差点,比想象中更多。持续做下去,技能库才会越来越稳。