资讯详情

Agent-Skills模式详解:从工具调用到智能体技能库的设计实践

📅 2026/10/28 11:51:14 | 华诺云谱 👁 阅读
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, encodingutf-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(fSkill not found: {skill_id}) module importlib.import_module(fagent_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(fSkill {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_idskill_id, actionaction, statusstatus, dataresult, duration_msduration_ms, error_messageerror_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(fDiscovered {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.pySCHEMA { 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__.pyimport 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(fUnsupported action: {action}) def stat_directory(params: dict) - dict: directory Path(params[directory]) recursive params.get(recursive, True) if not directory.exists(): return {error: fDirectory 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, keylambda x: x.stat().st_size, reverseTrue)[:5] ], }这个技能有两个 actionstat_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_idfile_stats, actionstat_directory, params{directory: ., recursive: True}, context{}, ) print(result)运行后如果输出类似这样链路就通了ExecutionResult(skill_idfile_stats, actionstat_directory, statussuccess, data{directory: ., file_count: 14, total_size_mb: 0.02}, duration_ms12, 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( modelgpt-4o, messagesmessages, toolsschemas, tool_choiceauto, ) 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_idskill_id, actionauto, paramsargs, context{}) messages.append({ role: tool, tool_call_id: tool_call.id, content: json.dumps(result.data, ensure_asciiFalse), }) final_response client.chat.completions.create( modelgpt-4o, messagesmessages, toolsschemas, ) return final_response.choices[0].message.content这里有一个细节actionauto是我做的一个约定当技能只有一个主流程时执行器会根据参数自动匹配合适的 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 怎么暴露”现在写技能层时想的是“模型需要什么信息才能理解这个能力”。视角从代码逻辑转到了模型认知这是非常关键的一次转变。如果你正要上手这个模式我的建议是不要一开始就追求通用引擎先用三个技能把它跑通体会模型选择技能时的决策逻辑然后逐步完善描述质量。技能描述这玩意儿写得越多、跑得越多、被模型打脸越多手感就越准。先把最小闭环跑起来其他都能迭代。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑