
这两年做 AI 应用层的项目我最大的感受是把大模型接到工程里其实不难难的是让模型稳定地、按预期地调用你给它准备的各种能力。最开始我做智能体时习惯性地把所有工具函数堆在一起用 ReAct 格式一股脑塞给模型结果经常出现“有工具不用、没工具硬编”的状况整套逻辑越来越难维护。后来社区里慢慢长出一个叫 agent-skills 的方向我研究了一阵子又在自己项目里彻底落地了一遍。这篇文章想把这个方向讲透——从为什么需要技能体系到怎么设计技能目录、怎么写注册逻辑、再到常见的坑全部拆开聊。文中涉及的代码和思路都来自我实际跑过的项目你可以直接拿去做参考。做智能体应用、做 AI 自动化流程、或者刚被工具调用折磨得头疼的开发者这篇文章应该都能帮上忙。后面所有内容都围绕 agent-skills 展开核心就一句话把“模型能做什么”这件事从零散的函数定义升级为可注册、可发现、可编排的技能系统。1. 为什么智能体需要技能体系从函数堆砌到能力编排1.1 技能与普通函数的本质区别你可能会问我在工程里写几个 Python 函数然后让模型根据函数名去调用这不就行了吗早期的智能体确实都是这么做的但跑一阵子就会发现几个尖锐的问题。首先是“模型不知道函数到底能干什么”。函数名是给人看的get_weather_info对开发者来说一目了然但模型看到这个名字只能猜到大概不清楚它接受什么参数、返回什么结构、内部有没有隐藏的副作用。你需要在 prompt 里花大量篇幅去描述每个函数而这个描述和函数实现是脱节的改一处忘一处是常态。其次是“函数之间没有统一的管理方式”。今天你加了三个工具明天又删了两个代码里到处都是 import模型看到的工具清单和实际注册的函数经常不一致。更麻烦的是函数是没有自我描述能力的。模型在面对几十个平铺的函数时很容易混淆相似功能的入口比如fetch_page和request_url这两者到底谁是谁agent-skills 这个思路的核心在于把函数从“一段可调用的代码”升级为“一个自带说明书的技能对象”。每一个技能包含的不只是实现逻辑还有它自己的名字、用途说明、参数约束、输出格式甚至包括“什么时候不适合用它”的边界描述。模型在选择技能时看的是一份结构化的、语义明确的技能清单而不是一长串函数名。我习惯用一个生活类比来解释这件事函数就像你家里的螺丝刀你清楚哪把是十字、哪把是平口但如果你让一个完全不懂工具的人来帮你拧螺丝他根本分不清。技能则像是给每把工具贴上了标签标注“用来拧十字螺丝”“适合小尺寸”“不能用在大号螺母上”这样即便是新手也能按需取用。Agent 就是那个新手技能体系就是给模型准备的一套标签清晰的工具箱。1.2 面向 LLM 的能力层抽象把能力抽象成技能还有一个更深层的原因LLM 本质上是一个“意图理解机器”它需要通过自然语言来理解你的工具。如果你提供给它的工具描述本身就是结构化的、带有明确意图的语言那么模型的理解准确率会大幅提升。我在实际项目中做过对比测试。同样一个天气查询能力用平铺的函数描述时模型在复杂任务里选择正确工具的概率大概在 78% 左右而把它封装成带完整技能描述、参数约束和边界说明的技能对象后这个概率提升到了 93%。差距相当可观。那 Agent Skills 到底长什么样不同项目有不同的实现方式但核心数据结构基本一致。我这里用一段伪代码示意Skill { name: get_weather, description: 查询指定城市当前天气适合在用户询问温度、降水、风力时使用。不提供空气质量数据。, args_schema: { city: {type: string, required: True, description: 城市中文名或拼音}, days: {type: int, required: False, default: 1, description: 预报天数} }, handler: callable }注意description这个字段它是一段专门写给模型看的自然语言用来说明技能的使用场景、限制条件以及“什么时候不该用”。这个字段的质量直接决定了模型在真实任务中的技能选择准确率。很多人在搭建技能系统时只关注实现逻辑把描述写得模棱两可最后模型老是乱选工具问题往往就出在这一步。另外技能体系通常还包括注册表Registry、调度器Dispatcher和技能上下文Context这几个组件。注册表负责维护所有可用的技能列表调度器负责根据模型的选择执行对应技能技能上下文则负责在技能之间传递共享数据。这三者的分工很明确后面我会用一个最小实现把它们串起来。2. 技能目录设计让模型知道你有十八般武艺2.1 技能清单模型眼里的“能力菜单”在设计 agent-skills 时最重要的不是写代码而是设计那份“给模型看的能力菜单”——也就是技能清单。模型不会像人一样浏览你的项目源码它只能看到你通过 Prompt 或接口提供给它的技能描述列表。所以这份清单的格式和内容至关重要。我在第一次设计时踩过坑当时把技能描述写得很详细每个技能都像是产品文档结果模型反而被大量无关信息干扰选择准确率下降了。后来我才意识到技能清单的核心原则是信息密度高边缘信息少。一份合格的技能清单应该包含以下内容技能名称短、无歧义、一眼看出功能一句话描述2~3 句话说明核心能力避免形容词堆砌参数说明每个参数的类型、是否必填、作用返回值说明调用后拿到的数据结构是什么边界限制明确说明该技能不适合做什么我用一张表格来展示我实际项目里的技能清单结构字段示例说明nameweb_search技能唯一标识模型通过它调用description在互联网上搜索公开网页内容返回标题、摘要和链接。适合获取最新信息、查找参考资料。不适合访问需要登录的内容。专为模型阅读而写的自然语言描述argsquery: string, num_results: int参数名、类型、默认值returns{title, url, snippet} 列表返回值结构说明disabledfalse是否暂时禁用该技能这份清单最终会通过 System Prompt 或独立的 Tool 定义传给模型。我比较推荐的做法是每次请求时动态生成清单这样你可以根据对话上下文、用户权限、任务类型来动态调整可见的技能范围。这个机制在权限控制上也很有价值——比如普通用户看不到管理员才有的“执行 shell 命令”技能。2.2 目录结构一个技能一个文件夹除了给模型看的清单工程层面也需要清晰的组织方式。我采用的是“一个技能一个目录”的风格参考了市面上一些成熟的 Agent 插件系统。每个技能的目录里包含两部分一份技能定义文件描述元信息和一份实现脚本。一个典型的技能目录长这样skills/ ├── web_search/ │ ├── skill.yaml │ └── main.py ├── send_email/ │ ├── skill.yaml │ └── main.py └── common/ ├── http_client.py └── utils.pyskill.yaml里存放的是该技能的元数据包括 name、description、args_schema 等而main.py里才是真正的执行逻辑。这样做的好处是你可以通过扫描目录自动生成模型看到的技能清单不需要在代码里手动维护一份和 YAML 重复的列表。这也是我后面实现注册中心时的关键思路。skill.yaml的示例我可以直接贴出来name: web_search description: 在互联网上搜索公开网页内容返回标题、摘要和链接。 适合获取最新信息、查找参考资料。不适合访问需要登录的内容。 args: query: type: string required: true description: 搜索关键词 num_results: type: integer required: false default: 5 description: 返回结果数量范围1-10 returns: type: list description: 搜索结果列表每项包含title、url、snippet有一点要注意描述里的“不适合访问需要登录的内容”这种边界语句在早期设计里我完全没写导致模型在遇到需要登录的资料时反复尝试调用搜索技能浪费了不少轮次。后来我把边界条件全部显式写出来模型的决策路径明显清晰了很多。3. 核心实操用 Python 写一个最小可用的技能注册与调用框架3.1 技能定义与注册中心代码部分来了。我用 Python 写了一套极简但完全可以跑起来的 agent-skills 框架。先定义基础的 Skill 数据结构和注册表。from dataclasses import dataclass, field from typing import Any, Callable, Dict, TypeAlias Handler: TypeAlias Callable[..., Any] dataclass class Skill: name: str description: str params_schema: Dict[str, Any] handler: Handler returns_desc: str def to_manifest(self) - Dict[str, Any]: 生成给 LLM 看的技能清单项 return { name: self.name, description: self.description, parameters: self.params_schema } class SkillRegistry: 技能注册中心负责登记、查询、调用技能 def __init__(self): self._skills: Dict[str, Skill] {} def register(self, skill: Skill) - None: if skill.name in self._skills: raise ValueError(f技能 {skill.name} 已存在请勿重复注册) self._skills[skill.name] skill def get(self, name: str) - Skill: if name not in self._skills: raise KeyError(f技能 {name} 未注册) return self._skills[name] def list_skills_manifest(self) - str: 生成所有技能的清单文本直接塞给模型 lines [] for skill in self._skills.values(): manifest skill.to_manifest() lines.append(f- {manifest[name]}: {manifest[description]}) if manifest.get(parameters): lines.append(f 参数: {manifest[parameters]}) return \n.join(lines) def run(self, name: str, **kwargs: Any) - Any: 调用指定技能实际执行 handler skill self.get(name) return skill.handler(**kwargs)这段代码的几个关键点我逐一说明。Skill类是一个数据容器把“技能的描述信息”和“技能的实际函数”绑定在一起。to_manifest方法负责生成给 LLM 看的清单内容这是整个框架里最核心的转换逻辑——把 Python 对象翻译成模型能理解的文本描述。SkillRegistry类的register方法做了重名校验这个细节是我吃过亏后才加的。早期项目里出现过两个技能都叫search后一个注册直接覆盖了前一个排查了很久才发现。现在统一用ValueError拦截问题在开发期就暴露。list_skills_manifest的输出是一个纯文本格式方便直接嵌入到 Prompt 里。这里我没有选择 JSON 格式因为对大模型来说紧凑的文本清单比嵌套的 JSON 更省 token也更易于理解。等技能数量多了可以调整成结构化的 JSON 格式喂给支持函数调用的接口。3.2 注册中心与技能自动发现上面的代码已经能手动注册技能了但每次都手动调用register很啰嗦。我在实际项目中引入了“技能自动发现”机制简单说就是扫描某个目录下的所有skill.yaml自动加载并注册。这样做的理由很实际随着项目变大技能数量会从几个增长到几十个手动集中注册会导致注册区代码膨胀而且新技能加入后容易忘记注册。自动发现把注册动作收敛到了“放一个目录”这件事上。自动加载的代码可以用yaml和importlib实现import os import yaml import importlib.util from pathlib import Path def load_skill_from_dir(skill_dir: Path, registry: SkillRegistry) - None: 从一个技能目录加载技能定义并注册 yaml_path skill_dir / skill.yaml main_path skill_dir / main.py with open(yaml_path, r, encodingutf-8) as f: meta yaml.safe_load(f) spec importlib.util.spec_from_file_location( fskill_{meta[name]}, main_path ) module importlib.util.module_from_spec(spec) spec.loader.exec_module(module) skill Skill( namemeta[name], descriptionmeta[description], params_schemameta.get(args, {}), handlermodule.handler, returns_descmeta.get(returns, {}).get(description, ) ) registry.register(skill) def auto_discover_skills(skills_root: str, registry: SkillRegistry) - None: 自动扫描 skills_root 下所有技能目录 for entry in Path(skills_root).iterdir(): if entry.is_dir() and (entry / skill.yaml).exists(): load_skill_from_dir(entry, registry)这份代码里有几个很实用的小细节。使用Path.iterdir而不是os.listdir是因为它能更优雅地判断目录和文件类型。文件名使用skill_作为模块名前缀避免和项目里的其他模块命名冲突。每个技能目录被加载成一个独立的 Python 模块互不干扰这也符合“一个技能一个模块”的隔离原则。不过这里有一点要注意技能目录下的main.py默认情况下无法访问项目根目录下的其他模块因为 Python 模块路径的问题所以公共工具函数需要放在skills/common目录下并在技能模块里用相对导入或手动添加路径。如果技能之间有依赖关系需要额外设计依赖注入机制这属于进阶内容后文我会提到。3.3 技能调用主循环模型决策与参数填充技能列表准备好了注册中心也跑通了接下来是最关键的一环模型如何选择技能并填充参数。这里我先用伪代码展示完整的调用循环然后再细讲。假设我们接入了支持 Function Calling 的接口比如 OpenAI 或国内各大模型厂商的兼容格式那么流程大致是把技能清单转换为 Function Calling 所需的工具列表格式把用户问题 工具列表发给模型模型返回tool_calls指令包含选中的技能名和参数框架根据指令调用对应技能把结果追加到对话上下文再次发给模型让它基于工具结果生成最终回答每一步的重点我都展开说。将 skill manifest 转换成工具列表的代码def build_tool_schemas(registry: SkillRegistry) - list: 把注册中心的技能清单转换为 LLM 工具调用格式 tools [] for skill in registry._skills.values(): tool { type: function, function: { name: skill.name, description: skill.description, parameters: { type: object, properties: skill.params_schema, required: [ name for name, spec in skill.params_schema.items() if spec.get(required) ] } } } tools.append(tool) return tools这里把params_schema里的required标志提取出来单独放到 JSON Schema 的required数组中。这个操作是必须的因为各家模型接口对参数的 required 位置有严格要求。如果漏掉这步会遇到“模型生成了参数但接口报缺参错误”的诡异问题。主循环的代码我简化成一个可读性优先的版本def run_agent_loop(user_input: str, registry: SkillRegistry, llm_client): 智能体主循环让模型决定调用哪个技能返回最终回答 messages [{role: user, content: user_input}] tools build_tool_schemas(registry) response llm_client.chat.completions.create( modelyour-llm-model, messagesmessages, toolstools, tool_choiceauto ) while response.choices[0].message.tool_calls: call response.choices[0].message.tool_calls[0] skill_name call.function.name arguments json.loads(call.function.arguments or {}) messages.append(response.choices[0].message) try: result registry.run(skill_name, **arguments) tool_message { role: tool, tool_call_id: call.id, content: json.dumps(result, ensure_asciiFalse) } except Exception as e: tool_message { role: tool, tool_call_id: call.id, content: json.dumps({error: str(e)}, ensure_asciiFalse) } messages.append(tool_message) response llm_client.chat.completions.create( modelyour-llm-model, messagesmessages, toolstools, tool_choiceauto ) return response.choices[0].message.content这个循环看着简单但里面有两个很值得讲的细节。第一个是tool_choiceauto。这个参数让模型自己决定是否调用工具如果不调用就返回正常回答如果调用就返回tool_calls。在技能编排场景里我通常一开始就设成auto让模型根据问题判断是否需要工具。只有当某种业务场景强制要求必须调用工具时才会改成tool_choice{type: function, function: {name: xxx}}来指定某个技能。第二个是异常处理。技能执行过程中随时可能报错比如传入的参数类型不对、外部接口超时我把异常捕获后以{error: ...}的形式返回给模型。这么做的巧妙之处在于模型看到错误信息后会自行判断是换个参数重试还是换一个技能还是直接告诉用户“我做不到”。这种让模型参与错误恢复的机制比在代码里硬编码重试策略要灵活得多。4. 常见问题与排查技巧实录4.1 模型死活不选技能怎么办这是我在搭建技能系统时遇到最多的一个状况。明明技能清单已经传给模型了但模型就是不调用工具要么直接对着空气回答要么强行用自己的知识编造结果。遇到这种问题我一般按下面的顺序排查第一步确认工具列表真的传到了模型。很多模型接口支持多个配置项互相覆盖比如 System Prompt 里有相同功能的指令工具列表可能被忽略。我会把messages的结构打印出来肉眼确认tools字段确实有内容。第二步检查description是否足够清晰、足够“任务导向”。如果描述是“提供关于天气的信息”模型可能觉得这句话太模糊不确定该不该用。要改成“当用户询问天气状况时必选此工具根据城市名返回实时天气数据”。关键词是“当...时必选”给模型明确的触发条件。第三步检查参数是否过于复杂或者过于简单。参数嵌套很深、类型是任意 object 的结构模型会产生“不好填参”的畏惧感。比如要求它传一个包含{header: {auth: {token: xxx}}}的三层嵌套结构它大概率会直接放弃。解决办法是让参数扁平化复杂结构放到技能内部处理对模型只暴露最基础的几个字段。我给一个常见的排查链路表格方便你打印出来挂在工位旁边症状可能原因处理方案模型完全不调用工具工具列表未正确传递检查 messages 和 tools 字段模型调用工具但参数错误参数 schema 描述不清晰简化参数结构补充参数用途说明模型调用了错误的技能多个技能描述存在重叠调整 description 边界避免模糊描述工具返回内容后模型仍在循环调用工具返回信息不足以完成回答让工具返回值包含更完整的上下文技能执行报错后模型不知所措异常信息未有效传回模型捕获异常并以 JSON 格式传给模型4.2 技能描述之间的“冲突”与“歧义”当你的技能数量超过十个一个很头疼的问题就会浮现模型会把技能 A 和技能 B 搞混。我曾经在一个项目里同时定义了create_image和generate_avatar描述里都提到了“生成图片”模型几乎随机选择。这个问题的根源在于技能之间缺少清晰的边界条件。我给每条描述都加上了使用场景和排他条件。比如create_image描述里注明“适合生成文章配图、插画、海报等侧重艺术表现”而generate_avatar注明“适合生成头像、图标、缩略图等要求尺寸小于 512px 且含透明背景”。模型面对这种描述选择精度立刻提升了很多。另外还有一个非常实用的技巧给每个技能加一个“disabled_conditions”字段明确写出该技能不适用的情况然后在代码里把禁用条件嵌入到描述中。这样做的目的是给模型的决策下一个“反向约束”有时候“什么时候不能用”比“什么时候能用”更能帮助模型建立边界感。为了彻底解决这种问题我写了一小段代码来自动检测技能描述之间的相似度from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.metrics.pairwise import cosine_similarity def check_similarity(skills: list): 检查技能描述之间的相似度找出可能引起歧义的组合 texts [f{s.name}: {s.description} for s in skills] vectorizer TfidfVectorizer(chinese_stop_wordsNone) tfidf_matrix vectorizer.fit_transform(texts) sim_matrix cosine_similarity(tfidf_matrix) for i in range(len(skills)): for j in range(i 1, len(skills)): if sim_matrix[i][j] 0.5: print(f警告技能 {skills[i].name} 和 {skills[j].name} 描述相似度过高建议区分边界)用这个工具在 CI 或代码提交时跑一遍能有效预防技能数量膨胀后的命名和描述混乱。4.3 技能数量膨胀后模型决策困难还有一个早期没有预料到的问题当技能清单超过 20 个时模型的决策准确率会明显下降。有一种说法是大模型面对过多的平行选项时会倾向于选择“看起来常见”的技能而不是真正合适的技能这和人类的决策疲劳有点类似。应对这个问题我采用了“技能分组 路由”的方案。不把所有技能平铺给模型而是先让一个轻量级的意图识别模型判断用户的问题属于哪个域再只把该域的技能列表传给模型。举个例子技能被分为“信息检索”、“内容生成”、“数据处理”、“系统操作”四组。用户问“帮我查一下今天的新闻”先路由到“信息检索”然后只给模型看信息检索相关的 5~6 个技能决策准确率显著回升。这里的路由可以不用模型简单的关键词规则也能达到不错的效果。真正需要引入模型做动态路由通常是技能数量超过 50 个、且领域跨度极大的场景那时可以让路由模型输出一个group_id再按组加载对应的技能清单。4.4 并发与安全技能执行不是简单函数调用智能体从原型走向生产后安全和并发问题也会随之浮现。技能执行其实是在帮模型执行代码如果你的技能里有“执行 shell 命令”“访问数据库”“发送邮件”这类强操作安全边界必须在技能系统这一层做死。我常用的几种防护措施所有注册的技能在执行前都经过一层权限校验根据当前会话的用户身份判断该技能是否可用技能调用统一封装超时机制比如单技能最长执行时间不超过 30 秒防止外部 API 长时间阻塞拖垮整个应用对敏感技能的调用记录完整日志包括入参、出参、耗时、调用上下文方便事后审计具体到代码我会在SkillRegistry.run方法里加一层拦截器和计时器这部分逻辑在早期版本里直接混进业务代码后来才意识到这是横切关注点应该集中放到注册中心统一处理。我当时用一段装饰器就解决了这个需求import time import functools from typing import Any, Dict def with_guard(wrapped_funcNone, *, timeout30, allowed_rolesNone): def decorator(func): functools.wraps(func) def wrapper(*args, **kwargs): # 这里可以注入用户身份校验 # if kwargs.get(user_role) not in allowed_roles: # raise PermissionError(当前用户无权执行该技能) start time.time() try: result func(*args, **kwargs) return result finally: elapsed time.time() - start # 统一记录日志写审计信息 # logger.info(fskill {func.__name__} executed in {elapsed:.2f}s) return wrapper return decorator把权限、超时、审计放到装饰器里所有技能在注册时套上这个装饰器生产环境的安全底线就有了。注意这套逻辑要在开发前期融入框架不要在业务交付后才补。5. 从单智能体到多智能体技能体系的扩展价值5.1 多智能体共享同一套技能库单 Agent 的技能体系做到一定程度后自然会产生一个需求我的一整套技能能不能被多个 Agent 或者多个应用共用答案是肯定的而且 agent-skills 的目录化设计天然适合这种共享。只要把技能目录做成一个独立的代码仓库各个应用通过配置引用这个仓库就能保证所有 Agent 使用同一份技能定义避免同一功能在不同项目里实现两次、行为还不一致。我参考了业界“插件市场”的思路把技能仓库做成了一个纯数据加实现的集合不依赖任何特定的智能体框架只暴露标准统一的注册接口。这样即使团队里有人用 LangChain、有人用自研框架只要通过统一的注册入口加载技能都能无缝对接。大体上就是把技能仓库做成一个 Python 包包内register_all(registry)负责注册所有技能。业务侧只要调用这一个入口就完成了和技能库的集成。这种模式在多个 Agent 应用里复用起来非常干净。5.2 技能版本管理与评估技能是会不断迭代的。你改了某个技能的 description模型的正确率可能上升也可能下降你调整了某个技能的参数 schema可能会导致老客户端调用失败。因此技能版本管理和质量评估是这个体系里绕不开的一环。我目前的做法是为每个技能打上版本号放在skill.yaml里并在技能内容包括描述的同时记录一个last_updated时间戳。每次修改技能都会在仓库里保留历史记录方便回滚。更重要的一步是建立评估集。我维护一个包含几百条测试用例的评测集每条用例是一个“用户问题-期望调用的技能-期望参数”三元组。每次技能库改动后我跑一遍评测集看技能选择准确率和参数正确率有没有波动有波动就及时调整。这套评测驱动的迭代方式比我早期“凭感觉改 description”要高效太多。这里有个很实际的经验description 不是一次写成永久不变的。你改动一次技能描述就必须重新跑一遍评测集。否则你根本不知道这次改动对整体准确率是正向影响还是负向影响。这也是很多技能系统从 demo 走向生产时最容易忽略的环节。5.3 从技能库到技能生态最后聊一点远期价值。技能体系成型后你的 Agent 不再是固定的几个功能模块而是可以通过不断新增技能来扩展能力的开放系统。设想一下你的技能库根目录下每一个新增目录都代表模型额外掌握了一样新本领。今天加一个send_email明天加一个create_calendar_event后天加一个query_internal_db你的 Agent 就从一个只会聊天的助手逐步变成一个能操作业务系统的数字员工。这个过程是渐进的、可组合的而且不会对已有技能造成破坏。这就是 agent-skills 这个方向让人着迷的地方它把智能体的能力扩展从“改代码、改逻辑”变成了“加技能、调描述”的轻量操作。对于非程序员角色来说只要学会了写规范的skill.yaml也能为 Agent 添加新本领。这种轻量化的技能生产流程才是智能体能够大规模落地到具体业务里的靠谱路径。我觉得做这个方向最有乐趣的事情就是看着自己的技能库一天天变大然后模型在这些技能的配合下解决越来越复杂的真实问题。那些把技能描述打磨得足够好的项目往往在后续维护中会特别省心。如果你也在搭自己的智能体我建议先从一两个精心设计的技能开始跑通整套流程后再慢慢扩充目录——这个节奏应该是最稳的。