做Agent应用的人基本都经历过这么一幕:同一个能力,上一个Agent里写了一遍,换个场景又要重写一遍;明明只是改个参数,却因为工具定义塞满上下文,把模型都带偏了。时间一长,项目里堆满了没人敢动的“一次性脚本”。agent-skills要解决的,就是这件事——把Agent能执行的具体任务,封装成结构化的、可注册、可发现、可复用的技能单元,让Agent不再每次都从零理解一个工具,而是像人一样,按需调用已经练好的“本领”。这篇内容适合正在做Agent应用、被工具调用和流程编排折磨的开发者,也适合想把自己的多步业务流程沉淀成标准件的人。
1. Agent为什么需要“技能”而不只是“工具”
先说清楚一个很多人混淆的概念:工具是能力的最小单位,技能是完成目标的方法单元。工具告诉你“能做什么”,技能告诉你“什么时候做、怎么做、做砸了怎么收场”。一个技能可以只调用一个工具,也可以编排多个工具和多步决策,甚至调用另一个技能。
1.1 工具调用的四个典型痛点
如果你只给Agent挂一堆工具,很快会遇到四个问题。
第一是上下文被工具定义填满。一个稍复杂的Agent可能要挂二十个工具,每个工具定义、参数说明、示例加起来轻轻松松几千token。模型每次请求都要把这些定义反复看完,真正留给业务数据的空间就被压缩了。我还见过有人给一个工具写了上千字的说明文档,模型没被工具能力帮助到,反而被冗长描述搞得分心。
第二是工具不知道在什么场景下用。工具定义里通常只有“输入参数”和“返回值”,但不说“当用户表达某类意图时优先用我”“当遇到某类请求时不要用我”。模型只能凭名字猜,一猜就容易错。比如一个“删除文件”工具,模型在用户说“把没用的归档文件移走”时,可能直接调删除,而不是调用归档技能。
第三是多步流程没法复用。导出报表、生成周报、安排日程,这类任务本质上是“多个工具按顺序执行”的流程。如果每一次都让模型现场编排,它会走很多弯路,而且上一步和下一步之间缺少约束,中间结果稍微不符合预期就断掉。更麻烦的是,团队里每个Agent都在重复写同一套编排逻辑。
第四是没有反馈闭环。工具调用成功或失败,模型并不会自动记住这次教训。同一个错误,下一次换个说法又踩一遍。工具本身没有“经验”这个概念,也无法沉淀“上次在这个场景应该这么做”的知识。
这四个痛点凑在一起,结论就很明显:单纯给Agent增加工具数量,边际收益会快速下降,甚至变成负收益。需要一种更高层的抽象,把工具、流程、约束、经验打包在一起,这就是技能。
1.2 技能到底是什么:原子技能与组合技能
我给技能下的定义是:完成一个业务目标所需的触发条件、执行流程、工具调用、参数约束、错误处理和经验示例的集合。它不只是一个函数,更像是给模型看的一份“操作手册”,也是给执行器跑的一份“流程脚本”。
技能可以分为原子技能和组合技能。原子技能只有一个步骤,比如“查询天气”“获取当前时间”“读取某个文档内容”。组合技能包含多个步骤,内部可以调用原子技能,也可以调用其他组合技能。比如“安排出差行程”这个技能,它的步骤可能包括:查询日程空闲时间、查询航班、预订酒店、生成差旅确认单。每一步可能对应一个原子技能。
组合技能和工具的区别在于,模型只需要做一次决策——“这个用户请求应该用安排出差行程技能”,而不是在第一步、第二步、第三步分别做决策。决策次数减少,出错概率就下降,执行路径也更可控。这就像人开汽车,不需要每次起步都重新思考踩哪个踏板,因为“开车”已经是一个自动化技能。
从工程实现角度看,技能是对工具、提示词、流程脚本、错误处理逻辑的统一封装。它让Agent项目从“一堆散装工具函数”变成“一组可插拔的能力模块”,这是agent-skills的核心价值。
2. agent-skills 的整体设计思路
搭技能库不只是一件事,而是一套从定义到执行、从存储到评估的完整机制。我建议按五层来设计:技能描述层、注册发现层、加载调度层、执行层、评估反馈层。
前后顺序很关键。先想清楚技能怎么描述,才能设计注册表;先有注册表,才能按需加载;按需加载之后,执行器才好调度;最后才能收集数据做评估。别一上来就写执行器,描述和注册层才是地基。
2.1 技能描述结构:给模型看的“使用说明书”
技能描述是写给大模型看的,所以要能被模型快速理解,并且包含明确的触发条件和禁用场景。我常用的描述结构是下面这样。
{ "name": "export_sales_report", "version": "1.2.0", "description": "导出指定时间段的销售报表,聚合数据后生成Excel文件,并通过邮件发送给收件人。适用于用户明确要求生成销售报表、导出销售数据、发送统计邮件等场景。不适用于实时查询单条订单明细,也不适用于分析销售趋势并给出结论。", "input_schema": { "type": "object", "properties": { "date_range": { "type": "string", "description": "日期范围,格式为YYYY-MM-DD~YYYY-MM-DD", "pattern": "^\\d{4}-\\d{2}-\\d{2}~\\d{4}-\\d{2}-\\d{2}$" }, "recipients": { "type": "array", "items": { "type": "string", "format": "email" }, "description": "收件人邮箱列表" } }, "required": ["date_range", "recipients"] }, "output_schema": { "type": "object", "properties": { "file_path": { "type": "string" }, "email_status": { "type": "string", "enum": ["sent", "partial_failed", "failed"] } } }, "steps": [ { "step": "validate_input", "description": "校验date_range格式与recipients合法性" }, { "step": "query_sales", "description": "调用销售数据查询接口,聚合数据" }, { "step": "generate_excel", "description": "将聚合结果写入Excel文件" }, { "step": "send_email", "description": "发送邮件,附带生成的Excel文件" } ], "tools": ["sales_api", "excel_writer", "mail_sender"], "guardrails": [ "date_range跨度不得超过31天", "recipients数量不得超过20个", "禁止在邮件正文中使用风险字符" ], "examples": [ { "input": { "date_range": "2024-05-01~2024-05-31", "recipients": ["ops@example.com"] }, "output": { "file_path": "/tmp/report_202405.xlsx", "email_status": "sent" } }, { "input": { "date_range": "2024-01-01~2024-12-31", "recipients": [] }, "output": { "file_path": null, "email_status": "failed" } } ], "error_policy": { "on_timeout": "返回部分结果,并在email_status中标记failed", "on_data_missing": "使用最近一周可用数据代替,并在备注中说明" } }description字段是最重要的,它决定了模型会不会在正确时机选择这个技能。光写“导出销售报表”不够,还要写清楚“适用于什么”“不适用于什么”。尤其要写反例,模型对“不要做什么”的记忆往往比“要做什么”更牢。
input_schema和output_schema是给模型用的参数契约,也是给执行器做校验用的。字段类型、格式、必填项都要写死。一个常见错误是参数描述太宽松,比如“recipients”写成“邮箱列表”,结果模型传了一个字符串而不是数组。越严格的schema,越能减少执行阶段的低级错误。
steps是给执行器和模型共同看的。模型可以通过steps理解整个技能大致执行流程,从而更准确地判断是否适合当前任务。执行器则按steps逐项执行,也可以把它当作一种流程日志。guardrails是一些硬性规则,比如最大跨度、最大数量、禁用字符,执行器在每一步之前都应该检查。
examples是给模型的few-shot参考,建议每个技能放两到四个示例,并且至少包含一个失败场景。这样模型能更早意识到“输入不符合条件时技能会失败”,而不是盲目乐观地强行调用。
error_policy是很容易被忽略的部分。它告诉执行器在出错时应该怎么降级:等待、跳过、用替代数据,还是直接失败。没有error_policy的技能,一旦某一步异常,整个任务就卡死,Agent也只能干瞪眼。
2.2 注册与发现机制:让技能可被Agent找到
技能描述写好之后,要有一个地方让Agent“知道有哪些技能可用”。注册表就是这个索引。
我推荐用一个registry.json文件作为注册中心,每条记录只保存最核心的信息:技能名称、描述摘要、版本、入口文件、依赖关系。完整的技能描述放在各自的目录中。Agent启动时只加载registry里的摘要,而不是把所有技能全文都读进上下文。
{ "skills": [ { "name": "export_sales_report", "summary": "导出销售报表,聚合数据生成Excel并邮件发送", "version": "1.2.0", "entry": "report_export/manifest.json", "dependencies": ["sales_api", "excel_writer", "mail_sender"] }, { "name": "schedule_trip", "summary": "安排出差行程,包括机票、酒店和日程冲突检查", "version": "2.0.1", "entry": "trip_schedule/manifest.json", "dependencies": ["export_sales_report"] } ] }注册表的好处是治理简单。要下线一个技能,从注册表里移除条目就行;要升级一个技能,先改版本号,再改注册表。执行器不会直接扫描目录里的所有文件,所以不会出现“技能文件存在但没注册”的混乱情况。
技能发现机制可以做成两类:一类是Agent启动时的全量摘要注入,适用于技能总数不多的情况;另一类是动态搜索,当技能超过几十个时,先让模型用一个“技能搜索技能”去检索注册表,再决定加载哪些。第二种方式更省上下文,但需要额外调试搜索逻辑。我建议技能少于三十个时先用摘要全量注入,简单可靠。
2.3 按需加载与缓存策略:省token的诀窍
按需加载是agent-skills省token的关键。常见方案是三级上下文:注册表摘要、技能海报、技能全文。
第一级是注册表摘要,每条几十个token,Agent一眼扫过就知道有哪些能力。第二级是技能海报,包含更详细的触发条件、参数概要和典型场景,但不包含完整steps和examples。当模型觉得某个技能可能适合当前任务时,可以要求查看海报。第三级是技能全文,包含完整的描述、steps、guardrails、examples和error_policy,真正在执行前才加载。
这套三级策略很像看书的目录、扉页推荐和正文。模型不需要把整本书背下来,只会在需要时翻开某一页。配合会话级缓存,同一个技能在会话内只加载一次全文,后续调用直接复用,能进一步减少重复token消耗。
缓存还要注意版本失效:如果技能在会话中途被更新,已缓存的旧版定义可能还在模型上下文里。所以执行器在每次调用时都应该对比当前注册表中的版本号和缓存版本号,不一致就强制刷新,或者干脆在会话开始时固定所有技能版本,防止中途不一致导致行为漂移。
3. 核心细节解析与实操要点
整体设计清楚了,再看具体实现中的细节。这一部分最容易踩坑,也最影响Agent实际表现。
3.1 编写高质量技能描述的五个要点
我看了很多失败案例,发现高质量技能描述基本都满足五个要点。
第一,描述要写触发条件和反例。触发条件最好具体到“当用户明确提到某个概念或动词时”,反例最好覆盖相似但不同的请求类型。比如“发通知”技能的描述里,可以写“适用于用户要求发送站内信、邮件、短信等消息的场景;不要用于创建定时任务,也不要用于发送营销活动邀请”。
第二,参数要严格定义,给足约束。不要只写“姓名”,要写明格式、长度限制、是否必填。能用枚举就枚举,能用正则就正则。参数越宽松,模型发挥空间越大,出错率也越高。参数校验失败在Agent场景里非常常见,大部分是定义太模糊造成的。
第三,步骤要原子化,每一步都可验证。一个步骤应当是“调用查询接口”或者“生成Excel文件”这种有明确产出物的动作,而不是“处理数据”这种没有验收标准的黑盒步骤。可验证是什么意思?就是执行到这个步骤后,程序能明确知道这一步是否成功,比如检查接口状态码、检查文件是否存在。
第四,错误处理要显式写出来。每个技能都应该有error_policy,至少回答三个问题:超时怎么办、某一步失败怎么办、部分数据缺失怎么办。别让模型在运行时自己猜,它一猜就猜出各种匪夷所思的降级方案。
第五,few-shot示例要精不要多,且覆盖边界。放两个成功例子加一个失败例子,比放五个差不多的成功例子有效得多。失败例子能让模型知道“这个技能不是万能的”,在不确定时多考虑一下。
3.2 技能之间的依赖与冲突管理
组合技能必然带来依赖关系。A技能依赖B技能,B技能依赖C技能,这在技能库里很常见。依赖关系要在注册表里显式声明,并且启动时做一次完整性检查。如果发现某个依赖不存在或版本不兼容,直接报错,不要等到运行到一半才失败。
技能冲突是另一个高频问题。比如“导出报表”和“发送报表”两个技能,功能有重叠。模型可能面对一个“把报表发给老板”的请求,不知道该选哪个。解决思路是:一个请求只能由一个“主技能”负责,其他技能只作为辅助被主技能调用。在注册表或系统提示里明确定义优先级:专用技能优先于通用技能;更具体的描述优先于更模糊的描述。如果实在无法消解,就把两个技能合并成一个,把差异点作为参数。
我常用的简便方法是:在技能描述里写一句“如果另一个技能也能处理当前请求,优先使用本技能,因为本技能能够额外完成X”。这会让模型在意图模糊时偏向选择更全面的那个,减少冲突。
3.3 面向模型还是面向代码:双轨表达
一个技能有两条表达路径:一条面向模型,一条面向机器。面向模型的是描述、步骤说明、示例,目标是让模型“知道怎么选”;面向机器的是执行代码、校验逻辑、API调用,目标是让程序“知道怎么跑”。
这两条路径如果耦合得太紧,后面很难维护。比如改了一行参数校验代码,却忘了更新描述里的input_schema,模型就会按旧定义传参,导致大量校验失败。所以我坚持把manifest.json(模型视图)和run.py(机器视图)分开存放,并且要求任何一端变动时,另一端必须同步修改。也可以写一个简单的自动化检查脚本,比对两边的字段定义是否一致。
在实际项目里,我见过很多技能设计得“两头不靠”:描述写得像技术文档,代码却没有任何参数校验。结果模型完全看不懂触发条件,代码又被非法参数击穿。正确的做法是,描述要像给同事写的业务说明,代码要像给新员工准备的防御性实现。
4. 实操过程与核心环节实现
理论说再多,不如跑通一次。这一节从目录结构开始,到执行器、评估闭环,完整走一遍agent-skills的最小实现。
4.1 从零搭建一个技能库的目录结构
我推荐的目录结构是这样的:
skills/ registry.json report_export/ manifest.json run.py examples.json deps.json calendar_query/ manifest.json run.py examples.json deps.json每个技能一个独立目录,目录名就是技能名。manifest.json存放给模型看的完整技能描述,run.py存放执行逻辑,examples.json单独存放few-shot样本,deps.json存放依赖技能和依赖API的清单。分文件的好处是,更新示例时不用动manifest主文件,diff更清晰。
registry.json是整个技能库的入口。Agent框架启动时,先读registry.json,把技能摘要注入系统提示。等模型选中某个技能后,执行器再动态读取对应目录下的manifest.json和run.py。目录结构保持扁平,不建议嵌套太深,否则加载路径容易出错。
首次搭建时,建议先用三到五个高频技能跑通全链路,不要一上来就做几十个技能的大目录。技能库的成功标准不是数量多,而是模型能在正确时机选对技能,并且执行稳定。
4.2 实现一个轻量技能执行器
技能执行器负责加载技能、校验输入、执行步骤、处理错误。下面是一个极简Python实现,重点不是完备,而是把最关键的控制流展示清楚。
import json from pathlib import Path class SkillExecutor: def __init__(self, registry_path="skills/registry.json"): self.registry = json.loads(Path(registry_path).read_text()) self.call_stack = [] self.max_depth = 5 def list_summaries(self): return [ {"name": s["name"], "summary": s["summary"], "version": s["version"]} for s in self.registry["skills"] ] def get_skill_full(self, skill_name): entry = next((s for s in self.registry["skills"] if s["name"] == skill_name), None) if not entry: raise ValueError(f"skill not found: {skill_name}") manifest_path = Path(entry["entry"]) return json.loads(manifest_path.read_text()) def run(self, skill_name, params): if len(self.call_stack) >= self.max_depth: raise RuntimeError("skill call depth exceeded") if skill_name in self.call_stack: raise RuntimeError(f"circular skill call detected: {skill_name}") skill = self.get_skill_full(skill_name) self.call_stack.append(skill_name) try: # 参数校验 input_schema = skill.get("input_schema", {}) self._validate(params, input_schema) # 按步骤执行 result = params for step in skill.get("steps", []): step_name = step["step"] handler = getattr(self, f"step_{step_name}", None) if handler: result = handler(result) else: # 未实现特殊步骤时,默认交给模型或外部worker处理 result = self._generic_step(skill_name, step_name, result) return result except Exception as e: # 调用error_policy,返回结构化失败 return self._handle_error(skill, e) finally: self.call_stack.pop() def _validate(self, params, schema): # 简单校验:必填字段和类型 required = schema.get("required", []) for field in required: if field not in params: raise ValueError(f"missing required field: {field}") # 更完整的校验可按JSON Schema实现这是一个骨架代码,实际项目中你还要补上类型检查、格式校验、超时控制、日志埋点。关键点有三个:调用深度限制、循环调用检测、统一异常处理。没有这三个保护,组合技能迟早会出事故。
执行器还需要支持“模型在步骤之间做决策”的情况。比如某一步的结果不确定时,可以回到模型,让模型根据当前上下文选择下一步。这种设计更灵活,但会让执行时间变得不可控。我建议把这类需要决策的技能单独标记为“智能技能”,并给它们设置更高的优先级和更明显的步骤说明,避免与普通自动化技能混淆。
4.3 技能评估与迭代闭环
技能库不是写完了就完事,而是要持续迭代。没有评估,你根本不知道一次描述修改是变好了还是变坏了。
离线评估是第一批要做的。收集一批历史任务,每个任务记录用户原始请求、期望调用的技能、期望参数、期望输出。跑一遍技能库,统计成功率、平均耗时、token消耗、参数校验失败率。下面是一个简化评估表的示例。
| 技能名称 | 任务数量 | 成功率 | 平均耗时 | 平均Token消耗 | 主要失败类型 |
|---|---|---|---|---|---|
| export_sales_report | 50 | 94% | 2.3s | 1840 | 参数格式错误 |
| schedule_trip | 30 | 86% | 8.1s | 3260 | 依赖技能超时 |
| calendar_query | 42 | 98% | 1.2s | 720 | 无 |
我通常把成功率低于90%、耗时超过5秒的技能列为重点优化对象。优化顺序一般是:先改描述解决误触发,再改参数校验减少格式错误,最后才改执行逻辑。因为大部分失败不是代码跑不通,而是模型压根没用对技能。
线上追踪也很关键。日志里至少要记录:技能名称、输入参数快照、输出结果、错误类型、耗时、模型选择技能的推理过程摘要(如果框架能拿到)。每周复盘一次,把所有失败案例按原因分类。你会很快发现一些规律,比如“某个技能总是在下午被误触发”“日期格式在节假日特别容易传错”。
有了这套闭环,技能库才能从“能跑”进化到“好用”。我见过很多团队只建库不评估,结果技能越加越多,模型选择准确率反而下降。那已经不是技能库,而是垃圾堆。
5. 常见问题与排查技巧实录
最后这一部分,都是实际项目里最容易踩的坑。
5.1 技能误触发:模型在该用的时候不用,不该用的时候乱用
误触发是最高频问题。模型面对“帮我找一下上个月给客户发的合同”这种请求,明明该用文档搜索技能,却错误调用了邮件发送技能。原因几乎都出在描述上:触发条件写得太宽泛,或者没有反例。
排查时要先看模型在做出选择时看到了哪些技能摘要。如果摘要里写着“发送合同相关邮件”,那模型被误导就一点也不奇怪。修复方法很简单:把摘要和描述改得更精确,例如“仅在用户明确要求发送邮件时使用,不包括查找、预览、编辑邮件内容等场景”。
还有一个技巧是给相似技能设置不同的关键词权重。在描述里反复出现“邮件发送”“发送给收件人”“附件发送”这些词,模型才会在真正需要发邮件时优先想到它。不过要小心,权重词堆太多也会造成新的误触发,适度就好。
5.2 技能执行超时或部分成功
多步骤技能里只要有一个步骤慢,整个技能就会超时。尤其是组合技能,内部调了好几个外部API,每个API响应时间不一样。
解决办法是“分层超时”。执行器给整个技能设置一个总超时,比如30秒;给每个子步骤单独设置超时,比如查询接口10秒、生成文件15秒。子步骤超时后,先检查error_policy,能跳过就跳过,不能跳过就把已经拿到部分数据返回给模型,并在结果里标记哪个步骤失败。这样至少Agent能基于部分结果做下一步决策,而不是干等。
我在实际项目里发现,很多技能失败不是逻辑错误,而是因为缺少超时保护。给执行器加上总超时后,看起来“卡死”的问题会减少一大半。
5.3 组合技能死循环与幂等性
组合技能调用另一个技能时,最怕出现循环依赖。技能A调B,B调C,C又调A,执行器直接递归到栈溢出。这种问题在设计阶段就要用依赖图检查拦下来。实现层面,执行器里要记录调用栈,检测到重复技能名就立刻报错。
幂等性是另一个老坑。发送邮件、创建订单、转账这类非幂等操作,如果技能被重复执行两次,后果很严重。所以每次调用这类技能时,要生成一个requestId作为去重键。执行器缓存最近处理过的requestId,发现重复就直接返回上次结果,不真正执行。在模拟项目X里,我们就是因为没做幂等,导致同一个归档任务被跑了两次,文件被重复处理。后来在参数里强制带上requestId,这个问题才彻底解决。
5.4 版本更新后旧技能失效
技能库一旦被多个Agent共用,版本问题就绕不开。升级一个技能,可能影响所有依赖它的Agent。我在实践中总结了几条规则:技能版本号必须遵循语义化版本,破坏性变更要升主版本号;Agent启动时可以指定允许的技能版本范围,旧Agent继续用旧版,新Agent用新版;所有外部接口调用统一走网关层,接口路径变了只改网关,不逐一改技能。
技能变更后,还要更新registry里的版本号和摘要。很多团队改完run.py,忘了改manifest里的版本,导致Agent加载的是旧描述,执行的是新代码,最典型的后果就是模型按旧参数传参,执行器按新逻辑校验,白白浪费大量token。
最后分享一个我自己的习惯:每次往技能库里加新技能,先为它写一条“不该触发”的反例,再写三条成功示例。这比写任何测试用例都更能提升技能库的稳定度,因为它直接约束了模型的选择行为,而不是只校验执行结果。技能库本质上是一个沉淀业务判断的系统,维护越勤快,Agent就越“懂事”。如果你刚开始做,别贪多,选出三个高频场景封装成技能,跑通一轮评估,再慢慢扩展,这条路比我一开始就铺几十个技能要稳得多。