盒子
盒子

AIAgent - SKILLS

系列文章:

  1. AIAgent - 简易框架搭建
  2. AIAgent - LiteLLM
  3. AIAgent - 流式输出与视觉支持
  4. AIAgent - MCP
  5. AIAgent - SKILLS

在上一篇博客我们讲了mcp的原理,但就近期ai圈来看mcp的热点已经逐渐降低了,大家更多开始关注skills.

从原理上来讲skills也是一个比较简单的东西,基本上就是人类或者llm可读的操作文档按照一定格式存放:

1
2
3
4
5
my-skill/
├── SKILL.md # 必要: 具体的操作文档 + 元数据
├── scripts/ # 可选: 脚本
├── references/ # 可选: 二级文档
└── assets/ # 可选: 各种资源

例如我demo里面的clean-up-computer这个skill。其中对格式要求最强的就是这个SKILL.md了,它要求文档的开头以YAML的格式定义文档的元数据。

1
2
3
4
5
6
7
8
9
10
---
name: 清理电脑垃圾
description: 删除电脑上无用的文件
---

各个目录的文件删除规则如下

- ~/Downloads : references/下载目录清理规则.md
- ~/Documents : 使用scripts/clean_documents.py去清理,该脚本需要你传入需要清理的绝对路径
- ~/Movies : 所有非视频类的文件都可以删除

例如上面这个skill,它的名字就是清理电脑垃圾,概要描述就是删除电脑上无用的文件

agent在初始化的时候会扫描skills目录下的SKILL.md,加载他们的元数据作为系统提示词。也就是说每次传给llm的只是元数据的内容,这样消耗的token就会很少:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class SkillLoader:
...
def _load_skills(self):
skills = []
for file in os.listdir(self._skill_dir):
skill_path = os.path.join(self._skill_dir, file, "SKILL.md")
if not os.path.exists(skill_path):
continue
with open(skill_path, "r") as f:
content = f.read()
metadata = self._get_skill_metadata(content)
metadata["location"] = skill_path
skills.append(metadata)
return skills

def _get_skill_metadata(self, content: str) -> dict | None:
if not content.startswith("---"):
return None
match = re.match(r"^---\n(.*?)\n---", content, re.DOTALL)
if not match:
return None
metadata = {}
for line in match.group(1).split("\n"):
if ":" in line:
key, value = line.split(":", 1)
metadata[key.strip()] = value.strip().strip('"\'')
return metadata
...

然后我们在系统提示词里将skills列出来给ai,并且要求它在需要的时候再向用户请求加载完整的内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class SkillLoader:
...
def get_skills_prompt(self):
prompt = "下面是你的SKILLS列表,在需要的时候向用户请求加载skill权限通过后读取对应SKILL的完整描述并执行. \n"
prompt += "<skills>\n"
for skill in self._load_skills():
prompt += f" <skill>\n"
prompt += f" <name>{skill['name']}</name>\n"
prompt += f" <description>{skill['description']}</description>\n"
prompt += f" <location>{skill['location']}</location>\n"
prompt += f" </skill>\n"
prompt += "</skills>\n"
return prompt

class AgentMemory:
...
def _get_system_prompt(self, skills_prompt: str):
runtime = f"{platform.system()} {platform.machine()}, Python {platform.python_version()}"
return f"""
你是一个AI智能助手.

## 运行环境
{runtime}

## SKILLS
{skills_prompt}
"""

完整的系统提示词打印出来是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
你是一个AI智能助手.

## 运行环境
Darwin arm64, Python 3.12.12

## SKILLS
下面是你的SKILLS列表,在需要的时候向用户请求加载skill权限通过后读取对应SKILL的完整描述并执行.
<skills>
<skill>
<name>清理电脑垃圾</name>
<description>删除电脑上无用的文件</description>
<location>/Users/linjw/workspace/py-agent/skills/clean-up-computer/SKILL.md</location>
</skill>
</skills>

llm在接收到用户清理电脑的指令的时候就可以从skills列表里面找到清理电脑垃圾这个skill并且知道它的路径是/Users/linjw/workspace/py-agent/skills/clean-up-computer/SKILL.md,然后他就会通过我们新增加的request_load_skill_permission这个工具去请求权限:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class LocalToolProvider(ToolProvider):
def __init__(self, skill_loader: SkillLoader):
self._skill_loader = skill_loader
...
def read_file(self, path: str):
"""
读取指定文件的内容
Args:
path: 要读取的文件路径
Returns:
文件内容
"""
with open(os.path.expanduser(path), "r") as f:
return f.read()

def request_load_skill_permission(self, skill_name: str):
"""
请求加载skill的权限
Args:
skill_name: 需要加载的skill的名字
"""
return self._skill_loader.request_load_skill_permission(skill_name)

class SkillLoader:
def request_load_skill_permission(self, skill_name: str):
user_input = input(f"是否运行加载skill {skill_name} (Y/N): ")
return user_input.upper() == "Y"

一旦用户输入y或者Y,它就会用我们之前定义的read_file工具去读取完整的SKILL.md:

1
2
3
4
5
6
7
8
9
10
---
name: 清理电脑垃圾
description: 删除电脑上无用的文件
---

各个目录的文件删除规则如下

- ~/Downloads : references/下载目录清理规则.md
- ~/Documents : 使用scripts/clean_documents.py去清理,该脚本需要你传入需要清理的绝对路径
- ~/Movies : 所有非视频类的文件都可以删除

agent就会根据定义的规则先使用read_file工具读取下载目录清理规则.md的内容:

1
下载目录的所有文件都可以删除

然后再使用list_dir工具列出~/Downloads目录的所有文件,然后一个个调用remove_file去删除(为了安全我这里只是打印了下并没有真的删除文件):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class LocalToolProvider(ToolProvider):
...
def list_dir(self, path: str):
"""
列出指定目录下的文件和目录
Args:
path: 要列出的目录路径
Returns:
目录下的文件和目录列表
"""
expanded_path = os.path.expanduser(path)
try:
return os.listdir(expanded_path)
except FileNotFoundError as e:
return str(e)

def remove_file(self, path: str):
"""
删除指定了解的文件
Args:
path: 要删除的文件路径
"""
print(f"模拟删除文件: {path}")
return True
...

然后再去调用我们新增的exec_skill_py_script工具执行scripts/clean_documents.py去清理~/Documents目录:

1
2
3
4
5
6
7
8
def exec_skill_py_script(self, script_path: str, arguments: list[str]):
"""
执行指定路径的Python脚本
Args:
script_path: 要执行的脚本路径
arguments: 要执行的脚本参数列表,类型为list[str]
"""
return self._skill_loader.exec_skill_py_script(script_path, arguments)

我们在SKILL.md里面说明了这个clean_documents.py的使用方式,但实际上即使我们不说,足够聪明的模型也会自己调用read_file工具去读取脚本内容然后自己分析出应该如何调用。

最后的~/Movies同样是用list_dir列出文件然后再由llm去判断是否为视频文件再决定是否删除

整个demo完整的运行视频如下: