☰
AI Agent技能库实战:从工具封装到模型自动选择
2026/10/14 8:25:56 网站建设 项目流程

做AI Agent这半年多,我最大的感受不是模型不够聪明,也不是编排框架不好用,而是能力散落一地。今天要聊的是我一直在维护的一个叫 agent-skills 的技能库项目,核心就三件事:怎么把Agent需要用到的能力封装成标准化的“技能”,怎么让模型在合适的时机自动选对技能,以及怎么让这些技能跨项目复用。如果你和我一样,被工具列表冗长、提示词越写越长、模型乱调工具这些问题困扰,这篇文章应该能给你一些可以直接落地的方案。

1. 为什么Agent需要一套“技能”机制?

1.1 从“提示词堆砌”到“技能复用”

我最早做的Agent很简单:系统提示词里塞几十个工具的JSON Schema,然后让模型自己决定调用哪一个。结果大家应该都猜得到——工具一多,模型就开始犯迷糊。它经常把参数填错,甚至明明有搜索工具不去用,非要凭记忆编一个答案出来。

后来我意识到一个问题:Agent的能力本质上是“模型+上下文+可执行单元”,但大多数人只关注前两者,对“可执行单元”的组织方式非常随意。工具函数有了,说明文档没有;说明文档写了,依赖又没管;某个Agent调得好好的技能,换一个项目又要重新写一遍。这些痛点让我下定决心做一套自己的技能库,名字就叫 agent-skills。

技能机制和普通工具最大的区别在于:工具解决的是“能做什么”,技能解决的是“什么时候做、怎么做、做完了怎么验证”。一个技能包里除了可执行的代码,还有模型需要理解的触发条件、参数合约、使用限制、依赖环境和测试用例。它更像一个完整的“岗位说明书”,而不是一个孤零零的函数签名。

1.2 技能与工具的核心差异

为了把这个概念说清楚,我整理过一个对比表:

维度普通工具 Tool技能 Skill
内容函数名+参数签名SKILL.md + 入口脚本 + 依赖 + 测试
触发方式模型根据函数名猜测模型根据描述中的触发条件决策
使用限制没有或很少明确写出禁忌场景和边界
依赖管理项目级全局依赖技能自带依赖清单,可按需隔离
复用性需要重新注册目录拷贝即可跨项目复用
可验证性基本靠人工内置测试夹具,可离线验证

我当时决定用“目录即技能”的设计思路:每个技能占用独立目录,目录里必须有SKILL.md入口描述,其余文件按需放置。这样整个仓库看起来极其规整,加载器也能通过扫描目录自动发现技能,不需要每次手动注册。

这个设计还有一个隐藏好处:团队协作时,谁要新增一个能力,只要开一个目录建一个SKILL.md就能提交,不用去动核心代码。技能和主程序之间的耦合被降低了,Agent的扩展从“改代码”变成了“加配置”。

2. 技能仓库的整体设计

2.1 目录结构:让“技能包”一眼看懂

仓库结构我一直沿用下面这套,没什么花哨的,胜在清晰:

agent-skills/ ├── skills/ │ ├── web-search/ │ │ ├── SKILL.md │ │ ├── search.py │ │ ├── requirements.txt │ │ └── tests/ │ │ ├── fixtures.json │ │ └── test_search.py │ ├── pdf-summary/ │ │ ├── SKILL.md │ │ ├── summarize.py │ │ ├── templates/ │ │ └── requirements.txt │ └── schedule-helper/ │ ├── SKILL.md │ └── calendar_utils.py ├── loader/ │ ├── skills_loader.py │ └── action_table.json └── examples/ └── quickstart.py

这套结构里,skills/下面每个子目录就是一个独立技能。命名统一用小写加中划线,和Python模块名保持一致,避免大小写问题。入口文件我习惯叫run.py或者直接根据功能命名,比如search.py,只要在SKILL.md里写清楚即可。

你可能会问:为什么不直接在代码库里建一个大的tools/包,而是要用目录隔离?因为技能是给模型“看”的东西,模型不看代码,只看SKILL.md。目录隔离之后,加载器可以单独读取元信息,也可以在后续做权限控制时按技能维度配置权限,灵活性高很多。

2.2 SKILL.md:让模型“读懂”技能的关键

整个设计里最核心的文件就是SKILL.md。它相当于是技能的“说明书”,模型通过读它来决定是否调用、如何调用。我早期失败的原因之一就是说明书写得像接口文档,后来参考了一些主流技能格式后,总结出一套好用的模板。

以web-search技能为例,SKILL.md是这样的:

--- name: web-search description: >- 当用户需要获取最新资讯、查询实时信息、搜索网页内容时使用此技能。 支持关键词搜索、指定站点限制、控制返回条数。 注意:知识性问答和历史事件请直接回答,不需要使用本技能。 version: 1.3.0 tags: [search, web, tool] parameters: type: object properties: query: type: string description: "搜索词,建议控制在30字以内" examples: ["为什么今年桂花不开"] domain: type: string description: "可选站点限制,例如 example.com" default: "" count: type: integer default: 5 minimum: 1 maximum: 20 required: [query] --- 实现说明:入口文件为 search.py,调用方式为 `python search.py --query "关键词" --count 5`。 返回 JSON 数组,数组元素包含 title、url、snippet 三个字段。

我来说说每个字段为什么重要:

  • description:这是整个技能被正确触发的关键。第一句话必须写明“什么时候用”,第二句话写“能提供什么”,最后要写“什么时候别用”。负向描述特别有用,能显著减少误调用。
  • parameters:必须用JSON Schema格式,别自定义格式。模型对JSON Schema的训练数据足够多,识别准确率高。而且后续可以直接复用校验库做参数检查。
  • version:技能的迭代要有版本号。很多技能在Agent里跑得好好的,某天你改了输入输出格式,没注意版本,模型仍然按老格式传参,当场翻车。有了版本号,至少能追踪问题。
  • tags:不是给模型看的,是给加载器做分组和筛选用的。比如按“搜索类”“文档类”“日常工具类”分组,在Agent侧可以按需加载。

SKILL.md的正文部分,我建议写得简短但明确:入口文件是什么、调用方式是什么、返回结构长什么样。这里不是给模型看的——模型只看frontmatter就够了——而是给维护者和其他开发者看的。相当于“技术规格”,把实现细节留在正文,把决策信息放进元数据。

2.3 参数合约:一份价值极高的JSON Schema

参数设计直接影响模型调用的成功率。我看到很多初学者把参数定义得很粗,比如一个技能只写一个input字符串字段,让模型自己把参数拼进去。结果就是模型自由发挥,每次传参格式都不一样。

我现在的原则是:参数尽量结构化,但不要过度拆。比如搜索技能只需要query、domain、count三个字段,每一个都有类型、默认值和取值范围。模型拿到这样的结构,传参几乎不会出错。

还有一个细节:description字段里要写“建议控制在30字以内”这类约束,模型真的会遵守。你写“query 是搜索词”,它会传一个完整句子;你写“搜索词,建议控制在30字以内”,它就会尝试精简关键词。这算是提示工程在Schema层面的应用,实测很有用。

另外,所有参数尽量给默认值。模型如果拿不准某个非必填字段,就不会填,你的程序要有兜底逻辑。例如count默认5,domain默认空字符串,这样调度器永远能收到合法参数。

2.4 技能描述怎么写,调用率才高?

技能描述是影响模型决策的最重要因素,没有之一。我对比过两种写法:

写法效果
“提供网页搜索服务,支持关键词查询”模型经常在应该搜索的知识问题上不调用,或者乱调用
“当用户需要获取最新资讯、查询实时信息时使用;知识性问答请直接回答”明显更能把握调用时机

核心技巧是:描述里要有触发词,比如“最新资讯”“实时信息”这类词等于给模型装了感应器。同时要有负向排除,告诉模型哪些场景不该用它。模型在决策时其实是在做文本匹配,触发词越明确,匹配准确率越高。

我还有一个习惯:每个技能描述里写一个典型示例场景,比如“例如‘帮我查一下今天某公司股价’,或‘搜索某某教程的最新版’”。示例相当于给模型提供了一个小样本,它会更自信地选择这个技能。

3. 加载器实现:把技能变成Agent的能力

3.1 加载流程:扫描、解析、校验、注册

有了技能包,接下来要解决的是怎么把它们装进Agent。我实现了一个轻量级加载器,整体流程分四步:

  1. 扫描技能目录,找到所有SKILL.md文件;
  2. 解析frontmatter,提取技能名、描述、参数Schema;
  3. 校验必需字段,缺失的直接跳过并记录警告;
  4. 注册到内存中的技能表,供调度器使用。

这个流程听起来简单,但每一个环节都有细节。比如扫描时你要处理“一个技能被禁用”的情况,我采用约定:技能目录下放一个.disabled标记文件,扫描时遇到就跳过。这样不需要改代码,也不用删目录,临时禁用特别方便。

3.2 核心代码实现

技能加载器的核心代码不长,我直接贴一个简化版本:

import yaml from pathlib import Path SKILL_ROOT = Path("./skills") def parse_skill_md(path: Path): """解析 SKILL.md,返回 (frontmatter, 正文)""" text = path.read_text(encoding="utf-8") if not text.startswith("---"): raise ValueError("SKILL.md 必须以frontmatter开头") parts = text.split("---", 2) meta = yaml.safe_load(parts[1]) body = parts[2].strip() if len(parts) > 2 else "" return meta, body def load_skills(): skills = [] for md in SKILL_ROOT.glob("*/SKILL.md"): # 判断是否被禁用 if (md.parent / ".disabled").exists(): print(f"[skip] disabled skill: {md.parent.name}") continue try: meta, body = parse_skill_md(md) except Exception as exc: print(f"[error] parse {md}: {exc}") continue # 必需字段检查 if not meta.get("name") or not meta.get("description"): print(f"[warn] {md} 缺少name或description") continue skills.append({ "name": meta["name"], "description": meta["description"], "parameters": meta.get("parameters", {}), "path": md.parent, "doc": body, }) return skills

这段代码大概60行,已经能应付绝大多数场景。你可能注意到glob("*/SKILL.md")只扫描一层目录,没有递归。这是故意的,因为我不支持技能嵌套技能,那样会让加载逻辑复杂化。技能保持扁平,组合能力通过后面的“技能编排”解决。

3.3 动态决策:让模型自己选技能

加载器把技能表准备好之后,下一步是把技能列表注入到模型的对话上下文中。通常的做法是先将每个技能的name和description合并成一个列表,然后拼进系统提示词,或者作为工具列表的一部分。我用的格式如下:

[ { "name": "web-search", "description": "当用户需要获取最新资讯、查询实时信息、搜索网页内容时使用此技能。", "parameters": { "type": "object", "properties": { "query": {"type": "string", "description": "搜索词"}, "count": {"type": "integer", "default": 5} }, "required": ["query"] } } ]

模型每次请求前,Agent框架会生成一个待调用的指令,结构大概是{"skill": "web-search", "arguments": {"query": "..."}}。调度器收到指令后,在技能表中查找到对应的技能目录,然后执行入口脚本,再把运行结果返回给模型。

我建议不要把全部技能一次性塞给模型,而是做一个粗粒度分组。比如默认加载通用技能,遇到文档处理任务时再把文档相关的几个技能拼进来。这样既能控制上下文长度,又能让模型在小范围内做精确选择,效果比“一口气给50个技能”好不少。

3.4 实测效果:有技能和没技能的对比

我拿一个真实场景跑过对比:让Agent完成“搜索某产品的最新评测,然后总结成三句话”。没有技能库时,模型要么直接编造,要么需要我在提示词里手写一大段搜索API的调用说明。有了web-search技能后,模型自动识别“最新评测”触发词,选择技能并正确传参,返回结果后我自己再经过一层摘要技能,整个流程非常顺。

另一个变化是提示词长度。以前工具说明要占系统提示词一大半,经常挤占任务指令的空间。现在提示词里只保留技能清单和一句“按需调用技能”,上下文清爽很多,模型更关注任务本身。

这不是说技能机制是银弹。说到底,它只是把工具的组织方式捋顺了,模型本身的推理能力该什么样还是什么样。但在工程层面,Azure式的调用混乱问题确实减少了一个数量级。

4. 实战中的典型问题与排查技巧

4.1 路径与命名导致的诡异故障

技能加载器第一次在同事电脑上跑的时候,出现了SKILL.md正常但入口脚本找不到的情况。排查半天发现是目录名带空格,subprocess.run(["python", skill_path])直接崩了。后来我做了两个规定:技能目录名必须是小写字母加中划线;入口文件路径统一从SKILL.md所在目录推导,不接收外部传入的相对路径。

还有一次是Windows和Linux的路径分隔符问题。我在Linux上写的技能脚本用Path拼接路径没问题,到了Windows上某个测试函数里硬编码/就出问题了。这类问题防不胜防,最好的办法是所有路径操作都走pathlib,并且技能测试必须在目标运行环境跑一遍。

4.2 技能描述太长,模型开始乱选

技能描述一开始写得很详细,每个都超过300字,甚至有一些场景示例。结果发现模型的选择准确率不升反降,经常在两个技能之间犹豫,或者干脆选错。原因不难理解:描述过长,核心触发词被淹没,模型要处理的文本噪声变多了。

后来我把描述压缩到150字以内,保证第一句话就是触发条件,后面最多补一句限制或示例。挤出来的详细内容放到SKILL.md正文里,模型不需要读,只有人才需要读。这条经验后来成了我的铁律:给模型的描述要短、准、狠。

4.3 参数合约和实际实现脱节

比较常见的问题是:SKILL.md里的parameters定义了date字段,要求YYYY-MM-DD格式,但入口脚本里用的是datetime对象,又没有做字符串解析。模型传了"2025-03-01",脚本报错,Agent返回乱糟糟的错误信息。为了根治,我在调度器和技能入口之间加了一层参数校验,按照JSON Schema校验后再执行。校验不过的情况,直接生成友好的错误信息返回给模型,让它重新传参。

这层校验一开始用现成的jsonschema库,后面发现对于简单技能有点重,就自己写了一个轻量校验器,只检查必填字段、类型和取值范围。够用就好,不想为了一个技能库引入一堆依赖。

4.4 技能冲突:同名覆盖和标签混乱

技能多了以后,很容易出现两个技能名字一样但功能不同的情况。我的加载器之前是遍历目录直接按name存字典,后来发现后面的会把前面的覆盖掉。解决办法是加了命名空间,一个技能包在仓库里要有唯一ID,形如search/web-search这样的group/name形式。在传给模型时,仍然只传短名字,但在内部调度表里用完整ID,避免覆盖。

另一个相关问题是标签体系混乱。一开始大家随意打标签,后来筛技能时基本没用。现在规定一个技能最多三个标签,且必须是预先定义好的标签枚举,比如search、document、automation,这样才具备筛选价值。

4.5 依赖隔离:多个技能撞车

技能多了以后,依赖冲突是早晚的事。某个技能要pandas 1.x,另一个技能要pandas 2.x,同一个环境里直接装会打架。轻量方案是按进程隔离,也就是调度器调用技能时,在子进程里执行对应脚本,且该技能使用自己固定的虚拟环境。

我实际用的方案更朴素:能不用重依赖的技能就不用,非要用的单独建虚拟环境,并在SKILL.md里写明环境名。调度器根据skill_env字段找到对应的Python解释器,再执行入口脚本。虽然慢一点,但稳定是最重要的。

5. 如何写好一个高质量技能

5.1 范围要小,目标要单一

一个技能只做一件事,这是我从失败案例里得出的教训。最早我把“处理PDF”写成一个技能,包含解析、提取、摘要、翻译四个功能。模型在调用时经常搞不清到底用哪个参数来控制行为,传错参数的概率很高。拆成pdf-text-extract、pdf-summary、pdf-translate三个技能后,每个技能职责清晰,模型选择也就准确了。

拆分的代价是技能数量变多,但描述变简单了。模型更喜欢明确的选项,而不是一个复杂的多功能开关。别担心技能太多,你可以通过标签和分组来管理数量。

5.2 容易照抄的技能结构

结合自己的经验,我整理了一个技能编写模板,新技能照着这个套路写基本不会跑偏:

1. SKILL.md frontmatter - name: 唯一短名 - description: 第一句触发条件,第二句能力,最后一句副作用边界 - parameters: JSON Schema,必须提供默认值和取值范围 - version: 语义化版本号 2. 实现脚本 - 入口函数或CLI,从stdin或参数接收输入 - 输出统一为JSON,便于调度器解析 - 禁止在脚本里print非JSON内容 3. requirements.txt - 列出所有依赖,注明版本范围 4. tests/ - 至少一组典型输入和期望输出 - 用fixtures模拟真实数据

入口脚本的输出格式我非常强调:统一JSON。很多技能实现者喜欢在代码里print("正在搜索...")这类日志输出,结果混进JSON里,调度器解析直接失败。正确做法是日志输出到stderr,stdout只留给结构化数据,这是所有技能实现必须遵守的约定。

5.3 测试与回归:把技能当代码对待

技能不是一次性写的,它会持续被模型调用,也会被其他开发者修改。每次改动都可能引入问题。我在每个技能目录下放了一些轻量测试脚本,改动后本地运行一次,确认基本功能没坏再提交。虽然做不到自动化CI的全套流程,但至少能挡住大部分低级错误。

有一个技巧很实用:测试时把Agent的决策能力也纳入覆盖。简单做法是准备几段典型的用户问题,比如“查一下最新天气”“把这个PDF总结一下”,然后看模型最终选择了哪个技能。如果正确答案没被选上,多半是技能描述需要优化。这类回归测试不需要代码,就是整理一组问法,手动跑一遍,非常值得。

6. 后续扩展方向

6.1 技能版本与技能市场

技能库用了一段时间后,我觉得可以再往前走一步:做技能分发。为每个技能增加作者、发布时间、使用次数等元数据,形成一个可浏览的技能市场。不过这个优先级不是很高,因为核心团队用的话,仓库本身已经够用了。

对个人开发者来说,我更推荐先把版本管理做好。每次修改技能时,应当更新version字段,逗号之间不改也别动。这样一旦线上出现问题,可以快速确认Agent当前用的是哪个版本的技能,避免“昨天还能用今天就不能用”还查不出原因。

6.2 技能组合与工作流

单个技能解决单点问题,但真实任务往往是多个技能串联。比如“搜索最新资讯并生成摘要”需要先调用web-search再调用summarize。我现在会在Agent提示词里描述这类组合路径,让模型自己编排调用顺序,效果还行。

更进一步,可以在技能库里加入简单的“工作流文件”,用DSL描述技能之间的依赖顺序和参数传递。不过说实话,这套机制做重了就容易变成另一个编排框架,而技能库的核心价值仍然是“组织能力”,不是“编排流程”。我也不建议一开始就搞工作流,等技能数量和组合需求都稳定后再考虑。

6.3 跨框架兼容:从技能到标准协议的桥梁

现在的技能格式五花八门,换一个Agent框架往往意味着重新封装一遍工具。最省事的做法是让技能底层适配标准工具调用协议,在调度器层做一层转换,对外输出成为标准格式。这样技能库既服务于本地方案,也能接入其他协议生态,避免被单一平台锁定。

我在自己的实现里做了一层薄薄的适配:技能执行器不关心外层是谁在调用,只暴露一个统一的run(json_input) -> json_output接口。任何Agent框架只需要用标准方式调用这个接口即可。这不仅是兼容性问题,也是一种架构纪律——技能作为能力原子,必须保持对外接口稳定。

实操中的个人体会

折腾 agent-skills 这段过程,最大的收获不是代码,而是把Agent能力组织这件事想明白了。工具再多,如果不解决好“什么时候用、怎么用、用错了怎么办”这三件事,模型的输出质量就会永远卡在一个不上不下的位置。技能库未必是唯一答案,但它给我提供了一个很稳定的工程抓手。最后再分享一个小技巧:在技能调试阶段,别一上来就接Agent,先写一个命令行脚本直接调用技能入口,确认输入输出都稳了,再连模型。这样能把“模型决策问题”和“技能实现问题”彻底隔离开,排查问题会轻松很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询