9. 技能系统

本章目标

让 Agent 拥有可复用的 Prompt 模块:用户定义一次,反复调用。像 Shell 脚本一样即装即用。

graph TB
    subgraph 技能系统
        Discover[扫描 .claude/skills/] --> Parse[解析 SKILL.md<br/>frontmatter + 模板]
        Parse --> Inject[注入 system prompt<br/>skills变量]
        Parse --> Invoke{调用方式}
        Invoke -->|用户 /name| REPL[CLI 直接执行]
        Invoke -->|模型判断| Tool[skill 工具调用]
    end

    subgraph 共享基础
        FM[frontmatter.ts<br/>YAML 解析/序列化]
    end

    Parse -.-> FM

    style FM fill:#7c5cfc,color:#fff
    style Inject fill:#e8e0ff

Claude Code 怎么做的

技能是 Claude Code 的”AI Shell 脚本”——把 AI 工作流模板化,一次定义,反复复用。一个 /commit 技能封装了”读 diff → 分析变更 → 撰写 commit message → 提交”的完整 prompt。

技能从 6 个来源加载,优先级从高到低:企业策略(managed)> 项目级 > 用户级 > 插件 > 内置(bundled)> MCP。规律很简单:越接近用户控制的来源优先级越高,MCP 来自远程不受信任的服务端所以垫底。每个技能必须是目录格式 skill-name/SKILL.md,允许技能附带资源文件并通过 ${CLAUDE_SKILL_DIR} 引用。

启动时只预加载 frontmatter(name/description/whenToUse),完整 prompt 在调用时才读取。几十个技能全量加载会挤占大量上下文,懒加载把成本推迟到真正需要的时刻。即使只是 frontmatter,技能列表也需要 token 空间——formatCommandsWithinBudget() 用三阶段算法控制:预算充足时全量展示;超出时内置技能(/commit/review)始终保留完整描述,其余按剩余预算均分;每个技能不足 20 字符时降级为仅显示名称。

技能 prompt 执行前经过多层替换:$ARGUMENTS 替换用户参数,${CLAUDE_SKILL_DIR} 替换技能目录路径,!`command` 内联 Shell 执行(MCP 技能禁用此特性,防止远程提示词注入执行任意命令)。

执行模式有两种:inline(默认)直接注入当前对话,fork 创建独立子 Agent 执行后返回结果。fork 适合需要大量工具调用的技能——比如代码审查要读多个文件,这些调用会污染主对话上下文,fork 后只有最终结果回到主线。


我们的实现

SKILL.md 格式

---
name: commit
description: Create a git commit with a descriptive message
when_to_use: When the user asks to commit changes or says "commit"
allowed-tools: run_shell, read_file
user-invocable: true
---
Look at the current git diff and staged changes. Write a clear, concise
commit message following conventional commits format.
 
The user's request: $ARGUMENTS
 
Project skill directory: ${CLAUDE_SKILL_DIR}
  • when_to_use:给模型看的触发条件,模型根据此判断是否自动调用
  • allowed-tools:安全边界,限制技能可使用的工具
  • user-invocablefalse 的技能只能被模型自动触发

发现与加载

flowchart LR
    U["~/.claude/skills/*"] -->|低优先级| Map["Map<name, Skill>"]
    P[".claude/skills/*"] -->|高优先级覆盖| Map
    Map --> Cache["cachedSkills[]"]

TypeScript

// skills.ts — discoverSkills
 
let cachedSkills: SkillDefinition[] | null = null;
 
export function discoverSkills(): SkillDefinition[] {
  if (cachedSkills) return cachedSkills;
 
  const skills = new Map<string, SkillDefinition>();
 
  loadSkillsFromDir(join(homedir(), ".claude", "skills"), "user", skills);
  loadSkillsFromDir(join(process.cwd(), ".claude", "skills"), "project", skills);
 
  cachedSkills = Array.from(skills.values());
  return cachedSkills;
}

Python

# skills.py — discover_skills
 
_cached_skills: list[SkillDefinition] | None = None
 
 
def discover_skills() -> list[SkillDefinition]:
    global _cached_skills
    if _cached_skills is not None:
        return _cached_skills
 
    skills: dict[str, SkillDefinition] = {}
 
    _load_skills_from_dir(Path.home() / ".claude" / "skills", "user", skills)
    _load_skills_from_dir(Path.cwd() / ".claude" / "skills", "project", skills)
 
    _cached_skills = list(skills.values())
    return _cached_skills

用 Map 去重自然实现”项目级覆盖用户级”——先加载 user,再加载 project,同名 key 被后者覆盖。Claude Code 有 6 个来源是因为要支持企业和 MCP 场景,project + user 覆盖了个人开发者的核心需求。

技能解析

TypeScript

// skills.ts — parseSkillFile
 
function parseSkillFile(
  filePath: string, source: "project" | "user", skillDir: string
): SkillDefinition | null {
  const raw = readFileSync(filePath, "utf-8");
  const { meta, body } = parseFrontmatter(raw);
 
  const name = meta.name || skillDir.split("/").pop() || "unknown";
  const userInvocable = meta["user-invocable"] !== "false";
 
  let allowedTools: string[] | undefined;
  if (meta["allowed-tools"]) {
    const raw = meta["allowed-tools"];
    if (raw.startsWith("[")) {
      try { allowedTools = JSON.parse(raw); } catch {
        allowedTools = raw.replace(/[\[\]]/g, "").split(",").map((s) => s.trim());
      }
    } else {
      allowedTools = raw.split(",").map((s) => s.trim());
    }
  }
 
  return {
    name, description: meta.description || "",
    whenToUse: meta.when_to_use || meta["when-to-use"],
    allowedTools, userInvocable,
    promptTemplate: body, source, skillDir,
  };
}

Python

# skills.py — _parse_skill_file
 
def _parse_skill_file(
    file_path: Path, source: str, skill_dir: str
) -> SkillDefinition | None:
    try:
        raw = file_path.read_text()
        result = parse_frontmatter(raw)
        meta = result.meta
 
        name = meta.get("name") or file_path.parent.name or "unknown"
        user_invocable = meta.get("user-invocable", "true") != "false"
        context = "fork" if meta.get("context") == "fork" else "inline"
 
        allowed_tools: list[str] | None = None
        if "allowed-tools" in meta:
            raw_tools = meta["allowed-tools"]
            if raw_tools.startswith("["):
                try:
                    allowed_tools = json.loads(raw_tools)
                except Exception:
                    allowed_tools = [s.strip() for s in raw_tools.strip("[]").split(",")]
            else:
                allowed_tools = [s.strip() for s in raw_tools.split(",")]
 
        return SkillDefinition(
            name=name, description=meta.get("description", ""),
            when_to_use=meta.get("when_to_use") or meta.get("when-to-use"),
            allowed_tools=allowed_tools, user_invocable=user_invocable,
            context=context, prompt_template=result.body,
            source=source, skill_dir=skill_dir,
        )
    except Exception:
        return None

allowed-tools 同时支持逗号分隔和 JSON 数组两种写法,先尝试 JSON.parse,失败就按逗号拆——用户写 YAML 时两种格式都很自然,容错解析避免因格式问题导致技能加载失败。when_to_use 同时兼容下划线和连字符两种 key 名,同理。

Prompt 模板替换

TypeScript

// skills.ts — resolveSkillPrompt
 
export function resolveSkillPrompt(skill: SkillDefinition, args: string): string {
  let prompt = skill.promptTemplate;
  prompt = prompt.replace(/\$ARGUMENTS|\$\{ARGUMENTS\}/g, args);
  prompt = prompt.replace(/\$\{CLAUDE_SKILL_DIR\}/g, skill.skillDir);
  return prompt;
}

Python

# skills.py — resolve_skill_prompt
 
def resolve_skill_prompt(skill: SkillDefinition, args: str) -> str:
    prompt = skill.prompt_template
    prompt = re.sub(r"\$ARGUMENTS|\$\{ARGUMENTS\}", args, prompt)
    prompt = prompt.replace("${CLAUDE_SKILL_DIR}", skill.skill_dir)
    return prompt

$ARGUMENTS 替换用户传入的参数,${CLAUDE_SKILL_DIR} 替换技能目录路径(技能可以在目录里放模板文件,在 prompt 中用 read_file 引用)。Claude Code 还支持 !`shell_command` 内联执行,我们没有实现——它增加了安全风险,教程场景不需要。

双重调用路径

flowchart TD
    User["用户输入"] --> Check{以 / 开头?}
    Check -->|"/commit fix types"| Parse["解析: name=commit, args=fix types"]
    Check -->|"帮我提交代码"| Model["模型理解意图"]

    Parse --> Resolve["resolveSkillPrompt()"]
    Model --> SkillTool["调用 skill 工具"]
    SkillTool --> Execute["executeSkill()"]
    Execute --> Resolve

    Resolve --> Inject["注入为 user message"]
    Inject --> Chat["agent.chat()"]

    style Check fill:#7c5cfc,color:#fff

路径 1:用户手动调用(cli.ts)

TypeScript

if (input.startsWith("/")) {
  const spaceIdx = input.indexOf(" ");
  const cmdName = spaceIdx > 0 ? input.slice(1, spaceIdx) : input.slice(1);
  const cmdArgs = spaceIdx > 0 ? input.slice(spaceIdx + 1) : "";
  const skill = getSkillByName(cmdName);
  if (skill && skill.userInvocable) {
    const resolved = resolveSkillPrompt(skill, cmdArgs);
    printInfo(`Invoking skill: ${skill.name}`);
    await agent.chat(resolved);
    return;
  }
}

Python

if inp.startswith("/"):
    space_idx = inp.find(" ")
    cmd_name = inp[1:space_idx] if space_idx > 0 else inp[1:]
    cmd_args = inp[space_idx + 1:] if space_idx > 0 else ""
    skill = get_skill_by_name(cmd_name)
    if skill and skill.user_invocable:
        resolved = resolve_skill_prompt(skill, cmd_args)
        print_info(f"Invoking skill: {skill.name}")
        await agent.chat(resolved)
        continue

路径 2:模型程序化调用(tools.ts)

TypeScript

// tools.ts — skill 工具定义与执行
 
{
  name: "skill",
  description: "Invoke a registered skill by name...",
  input_schema: {
    properties: {
      skill_name: { type: "string" },
      args: { type: "string" },
    },
    required: ["skill_name"],
  },
}
 
function runSkillTool(input: { skill_name: string; args?: string }): string {
  const result = executeSkill(input.skill_name, input.args || "");
  if (!result) return `Unknown skill: ${input.skill_name}`;
  return `[Skill "${input.skill_name}" activated]\n\n${result.prompt}`;
}

Python

# tools.py — skill 工具定义与执行
 
{
    "name": "skill",
    "description": "Invoke a registered skill by name...",
    "input_schema": {
        "type": "object",
        "properties": {
            "skill_name": {"type": "string"},
            "args": {"type": "string"},
        },
        "required": ["skill_name"],
    },
}
 
async def _execute_skill_tool(self, inp: dict) -> str:
    result = execute_skill(inp.get("skill_name", ""), inp.get("args", ""))
    if not result:
        return f"Unknown skill: {inp.get('skill_name', '')}"
    return f'[Skill "{inp.get("skill_name", "")}" activated]\n\n{result["prompt"]}'

模型调用 skill 工具后得到的是展开后的 prompt 文本,在接下来的回合中按这个 prompt 执行任务。本质上是元工具——工具的返回值不是数据,而是指令。

执行模式:inline vs fork

TypeScript

// agent.ts — executeSkillTool
 
private async executeSkillTool(input: Record<string, any>): Promise<string> {
  const result = executeSkill(input.skill_name, input.args || "");
  if (!result) return `Unknown skill: ${input.skill_name}`;
 
  if (result.context === "fork") {
    const tools = result.allowedTools
      ? this.tools.filter(t => result.allowedTools!.includes(t.name))
      : this.tools.filter(t => t.name !== "agent");
    const subAgent = new Agent({
      customSystemPrompt: result.prompt,
      customTools: tools,
      isSubAgent: true,
      permissionMode: "bypassPermissions",
    });
    const subResult = await subAgent.runOnce(input.args || "Execute this skill task.");
    return subResult.text;
  }
 
  return `[Skill "${input.skill_name}" activated]\n\n${result.prompt}`;
}

Python

# agent.py — _execute_skill_tool
 
async def _execute_skill_tool(self, inp: dict) -> str:
    result = execute_skill(inp.get("skill_name", ""), inp.get("args", ""))
    if not result:
        return f"Unknown skill: {inp.get('skill_name', '')}"
 
    if result["context"] == "fork":
        tools = (
            [t for t in self.tools if t["name"] in result["allowed_tools"]]
            if result.get("allowed_tools")
            else [t for t in self.tools if t["name"] != "agent"]
        )
        sub_agent = Agent(
            model=self.model,
            custom_system_prompt=result["prompt"],
            custom_tools=tools,
            is_sub_agent=True,
            permission_mode="bypassPermissions",
        )
        sub_result = await sub_agent.run_once(inp.get("args") or "Execute this skill task.")
        return sub_result["text"] or "(Skill produced no output)"
 
    return f'[Skill "{inp.get("skill_name", "")}" activated]\n\n{result["prompt"]}'

fork 时子 Agent 工具受 allowedTools 白名单约束,没指定则排除 agent 工具防止递归。技能需要多轮工具调用(如代码审查读多个文件)时选 fork,保持主对话干净。

System Prompt 描述

TypeScript

// skills.ts — buildSkillDescriptions
 
export function buildSkillDescriptions(): string {
  const skills = discoverSkills();
  if (skills.length === 0) return "";
 
  const lines = ["# Available Skills", ""];
  const invocable = skills.filter((s) => s.userInvocable);
  const autoOnly = skills.filter((s) => !s.userInvocable);
 
  if (invocable.length > 0) {
    lines.push("User-invocable skills (user types /<name> to invoke):");
    for (const s of invocable) {
      lines.push(`- **/${s.name}**: ${s.description}`);
      if (s.whenToUse) lines.push(`  When to use: ${s.whenToUse}`);
    }
  }
 
  if (autoOnly.length > 0) {
    lines.push("Auto-invocable skills (use the skill tool when appropriate):");
    for (const s of autoOnly) {
      lines.push(`- **${s.name}**: ${s.description}`);
      if (s.whenToUse) lines.push(`  When to use: ${s.whenToUse}`);
    }
  }
 
  lines.push("To invoke a skill programmatically, use the `skill` tool.");
  return lines.join("\n");
}

Python

# skills.py — build_skill_descriptions
 
def build_skill_descriptions() -> str:
    skills = discover_skills()
    if not skills:
        return ""
 
    lines = ["# Available Skills", ""]
    invocable = [s for s in skills if s.user_invocable]
    auto_only = [s for s in skills if not s.user_invocable]
 
    if invocable:
        lines.append("User-invocable skills (user types /<name> to invoke):")
        for s in invocable:
            lines.append(f"- **/{s.name}**: {s.description}")
            if s.when_to_use:
                lines.append(f"  When to use: {s.when_to_use}")
        lines.append("")
 
    if auto_only:
        lines.append("Auto-invocable skills (use the skill tool when appropriate):")
        for s in auto_only:
            lines.append(f"- **{s.name}**: {s.description}")
            if s.when_to_use:
                lines.append(f"  When to use: {s.when_to_use}")
        lines.append("")
 
    lines.append("To invoke a skill programmatically, use the `skill` tool.")
    return "\n".join(lines)

技能分两组展示:用户可调用的加 / 前缀,仅模型可调用的不加。whenToUse 是给模型看的判断条件,决定是否主动触发。Claude Code 还做了 token 预算控制(formatCommandsWithinBudget()),我们跳过——教程场景技能数量有限。


关键设计决策

为什么技能用 Markdown 而非 JSON/YAML? 技能的本体是大段自然语言 prompt。Markdown 的 body 直接就是 prompt 本身,frontmatter 提供结构化元数据。JSON 存储的话 prompt 需要转义换行符和引号,可读性很差。

为什么需要双重调用路径? 只支持 /commit 手动调用不够——用户可能说”帮我提交代码”而不知道有这个技能;只支持模型自动调用也不够——用户有时想精确控制触发时机。两条路径最终汇合到同一个 resolveSkillPrompt(),逻辑不重复。

简化对比总览

维度Claude Codemini-claude
技能来源6 个(managed/project/user/plugin/bundled/MCP)2 个(project + user)
技能加载懒加载 + token 预算控制启动时全量加载 + 缓存
Prompt 替换$ARGUMENTS + ${CLAUDE_SKILL_DIR} + !`shell`$ARGUMENTS + ${CLAUDE_SKILL_DIR}

下一章:让 Agent 先想清楚再动手——Plan Mode,只读规划模式。