Agent技能库设计:从零搭建可复用的LLM工具调用体系 如果你最近也在做 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(encodingutf-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 fskill_{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] ferror: {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(fskill 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, timeout10, 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( modelgpt-4o-mini, messagesmessages, toolsmanager.to_openai_tools(), tool_choiceauto, ) 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_asciiFalse), })这里我不推荐在技能管理器内部直接绑定某个模型厂商的 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 应用不妨从今天开始把手头的工具函数一个一个拆成独立技能你会发现“加功能”这件事第一次变得这么轻松。