7.2. 构建 Skills 市场是推广与分发的核心策略

你需要什么

在开始搭建内部 Skills 注册中心之前,你需要准备好以下环境与基础工具。整个过程预计耗时 40 分钟,其中阅读和理解大约 10 分钟,动手操作 30 分钟。

  • 运行环境:Python 3.10 及以上,pip 已安装并可正常联网
  • 依赖框架:Flask(用于构建注册中心 API)、Click(用于构建 CLI 客户端)
  • 存储:SQLite(内置于 Python 标准库,无需额外安装)
  • 测试工具curl 或任意 HTTP 客户端
  • 基础认知:你已经了解什么是 Agent Skill,以及上一章讨论过的部署形态;也熟悉命令行基础操作和 JSON 格式

最终成果

完成本章后,你将拥有一个可私有部署的内部 Skills 注册中心,并且具备:

  • 一个标准化的 Skills 包格式 定义,所有 Skill 都按照该格式打包
  • 一套 搜索、安装与更新 的 CLI 工具,团队可以用一条命令从注册中心拉取指定版本的 Skill
  • 基于 语义版本(SemVer) 的依赖解析引擎,能够自动处理依赖链,并在版本冲突时给出明确提示

为什么做这个? 在很多团队中,Agent 的开发者和使用者往往是分离的。开发者在本地写完 Skill 后,如果没有一个统一的发布与发现渠道,团队里的其他人只能通过拷贝文件或“我发你一个 zip”来分享。一旦 Skill 的数量超过 5 个,版本错乱、依赖缺失就会频频发生。内部注册中心正是为了解决这一“推广与分发的最后一公里”问题,使得上一章中部署模式触达的每一个终端,都能在正确的时间拿到正确的 Skill 版本。

特别说明:本章的设计深受 LobeHub Skills 市场等社区实践的启发。LobeHub 市场为每个 Skill 提供了独立的详情页、一键安装指令以及版本历史,这让分发变得极其轻量。我们要构建的内部注册中心会保留这些优点,同时增加对依赖管理的深度控制,更适合企业内封闭环境。


步骤一:定义 Skills 包格式规范

任何市场都需要先约定“商品”的包装方式。我们的 Skill 包采用一个目录结构,内含一份元数据描述文件 manifest.json 和一份指令文件 skill.md(参考 LobeHub 实践,Markdown 是 Skill 的行为定义核心)。

标准包结构

my-skill/
├── manifest.json
└── skill.md

你可以根据需要增加 README.mdexamples/assets/ 等目录,但注册中心只会强制校验前两个文件的存在。

manifest.json 字段规范

字段 类型 必填 说明
name string Skill 的唯一标识符,使用小写字母和连字符,例如 code-review
version string 严格遵循 SemVer,例如 1.1.0。禁止使用 latestdev 等非规范标签
description string 一句话描述 Skill 的功能
author string 维护者或团队名
dependencies object 键为其他 Skill 的 name,值为语义版本范围(见下文依赖解析)
min_agent_version string 最低要求的 Agent 框架版本,用于阻止不兼容的安装
created string ISO 8601 格式的创建时间,由注册中心在首次上传时自动生成
entrypoint string 如果 skill.md 不是主入口,可以指向自定义文件名,默认为 skill.md

一个最小化的范例:

{
  "name": "code-review",
  "version": "1.1.0",
  "description": "根据团队规范自动审查代码并给出修改建议",
  "author": "DevOps Team",
  "dependencies": {
    "code-style-check": "~0.9.0"
  },
  "min_agent_version": "2.0.0"
}

为什么把依赖声明放在 manifest 里? 因为 Agent 在执行 Skill 前需要预先加载所有必需的子 Skills,注册中心在安装阶段就需要解析整个依赖树,而不是等到运行时才发现缺失组件。

skill.md 的作用

skill.md 是 Skill 的实际行为指导书。它是发给 Agent 的提示词(Prompt)和上下文知识,采用 Markdown 格式书写。在 LobeHub 市场中,用户安装 Skill 的方式就是获取这个文件并交给 Agent。在我们的内部注册中心里,这个文件会随包一起被下载到本地,由 Agent 框架读取并执行。


步骤二:搭建注册中心后端

我们使用 Flask 快速实现一个注册中心,数据库选用 SQLite,文件存储直接用本地文件系统。所有 Skill 包在上传时会被解压并存入 packages/ 目录,以便后续提供直接下载。

2.1 初始化项目

pip install flask click
mkdir my-registry
cd my-registry
mkdir packages uploads

创建 registry.py 作为后端入口。

2.2 数据库模型

sqlite3 创建一个表 skills,记录每个 Skill 的元数据和本地存储路径。

import sqlite3

def init_db():
    conn = sqlite3.connect('registry.db')
    c = conn.cursor()
    c.execute('''CREATE TABLE IF NOT EXISTS skills (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        name TEXT NOT NULL,
        version TEXT NOT NULL,
        description TEXT,
        author TEXT,
        min_agent_version TEXT,
        path TEXT NOT NULL,
        created TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
        UNIQUE(name, version)
    )''')
    conn.commit()
    conn.close()

2.3 上传 API

接收 .zip 格式的 Skill 包,解压并校验是否存在 manifest.jsonskill.md,然后将元信息写入数据库。

from flask import Flask, request, jsonify
import zipfile, os, json, shutil

app = Flask(__name__)

@app.route('/api/skills/upload', methods=['POST'])
def upload_skill():
    file = request.files['package']
    if not file or not file.filename.endswith('.zip'):
        return jsonify({"error": "Only .zip packages are accepted"}), 400

    # 解压到临时目录
    tmp_dir = f'uploads/{os.path.splitext(file.filename)[0]}'
    with zipfile.ZipFile(file, 'r') as z:
        z.extractall(tmp_dir)

    # 校验 manifest
    manifest_path = os.path.join(tmp_dir, 'manifest.json')
    if not os.path.exists(manifest_path):
        shutil.rmtree(tmp_dir)
        return jsonify({"error": "manifest.json missing"}), 400

    with open(manifest_path) as f:
        manifest = json.load(f)

    if 'name' not in manifest or 'version' not in manifest:
        shutil.rmtree(tmp_dir)
        return jsonify({"error": "manifest must contain name and version"}), 400

    # 校验 skill.md
    md_path = os.path.join(tmp_dir, manifest.get('entrypoint', 'skill.md'))
    if not os.path.exists(md_path):
        shutil.rmtree(tmp_dir)
        return jsonify({"error": "skill.md missing"}), 400

    # 存储到 packages 目录
    target = f"packages/{manifest['name']}/{manifest['version']}"
    if os.path.exists(target):
        shutil.rmtree(target)
    shutil.move(tmp_dir, target)

    # 写入数据库
    conn = sqlite3.connect('registry.db')
    c = conn.cursor()
    c.execute('''INSERT OR REPLACE INTO skills
                 (name, version, description, author, min_agent_version, path)
                 VALUES (?, ?, ?, ?, ?, ?)''',
              (manifest['name'], manifest['version'], manifest.get('description', ''),
               manifest.get('author', ''), manifest.get('min_agent_version', ''), target))
    conn.commit()
    conn.close()

    return jsonify({"status": "ok", "name": manifest['name'], "version": manifest['version']})

运行 python registry.py 后,即可通过 curl -F "package=@my-skill.zip" http://localhost:5000/api/skills/upload 上传 Skill。

预期结果:上传成功后,你会在 packages/code-review/1.1.0/ 下看到解压后的两个文件,并且数据库里多了一条记录。

2.4 搜索与版本列表 API

提供两个公开端点:按名称模糊搜索,以及查询某个 Skill 的所有历史版本。

@app.route('/api/skills/search')
def search():
    q = request.args.get('q', '').lower()
    conn = sqlite3.connect('registry.db')
    c = conn.cursor()
    c.execute("SELECT DISTINCT name, description, author FROM skills WHERE name LIKE ?", (f'%{q}%',))
    results = [{'name': r[0], 'description': r[1], 'author': r[2]} for r in c.fetchall()]
    conn.close()
    return jsonify(results)

@app.route('/api/skills/<name>/versions')
def list_versions(name):
    conn = sqlite3.connect('registry.db')
    c = conn.cursor()
    c.execute("SELECT version, created, min_agent_version FROM skills WHERE name=? ORDER BY created DESC", (name,))
    versions = [{'version': r[0], 'created': r[1], 'min_agent_version': r[2]} for r in c.fetchall()]
    conn.close()
    return jsonify(versions)

预期结果:用 curl http://localhost:5000/api/skills/search?q=code 应该能返回包含 code-review 的列表;查看版本列表时,按上传时间倒序显示所有历史版本。


步骤三:搜索、安装与更新客户端

有了注册中心 API,我们还需要一个让用户无需关心底层 HTTP 调用的 CLI 工具。我们使用 Click 构建一个类似 npmpip 的命令行客户端。

3.1 安装 Skill

客户端会先向注册中心查询目标 Skill 的版本列表,然后下载指定版本的包(可以是 .zip 或直接复用注册中心的服务端路径)。为简化,我们让注册中心额外提供一个直接下载文件夹内容的端点。

registry.py 中添加:

import io
@app.route('/api/skills/<name>/<version>/download')
def download_skill(name, version):
    target = f'packages/{name}/{version}'
    if not os.path.exists(target):
        return jsonify({"error": "Version not found"}), 404
    # 将目录打包为 zip 流返回
    data = io.BytesIO()
    with zipfile.ZipFile(data, 'w') as z:
        for root, dirs, files in os.walk(target):
            for f in files:
                z.write(os.path.join(root, f), os.path.relpath(os.path.join(root, f), target))
    data.seek(0)
    return send_file(data, mimetype='application/zip', as_attachment=True, download_name=f'{name}-{version}.zip')

然后在客户端 cli.py 中实现安装逻辑:

import click, requests, zipfile, os, json, shutil

REGISTRY_URL = 'http://localhost:5000'
SKILLS_DIR = os.path.expanduser('~/.myagent/skills')  # 默认安装位置

@click.group()
def cli():
    pass

@cli.command()
@click.argument('name')
@click.option('--version', default=None, help='Target version, e.g., 1.1.0')
def install(name, version):
    """Install a skill from the registry"""
    # 如果没有指定版本,找最新版
    if version is None:
        resp = requests.get(f'{REGISTRY_URL}/api/skills/{name}/versions')
        if resp.status_code != 200 or not resp.json():
            click.echo(f"Skill '{name}' not found.")
            return
        version = resp.json()[0]['version']  # 最新的排第一

    # 下载
    dl = requests.get(f'{REGISTRY_URL}/api/skills/{name}/{version}/download', stream=True)
    if dl.status_code != 200:
        click.echo(f"Version {version} not available.")
        return

    target_dir = os.path.join(SKILLS_DIR, name, version)
    os.makedirs(target_dir, exist_ok=True)
    with zipfile.ZipFile(dl.raw) as z:
        z.extractall(target_dir)

    click.echo(f"Skill {name}=={version} installed to {target_dir}")

    # 读取依赖,依次安装(依赖解析将在下一步增强)
    manifest_path = os.path.join(target_dir, 'manifest.json')
    if os.path.exists(manifest_path):
        with open(manifest_path) as f:
            manifest = json.load(f)
        for dep_name, dep_ver_range in manifest.get('dependencies', {}).items():
            # 简单递归安装,实际应按版本范围选择
            install_dependency(dep_name, dep_ver_range)

def install_dependency(name, version_range):
    # 省略详细实现,可复用 install 逻辑并解析 range
    pass

预期结果:在终端执行 python cli.py install code-review --version 1.1.0,你会在 ~/.myagent/skills/code-review/1.1.0/ 下看到完整的 Skill 包,并且它的依赖(如 code-style-check)也会被自动拉取。

3.2 更新 Skill

更新操作本质上是安装一个更高版本。我们提供一个 update 命令,它会对比本地已安装版本与注册中心最新版本,如果注册中心有更新的版本则自动安装。

@cli.command()
@click.argument('name')
def update(name):
    # 获取已安装版本(简化为检查最高版本目录)
    local_versions = []
    local_base = os.path.join(SKILLS_DIR, name)
    if os.path.exists(local_base):
        local_versions = os.listdir(local_base)
    # 获取注册中心最新版本
    resp = requests.get(f'{REGISTRY_URL}/api/skills/{name}/versions')
    if resp.status_code != 200:
        click.echo(f"Skill '{name}' not in registry.")
        return
    remote_latest = resp.json()[0]['version']
    if not local_versions or remote_latest not in local_versions:
        click.echo(f"Updating {name} to {remote_latest}...")
        install.callback(name, version=remote_latest)
    else:
        click.echo(f"{name} is already up-to-date.")

踩坑经验:注册中心的下载端点如果直接提供目录路径,需要特别注意路径遍历攻击。你永远不应该信任用户输入的 nameversion 参数,务必进行严格的字符白名单过滤(只允许字母、数字、连字符和点号),并检查实际路径是否确实落在 packages/ 目录下。本章示例为简化略去了这些检查,但在生产环境中必须加上。


步骤四:依赖解析与版本冲突处理

如果只是粗暴地“先下载最新版”,当两个 Skill 依赖同一个库的不同版本时(比如 A 要求 code-style-check ~0.9.0,B 要求 code-style-check ^1.0.0),Agent 就会陷入混乱。语义版本(SemVer)和海象算符(~、^)正是为解决这类问题而生的。

4.1 版本范围约定

我们在 manifest 的 dependencies 值中使用标准的 npm 式范围表示法:

  • ^1.2.3 表示兼容 1.x 的最新版本(Major 相同,Minor 和 Patch 可以更高)
  • ~1.2.3 表示 1.2.x 的任意 Patch
  • >=1.0.01.0.0 - 2.0.0 等也可接受,但推荐使用 ^~

示例:

"dependencies": {
  "code-style-check": "~0.9.0"
}

这表示允许 0.9.00.9.x 的任意修订版本,但不能升级到 0.10.0(可能会引入不兼容的变更)。

4.2 在安装流程中集成解析器

我们可以使用 Python 的 semver 库(需另外安装)来处理版本比较,但为了演示核心逻辑,这里手动实现一个简易的比较器。

在客户端 CLI 中,增加 resolve_dependencies 函数,它会:

  1. 收集所有已安装的直接依赖和间接依赖
  2. 对于每个新加入的依赖,检查其版本范围是否与已有版本冲突
  3. 若无冲突,选择满足所有约束的最高版本;若冲突,报错并提示冲突来源
from collections import defaultdict

def resolve_dependencies(name, version_range, resolved=None, conflict_check=None):
    if resolved is None:
        resolved = {}
    if conflict_check is None:
        conflict_check = defaultdict(list)

    # 查找注册中心中符合 version_range 的所有版本
    resp = requests.get(f'{REGISTRY_URL}/api/skills/{name}/versions')
    if resp.status_code != 200:
        raise Exception(f"Skill {name} not found.")
    candidates = [v['version'] for v in resp.json()]

    best = pick_best_version(candidates, version_range)
    if not best:
        raise Exception(f"No version of {name} matches range {version_range}")

    # 检查冲突:如果 name 已经被解析过,且与之前的范围冲突
    if name in resolved:
        # 已解析的版本必须同时满足所有记录的范围
        if not version_in_range(resolved[name], version_range):
            conflict_check[name].append(version_range)
            raise Exception(f"Conflict: {name} required by multiple with incompatible ranges: {conflict_check[name]}")
    else:
        resolved[name] = best
        # 读取该版本的 manifest.json 以获取其依赖
        target_dir = os.path.join(SKILLS_DIR, name, best)
        # 如果在本地还没安装,则先安装(这里仅演示递归解析)
        # 实际流程中,解析完所有依赖树后再统一安装
        # ...

上面的伪代码展示了核心思路。生产级实现建议使用现成的依赖解析库(如 Python 的 pip 内部的解析器模型),或者直接采用 Go mod / Cargo 的解析策略——优先满足直接依赖,若出现 Diamond Dependency 冲突则提示用户手动选择。

预期结果:当你尝试安装一个要求 code-style-check ~0.9.0 的 Skill,而另一个已安装的 Skill 要求 code-style-check =0.8.5 时,命令行会立即报错并列出冲突:

Error: Dependency conflict for code-style-check:
  - code-review requires ~0.9.0
  - code-review-legacy requires =0.8.5
Please resolve manually.

4.3 关于依赖锁文件

强烈建议在团队项目中使用一个 锁文件(如 skills.lock),记录每次安装后确切的依赖树和版本哈希。这样可以保证所有开发人员环境一致,也便于 CI/CD 重现构建。锁文件结构可以仿照 package-lock.json,记录每个 Skill 的名称、版本和校验和。


回顾

本章我们完成了:

  1. 定义了一套标准化的 Skills 包格式,包含 manifest.jsonskill.md
  2. 使用 Flask + SQLite 搭建了一个带上传、搜索、版本列表、下载功能的注册中心后端
  3. 用 Click 编写了一个 CLI 客户端,实现了 installupdate 命令
  4. 引入了语义版本与依赖范围解析,在安装时自动处理依赖树并检测版本冲突

整个过程从零开始构建了一个内部 Skills 市场的最小可用原型。实际落地时,你还可以进一步添加 Web 管理界面、身份认证、审批流程等功能。

下一步行动清单

  1. 在团队内部试用上传和安装流程,确保 manifest.json 填写无误
  2. 为高优先级的 Skill 补全 dependencies 声明,并测试冲突场景
  3. 将注册中心后端部署到内网服务器,并配置持久化存储
  4. 给 CLI 增加 skill list 命令,展示本地已安装的 Skill 及其版本
  5. 开始规划接入认证机制,为下一章做准备

下一章《认证、授权与审计缺一不可》将为你构建的注册中心加上多层级权限控制,确保只有授权用户才能发布、安装或更新 Skill,并且每一次操作都留有审计记录——这是 Skills 市场从“能用”走向“安全可控”的关键一步。

本文章首发在 LearnKu.com 网站上。

上一篇 下一篇
讨论数量: 0
发起讨论 只看当前版本


暂无话题~