Skip to content

s09 — Skills:两层知识注入

"Know what's available; load how on demand" · 预计阅读 20 分钟

核心洞察:50 个 token 的技能名称就能让模型知道"有这个能力",5000 token 的完整内容只在需要时才加载——这是两层注入的精髓。

Key Takeaways

  • 两层注入 — Layer 1: ~50 token 技能名进 system prompt; Layer 2: 完整 SKILL.md 按需加载到 tool_result
  • 六种来源 — bundled / user / project / MCP / dynamic / conditional
  • 路径条件激活 — 基于文件路径的 Skill 条件加载,减少无关信息
  • Context Engineering = Select — Skill 系统是"选择放入什么"的典型实现

安全提示

ToxicSkills 研究 (Snyk) 发现了针对 Agent Skills 的供应链攻击,恶意 Skill 可注入有害指令。安装第三方 Skill 前务必审查内容。

问题

如何让 agent 拥有可扩展的领域知识?

Agent 的 system prompt 里不可能塞下所有领域的知识。如果你的 agent 需要知道怎么用 Claude API、怎么创建 PR、怎么做代码审查、怎么循环执行任务,把这些知识全部硬编码在 prompt 里会导致 token 爆炸——每次对话都要为不相关的知识付费。

但如果什么都不放,agent 就不知道自己"会"什么。用户问"帮我做个代码审查",agent 可能自己临时编一套流程,而不是使用经过验证的最佳实践。

Claude Code 用两层注入解决这个问题:

  1. Layer 1 — 名称层:所有 Skill 的名称 + 描述放入 system prompt,让模型知道"我有哪些能力"。成本低(每个 Skill 只占 ~50 tokens)。
  2. Layer 2 — 内容层:只有当 Skill 被调用时,才加载完整的 SKILL.md 内容。成本高(一个 Skill 可能 5-20KB),但按需付费。

这种设计让 Claude Code 可以注册几十个 Skill,而系统 prompt 只增加几百个 token。

架构图

核心机制

Skill 的结构

每个 Skill 是一个目录,包含一个 SKILL.md 文件:

my-skill/
└── SKILL.md

SKILL.md 使用 YAML frontmatter + Markdown 正文:

markdown
---
name: Deploy Helper
description: Guides deployment to staging and production environments
when_to_use: >
  Use when the user asks to deploy, release, or push to staging/prod.
  Also trigger when discussing CI/CD pipeline configuration.
allowed-tools: Bash, Read, Write
model: claude-sonnet-4-20250514
argument-hint: "<environment> [--dry-run]"
user-invocable: true
---

# Deploy Helper

When deploying, follow these steps:
1. Run pre-deploy checks...
2. ...

关键的 frontmatter 字段:

字段用途默认值
name显示名称目录名
descriptionLayer 1 显示的描述从 Markdown 正文提取
when_to_use模型自动调用的触发条件无(仅用户 /skill 调用)
allowed-tools执行时可用的工具白名单全部工具
model执行时使用的模型继承主模型
user-invocable用户是否可以通过 /skill 调用true
paths条件激活的文件路径模式无(始终加载)
context执行上下文:inline(默认)或 forkinline
hooksSkill 自带的 hooks 配置

context: fork 模式下 Skill 在子 agent 中执行,有独立的上下文和工具集。适合长时间运行的复杂 Skill(如代码审查),不影响主会话上下文。

Layer 1: 名称层注入

启动时,Claude Code 从多个来源扫描所有 Skill:

  1. Policy Skills<managed-path>/.claude/skills/ — 企业策略 Skill
  2. 用户 Skills~/.claude/skills/ — 个人 Skill
  3. 项目 Skills.claude/skills/(从当前目录向上遍历到 home)
  4. 内置 Skills:编译进二进制的 Skill(src/skills/bundled/
  5. MCP Skills:从 MCP server 的 skill/ 资源发现

扫描结果去重后,每个 Skill 的 名称 + description + when_to_use 被注入到 system prompt 中。这就是 Layer 1——让模型知道"我有哪些 Skill 可用"。

Layer 1 的 token 成本很低。estimateSkillFrontmatterTokens() 只估算 name + description + whenToUse 的 token 数,通常每个 Skill 不超过 50-100 tokens。即使注册了 30 个 Skill,也只增加 ~3000 tokens。

优先级冲突解析:去重通过 getFileIdentity()(基于 realpath)实现,检测的是同一文件的不同路径引用。如果 project 和 user 目录各有同名但不同内容的 Skill,行为取决于加载顺序(先加载的 wins)。

Layer 2: 内容层加载

当 Skill 被调用时(用户输入 /skill-name 或模型通过 SkillTool 调用),才加载完整的 SKILL.md 内容。加载流程:

  1. 读取 SKILL.md 全文
  2. 如果有 baseDir(Skill 所在目录),在内容前面加上 Base directory for this skill: <dir>
  3. 替换参数占位符(${1}, ${CLAUDE_SKILL_DIR}, ${CLAUDE_SESSION_ID}
  4. 执行内联 shell 命令(! 前缀的代码块)——注意:MCP Skill 跳过此步骤,安全考虑
  5. 注入到当前对话上下文

内置 Skills

Claude Code 预装了一批核心 Skill,编译进二进制:

Skill功能源码
remember保存/检索记忆bundled/remember.ts
loop定时循环执行命令bundled/loop.ts
verify验证工作是否完成bundled/verify.ts
claude-apiClaude API / SDK 使用指南bundled/claudeApi.ts
debug系统化调试流程bundled/debug.ts
simplify审查代码质量和复用bundled/simplify.ts
update-config配置 settings.jsonbundled/updateConfig.ts
batch批量处理多个文件/任务bundled/batch.ts
stuck卡住时的自我诊断和恢复bundled/stuck.ts
skillify将对话中的模式转化为新 Skillbundled/skillify.ts
keybindings快捷键配置和说明bundled/keybindings.ts

以上为常见内置 Skill,完整列表包括但不限于上述条目。Claude Code 团队会持续新增内置 Skill,具体以 src/skills/bundled/ 目录下的源码为准。

内置 Skill 通过 registerBundledSkill() 注册。它们也支持 files 字段——额外的参考文件会在首次调用时解压到磁盘(getBundledSkillExtractDir()),模型可以按需 Read。

动态 Skill 发现

当模型读取或编辑文件时,Claude Code 会检查文件所在目录链上是否有 .claude/skills/ 目录。如果有,自动加载其中的 Skill:

# 用户编辑 src/services/deploy/handler.ts
# Claude Code 检查:
#   src/services/deploy/.claude/skills/  → 发现并加载
#   src/services/.claude/skills/         → 检查
#   src/.claude/skills/                  → 检查
# 注意:项目根目录的 .claude/skills/ 在启动时已加载,不重复

这让 monorepo 中的不同子项目可以有自己的 Skill 集合。

条件 Skill(Paths Frontmatter)

Skill 可以声明 paths frontmatter,只在操作匹配文件时激活:

yaml
---
name: React Component Guide
description: Best practices for React component development
paths: src/components/**
---

激活使用 ignore 库(gitignore 风格的匹配)。一旦激活,Skill 在整个会话中保持可用。

MCP 衍生 Skill

MCP server 可以通过 skill/ 资源前缀暴露 Skill。Claude Code 从 MCP server 发现这些资源,用与本地 Skill 相同的 createSkillCommand() + parseSkillFrontmatterFields() 创建 Skill 命令。

关键安全限制:MCP Skill 的 Markdown 正文中的内联 shell 命令(!...)**不会被执行**——loadedFrom !== 'mcp'` 检查确保远程内容无法在本地执行任意命令。

Skill 在压缩中的保留

从 s07 我们知道,压缩会清除旧的上下文。但已调用的 Skill 不应该丢失——用户可能在压缩后继续使用同一个 Skill。

createSkillAttachmentIfNeeded() 在压缩后重新注入已调用 Skill 的内容:

  • 按最近使用排序
  • 每个 Skill 最多 5K tokens(截断保留头部——使用说明通常在开头)
  • 总共最多 25K tokens

Python 伪代码

两层注入的核心逻辑:

python
# Skills 两层注入(精简版)

# Layer 1: 名称层 — 随 system prompt 发送(~50 tokens/skill)
def inject_skill_names(system_prompt, skills):
    """只注入名称和一句话描述,不加载完整内容"""
    menu = "\n".join(f"- {s.name}: {s.description}" for s in skills)
    system_prompt += f"\nAvailable skills:\n{menu}"
    # 模型现在知道"有这些技能可用",但不知道具体怎么用

# Layer 2: 内容层 — 模型调用 /skill 时才加载(~5K tokens/skill)
def load_skill_on_demand(skill_name):
    """读取完整 SKILL.md,作为 tool_result 注入"""
    skill = find_skill(skill_name)
    content = read_file(skill.path)          # 完整 Markdown 内容
    content = truncate(content, max=5000)     # 截断保留头部
    return {"type": "tool_result", "content": content}

# 来源优先级: project > user > bundled > MCP

完整参考实现(含六种来源、路径条件激活、compact 恢复):

展开查看完整 Python 伪代码(671 行)
python
"""
Claude Code Skills 系统的 Python 参考实现。

两层注入:
  Layer 1 — 名称层:skill 名称 + 描述注入 system prompt
  Layer 2 — 内容层:调用时按需加载完整 SKILL.md
"""

import os
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional, Callable, Awaitable


# ─── 常量 ──────────────────────────────────────────────

SKILL_FILENAME = "SKILL.md"
POST_COMPACT_MAX_TOKENS_PER_SKILL = 5_000
POST_COMPACT_SKILLS_TOKEN_BUDGET = 25_000


# ─── 数据结构 ──────────────────────────────────────────

@dataclass
class SkillFrontmatter:
    """SKILL.md 的 YAML frontmatter 解析结果"""
    name: Optional[str] = None
    description: str = ""
    when_to_use: Optional[str] = None
    allowed_tools: list[str] = field(default_factory=list)
    argument_hint: Optional[str] = None
    model: Optional[str] = None
    user_invocable: bool = True
    disable_model_invocation: bool = False
    paths: Optional[list[str]] = None
    context: Optional[str] = None  # "inline" | "fork"
    version: Optional[str] = None


@dataclass
class Skill:
    """一个完整的 Skill 定义"""
    name: str                    # 技能名(用户通过 /name 调用)
    description: str             # Layer 1 显示的描述
    when_to_use: Optional[str]   # 模型自动调用的触发条件
    content: str                 # 完整的 Markdown 内容
    content_length: int          # 内容长度(用于 token 估算)
    source: str                  # "bundled" | "userSettings" | "projectSettings"
    loaded_from: str             # "bundled" | "skills" | "mcp" | "commands_DEPRECATED"
    base_dir: Optional[str]      # Skill 目录路径
    frontmatter: SkillFrontmatter = field(default_factory=SkillFrontmatter)

    # Layer 1 token 估算
    @property
    def frontmatter_tokens(self) -> int:
        """估算名称层占用的 tokens"""
        text = " ".join(filter(None, [
            self.name,
            self.description,
            self.when_to_use,
        ]))
        return len(text) // 4  # ~4 chars/token

    # Layer 2 加载
    async def get_prompt(self, args: str = "") -> str:
        """
        加载完整的 Skill 内容(Layer 2)。
        包括参数替换、目录前缀、shell 命令执行。
        """
        content = self.content

        # 添加 base directory 前缀
        if self.base_dir:
            content = f"Base directory for this skill: {self.base_dir}\n\n{content}"

        # 参数替换
        if args:
            content = substitute_arguments(content, args)

        # 替换内置变量
        if self.base_dir:
            content = content.replace("${CLAUDE_SKILL_DIR}", self.base_dir)

        # MCP Skill 不执行 shell 命令(安全限制)
        if self.loaded_from != "mcp":
            content = await execute_shell_in_prompt(content)

        return content


@dataclass
class InvokedSkill:
    """已调用的 Skill 记录(用于压缩后恢复)"""
    skill_name: str
    skill_path: str
    content: str
    invoked_at: float  # timestamp


# ─── Frontmatter 解析 ──────────────────────────────────

def parse_skill_frontmatter(content: str) -> tuple[SkillFrontmatter, str]:
    """解析 SKILL.md 的 YAML frontmatter"""
    if not content.startswith("---"):
        return SkillFrontmatter(), content

    end = content.find("---", 3)
    if end == -1:
        return SkillFrontmatter(), content

    yaml_block = content[3:end].strip()
    body = content[end + 3:].strip()

    # 简化的 YAML 解析
    data = {}
    for line in yaml_block.split("\n"):
        if ":" in line:
            key, value = line.split(":", 1)
            data[key.strip()] = value.strip()

    # 解析 allowed-tools
    allowed_tools = []
    raw_tools = data.get("allowed-tools", "")
    if raw_tools:
        allowed_tools = [t.strip() for t in raw_tools.split(",")]

    # 解析 paths
    paths = None
    raw_paths = data.get("paths")
    if raw_paths:
        patterns = [p.strip() for p in raw_paths.split(",")]
        # 去除 /** 后缀,ignore 库会自动匹配子路径
        patterns = [
            p[:-3] if p.endswith("/**") else p
            for p in patterns
        ]
        if patterns and not all(p == "**" for p in patterns):
            paths = patterns

    return (
        SkillFrontmatter(
            name=data.get("name"),
            description=data.get("description", ""),
            when_to_use=data.get("when_to_use"),
            allowed_tools=allowed_tools,
            argument_hint=data.get("argument-hint"),
            model=data.get("model"),
            user_invocable=data.get("user-invocable", "true").lower() != "false",
            paths=paths,
            context=data.get("context"),
        ),
        body,
    )


# ─── Skill 加载器 ──────────────────────────────────────

async def load_skills_from_dir(
    skills_dir: str,
    source: str = "projectSettings",
) -> list[Skill]:
    """
    从 /skills/ 目录加载所有 Skill。
    只支持目录格式:skill-name/SKILL.md
    """
    skills_path = Path(skills_dir)
    if not skills_path.exists():
        return []

    skills = []
    for entry in skills_path.iterdir():
        if not entry.is_dir():
            continue

        skill_file = entry / SKILL_FILENAME
        if not skill_file.exists():
            continue

        try:
            content = skill_file.read_text()
            frontmatter, markdown_content = parse_skill_frontmatter(content)

            skill = Skill(
                name=entry.name,
                description=(
                    frontmatter.description
                    or extract_description_from_markdown(markdown_content)
                ),
                when_to_use=frontmatter.when_to_use,
                content=markdown_content,
                content_length=len(markdown_content),
                source=source,
                loaded_from="skills",
                base_dir=str(entry),
                frontmatter=frontmatter,
            )
            skills.append(skill)
        except Exception:
            continue

    return skills


def extract_description_from_markdown(content: str) -> str:
    """从 Markdown 正文的第一段提取描述"""
    lines = content.strip().split("\n")
    for line in lines:
        line = line.strip()
        if line and not line.startswith("#"):
            return line[:200]
    return "Skill"


# ─── 内置 Skill 注册 ───────────────────────────────────

class BundledSkillRegistry:
    """内置 Skill 注册表"""

    def __init__(self):
        self._skills: list[Skill] = []

    def register(
        self,
        name: str,
        description: str,
        get_prompt: Callable[[str], Awaitable[str]],
        when_to_use: Optional[str] = None,
        allowed_tools: list[str] | None = None,
        user_invocable: bool = True,
        files: dict[str, str] | None = None,
    ):
        """
        注册一个内置 Skill。

        files: 额外的参考文件,首次调用时解压到磁盘。
        """
        skill = Skill(
            name=name,
            description=description,
            when_to_use=when_to_use,
            content="",  # 内置 Skill 通过 get_prompt 动态生成
            content_length=0,
            source="bundled",
            loaded_from="bundled",
            base_dir=None,
            frontmatter=SkillFrontmatter(
                allowed_tools=allowed_tools or [],
                user_invocable=user_invocable,
            ),
        )
        self._skills.append(skill)

    def get_all(self) -> list[Skill]:
        return list(self._skills)


# ─── MCP Skill 发现 ────────────────────────────────────

async def discover_mcp_skills(mcp_client) -> list[Skill]:
    """
    从 MCP server 发现 Skill。
    查找 skill/ 前缀的资源,使用相同的 frontmatter 解析逻辑。
    """
    skills = []

    try:
        resources = await mcp_client.list_resources()
        for resource in resources:
            if not resource.uri.startswith("skill/"):
                continue

            content = await mcp_client.read_resource(resource.uri)
            frontmatter, markdown_content = parse_skill_frontmatter(content)

            skill = Skill(
                name=resource.name,
                description=frontmatter.description or resource.description,
                when_to_use=frontmatter.when_to_use,
                content=markdown_content,
                content_length=len(markdown_content),
                source="mcp",
                loaded_from="mcp",  # 标记为 MCP 来源
                base_dir=None,
                frontmatter=frontmatter,
            )
            skills.append(skill)
    except Exception:
        pass

    return skills


# ─── 动态 Skill 发现 ───────────────────────────────────

class DynamicSkillDiscovery:
    """
    动态发现子目录中的 Skill。
    当模型操作文件时,检查文件所在目录链上是否有 .claude/skills/。
    """

    def __init__(self, project_root: str):
        self.project_root = project_root
        self.discovered_dirs: set[str] = set()
        self.dynamic_skills: dict[str, Skill] = {}

    async def discover_for_paths(
        self,
        file_paths: list[str],
    ) -> list[str]:
        """
        为文件路径发现新的 Skill 目录。
        从文件所在目录向上遍历到 project_root(不含)。
        返回新发现的目录列表。
        """
        new_dirs = []

        for file_path in file_paths:
            current = os.path.dirname(file_path)

            while current.startswith(self.project_root + os.sep):
                skill_dir = os.path.join(current, ".claude", "skills")

                if skill_dir not in self.discovered_dirs:
                    self.discovered_dirs.add(skill_dir)
                    if os.path.isdir(skill_dir):
                        # 检查是否被 .gitignore 忽略
                        if not is_gitignored(current, self.project_root):
                            new_dirs.append(skill_dir)

                parent = os.path.dirname(current)
                if parent == current:
                    break
                current = parent

        # 按深度排序(最深的优先,离文件近的覆盖远的)
        new_dirs.sort(key=lambda d: d.count(os.sep), reverse=True)
        return new_dirs

    async def load_discovered_skills(self, dirs: list[str]):
        """加载新发现的 Skill"""
        for skill_dir in dirs:
            skills = await load_skills_from_dir(skill_dir)
            for skill in skills:
                self.dynamic_skills[skill.name] = skill


# ─── 条件 Skill 激活 ───────────────────────────────────

class ConditionalSkillManager:
    """
    管理带 paths frontmatter 的条件 Skill。
    只在操作匹配文件时激活。
    """

    def __init__(self):
        self.pending: dict[str, Skill] = {}   # 待激活
        self.activated: set[str] = set()       # 已激活名称

    def register(self, skill: Skill):
        """注册一个条件 Skill"""
        if skill.frontmatter.paths and skill.name not in self.activated:
            self.pending[skill.name] = skill

    def activate_for_paths(
        self,
        file_paths: list[str],
        cwd: str,
    ) -> list[str]:
        """
        检查文件路径是否匹配任何待激活 Skill 的 paths 模式。
        使用 gitignore 风格的匹配。返回新激活的 Skill 名称。
        """
        activated = []

        for name, skill in list(self.pending.items()):
            if not skill.frontmatter.paths:
                continue

            for file_path in file_paths:
                rel_path = os.path.relpath(file_path, cwd)
                if self._matches_pattern(rel_path, skill.frontmatter.paths):
                    self.activated.add(name)
                    del self.pending[name]
                    activated.append(name)
                    break

        return activated

    def _matches_pattern(
        self, path: str, patterns: list[str]
    ) -> bool:
        """gitignore 风格的路径匹配"""
        import fnmatch
        return any(fnmatch.fnmatch(path, p) for p in patterns)


# ─── Skill 系统主类 ────────────────────────────────────

class SkillSystem:
    """
    Claude Code Skills 系统。

    两层注入:
      Layer 1: 名称 + 描述 → system prompt(始终)
      Layer 2: 完整内容 → 调用时加载(按需)
    """

    def __init__(self, project_root: str, user_home: str = "~"):
        self.project_root = project_root
        self.user_home = os.path.expanduser(user_home)

        self.skills: dict[str, Skill] = {}
        self.invoked_skills: dict[str, InvokedSkill] = {}

        self.bundled_registry = BundledSkillRegistry()
        self.dynamic_discovery = DynamicSkillDiscovery(project_root)
        self.conditional_manager = ConditionalSkillManager()

    async def initialize(self):
        """
        启动时加载所有 Skill。
        优先级:managed > user > project > bundled > mcp
        """
        all_skills = []

        # 1. 用户 Skills
        user_skills_dir = os.path.join(
            self.user_home, ".claude", "skills"
        )
        all_skills.extend(
            await load_skills_from_dir(user_skills_dir, "userSettings")
        )

        # 2. 项目 Skills(从 cwd 向上遍历)
        current = self.project_root
        while True:
            project_skills_dir = os.path.join(current, ".claude", "skills")
            all_skills.extend(
                await load_skills_from_dir(project_skills_dir, "projectSettings")
            )
            parent = os.path.dirname(current)
            if parent == current or parent == self.user_home:
                break
            current = parent

        # 3. 内置 Skills
        all_skills.extend(self.bundled_registry.get_all())

        # 4. 去重(通过 realpath 检测同一文件的不同路径)
        seen_paths = set()
        for skill in all_skills:
            if skill.base_dir:
                real = os.path.realpath(
                    os.path.join(skill.base_dir, SKILL_FILENAME)
                )
                if real in seen_paths:
                    continue
                seen_paths.add(real)

            # 分离条件 Skill
            if skill.frontmatter.paths:
                self.conditional_manager.register(skill)
            else:
                self.skills[skill.name] = skill

    # ── Layer 1: 名称层注入 ──

    def inject_layer1(self) -> str:
        """
        生成 Layer 1 的 system prompt 片段。
        每个 Skill 一行:名称 + 描述 + 触发条件。
        """
        lines = ["Available skills:"]

        for name, skill in sorted(self.skills.items()):
            if not skill.frontmatter.user_invocable:
                continue

            entry = f"- {name}: {skill.description}"
            if skill.when_to_use:
                entry += f"\n  TRIGGER: {skill.when_to_use}"
            lines.append(entry)

        total_tokens = sum(s.frontmatter_tokens for s in self.skills.values())
        lines.append(f"\n(Layer 1 total: ~{total_tokens} tokens)")

        return "\n".join(lines)

    # ── Layer 2: 内容层加载 ──

    async def invoke_skill(
        self,
        skill_name: str,
        args: str = "",
    ) -> Optional[str]:
        """
        调用一个 Skill(Layer 2 加载)。
        记录调用信息用于压缩后恢复。
        """
        skill = self.skills.get(skill_name)
        if not skill:
            # 检查动态发现的 Skill
            skill = self.dynamic_discovery.dynamic_skills.get(skill_name)
        if not skill:
            return None

        # 加载完整内容
        prompt = await skill.get_prompt(args)

        # 记录调用(用于压缩后恢复)
        import time
        self.invoked_skills[skill_name] = InvokedSkill(
            skill_name=skill_name,
            skill_path=os.path.join(
                skill.base_dir or "", SKILL_FILENAME
            ),
            content=prompt,
            invoked_at=time.time(),
        )

        return prompt

    # ── 动态发现 ──

    async def on_file_operation(self, file_paths: list[str]):
        """
        文件操作时触发动态 Skill 发现和条件激活。
        """
        # 发现新的 Skill 目录
        new_dirs = await self.dynamic_discovery.discover_for_paths(
            file_paths
        )
        if new_dirs:
            await self.dynamic_discovery.load_discovered_skills(new_dirs)

        # 激活条件 Skill
        activated = self.conditional_manager.activate_for_paths(
            file_paths, self.project_root
        )
        for name in activated:
            if name in self.conditional_manager.activated:
                # 从 pending 中取出(已被删除),从 dynamic 或原始列表恢复
                pass

    # ── 压缩后恢复 ──

    def create_compact_skill_attachment(self) -> Optional[dict]:
        """
        创建压缩后的 Skill 恢复附件。
        按最近使用排序,每个限 5K tokens,总共限 25K tokens。
        """
        if not self.invoked_skills:
            return None

        # 按最近使用排序
        sorted_skills = sorted(
            self.invoked_skills.values(),
            key=lambda s: s.invoked_at,
            reverse=True,
        )

        used_tokens = 0
        skills_to_restore = []

        for skill in sorted_skills:
            # 截断到 per-skill token limit
            truncated = truncate_to_tokens(
                skill.content,
                POST_COMPACT_MAX_TOKENS_PER_SKILL,
            )
            tokens = len(truncated) // 4

            if used_tokens + tokens > POST_COMPACT_SKILLS_TOKEN_BUDGET:
                break

            skills_to_restore.append({
                "name": skill.skill_name,
                "path": skill.skill_path,
                "content": truncated,
            })
            used_tokens += tokens

        if not skills_to_restore:
            return None

        return {
            "type": "invoked_skills",
            "skills": skills_to_restore,
        }


# ─── 辅助函数 ──────────────────────────────────────────

def truncate_to_tokens(content: str, max_tokens: int) -> str:
    """
    截断内容到 max_tokens(~4 chars/token)。
    保留头部(使用说明通常在开头),尾部附加截断标记。
    """
    if len(content) // 4 <= max_tokens:
        return content

    marker = (
        "\n\n[... skill content truncated for compaction; "
        "use Read on the skill path if you need the full text]"
    )
    char_budget = max_tokens * 4 - len(marker)
    return content[:char_budget] + marker


def substitute_arguments(content: str, args: str) -> str:
    """替换参数占位符 ${1}, ${2}, ..."""
    parts = args.split() if args else []
    for i, part in enumerate(parts, 1):
        content = content.replace(f"${{{i}}}", part)
    return content


async def execute_shell_in_prompt(content: str) -> str:
    """执行内联 shell 命令(!`...` 或 ```! ... ```)"""
    # 安全:仅本地 Skill 执行,MCP Skill 跳过
    return content  # mock


def is_gitignored(path: str, cwd: str) -> bool:
    """检查路径是否被 .gitignore 忽略"""
    return False  # mock


# ─── 使用示例 ──────────────────────────────────────────

async def main():
    system = SkillSystem(
        project_root="/home/user/my-project",
        user_home="/home/user",
    )

    # 1. 注册内置 Skill
    system.bundled_registry.register(
        name="verify",
        description="Verify work is complete and correct",
        when_to_use="Use after completing a task to verify correctness",
        get_prompt=lambda args: "Run tests, check linting...",
    )

    # 2. 初始化:扫描所有来源
    await system.initialize()

    # 3. Layer 1: 注入名称层到 system prompt
    layer1 = system.inject_layer1()
    print(f"[system prompt]\n{layer1}")
    # Available skills:
    # - verify: Verify work is complete and correct
    #   TRIGGER: Use after completing a task to verify correctness
    # - deploy: Guides deployment to staging and production
    # (Layer 1 total: ~150 tokens)

    # 4. Layer 2: 用户调用时加载完整内容
    content = await system.invoke_skill("verify")
    print(f"[loaded] verify: {len(content)} chars")

    # 5. 文件操作时触发动态发现
    await system.on_file_operation([
        "/home/user/my-project/packages/api/src/handler.ts"
    ])

    # 6. 压缩后恢复
    attachment = system.create_compact_skill_attachment()
    if attachment:
        print(f"[compact] restoring {len(attachment['skills'])} skills")

源码映射

概念真实源码路径说明
Skill 加载主逻辑src/skills/loadSkillsDir.tsgetSkillDirCommands() — 从所有来源加载并去重
目录扫描src/skills/loadSkillsDir.ts:407-480loadSkillsFromSkillsDir() — 只支持 skill-name/SKILL.md 格式
Frontmatter 解析src/skills/loadSkillsDir.ts:185-265parseSkillFrontmatterFields() — 共享解析逻辑
Skill 命令创建src/skills/loadSkillsDir.ts:270-401createSkillCommand() — 含参数替换和 shell 执行
Skill 路径配置src/skills/loadSkillsDir.ts:78-94getSkillsPath() — 各来源的目录路径
Token 估算src/skills/loadSkillsDir.ts:100-105estimateSkillFrontmatterTokens() — 仅估算 frontmatter
内置 Skill 注册src/skills/bundledSkills.tsregisterBundledSkill() + getBundledSkills()
内置 Skill 文件解压src/skills/bundledSkills.ts:131-145extractBundledSkillFiles() — 首次调用时解压
MCP Skill 桥接src/skills/mcpSkillBuilders.ts写入式注册,避免循环依赖
动态发现src/skills/loadSkillsDir.ts:861-915discoverSkillDirsForPaths() — 文件操作时向上遍历
动态加载src/skills/loadSkillsDir.ts:923-975addSkillDirectories() — 加载并合并到动态 Skill 表
条件激活src/skills/loadSkillsDir.ts:997-1058activateConditionalSkillsForPaths() — gitignore 风格匹配
路径解析src/skills/loadSkillsDir.ts:159-178parseSkillPaths() — 去除 /** 后缀
去重src/skills/loadSkillsDir.ts:728-769通过 realpath 检测同一文件的不同路径
压缩后恢复src/services/compact/compact.ts:1494-1534createSkillAttachmentIfNeeded()
内置 Skill 列表src/skills/bundled/verify, loop, remember, claudeApi, debug, simplify 等

设计决策

两层注入 vs OpenCode 单层

维度Claude Code (两层)OpenCode (单层)
Layer 1 (始终可见)名称 + 描述 (~50 tok/skill)名称 + 完整描述
Layer 2 (按需加载)完整 SKILL.md (5-20 KB)按需加载
30 个 Skill 的固定成本~1,500 tokens更高
模型判断依据name + description + when_to_use完整描述

Claude Code 的两层设计更省 token。关键 insight 是:模型只需要知道 Skill 的存在和触发条件就够了,不需要看到完整内容来决定是否调用。when_to_use 字段提供了足够的触发判断依据。

Cursor Rules:Cursor 的 .cursor/rules/ 系统支持 glob 路径匹配的条件激活,类似 Claude Code 的 paths frontmatter。但 Cursor Rules 没有两层注入——所有匹配的 Rules 内容直接注入 system prompt,大量 Rules 时 token 成本更高。

SKILL.md 目录格式 vs 单文件格式

新的 /skills/ 目录只支持 skill-name/SKILL.md 格式,不支持单 .md 文件。好处:

  1. 可扩展:Skill 目录可以放额外的参考文件、测试、脚本
  2. 命名清晰:目录名就是 Skill 名,避免命名冲突
  3. ${CLAUDE_SKILL_DIR} 变量:Skill 可以引用自己目录下的其他文件

旧的 /commands/ 目录仍然支持单文件格式(向后兼容),但不推荐。

MCP Skill 的安全边界

MCP Skill 来自远程 server,属于不受信任的内容。Claude Code 做了一个关键的安全决策:MCP Skill 的 Markdown 正文中的内联 shell 命令不会被执行

typescript
// loadSkillsDir.ts:373-375
if (loadedFrom !== 'mcp') {
    finalContent = await executeShellCommandsInPrompt(finalContent, ...)
}

这防止了一种攻击场景:恶意 MCP server 在 Skill 内容中嵌入 !rm -rf / 这样的命令。

安全:供应链攻击防护

Snyk 的 ToxicSkills 研究发现了 335 个恶意 Skills,影响 300,000+ 用户。攻击向量包括:通过 skill 内容的 prompt injection、通过 shell 命令的数据渗出。Claude Code 的防御措施:

  • MCP 来源的 Skill 禁止 shell 命令(loadedFrom !== 'mcp' 检查,loadSkillsDir.ts:374
  • ${CLAUDE_SKILL_DIR} 参数替换限制在本地 Skill
  • 建议:安装第三方 Skill 前审查 SKILL.md 内容,特别关注 shell 命令

条件激活 vs 始终加载

paths frontmatter 让 Skill 只在相关文件被操作时才出现在模型视野中。这解决了大型 monorepo 的问题——前端组件指南不应该在后端代码操作时出现。

使用 ignore 库(gitignore 风格)做匹配是精心选择的——开发者已经习惯了 gitignore 的模式语法,学习成本为零。

压缩后 Skill 恢复的截断策略

Skill 可能很大(verify=18.7KB, claude-api=20.1KB)。压缩后全量恢复太贵。Claude Code 的策略是:

  • 每个 Skill 限 5K tokens
  • 保留头部,截断尾部——Skill 的使用说明和关键规则通常在开头
  • 附加截断标记告诉模型可以 Read 完整文件
  • 总共限 25K tokens,最近使用的优先

这比简单地丢弃整个 Skill 好得多——头部通常包含了最关键的指导信息。

Why:设计决策与行业上下文

Progressive Disclosure:90 年代 UX 智慧的 AI 应用

Skills 的两层注入(名称层 ~50 token + 按需加载完整内容)是 Progressive Disclosure(渐进披露)策略的实现 [R1-11][R1-12]。这个概念源自 90 年代的 UX 设计原则:不在启动时展示所有功能,而是按需逐步披露。

在 Agent 上下文中,这意味着:模型知道 WHAT is available(便宜,~50 token/skill),按需加载 HOW(贵,数千 token)。这是 token 成本优化的通用模式。

"From the agent's perspective, it's all just tools"

Arcade.dev 指出:Skills、toolkits、functions、MCP servers 对模型而言都是工具 [R2-7]。Skill 和 Tool 的分类是给人看的——对模型而言,它只关心描述是否清晰、行动空间是否完整。Claude Code 的 Skills 系统正是在这一认知上设计的:skill 被加载后,就变成了 system prompt 中的一段指令,与内置工具的描述没有本质区别。

参考来源: AI Positive [R1-12]、Arcade.dev [R2-7]。完整引用见 docs/research/ 目录下的调研报告。

变化表

对比 s08s09 新增
Memory 系统存储跨对话的记忆Skill 系统提供可扩展的领域知识
两层注入架构(名称层 + 内容层)
SKILL.md 格式(YAML frontmatter + Markdown)
多来源加载(bundled / user / project / MCP)
动态发现(文件操作触发子目录扫描)
条件激活(paths frontmatter + gitignore 匹配)
MCP Skill 安全边界(禁止执行 shell 命令)
压缩后 Skill 恢复(截断保留头部)

动手试试

练习 1:实现两层 Skill 注入

实现 SkillSystem 的核心两层架构:

  • initialize():从 ~/.claude/skills/.claude/skills/ 加载所有 Skill
  • inject_layer1():生成 system prompt 片段(名称 + 描述 + when_to_use)
  • invoke_skill():加载完整 SKILL.md 内容,执行参数替换
  • 验证:Layer 1 的 token 占用远小于 Layer 2 的完整加载

练习 2:实现动态发现和条件激活

实现文件操作触发的 Skill 发现:

  • discover_for_paths():从文件路径向上遍历,发现 .claude/skills/ 目录
  • activate_for_paths():检查 paths frontmatter 匹配,激活条件 Skill
  • 去重逻辑:通过 realpath 检测同一文件的不同路径
  • 验证:被 .gitignore 忽略的目录中的 Skill 不被加载

练习 3:实现压缩后 Skill 恢复

实现 create_compact_skill_attachment()

  • invoked_at 降序排列已调用 Skill
  • 每个 Skill 截断到 5K tokens(保留头部)
  • 总共不超过 25K tokens
  • 截断时附加标记告知模型可以 Read 完整文件
  • 验证:最近使用的 Skill 优先恢复

推荐阅读


模拟场景

Skills 按需加载

Skill 名称始终在系统提示词中,完整定义按需加载以节省 token

⚙️ 系统事件
系统提示词组装 — Skills 列表注入:
"Available skills: commit, review-pr, debug, excalidraw..." (仅名称,不含完整内容)
Skill 名称列表注入系统提示词,让 Agent 知道有哪些能力可用,但不占用大量 token
👤 用户消息
/commit
用户通过斜杠命令触发 skill
⚙️ 系统事件
Skill 触发: 用户输入匹配 skill 名称 'commit'
系统检测到用户输入匹配已注册的 skill 名称
⚙️ 系统事件
按需加载完整 Skill 定义:
读取 skills/commit/SKILL.md (2,400 tokens)
注入到当前消息上下文
两层注入策略:名称在系统提示词中始终存在(几十 tokens),完整定义仅在触发时加载(数千 tokens),大幅节省 token
🤖 助手回复
我来检查变更并创建 commit...
Agent 加载了完整的 commit skill 指令,知道该如何规范地执行 commit 流程
🔧 Tool Call · Bash
git status && git diff --stat
按照 skill 定义的流程,先检查变更状态
✅ Tool Result · Bash
M src/auth.ts
M src/types.ts
2 files changed, 45 insertions(+)
检测到 2 个文件有改动
🤖 助手回复
检测到 2 个文件变更,正在生成 commit message...
Skill 指导 Agent 按规范流程操作:先查看变更,再生成语义化 commit message
🔧 Tool Call · Bash
git add src/auth.ts src/types.ts && git commit -m 'feat: add token refresh to auth flow'
按照 Conventional Commits 规范生成 commit message
✅ Tool Result · Bash
[main abc1234] feat: add token refresh to auth flow
Commit 成功创建
🤖 助手回复
已提交。Commit: abc1234
Skill 通过两层注入实现了「知道能做什么」(名称层) 和「知道怎么做」(定义层) 的分离,平衡了 token 开销与能力覆盖

架构决策卡片

设计决策:持久化队友 vs 一次性 Subagent

在 s04 中,Subagent 是临时的:创建、执行一个任务、返回结果、销毁。它们的知识随之消亡。在 s09 中,队友是具有身份(名称、角色)和配置文件的持久化线程。队友可以完成任务 A,然后被分配任务 B,并携带之前学到的所有知识。持久化队友积累项目知识,理解已建立的模式,不需要为每个任务重新阅读相同的文件。

替代方案

  • One-shot subagents (s04 style) are simpler and provide perfect context isolation -- no risk of one task's context polluting another. But the re-learning cost is high: every new task starts from zero. A middle ground (subagents with shared memory/knowledge base) was considered but adds complexity without the full benefit of persistent identity and state.
设计决策:团队配置持久化到 .teams/{name}/config.json

团队结构(成员名称、角色、agent ID)存储在 JSON 配置文件中,而非任何 agent 的内存中。任何 agent 都可以通过读取配置文件发现队友——无需发现服务或共享内存。如果 agent 崩溃并重启,它读取配置即可知道团队中还有谁。这与 s07 的理念一致:文件系统就是协调层。配置文件人类可读,便于手动添加或移除团队成员、调试团队配置问题。

替代方案

  • In-memory team registries are faster but don't survive process restarts and require a central process to maintain. Service discovery (like DNS or a discovery server) is more robust at scale but overkill for a local multi-agent system. File-based config is the simplest approach that works across independent processes.
设计决策:队友获得工具子集,组长获得全部工具

团队组长获得 ALL_TOOLS(包括 spawn、send、read_inbox 等),而队友获得 TEAMMATE_TOOLS(专注于任务执行的精简工具集)。这强制了清晰的职责分离:队友专注于做事(编码、测试、研究),组长专注于协调(创建任务、分配工作、管理沟通)。给队友协调工具会让他们创建自己的子团队或重新分配任务,破坏组长维持连贯计划的能力。

替代方案

  • Giving all agents identical tools is simpler and more egalitarian, but in practice leads to coordination chaos -- multiple agents trying to manage each other, creating conflicting task assignments. Static role-based filtering is predictable and easy to reason about.