· 5 分钟阅读

如何制作一个 skill

本文介绍 skill、MCP、CLI 三者的关系,并提供一个基于 CLI 实现 skill 的具体案例。

本文介绍 skillMCPCLI 三者的关系,并用一个基于 CLI 实现的 skill 作为例子,说明一个 skill 大概应该包含哪些内容。

先给出一个粗略结论:

  • MCP 解决的是客户端和工具服务器之间“怎么通信”的问题。
  • CLI 解决的是模型如何借助命令行工具完成具体任务的问题。
  • skill 解决的是“什么时候用、怎么用、参考资料在哪里、辅助脚本怎么跑”的问题。

也就是说,MCP 更像协议层,CLI 更像执行层,skill 更像给模型看的操作手册和工具包。

先说 MCP

MCP 是一种协议,它规定的是客户端和 MCP 服务器之间的交互规则。

这里不需要关心底层具体通过什么方式通信。它可能是 HTTP,也可能是 stdio。真正重要的是:客户端和服务器之间传递的消息长什么样,以及工具能力如何被发现和调用。

MCP 协议本身基于 JSON-RPC 2.0。按照 MCP 的设计,服务器可以暴露三类能力:

tools/list
tools/call
resources/list
resources/read
prompts/list
prompts/get

其中:

  • tools 系列用于暴露和调用工具。
  • resources 系列用于暴露一些资料或上下文。
  • prompts 系列用于暴露提示词模板。

后两者当然也有用途,但在日常做 agent 或工具集成时,最常用、也最关键的通常还是 tools

一次工具调用的完整流程

下面看一次 MCP 工具调用的大致过程。

1. Client 初始化

Client 先告诉 Server:我是谁、我支持什么协议版本、我有什么能力。

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "initialize",
  "params": {
    "protocolVersion": "2025-06-18",
    "clientInfo": {
      "name": "codex",
      "version": "1.0.0"
    },
    "capabilities": {
      "roots": {},
      "sampling": {}
    }
  }
}

2. Server 返回自身信息

Server 返回:我是谁、我支持什么协议版本、我暴露了哪些能力。

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "protocolVersion": "2025-06-18",
    "serverInfo": {
      "name": "Playwright",
      "version": "1.61.0"
    },
    "capabilities": {
      "tools": {}
    }
  }
}

3. Client 通知初始化完成

这是一个 notification,没有 id,所以 Server 不需要回复。

{
  "jsonrpc": "2.0",
  "method": "notifications/initialized",
  "params": {}
}

4. Client 请求工具列表

初始化完成后,Client 可以请求 Server 暴露的工具列表。

{
  "jsonrpc": "2.0",
  "id": 2,
  "method": "tools/list",
  "params": {}
}

5. Server 返回工具定义

Server 返回自己暴露的工具。工具定义里最重要的是三部分:

  • name:工具名称,后续调用时使用。
  • description:工具描述,给模型判断什么时候该用它。
  • inputSchema:参数结构,告诉模型这个工具需要什么参数。
{
  "jsonrpc": "2.0",
  "id": 2,
  "result": {
    "tools": [
      {
        "name": "browser_navigate",
        "description": "Navigate to a URL",
        "inputSchema": {
          "type": "object",
          "properties": {
            "url": {
              "type": "string",
              "description": "The URL to navigate to"
            }
          },
          "required": ["url"]
        },
        "annotations": {
          "title": "Navigate to a URL",
          "readOnlyHint": false,
          "destructiveHint": true,
          "openWorldHint": true
        }
      },
      {
        "name": "browser_snapshot",
        "description": "Capture accessibility snapshot of the current page",
        "inputSchema": {
          "type": "object",
          "properties": {}
        },
        "annotations": {
          "title": "Capture snapshot",
          "readOnlyHint": true
        }
      }
    ]
  }
}

6. Client 调用工具

当模型决定要打开网页时,Codex 运行时会向 MCP Server 发送 tools/call

{
  "jsonrpc": "2.0",
  "id": 3,
  "method": "tools/call",
  "params": {
    "name": "browser_navigate",
    "arguments": {
      "url": "https://example.com"
    }
  }
}

7. Server 执行并返回结果

Server 执行 Playwright 操作,然后把结果返回给 Client。

{
  "jsonrpc": "2.0",
  "id": 3,
  "result": {
    "content": [
      {
        "type": "text",
        "text": "### Ran Playwright code\n```js\nawait page.goto('https://example.com');\n```\n\n### Page\n- Page URL: https://example.com\n- Page Title: Example Domain\n\n### Snapshot\n```yaml\n- document:\n  - heading \"Example Domain\"\n  - paragraph: This domain is for use in illustrative examples\n```"
      }
    ],
    "isError": false
  }
}

到这里,客户端和 MCP 服务器之间的一次工具调用就完成了。

客户端、模型和 MCP Server 的关系

上面讲的是客户端和 MCP Server 之间的通信。接下来再看客户端如何把这些工具能力交给模型使用。

1. 客户端把工具注入模型请求

客户端拿到 MCP Server 的 tool 列表后,会解析这些工具定义,并把工具名、描述、参数结构放进发给模型的请求里。

以 Codex 为例,模型请求大概像这样:

{
  "model": "gpt-5.4",
  "instructions": "You are Codex, a coding agent. Use tools when needed and explain results clearly.",
  "input": [
    {
      "role": "user",
      "content": [
        {
          "type": "input_text",
          "text": "打开 https://example.com 看一下页面"
        }
      ]
    }
  ],
  "tools": [
    {
      "type": "function",
      "name": "browser_navigate",
      "description": "Navigate to a URL",
      "parameters": {
        "type": "object",
        "properties": {
          "url": {
            "type": "string",
            "description": "The URL to navigate to"
          }
        },
        "required": ["url"],
        "additionalProperties": false
      }
    },
    {
      "type": "function",
      "name": "browser_snapshot",
      "description": "Capture accessibility snapshot of the current page",
      "parameters": {
        "type": "object",
        "properties": {},
        "additionalProperties": false
      }
    }
  ],
  "tool_choice": "auto"
}

2. 模型决定是否调用工具

模型根据用户问题和工具描述,决定是否要调用工具,以及调用哪个工具。

例如它可能返回一次 function_call

{
  "id": "resp_001",
  "object": "response",
  "status": "completed",
  "model": "gpt-5.4",
  "output": [
    {
      "id": "fc_001",
      "type": "function_call",
      "status": "completed",
      "call_id": "call_001",
      "name": "browser_navigate",
      "arguments": "{\"url\":\"https://example.com\"}"
    }
  ]
}

3. 客户端执行工具并把结果喂回模型

Codex 收到模型的 function_call 后,会真正调用 MCP Server。拿到工具执行结果后,再通过 function_call_output 把结果发回模型。

{
  "model": "gpt-5.4",
  "previous_response_id": "resp_001",
  "input": [
    {
      "type": "function_call_output",
      "call_id": "call_001",
      "output": "### Ran Playwright code\n```js\nawait page.goto('https://example.com');\n```\n\n### Page\n- Page URL: https://example.com\n- Page Title: Example Domain\n\n### Snapshot\n```yaml\n- document:\n  - heading \"Example Domain\"\n  - paragraph: This domain is for use in illustrative examples in documents.\n  - link \"More information...\"\n```"
    }
  ],
  "tools": [
    {
      "type": "function",
      "name": "browser_navigate",
      "description": "Navigate to a URL",
      "parameters": {
        "type": "object",
        "properties": {
          "url": {
            "type": "string"
          }
        },
        "required": ["url"],
        "additionalProperties": false
      }
    },
    {
      "type": "function",
      "name": "browser_snapshot",
      "description": "Capture accessibility snapshot of the current page",
      "parameters": {
        "type": "object",
        "properties": {},
        "additionalProperties": false
      }
    }
  ]
}

最终,模型拿到工具结果后,生成面向用户的回答。

整个过程可以简化成下面这个时序:

用户 -> Codex
Codex -> OpenAI 模型:input + tools
OpenAI 模型 -> Codex:function_call
Codex -> MCP Server:tools/call
MCP Server -> Codex:tool result
Codex -> OpenAI 模型:function_call_output
OpenAI 模型 -> Codex:final message
Codex -> 用户

所以 MCP 本身并不直接“让模型变聪明”。它更像一个标准接口,让客户端能够稳定发现工具、调用工具、拿回工具结果。真正决定工具是否被使用的是模型,而负责把模型调用转成真实执行的是客户端运行时。

再说 CLI

CLI 相较于 MCP 更简单。

CLI 就是命令行工具。模型可以通过客户端提供的 shell tool 调用 Windows 命令行、PowerShell、Python 脚本或其他可执行程序,从而完成实际任务。

现在很多 skill 都是通过 CLI 来实现的。比如 Codex 的一些 skill,会在 scripts/ 目录里放 Python 脚本,然后在 SKILL.md 里告诉模型:

  • 什么时候应该使用这个脚本。
  • 脚本需要哪些参数。
  • 输入文件和输出文件在哪里。
  • 执行后应该如何校验结果。

CLI 和 MCP 的差异大概是:

维度MCPCLI
形式协议和服务命令行工具
工具发现通过 tools/list 暴露通过文档告诉模型
工具调用通过 tools/call通过 shell 命令
参数约束有结构化 schema依赖说明文档和脚本参数
适合场景长期运行的工具服务、多客户端复用本地脚本、文件处理、一次性任务

所以 CLI 少了一层协议,但也因此更轻。对于很多文件处理、生成、校验类任务来说,CLI 实现起来反而更直接。

再说 skill

以 Codex 为例,一个使用 CLI 的 skill 案例结构可以长这样。

这里拿 skill-creator 举例,也就是“用创建 skill 的 skill 来创建 skill”,有点套娃,但正好说明问题。

skill-creator/
├─ SKILL.md
├─ license.txt
├─ agents/
│  └─ openai.yaml
├─ assets/
│  ├─ skill-creator-small.svg
│  └─ skill-creator.png
├─ references/
│  └─ openai_yaml.md
└─ scripts/
   ├─ generate_openai_yaml.py
   ├─ init_skill.py
   └─ quick_validate.py

各部分大概作用如下:

路径作用
SKILL.mdskill 的核心说明,决定什么时候触发、触发后怎么工作。
agents/openai.yamlCodex UI 展示元数据,比如名称、简介、图标、默认 prompt。
assets/图标、图片等资源。
references/额外参考文档,按需读取,避免 SKILL.md 太长。
scripts/可执行辅助脚本,用来生成、初始化、校验。
license.txt许可证文本。

实际使用时,最重要的通常是这三个部分:

  • SKILL.md
  • references/
  • scripts/

SKILL.md 是入口,它告诉模型这个 skill 是干什么的、什么时候该触发、触发后先读什么、要不要跑脚本、结果怎么检查。

references/ 适合放比较长、但不是每次都必须读的资料。例如 openai.yaml 的字段说明、某种文件格式的完整规范、复杂案例等。这样可以避免 SKILL.md 变得太长。

scripts/ 适合放确定性强的辅助逻辑。比如初始化目录、生成配置文件、校验结构、渲染预览、导出产物等。模型负责判断和编排,脚本负责稳定执行。

一个 skill 文档应该写什么

我觉得一个好用的 SKILL.md 至少应该说清楚这些事情:

  1. 这个 skill 什么时候应该被使用。
  2. 输入通常来自哪里,比如用户提供的文件、当前仓库、某个目录。
  3. 输出应该是什么,比如新建文件、修改文件、生成报告或完成校验。
  4. 需要优先读取哪些参考文档。
  5. 有哪些脚本可以用,每个脚本怎么调用。
  6. 完成后怎么验证,失败时怎么处理。

比如可以这样组织:

# Skill Name

## When to use
说明触发条件。

## Workflow
说明模型应该按什么顺序工作。

## References
说明需要时读取 references/ 下的哪些文件。

## Scripts
说明 scripts/ 下每个脚本的用途和调用方式。

## Verification
说明如何检查结果是否正确。

这样写的好处是,模型不用猜。它不需要从一堆零散文件里推断你的意图,而是可以按文档里的流程一步一步执行。

如何创建一个 skill

我的建议是:使用创建 skill 的 skill 来创建 skill。

原因很简单:skill 本身有一套目录结构和文档约定,如果手写,容易漏掉元数据、参考文档说明或校验脚本。用专门的创建工具初始化一遍,再在生成结果上补充业务说明,会更稳。

一个比较舒服的流程是:

  1. 先用 skill-creator 初始化目录。
  2. SKILL.md 里写清楚触发条件和主流程。
  3. 把长规范、字段说明、案例放进 references/
  4. 把确定性的生成、校验逻辑放进 scripts/
  5. 用一个真实任务跑一遍,检查模型是否会按预期读取资料和调用脚本。

总结一下:MCP 负责标准化工具服务,CLI 负责执行具体命令,skill 负责把“何时使用、如何使用、如何验证”讲清楚。三者并不是互相替代的关系,而是处在不同层次上配合工作。