如果你最近也在做 agent 应用,大概率遇到过这种尴尬:模型很聪明,但不会干活。不是模型能力不行,而是它手里没有一套真正能用的工具。我重构自己的智能体项目时,把这个问题彻底拆了一遍,最后沉淀出来的方案就叫 agent-skills。这不是某个大厂的开源框架,而是一套把智能体能力组织成独立技能库的实践方式:每个技能独立成包,包含说明、参数结构、执行逻辑和示例,再由一个统一加载器把它们注册给模型。它能解决工具代码和业务逻辑耦合、技能无法复用、模型无法理解该调用哪个工具这几个核心问题。这篇文章我会从设计思路、目录规范、加载器实现到问题排查,完整讲一遍,适合正在做 LLM agent、自动化工具链,或者想把自己工作流沉淀成可复用技能包的开发者参考。
1. 为什么每个 Agent 项目都需要独立的技能库
1.1 技能写在代码里的痛
先说说我在没有技能库之前是怎么写的。最早做 agent 时,我习惯把所有工具函数直接塞进 agent 的主模块里:今天加一个查天气,明天加一个查汇率,后天再来一个定时提醒。三个月后再看那个文件,主流程已经膨胀到几千行,每个工具函数虽然都能跑,但它们是“长”在这个文件里的,不是“挂”在系统上的。
这种做法的第一个问题是扩展成本高。每新增一个能力,你都要动主模块的代码,改完还要手动更新工具描述给模型。第二个问题是复用几乎为零。另一个项目想要其中的“网页内容提取”功能,只能把人家的整个文件复制过来,连同那些无关的工具一起带过去。第三个问题是最隐蔽的:模型对工具的理解完全依赖你在代码里写的 description,而大家在赶工时往往三行两行就糊弄过去了,结果就是模型把工具调用得稀里哗啦。
所以我的体会是:agent 应用的技术难点早已不是“让模型输出一段 JSON”,而是“如何把你的能力边界组织成模型能理解的形态”。agent-skills 就是在回答这个问题。
1.2 技能库的本质:把“会做的事”和“做事的逻辑”分离
打个比方,你把一个非常聪明但完全不懂你们公司流程的新人招进来,他会写代码、会分析数据,但你让他去提交报销,他连表单在哪都不知道。这时候你需要的是一套 SOP 手册,而不是一遍遍在口头上教他。
技能库就是 agent 的 SOP 手册。它把“这个 agent 会做什么事”这个清单,从“这件事具体怎么做”的逻辑里彻底剥离开。技能清单是给模型看的,用来做决策;执行逻辑是给代码跑的,用来完成动作。两者之间靠一个标准协议对接:技能名、描述、输入输出格式。
这个分离带来一个很实际的好处:你可以只更新某个技能的执行逻辑,而不影响 agent 的整体结构;也可以只改技能描述来调整模型对工具的调用偏好,而完全不碰业务代码。我在重构之后,新增一个技能的操作就是往 skills 目录下丢一个文件夹,然后重跑一次加载,主程序一行都不用改。
1.3 适合什么场景,不适合什么场景
技能库设计不是银弹。我建议这样判断:如果你的项目里只有两三个固定的函数调用,而且未来基本不会增加,那就没必要上技能库,直接写死更高效。反过来,只要你的工具数量可能超过五个,或者你打算在多个项目间复用能力,或者你希望非技术人员也能通过写文档来扩展 agent 能力,技能库就是值得投入的方向。
不适合的场景也要说清楚。如果是在极其受限的安全环境里,系统不允许动态加载外部目录下的代码,那技能库的动态导入能力反而是个风险点,这种情况更适合用静态注册。另外,如果你希望 agent 完全自主地发明新技能并在运行时写代码执行,那需要的是代码解释器类方案,而不是技能库,技能库强调的是“人先审核、模型再调用”的稳定性。
2. agent-skills 的整体设计与目录规范
2.1 技能包的基础目录结构
一个技能就是一个独立的目录,目录名就是技能的唯一标识。我的标准结构是这样的:
skills/ web_extract/ SKILL.md main.py schema.json examples/ sample_input.json sample_output.json db_query/ SKILL.md main.py schema.json每个目录里,SKILL.md 是技能的身份证和使用说明书,写给人看、也写给模型看;main.py 是实际执行逻辑;schema.json 是入参出参的格式定义,不过我更推荐直接把这些定义写进 SKILL.md 的 front matter 里,这样加载器少读一个文件。examples 目录放调用示例。
刚开始我犯过一个错误:把 SKILL.md 和说明文档混为一谈,写得非常详细,结果模型注意力分散,反而抓不住重点。后来我明确了一个原则:SKILL.md 的正文只写“这个技能在什么情况下用、怎么用、有哪些禁忌”,不要写实现原理,原理写在代码注释里。
2.2 SKILL.md 与技能元数据格式
我这里使用 YAML front matter 来承载结构化信息,正文用 Markdown 写自然语言说明。一个典型的 SKILL.md 长这样:
--- name: web_extract description: 从指定 URL 提取网页正文内容,去除导航、广告等噪音。当用户要求获取某网页的文字内容、总结文章、或者提取新闻正文时使用。 version: 1.0.0 tags: [web, content, scraper] input: url: type: string required: true description: 目标网页的完整 URL,必须包含 http 或 https 协议头 output: type: object properties: title: type: string description: 网页标题 content: type: string description: 清洗后的正文纯文本 word_count: type: integer --- # 底层逻辑 - 只能提取公开可访问的网页,不处理需要登录的页面 - 若请求失败或返回非 HTML 内容,应返回错误信息而不是猜测内容 - 当用户给出的链接是 PDF、图片或视频地址时,不要调用本技能为什么 description 要写这么具体?因为模型在决定是否调用工具时,主要就看这段描述和当前对话的匹配度。写得太宽泛,比如“提取网页内容”,模型会把任何涉及链接的任务都交给它;写得太窄,比如“提取新闻标题”,模型遇到真正的正文提取需求又不会触发。所以我会在描述里同时写清楚“什么时候用”和“什么时候不用”,也就是正例加反例。
2.3 加载机制与优先级
技能加载器要做的事情很简单:扫描技能目录,读取每个技能的元数据,把可执行模块加载进来,形成一个技能注册表。但这里有几个细节决定了系统的健壮性。
第一个是命名空间隔离。我区分了全局技能和项目技能:全局技能放在用户主目录下的~/.agent-skills/,项目技能放在当前项目的./skills/。加载时先加载全局技能,再加载项目技能,如果名字冲突,项目技能覆盖全局技能。这样既能沉淀个人通用能力,又允许不同项目做定制。
第二个是加载失败不影响整体。我最初实现的版本是“加载一个技能抛异常就崩溃”,后来改成了“收集每个技能的加载状态和错误信息”,失败的技能跳过,但会生成一份状态报告。这样某次代码写错了,不会把整个 agent 带挂。
3. 核心实操:从零搭建一个可用的 skill 管理器
3.1 环境准备与项目初始化
为了让你能直接抄作业,我给出的实现只依赖 Python 3.10+ 和 PyYAML,不引入任何 agent 框架。如果后续你不做 YAML 解析,也可以用 JSON 替代,但我实测下来 YAML 的可读性对写技能说明的人更友好,尤其是给非技术背景的同事评审时,YAML 比 JSON 更容易看懂。
mkdir agent-skills-demo && cd agent-skills-demo python -m venv .venv && source .venv/bin/activate pip install pyyaml requests目录结构如下:
agent-skills-demo/ main.py skill_loader.py skills/ web_extract/ SKILL.md main.py3.2 实现技能加载器
技能加载器解决两个核心问题:一是把 SKILL.md 里的元数据解析出来,二是把 main.py 动态加载成一个可调用的 handler。动态加载这里有个坑,直接用importlib.import_module会污染sys.modules,而且如果两个技能目录下都有同名模块,导入会互相覆盖。我推荐用spec_from_file_location配合独立模块名加载。
# skill_loader.py from pathlib import Path import importlib.util import uuid import yaml class Skill: def __init__(self, path: Path): self.path = path self.meta = {} self.handler = None self.error = None self._parse_meta() if self.meta: self._load_handler() def _parse_meta(self): skill_file = self.path / "SKILL.md" text = skill_file.read_text(encoding="utf-8-sig") if not text.startswith("---"): self.error = "SKILL.md missing front matter" return _, fm, _ = text.split("---", 2) self.meta = yaml.safe_load(fm) def _load_handler(self): main_path = self.path / "main.py" module_name = f"skill_{self.meta.get('name', 'unnamed')}_{uuid.uuid4().hex[:8]}" spec = importlib.util.spec_from_file_location(module_name, main_path) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) self.handler = module.handler这里我用了utf-8-sig打开文件,而不是utf-8。这个细节是踩坑踩出来的:有些协作同事在 Windows 上编辑 SKILL.md,保存时会自动加上 BOM 头,直接读出来的 name 字段会变成\ufeffweb_extract,模型调用时名字永远对不上,排查了很久。
3.3 实现技能注册与调用分发
有了 Skill 类之后,管理器负责扫描目录、维护注册表、按名字调用。我还会在注册阶段把技能元数据转换成 OpenAI function calling 格式,方便直接拼进请求里。
# skill_loader.py class SkillManager: def __init__(self, *skill_dirs: str): self.skills = {} self.status = {} for d in skill_dirs: base = Path(d) if not base.exists(): continue for skill_path in base.iterdir(): if not skill_path.is_dir(): continue skill = Skill(skill_path) if skill.meta and skill.handler and not skill.error: name = skill.meta["name"] self.skills[name] = skill self.status[name] = "ok" else: self.status[skill_path.name] = f"error: {skill.error}" def to_openai_tools(self): tools = [] for name, skill in self.skills.items(): props = skill.meta.get("input", {}) required = [k for k, v in props.items() if v.get("required")] tools.append({ "type": "function", "function": { "name": name, "description": skill.meta.get("description", ""), "parameters": { "type": "object", "properties": props, "required": required, }, }, }) return tools def execute(self, name: str, arguments: dict): skill = self.skills.get(name) if not skill: raise KeyError(f"skill not found: {name}") return skill.handler(**arguments)这套设计有一个很重要的边界:参数校验应当在技能内部完成,管理器只做最基础的转发。因为不同技能的参数语义完全不同,一个统一的强校验逻辑要么太松,要么太紧。比如web_extract的 url 参数和db_query的 sql 参数,强行统一校验只会添乱。
3.4 实现一个“网页内容提取”技能
为了让这个过程完整,我写一个具体的技能实现。先看 main.py 的 handler:
# skills/web_extract/main.py import re import requests from html.parser import HTMLParser class _TextExtractor(HTMLParser): def __init__(self): super().__init__() self.title = "" self._in_title = False self._chunks = [] self._skip_tags = {"script", "style", "nav", "header", "footer", "aside"} def handle_starttag(self, tag, attrs): if tag == "title": self._in_title = True if tag in self._skip_tags: self._skip_depth = getattr(self, "_skip_depth", 0) + 1 self._in_skip = True else: self._in_skip = False def handle_endtag(self, tag): if tag == "title": self._in_title = False if tag in self._skip_tags and getattr(self, "_skip_depth", 0) > 0: self._skip_depth -= 1 self._in_skip = self._skip_depth > 0 def handle_data(self, data): if self._in_title: self.title += data.strip() if not getattr(self, "_in_skip", False) and not self._in_title: text = data.strip() if text: self._chunks.append(text) @property def content(self): return "\n".join(self._chunks) def handler(url: str): resp = requests.get(url, timeout=10, headers={ "User-Agent": "Mozilla/5.0 (compatible; agent-skills/1.0)" }) resp.raise_for_status() parser = _TextExtractor() parser.feed(resp.text) return { "title": parser.title, "content": parser.content, "word_count": len(parser.content), }为什么用标准库 HTMLParser 而不引入 BeautifulSoup?因为这个技能的特殊目的是演示最小实现,能少一个依赖就少一个。如果你要处理真实世界那些页面,建议直接换 trafilatura 或者 lxml,提取效果会好很多,但技能的接口不用变。这就是技能封装的好处:内部实现随便换,对外契约不变。
3.5 接入 LLM 调用链
到这里,技能库已经可以独立工作了。把它接入 LLM 的完整流程一般是:把manager.to_openai_tools()的结果塞进 chat completion 请求,模型返回 tool_calls 后,根据 function name 调用manager.execute,再把执行结果作为新的消息返回给模型。代码骨架大致是这样:
# main.py import json import openai # 仅示意,你可用任意 SDK from skill_loader import SkillManager manager = SkillManager("skills") client = openai.OpenAI() messages = [{"role": "user", "content": "帮我提取这个页面内容: https://example.com"}] resp = client.chat.completions.create( model="gpt-4o-mini", messages=messages, tools=manager.to_openai_tools(), tool_choice="auto", ) msg = resp.choices[0].message if msg.tool_calls: for tc in msg.tool_calls: fn = tc.function.name args = json.loads(tc.function.arguments) result = manager.execute(fn, args) messages.append({ "role": "tool", "tool_call_id": tc.id, "content": json.dumps(result, ensure_ascii=False), })这里我不推荐在技能管理器内部直接绑定某个模型厂商的 SDK,因为一旦绑定,你以后换模型就要动管理器。让管理器保持纯粹的“注册+执行”,把模型通信留在业务层,这样技能库才能在多个模型之间复用。
4. 让技能真正可复用的几个细节
4.1 提示词模板的管理:写给模型看的说明书
很多人在做技能库时,把精力都花在执行函数上,SKILL.md 就写两行描述,然后发现模型老是乱调用。实际上,对于 agent 系统,你花在写说明书上的时间,回报率比写执行代码高得多。执行代码只要跑通就行,但说明书决定模型会不会用、什么时候用、用错了怎么办。
我建议 SKILL.md 的正文固定包含三段:什么时候用(触发条件)、怎么用(参数填法、执行限制)、什么时候不要用(反例)。如果你发现某个技能经常被模型用错场景,不要急着改代码,先在说明书里加一段“如果用户只是问 X,不要调用本技能,直接回答”,往往立竿见影。
4.2 输入输出约定:一切都要能 JSON 序列化
技能在执行完任务后,返回结果是要塞回对话上下文里的。如果 handler 返回一个 pandas DataFrame、一个 PIL Image、或者一个生成器,模型那边拿到的是对象的内存地址,根本没有意义。我遇到过最夸张的一次,技能返回的是一段 NumPy 数组,模型在下一轮回答里直接引用数组地址当证据,简直荒谬。
所以我的铁律是:所有技能的返回值必须是 JSON 可序列化对象。如果你一定要返回图片,就封装成 base64 字符串加 content_type 字段;如果返回的是表格,就转成 Markdown 表格或者二维数组。为了统一,我还在管理器里加了一步后处理:handler 返回后强制走一遍json.loads(json.dumps(result)),不是 JSON 的字段直接报错,提前暴露问题。
4.3 技能之间的依赖与冲突处理
技能之间能不能互相调用?我的答案是:不建议技能之间直接 import。一来会造成隐式依赖,目录一多就乱成蜘蛛网;二来循环依赖排查起来非常痛。如果你确实需要一个技能调用另一个技能,正确的做法是在管理器中显式注册“依赖声明”,比如在 SKILL.md 里加一项depends_on: [web_extract],由管理器在执行前先把依赖技能的执行结果注入进来。
冲突也是绕不开的问题。最常见的冲突是技能重名,我在 2.3 里用优先级解决。另一种冲突是描述相似,模型面对两个相似工具会随机选一个。我的做法是给技能加前缀分类:web_extract、web_snapshot、db_query,同时在各自的 description 里明确写出“和另一个技能的区别”。如果同一个领域有超过三个技能,我还会写一个 router 技能,由它统一决定分发给哪个底层技能,而不是让模型直接面对一堆相似选项。
5. 常见问题与排查技巧实录
5.1 技能加载失败但没有任何报错
这个问题的典型现场是:管理器启动正常,日志也没异常,但模型说找不到工具。排查了一圈,发现是某个技能的 main.py 里用了相对导入from utils import helper,但 skills 里的子目录并不是一个被安装的包,Python 根本找不到 utils 模块。
我的排查方法是在管理器初始化时,把每个技能的加载状态输出成一张表:技能名、元数据是否存在、handler 是否加载、异常信息是什么。这张表在启动时打印一次,能省掉大量瞎猜时间。我还会在 SKILL.md 解析失败时留下详细原因,而不是简单地跳过,这样问题一出现就知道是 front matter 写错了,还是代码有问题。
5.2 模型不按技能说明调用工具
有时候模型明明看到了工具列表,却宁可直接瞎编答案,也不调用工具。这里有很多原因,我总结我实际遇到的三个。
第一,description 写得像一份 API 文档,而不是使用场景。模型擅长从“用户意图”匹配工具,如果你的描述是“执行 HTTP GET 请求”,它就不知道这跟“帮我查一下那个页面”有什么关系。改成“当用户想获取某个网址的内容时使用”这种描述后,调用率明显上升。
第二,工具列表太长,上下文太长,模型“忘记”了后面的工具。我实际测试下来,当工具数量超过二十个时,尾部工具的调用率会明显下降。对策是做一个语义路由层:先用一个轻量模型判断用户意图属于哪个领域,再只把该领域的几个技能挂到工具列表里。
第三,system prompt 里没有约束。我会在 system prompt 中明确写一句“当有可用工具且工具能回答用户问题时,必须调用工具并依据工具结果回答”,这句话看起来简单,实际效果很明显。
5.3 多个技能之间互相“抢戏”
技能多了之后,最常见的是两个技能描述高度重叠,模型随机乱选。我踩过的案例是:一个技能返回网页正文,一个技能返回网页截图,两个描述里都写了“获取网页内容”,结果模型经常在需要正文时调了截图,然后下一轮又把截图当正文用。
解决办法其实在 4.3 里提过:把每个技能 description 末尾加一句“不要和 XX 技能混淆,XX 技能返回的是文本/截图”。另外,我还吸取了一个教训:当模型连续两次在相同场景下调用同一个错误技能时,不要再死磕 description,直接在 SKILL.md 正文里加禁忌项,比反复调措辞更高效。
5.4 性能与并发问题
动态导入技能模块是有成本的。最开始我没有缓存,每个请求都会重新 import 一次,单次延迟能到几十毫秒,在本地开发时还能忍,一上生产并发一高马上就露馅。
我的做法是 manager 常驻,启动时一次性加载所有技能,后续请求直接复用 handler。如果需要支持热更新,就监听技能目录下文件的 mtime,只有文件变化时才重新加载对应技能。并发执行方面,handler 里如果有阻塞 IO,我会在业务层用线程池去跑,但每个技能内部最好自己控制并发粒度,管理器不做统一处理,避免一个耗时技能把整个事件循环卡死。
注意:技能本质上是可执行代码,动态加载它们等同于允许运行目录下的任意 Python 文件。如果你从不可信的来源下载了技能包,加载前一定要做代码审计。我在真实项目里只会从自己和团队评审过的目录下加载技能。
最后分享一个我做这个项目最大的体会:技能库这类东西,框架代码一天就能写完,真正的难点在维护技能说明书。你每增加一个技能,等于给 agent 增加一个“可以依赖”的能力,同时也增加一份“可能误用”的风险。我现在新增技能有一条硬性标准:SKILL.md 写成什么样,必须能通过一位不写代码的同事的评审,让他能看出这个技能什么时候该用、什么时候不该用。代码写得再漂亮,说明书一塌糊涂,模型照样给你表演什么叫无效调用。如果你也在做 agent 应用,不妨从今天开始,把手头的工具函数一个一个拆成独立技能,你会发现“加功能”这件事,第一次变得这么轻松。