Agent-Skills模式详解:从工具调用到智能体技能库的设计实践
直接把“agent-skills”这个模式聊透,不整那些虚的。
最近在给团队搭智能体应用时,我把工具调用这部分彻底重构了一遍,底层思路就是标题里这个 agent-skills。说白了一点:与其在代码里硬编码几十个工具函数,不如把每个能力封装成一个带配置、描述、参数契约的技能包,让大模型按需发现、加载、调用。这个思路解决了我之前两个最头疼的问题——一是工具一多 prompt 就爆炸,二是加一个新功能要同时改代码和提示词,改动面永远比想象中大。
这篇文章就围绕 agent-skills 这个模式展开,适合正在做 LLM 应用、智能体开发,或者单纯被“工具调用混乱”折磨过的工程师。我会把技能库从设计、编码、执行到踩坑的完整链路写清楚,最后附上可直接抄走的代码骨架。
1. 内容整体设计与思路拆解
1.1 为什么需要 agent-skills
先回忆一下没有技能库的时候,我们是怎么写智能体的。
最常见的做法:在系统提示词里塞一句“你可以使用以下工具”,然后把每个工具的 JSON Schema 直接拼进去。模型需要搜索就给它search_web,需要查天气就给它get_weather。一开始只有两三个工具时一切正常,但一旦工具数量超过十个,问题立刻出现。
最直接的痛点是prompt 长度失控。每个工具的描述平均 200 到 400 个 token,十个工具就是 3000 到 4000 token,这还没算函数签名里的参数说明。更要命的是,模型面对一长串同质化的工具描述时,选中率会明显下降——它不是不知道选哪个,而是“选择困难”,经常把send_email和schedule_meeting搞混,或者干脆挑一个参数类型完全对不上的工具硬调。
第二个痛点是扩展成本高。每加一个工具就要改系统提示词、改函数映射表、改参数校验逻辑,还要保证新工具和旧工具的请求格式没有冲突。改一次提示词就是一次回归测试,频繁改动后你根本分不清模型行为变化是工具引起的还是 prompt 引起的。
agent-skills 的核心思路,是把“工具”这个概念提升到“技能”的抽象层次。技能不是一个孤立的函数,而是包含:
- 技能元信息(名字、描述、作者、版本)
- 输入输出契约
- 执行逻辑(可以是代码,可以是 HTTP 调用,也可以是子 Agent 的编排)
- 使用说明和边界条件
模型像查目录一样浏览技能库,按需加载特定技能,而不是面对一个平铺的大列表。换句话说,我们不让模型在“一堆函数”里做选择,而是先做“检索”,再做“调用”。这跟人解决问题的思路是一致的——先翻工具箱,再拿扳手。
1.2 核心概念拆解:Agent、Skill、Action 的边界
很多人刚接触 agent-skills 时会把几个概念搞混,我先明确边界:
- Agent:决策主体,负责理解任务、规划步骤、选择技能、解析结果。它是大脑。
- Skill:一组能力的封装,包含元信息、描述、参数契约和执行逻辑。它是工具箱里的“抽屉”。
- Action:技能内部的具体执行动作,一次调用就是一个 action。同一个技能可以包含多个 action。比如“会议管理技能”里有“创建会议”“取消会议”“查询空闲会议室”三个 action。
- Tool:底层工具接口,是 action 的具体实现方式,比如一个 REST API 端点或者一个 Python 函数。
用生活中的例子类比:Agent 是厨师,Skill 是“食材处理技能”,Action 是“切丝”“切片”“剁末”,Tool 是那把厨刀。
这个分层带来的直接好处是:模型只关心“技能”这一层,不需要理解每个 action 的底层实现。技能内部怎么调用工具、怎么重试、怎么处理超时,完全由技能代码自己负责。这大大降低了模型的决策负担。
1.3 方案选型思考:为什么不做成普通函数库
可能有人会问:这听起来不就是把 Python 函数再包一层吗?直接写个tools.py然后注册到列表里不也一样?
区别非常大。
普通函数库是“给开发者用的”,调用方是人,人知道什么时候该调用哪个函数。但 agent-skills 是“给大模型用的”,调用方是一个概率模型,你必须在技能描述里把“什么时候该用这个技能”“什么情况下不能用这个技能”说清楚,否则模型就只能瞎猜。
另外,函数库的组织方式是代码逻辑导向的,而技能库的组织方式是意图导向的。函数库里send_email和send_email_with_attachment可能是两个函数,但在技能库里它们应该合并成一个“发送邮件技能”,由模型根据需求选择带附件还是不附带,而不是让模型去猜用哪个函数。这种组织方式的差异是本质性的。
我选的方案是一个“目录即技能库”的模式:文件系统里每个子目录代表一个技能,目录里有SKILL.md(描述文件)和若干个 Python 模块(实现代码)。加载器扫描目录,解析描述文件,注册进引擎。这样加一个新技能只需要建一个目录,既不用改提示词,也不用碰系统代码。
2. 技能库目录结构、配置标准与参数设计
2.1 目录结构设计
我先给出一套可以落地的目录结构,这是我在多个项目里迭代过几轮后相对稳定的形态:
agent_skills/ ├── skills/ │ ├── web_research/ │ │ ├── SKILL.md │ │ ├── __init__.py │ │ ├── search.py │ │ └── extract.py │ ├── meeting_scheduler/ │ │ ├── SKILL.md │ │ ├── __init__.py │ │ └── calendar_api.py │ └── data_analysis/ │ ├── SKILL.md │ ├── __init__.py │ └── pandas_runner.py ├── core/ │ ├── loader.py │ ├── registry.py │ └── executor.py └── main.py每个技能目录必须包含一个SKILL.md,这是技能库的门面,加载器首先读取它。Python 模块负责实际执行逻辑,__init__.py里通常会导出一个统一的执行入口函数。
这个结构有几个好处:
- 目录名即技能 ID,通过
web_research就能定位到技能 - 描述文件与代码分离,调整技能的 prompt 表现不需要改代码,反之亦然
- 天然支持热插拔,禁用某个技能就是重命名目录,不需要改任何注册表
2.2 SKILL.md 的格式设计
SKILL.md是整个 agent-skills 体系里最关键的配置文件。它决定了大模型能不能正确识别、选择、使用这个技能。我用的格式是 YAML front-matter 加正文描述:
--- name: web_research display_name: 网页研究技能 description: 用于检索网页内容、抓取正文信息、提取关键数据。当用户需要了解某个话题的最新信息、查找资料或验证事实时使用。 version: 1.0.0 author: ops-team tags: [search, web, research] allowed_actions: - search - extract_content - summarize max_concurrent_calls: 3 timeout_seconds: 30 --- # Web Research Skill 这个技能封装了搜索和网页信息提取能力,主要服务以下场景: 1. 用户询问最新新闻或事实性信息 2. 需要从指定 URL 提取正文内容 3. 对多个网页结果进行内容归纳 ## 使用注意 - 本技能不适用于访问需要登录的网站 - 搜索范围默认限定在最近一年的结果 - 如果用户请求的是本地文件操作,不要使用本技能description部分一定要写清楚“什么时候用”和“什么时候不用”,这是模型做技能选择时的关键依据。很多人忽略了这个,结果模型把网页搜索技能用在“总结我本地磁盘的文件”这种牛头不对马嘴的场景上。
2.3 参数契约与输入校验
每个技能必须对外声明自己的输入参数格式。我用 JSON Schema 定义:
# web_research/schema.py SEARCH_SCHEMA = { "type": "object", "properties": { "query": { "type": "string", "description": "搜索关键词,尽量简洁,不要包含修饰性词汇", "minLength": 2, "maxLength": 200, }, "max_results": { "type": "integer", "description": "返回的最大结果数量,默认5", "minimum": 1, "maximum": 20, "default": 5, }, "country": { "type": "string", "description": "搜索区域,使用ISO国家代码,默认cn", "enum": ["cn", "us", "jp", "global"], "default": "cn", }, }, "required": ["query"], "additionalProperties": False, }参数契约的意义不只是技术层面的校验,更重要的是给模型提供约束。additionalProperties: False能防止模型自己脑补出不存在的参数,enum能限制取值空间,避免模型传入一个"全世界"作为国家代码。
我在参数设计上踩过一个很深的坑:第一次设计时给每个技能留了十几个可选参数,结果模型经常漏传必填项,或者传一些奇怪的值。后来学乖了——参数越少越好,每个技能最多五个,能合并的合并,能自动推断的用默认值兜底。参数多了,模型的调用成功率指数级下降。
3. 技能执行引擎的实现要点
3.1 技能发现与加载策略
加载器负责扫描文件系统,识别出所有合法的技能目录。我用的策略是:扫描根目录下的每一个一级子目录,检查是否存在SKILL.md,存在就解析它,把技能元信息注册进内存。
这里有一个容易忽略的细节:技能发现必须是懒加载。大模型在一个会话里通常只用到两三个技能,如果把几十个技能的执行模块全部加载进内存,不仅启动慢,还可能引发依赖冲突。我的做法是只加载元信息,执行模块在第一次被调用时才导入。
# core/loader.py import os from pathlib import Path from dataclasses import dataclass import yaml import importlib @dataclass class SkillInfo: skill_id: str name: str description: str path: Path version: str allowed_actions: list[str] class SkillLoader: def __init__(self, skills_root: Path): self.skills_root = skills_root self._skills = {} self._modules = {} def discover(self) -> dict[str, SkillInfo]: """扫描目录,发现所有技能并解析元信息。""" for skill_dir in self.skills_root.iterdir(): if not skill_dir.is_dir(): continue skill_file = skill_dir / "SKILL.md" if not skill_file.exists(): continue with open(skill_file, "r", encoding="utf-8") as f: content = f.read() skill_info = self._parse_skill_meta(skill_dir, skill_file, content) self._skills[skill_info.skill_id] = skill_info return self._skills def load_module(self, skill_id: str): """按需加载技能执行模块,带缓存,避免重复导入。""" if skill_id in self._modules: return self._modules[skill_id] skill_info = self._skills.get(skill_id) if not skill_info: raise KeyError(f"Skill not found: {skill_id}") module = importlib.import_module(f"agent_skills.skills.{skill_id}") self._modules[skill_id] = module return module加载策略里的一个关键决策是“发现时只读元信息,执行时才加载模块”,这个选择直接影响系统的整体响应时间。很多人一开始图省事全部加载,等到技能数量涨到二十个以上就会发现启动时间从 200ms 涨到 2 秒,而且每个技能依赖的三方库互相污染环境。
3.2 技能描述如何注入上下文
技能发现完成后,需要把可用的技能列表告诉模型。这一步的处理方式直接影响模型的选择准确率。
我的方案是把技能描述压缩成一段紧凑的工具列表,注入到系统提示词的末尾:
可用技能列表: 1. web_research:用于检索网页内容、抓取正文信息、提取关键数据。 适用场景:用户需要最新信息、查找资料、验证事实时。 不支持:需要登录的网站。 2. meeting_scheduler:用于创建会议、取消会议、查询空闲时间。 适用场景:用户涉及会议日程管理时。 不支持:非工作日安排。 3. data_analysis:用于对结构化数据做统计分析和可视化。 适用场景:用户提供表格文件或要求计算指标时。 不支持:实时数据采集。注意描述里的措辞:要写“适用场景”和“不支持”,不要写“这个技能可以做什么”这种泛泛的表述。模型的工具选择能力本质上是在做意图匹配,你给的信息越有区分度,它选得越准。
在上下文长度允许的情况下,我会把每个技能的SKILL.md正文的前 200 字也拼进上下文。因为 front-matter 里的description是精炼版,而正文里有更细的边界条件和用法示例,这些对模型的规划能力很有帮助。
3.3 与 Function Calling 的对接
现在主流的大模型平台都支持 function calling,但直接让模型调用底层函数的问题在于:函数列表是平铺的、没有层级。我的做法是把“技能”当作 function calling 里的一个“远端函数”来暴露。
具体来说:
function name用技能 IDdescription用 SKILL.md 的descriptionparameters用 SKILL.md 里声明的 JSON Schema
模型调用某个技能时,引擎拦截这个调用,分发到对应技能的执行模块,然后执行模块内部再决定具体用哪个 action。
# core/registry.py def build_tool_schemas(skills: dict[str, SkillInfo]) -> list[dict]: """把技能元信息转为 function calling 所需的 JSON Schema 列表。""" schemas = [] for skill_id, info in skills.items(): schemas.append({ "type": "function", "function": { "name": skill_id, "description": info.description, "parameters": info.schema, }, }) return schemas有一个坑要注意:有些平台的 function calling 接口不识别数字开头的函数名,也不识别下划线之外的符号。技能 ID 一定要严格用“小写字母 + 下划线”,不要想当然地用什么web-search或者带有版本号的web_research_v1。
3.4 技能执行链与结果回传
技能执行不是简单的一调一返,中间还要处理错误、超时、部分失败等情况。我设计了一个三层执行链:
- 调用前校验层:检查参数合法性、检查技能是否禁用、检查并发占用
- 技能执行层:调用技能模块的
run(action, params, context)函数 - 结果规整层:把执行结果统一封装为结构化响应,处理截断、转义、长度控制
# core/executor.py import time from dataclasses import dataclass @dataclass class ExecutionResult: skill_id: str action: str status: str # success | error | timeout data: dict duration_ms: int error_message: str = "" class SkillExecutor: def __init__(self, loader: SkillLoader): self.loader = loader def execute(self, skill_id: str, action: str, params: dict, context: dict) -> ExecutionResult: start = time.time() try: module = self.loader.load_module(skill_id) if not hasattr(module, "execute_action"): raise ValueError(f"Skill {skill_id} does not export execute_action") result = module.execute_action(action, params, context) status = "success" error_message = "" except TimeoutError as e: result = {} status = "timeout" error_message = str(e) except Exception as e: result = {} status = "error" error_message = str(e) duration_ms = int((time.time() - start) * 1000) return ExecutionResult( skill_id=skill_id, action=action, status=status, data=result, duration_ms=duration_ms, error_message=error_message, )结果回传时有一个超容易忽略的点:给模型的返回信息必须简洁。如果你把搜索结果原始 HTML 直接塞回给模型,不仅 token 爆炸,模型的注意力也会被噪音带跑。技能模块内部应该先把结果清洗好,比如只提取标题和摘要,再返回给主流程。
4. 实操过程:从零搭一个可用的技能库
4.1 环境准备与基础工程结构
我以 Python 3.11 为例,依赖只用到PyYAML和某个大模型 SDK。先把工程结构建好:
mkdir agent-skills-demo cd agent-skills-demo python -m venv .venv source .venv/bin/activate pip install pyyaml openai目录结构按第二部分的设计创建。我先写好core/loader.py、core/executor.py和core/registry.py,这三个文件构成引擎的骨架。核心流程在main.py里串起来:
# main.py from pathlib import Path from core.loader import SkillLoader from core.executor import SkillExecutor from core.registry import build_tool_schemas SKILLS_ROOT = Path("skills") loader = SkillLoader(SKILLS_ROOT) skills = loader.discover() print(f"Discovered {len(skills)} skills: {list(skills.keys())}") executor = SkillExecutor(loader) schemas = build_tool_schemas(skills) for schema in schemas: print(schema["function"]["name"], "-", schema["function"]["description"][:40])这一步跑通了,说明技能发现和注册链路没问题。
4.2 编写第一个技能:文件统计技能
我拿一个最贴近日常的例子来演示——文件统计技能,用于统计目录下的文件数量、大小分布、类型分布。这个技能不需要外部 API,任何人都能跑通。
先创建skills/file_stats/SKILL.md:
--- name: file_stats display_name: 文件统计技能 description: 用于统计指定目录下的文件数量、大小分布和文件类型占比。当用户需要了解一个文件夹的整体情况、文件数量、最大的文件在哪时使用。 version: 1.0.0 author: demo tags: [files, stats, analysis] ---再写参数模式skills/file_stats/schema.py:
SCHEMA = { "type": "object", "properties": { "directory": { "type": "string", "description": "要统计的目录路径,绝对路径", }, "recursive": { "type": "boolean", "description": "是否递归统计子目录,默认 true", "default": True, }, "top_k": { "type": "integer", "description": "返回文件类型 Top N,默认 5", "default": 5, "minimum": 1, "maximum": 20, }, }, "required": ["directory"], "additionalProperties": False, }执行模块skills/file_stats/__init__.py:
import os from collections import Counter from pathlib import Path def execute_action(action: str, params: dict, context: dict) -> dict: if action == "stat_directory": return stat_directory(params) if action == "find_largest_files": return find_largest_files(params) raise ValueError(f"Unsupported action: {action}") def stat_directory(params: dict) -> dict: directory = Path(params["directory"]) recursive = params.get("recursive", True) if not directory.exists(): return {"error": f"Directory not found: {directory}"} total_size = 0 file_count = 0 type_counter = Counter() largest_files = [] iterator = directory.rglob("*") if recursive else directory.iterdir() files = [f for f in iterator if f.is_file()] file_count = len(files) for f in files: size = f.stat().st_size total_size += size ext = f.suffix.lower() or "[no_ext]" type_counter[ext] += 1 return { "directory": str(directory), "file_count": file_count, "total_size_mb": round(total_size / 1024 / 1024, 2), "type_distribution": type_counter.most_common(5), "largest_files": [ {"path": str(f), "size_mb": round(f.stat().st_size / 1024 / 1024, 2)} for f in sorted(files, key=lambda x: x.stat().st_size, reverse=True)[:5] ], }这个技能有两个 action:stat_directory做统计分析,find_largest_files找最大文件。模型只需要知道这个技能能“统计目录”,具体是哪个 action 由技能内部根据参数自动切换。这就是前面说的技能封装对模型友好的体现。
4.3 调通技能引擎:加载到执行全流程
把技能引擎跑起来,验证从技能发现到调用的完整链路:
# test_run.py from pathlib import Path from core.loader import SkillLoader from core.executor import SkillExecutor loader = SkillLoader(Path("skills")) skills = loader.discover() executor = SkillExecutor(loader) result = executor.execute( skill_id="file_stats", action="stat_directory", params={"directory": ".", "recursive": True}, context={}, ) print(result)运行后如果输出类似这样,链路就通了:
ExecutionResult(skill_id='file_stats', action='stat_directory', status='success', data={'directory': '.', 'file_count': 14, 'total_size_mb': 0.02}, duration_ms=12, error_message='')这一步的核心是验证“技能 ID -> 模块导入 -> action 分发 -> 参数校验 -> 结果规整”这条链路没有断裂。
4.4 接入大模型:让 Agent 自主调用技能
最后一步,把引擎接到大模型上。这里用 function calling 的方式对接:先构建技能 schema 列表传给模型,模型返回工具调用请求,引擎拦截并执行。
# chat_agent.py from pathlib import Path from core.loader import SkillLoader from core.executor import SkillExecutor from core.registry import build_tool_schemas loader = SkillLoader(Path("skills")) skills = loader.discover() executor = SkillExecutor(loader) schemas = build_tool_schemas(skills) def run_agent(user_message: str): messages = [{"role": "user", "content": user_message}] response = client.chat.completions.create( model="gpt-4o", messages=messages, tools=schemas, tool_choice="auto", ) for tool_call in response.choices[0].message.tool_calls: skill_id = tool_call.function.name args = json.loads(tool_call.function.arguments) result = executor.execute(skill_id=skill_id, action="auto", params=args, context={}) messages.append({ "role": "tool", "tool_call_id": tool_call.id, "content": json.dumps(result.data, ensure_ascii=False), }) final_response = client.chat.completions.create( model="gpt-4o", messages=messages, tools=schemas, ) return final_response.choices[0].message.content这里有一个细节:action="auto"是我做的一个约定,当技能只有一个主流程时,执行器会根据参数自动匹配合适的 action;当技能有多个 action 时,模型应该显式声明。为了减少模型负担,我在技能里把“主 action”标记了出来,这样大多数场景下模型不需要知道 action 的名字。
接完这个链路后,你可以直接问 Agent:“帮我看看当前目录里最大的三个文件是什么”,模型会自动调用file_stats技能,并返回最终结果。看到这一步跑通,一个最小的 agent-skills 闭环就完成了。
5. 常见问题与排查技巧实录
5.1 模型选错技能:描述不够“歧视性”
现象:用户问“北京的天气怎么样”,模型却调用了文件统计技能。
排查后发现,文件统计技能的描述里写了“用于统计指定目录下的文件情况”,但没写“仅限本地文件,不适合查询实时信息”。模型在做意图匹配时,看到“统计”“目录”这些词,跟用户问题里“怎么样”这种模糊表达产生了错误关联。
解决办法:在技能描述里加入强烈的正负条件词。正条件词告诉模型“什么时候用我”,负条件词告诉模型“什么时候别用我”。最好再加一句“如果用户请求的内容与文件系统无关,不要使用此技能”。
description: 统计目录下的文件信息。当用户明确提到文件夹、目录、文件大小分布时使用。 不适用:天气查询、实时资讯、网络搜索、人物查询。模型选型准确率在加了负条件词后有非常明显的提升。这条经验适用于所有技能的描述设计。
5.2 模型反复重试同一个失败技能:缺少错误类型反馈
现象:模型调用日历技能时,参数字段传错,技能返回“参数校验失败”。结果模型不理解这个错误,连续三次用几乎相同的参数重试,对话进入死循环。
根源是错误信息太笼统。“参数校验失败”这六个字模型没法判断该怎么修正。正确的做法是返回结构化错误,并且直接告诉模型下一步该怎么改。
return { "error": { "type": "invalid_params", "message": "参数校验失败:日期格式应为YYYY-MM-DD,你传的是2024/13/40", "suggestion": "请使用正确日期格式重新调用", } }模型在收到带suggestion的错误响应后,重试成功率明显提高。这个技巧同样适用于所有技能的错误处理设计。
5.3 技能库热更新:运行时新增技能不生效
技能加载器如果只在启动时扫描一次,后续新增的技能目录永远不会被发现。解决方法是给加载器一个refresh方法,在每次执行前调用,用文件修改时间做增量判断:
def refresh(self): for skill_dir in self.skills_root.iterdir(): skill_file = skill_dir / "SKILL.md" if skill_file.exists(): mtime = skill_file.stat().st_mtime if self._mtime.get(skill_dir.name) != mtime: self._skills.pop(skill_dir.name, None) self._modules.pop(skill_dir.name, None) # 重新解析技能 self.discover()注意不要每次调用都全量重扫,否则技能多时会产生明显的 I/O 压力。用 mtime 做增量判断是性价比很高的方案。
5.4 技能内部用到私有 API:凭据安全问题
技能不只是一个纯函数壳,实际场景里它往往要调用内部 API 或第三方服务。常见做法是把凭据放在技能目录下的配置文件里,但这样任何能读文件系统的人都能拿到密钥。
我强烈建议:不要把密钥写进 SKILL.md 或代码里,统一从环境变量读取,并且在技能加载时注入到 context 参数中。执行器在调用技能时从环境变量读取敏感信息,塞给context["secrets"],技能模块从 context 里取,而不是自己找密钥。这样安全性可控,技能模块也不需要知道密钥从哪来。
5.5 上下文中技能的“自我描述”太长:影响主任务推理
技能描述写得越详细,模型选得越准,但副作用是上下文里非任务信息变多,模型在复杂多跳推理场景下的表现会略有下降。我实测下来,技能描述压到 100 到 200 字是甜点区间——再短选型准确率下降,再长对复杂推理有微弱负面影响。
如果技能描述实在写不短,可以把完整版放在SKILL.md正文里,模型需要时通过一个“技能详情查询”的动作去拉取。这样大部分场景只花 100 字成本,只有模型真正需要时才付出长描述的 token 成本。
这个技巧的实际效果很微妙,但当你同时维护几十个技能、每个技能描述都是三百字的时候,你会发现上下文压力是真实存在的。
6. 一些写在最后的体会
我个人在把 agent-skills 这个模式推进到多个项目后,最大的感受是它改变的不仅是代码结构,更是设计思维。以前我写工具层时,脑子里想的是“这个 API 怎么暴露”;现在写技能层时,想的是“模型需要什么信息才能理解这个能力”。视角从代码逻辑转到了模型认知,这是非常关键的一次转变。
如果你正要上手这个模式,我的建议是:不要一开始就追求通用引擎,先用三个技能把它跑通,体会模型选择技能时的决策逻辑,然后逐步完善描述质量。技能描述这玩意儿,写得越多、跑得越多、被模型打脸越多,手感就越准。先把最小闭环跑起来,其他都能迭代。