从目录结构、工具封装到加载流程,把"一段能力"变成可挂载、可发现、可组合的单元
做 Agent 应用的人大多会走到同一个岔路口:模型本身越来越强,但"能力"却越来越难管。
一开始,你会在 system prompt 里塞一段指令,告诉模型"遇到用户要查订单,就调用这个函数"。这能跑通。等业务长起来,prompt 里堆了十几段这样的指令,函数散落在各个模块里,有的要权限、有的要联网、有的只在某个环境里可用。你想复用某段能力到另一个 Agent,发现它和业务代码耦合在一起,拔不出来。
Skill 这个思路,就是为了解决这件事:把"一段能力"封装成一个独立的、可挂载、可发现、可组合的单元。它自带元信息、自带指令、自带可选的脚本或工具,Agent 运行时按需加载,不用改核心代码。
这篇文章不讨论某个具体框架的实现细节,而是从架构层拆解一套可以自己实现的 Skill 机制。文末会给一个最小可运行的 Python 骨架,你可以拿去改。
先给一个工作定义,方便后面对齐:
Skill 是一个自带元信息(manifest)、指令(instruction)和可选脚本/工具的、可独立分发与按需加载的能力单元。
拆开看三个部分:
元信息(manifest):描述这个 Skill 是谁、干什么、依赖什么、需要什么权限。通常是一个声明式文件,比如 SKILL.md 或 skill.json。
指令(instruction):告诉 Agent"什么时候用我、怎么用我、注意什么"。这部分会注入到模型的上下文里。
脚本/工具:可选的执行体。可以是 Python 脚本、一段 API 调用封装,甚至是一个外部命令。
这里有一个容易混淆的点,值得单独说清楚。Skill 和下面这几个概念不是一回事:
Function / Tool,本质是单个可调用函数,输入输出明确。它与 Skill 的关系是:Skill 内部可以包含多个 Tool。
Plugin,本质是强调"接入外部系统"的能力。它与 Skill 的关系是:Skill 更偏"内部能力封装",两者可以重叠。
Agent,本质是有自主决策和任务拆解能力的执行体。它与 Skill 的关系是:Agent 是 Skill 的宿主,Skill 是 Agent 的能力来源。
再明确一下 Skill 不做什么,这个边界很重要:
把边界划清楚,后面做架构才不会越做越重。
我把它分成四层。这个分层不是唯一解,但它足够清晰,也足够通用,能覆盖大部分自研场景。
第一层,Skill 定义层。 开发者在这里写 SKILL.md 和脚本。这一层要解决的问题是"如何用最少的约定,描述清楚一个能力"。
第二层,注册与发现层。 启动时扫描目录、校验 manifest、建立索引。这一层决定了"能力能不能被快速找到和加载"。
第三层,执行层。 把脚本或 API 封装成可调用单元,做权限控制和参数校验。这一层决定了"能力能不能安全地跑起来"。
第四层,编排层。 Agent 运行时根据任务,按需加载 Skill,注入指令,调用工具,回传结果。这一层决定了"能力在什么时机、以什么方式被用上"。
数据流的方向是:定义层产出 Skill 目录,注册与发现层扫描并建立索引,执行层封装成可调用工具,编排层按需加载并回传结果。
两个关键设计取舍,直接影响后续实现:
第一,声明式还是命令式。 manifest 用声明式(描述"有什么"),执行体用命令式(描述"怎么做")。声明式负责发现和校验,命令式负责干活,两者分离,互不干扰。
第二,同步还是异步。 工具调用默认同步、短耗时;如果 Skill 内部有长任务(比如跑一个批处理),让 Skill 自己返回一个任务句柄,由宿主去轮询,而不是让调用方一直阻塞。
一个 Skill 就是一个目录。目录名即 Skill 的唯一标识,目录内至少有一个 manifest 文件。我用的约定是这样:
skills 目录下,每个 Skill 一个子目录。以 order-query 为例,目录里放三个东西:SKILL.md(元信息加指令)、main.py(可选的执行脚本)、requirements.txt(可选的依赖声明)。
SKILL.md 用 YAML 前置信息放元信息,正文放指令。这样人可读、机器可解析,一举两得。元信息里包含这几个字段:
正文是指令,直接注入模型上下文,所以它的措辞要面向模型写清楚"何时用、怎么用、注意什么"。
加载器做三件事:扫描、校验、索引。核心逻辑是:遍历 skills 目录下的每个子目录,解析 SKILL.md,把 YAML 前置信息和正文拆开,校验 name 与目录名是否一致,通过后构造成一个 Skill 对象放进索引。
这里有个值得注意的细节:单个 Skill 解析失败,不应该让整个注册过程失败。一个坏的 Skill 只影响它自己,日志里记一条,跳过即可。否则一个格式错误就能让所有能力都加载不出来。
脚本是 Skill 的执行体,但 Agent 不能直接"运行脚本",它需要的是一个有明确输入输出的调用接口。工具封装就是在这中间加一层适配。
实现上,用子进程隔离执行,而不是直接 import。这样做有两个好处:一是环境隔离,脚本崩了不会拖垮宿主;二是可以配合系统级沙箱做权限收敛。代价是每次调用有进程启动开销,对于低频、短耗时的工具调用,这个开销可以接受。
对应地,脚本侧约定一个统一的入口:用命令行参数传入一个 JSON 载荷,里面包含要调用的函数名和参数,脚本解析后调用对应函数,把结果以 JSON 打印到标准输出。
一个 Skill 从进来到退场,状态机很简单:未安装、已安装、已启用、运行中、已禁用、已卸载。
安装:把目录放到 skills 下,触发一次扫描。
启用或禁用:控制这个 Skill 是否参与加载。禁用不等于删除,只是不注入指令、不可调用。
卸载:从索引移除,删除目录或只移除引用。
这套状态机看着简单,但它是"按需加载"的基础——只有"已启用"的 Skill 才会进入索引,从而控制注入到模型上下文里的指令总量。
把上面的片段拼起来,就是一个能跑的骨架。完整流程:注册一个 skill,扫描发现,构造工具调用,拿到结果。核心代码大致如下(Python,可直接运行验证):
import json
import subprocess
from pathlib import Path
import yaml
class Skill:
def __init__(self, name, meta, instruction, root):
self.name = name
self.meta = meta
self.instruction = instruction
self.root = root
def parse_skill(dir_path: Path) -> Skill:
manifest = dir_path / "SKILL.md"
if not manifest.exists():
raise ValueError(f"{dir_path} 缺少 SKILL.md")
text = manifest.read_text(encoding="utf-8")
meta, instruction = split_frontmatter(text)
if meta.get("name") != dir_path.name:
raise ValueError(f"skill name 与目录名不一致: {dir_path.name}")
return Skill(dir_path.name, meta, instruction, dir_path)
def split_frontmatter(text: str):
if not text.startswith("---"):
return {}, text
parts = text.split("---", 2)
if len(parts) < 3:
return {}, text
meta = yaml.safe_load(parts[1]) or {}
return meta, parts[2].strip()
class SkillRegistry:
def __init__(self, root: Path):
self.root = root
self.index = {}
def discover(self):
self.index = {}
for d in self.root.iterdir():
if not d.is_dir():
continue
try:
skill = parse_skill(d)
self.index[skill.name] = skill
except ValueError as e:
print(f"[skip] {d.name}: {e}")
def get(self, name: str):
return self.index.get(name)
class Tool:
def __init__(self, skill: Skill, entry: str, function: str):
self.skill = skill
self.entry = entry
self.function = function
def run(self, args: dict) -> dict:
payload = json.dumps({"function": self.function, "args": args})
proc = subprocess.run(
["python", self.entry, "--invoke", payload],
capture_output=True,
text=True,
timeout=30,
)
if proc.returncode != 0:
return {"ok": False, "error": proc.stderr.strip()}
return json.loads(proc.stdout)
def main():
registry = SkillRegistry(Path("./skills"))
registry.discover()
print("已加载 skills:", sorted(registry.index.keys()))
skill = registry.get("order-query")
if skill is None:
return
tool = Tool(skill, entry=str(skill.root / "main.py"), function="query")
result = tool.run({"order_id": "10086"})
print(result)
if __name__ == "__main__":
main()脚本侧的入口约定如下(以 main.py 为例):
import sys
import json
def query(order_id: str) -> dict:
return {"ok": True, "status": "shipped", "amount": 128.00}
if __name__ == "__main__":
payload = json.loads(sys.argv[2])
fn = globals()[payload["function"]]
result = fn(**payload["args"])
print(json.dumps(result, ensure_ascii=False))跑起来之后,输出大致是:
已加载 skills: ['log-analysis', 'order-query']
{'ok': True, 'status': 'shipped', 'amount': 128.0}到这里,你已经有了一条从"目录里的一个文件夹"到"Agent 可调用的一个能力"的完整链路。剩下的工作,是在这条链路上加约束。
这几个坑,都是实际做的时候会踩到的,提前想清楚能省不少返工。
安全,安全,还是安全。 Skill 本质是"让模型去触发代码执行",这是最大的风险面。至少要做四件事:子进程或容器隔离;权限声明与实际执行做比对;输入严格校验(类型、长度、格式);敏感字段脱敏后再回传。如果 Skill 要联网或访问文件,权限粒度要细到"哪个资源、什么动作"。
版本与依赖。 manifest 里的 version 不是摆设。能力升级后,旧的 Agent 可能依赖旧行为,所以要么做语义化版本加兼容声明,要么让 Agent 显式声明它要哪个版本的 Skill。依赖(比如 requirements.txt)要能声明、能校验、能隔离,否则两个 Skill 依赖冲突会非常难查。
可观测性。 Skill 调用要留痕:哪个 Agent、在什么任务里、调了哪个 Skill、入参出参、耗时、是否报错。没有这层,出问题时你只能靠猜。
上下文预算。 每个启用的 Skill 都会往模型上下文里注入指令。Skill 一多,上下文会被挤爆。所以指令要短,而且要支持"按需注入"——不是把所有 Skill 的指令一次性全塞进去,而是让 Agent 先看到一份"能力清单加一句话描述",确定要用哪个,再加载那个 Skill 的完整指令。
Skill 机制的要点,一句话概括:用声明式的 manifest 描述能力,用独立的目录承载能力,用统一的工具封装暴露能力,用按需加载控制能力的注入时机。
这套东西不难,难的是把边界划清楚、把安全做扎实。真正落地的 Agent 应用,核心竞争力往往不在模型本身,而在你能不能把"能力"沉淀成可复用、可治理、可组合的资产。Skill 是往这个方向走的第一步。
如果你想继续往下做,两个自然的方向:一是给 Skill 加"组合"能力,让一个 Skill 能调用另一个 Skill;二是引入 Skill 的发布与版本管理,让团队之间能共享和复用。前者解决能力复用,后者解决能力治理。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。