首页
/ learn-claude-code 技能按需加载机制:SkillLoader、SKILL.md 与 load_skill 工具实现解析

learn-claude-code 技能按需加载机制:SkillLoader、SKILL.md 与 load_skill 工具实现解析

2026-09-04 20:44:45作者:盛欣凯Ernestine

本篇围绕 learn-claude-code 教程的 s07 章节(s07_skill_loading/README.md)展开,讲清楚 Agent harness 中「技能目录先行、全文按需加载」这一知识注入机制的设计动机、SkillLoader 扫描与 frontmatter 解析细节、build_system_promptload_skill 工具的接线方式,并给出可复制的运行步骤、测试用例级验证方法,以及该机制在完整 harness(s15)中的延续。读完后你能在自己的 Agent 项目里独立实现一套两层式技能加载系统。

Skill 加载机制总览:启动时扫描 skills 目录生成目录,模型调用 load_skill 后返回完整 SKILL.md

问题:把所有规范塞进 system prompt 的代价

假设一个项目里有 React 组件规范、SQL 风格指南和 API 设计文档三份资料,希望 Agent 开发时遵守。最直觉的做法是全部拼进 system prompt:

SYSTEM = (
    f"You are a coding agent. "
    + open("docs/react-style.md").read()
    + open("docs/sql-style.md").read()
    + open("docs/api-design.md").read()
)

这种做法的缺陷在于:三份文档被固定进 system prompt,无法按当前任务挑选。每次调用 LLM 时,三份全文都会随请求发出——哪怕当前任务只是改一个 React 组件,SQL 指南和 API 设计文档也照样消耗输入 token 和上下文窗口,挤压本可以留给代码、对话和工具结果的空间。技能(skill)本质上就是「领域知识文档」,因此它不应该默认常驻上下文,而应该先让模型知道有哪些技能可用,用到时再取全文

解决方案:两层知识注入

s07 的核心方案是启动时只做「目录登记」,全文延迟到工具调用:

  • 启动时SkillLoader 扫描 skills/*/SKILL.md,读取 YAML frontmatter 中的 namedescription,把这份目录(catalog)写进 system prompt;
  • 运行时:模型判断需要某个技能的完整说明时,调用 load_skill(name);返回的完整 SKILL.md 内容作为 tool_result 追加到消息列表,随下一轮请求进入模型上下文。

两层注入的落点与触发时机如下(直接来自文档):

内容 进入模型的位置 何时加入
技能名称和描述 system prompt 启动时
完整 SKILL.md tool_result 调用 load_skill

实现文件是 s07_skill_loading/code.py。其模块 docstring 用 ASCII 图画出了完整数据流:skills/ 目录在启动时流入 SkillLoader(只保留 name + summary),生成 system prompt 中的目录;之后 LLM -- load_skill(name) --> 完整 SKILL.md,结果再经 tool_result 回到 messages[]

技能目录结构与 SKILL.md 格式

每个技能是一个包含 SKILL.md 的目录。本仓库自带的四个技能正好可以作为格式样本:

skills/
  agent-builder/SKILL.md
  code-review/SKILL.md
  mcp-builder/SKILL.md
  pdf/SKILL.md

查看 skills/code-review/SKILL.md 可以确认标准格式——文件顶部是一段 YAML frontmatter,随后才是正文:

---
name: code-review
description: Perform thorough code reviews with security, performance,
  and maintainability analysis. Use when user asks to review code, check
  for bugs, or audit a codebase.
---

# Code Review Skill
...

description 的写法值得注意:不只说「这个技能做什么」,还写了「什么时候该用它」(Use when...),这正是让模型在仅有目录信息时做出正确 load_skill 决策的关键。skills/agent-builder/SKILL.md 则展示了多行 description: | 块标量写法,把多条适用场景逐条列出。

SkillLoader 实现剖析

扫描技能:scan()

scan() 在构造 SkillLoader 时被调用(见 s07_skill_loading/code.py),负责把目录变成注册表:

class SkillLoader:
    def scan(self):
        self.skills.clear()
        skills_root = self.skills_dir.resolve()
        for manifest in sorted(self.skills_dir.glob("*/SKILL.md")):
            if (not manifest.is_file()
                    or not manifest.resolve().is_relative_to(skills_root)):
                continue
            content = manifest.read_text()
            metadata, body = self.parse_frontmatter(content)
            raw_name = metadata.get("name")
            name = raw_name.strip() if isinstance(raw_name, str) else ""
            name = name or manifest.parent.name
            raw_description = metadata.get("description")
            description = (raw_description.strip()
                           if isinstance(raw_description, str) else "")
            description = description or body.split("\n", 1)[0]
            description = " ".join(str(description).lstrip("# ").split())
            self.skills[name] = {
                "name": name,
                "description": description,
                "content": content,
            }

从源码结构看,这里有几处精心设计:

  1. 安全边界manifest.resolve().is_relative_to(skills_root) 会拒绝符号链接指向 skills/ 之外的文件。测试 tests/test_skill_loading.py 中专门构造了 linked-skill(软链到 skills 目录外部的文件)并断言它不会进入注册表;
  2. name 兜底:frontmatter 缺 name 或非字符串时,回退为目录名(manifest.parent.name),保证技能总能以稳定名字被引用;
  3. description 兜底:缺 description 时取正文第一行,并做 lstrip("# ") 去标题符号、压缩空白,避免目录中出现 # Title 这种原始 Markdown 形态;
  4. 注册表结构:每个技能存 name / description / content 三个字段,content整份文件的原始文本,这就是之后 load_skill 返回的内容。

frontmatter 解析:parse_frontmatter()

@staticmethod
def parse_frontmatter(text: str) -> tuple[dict, str]:
    lines = text.splitlines(keepends=True)
    if not lines or lines[0].rstrip("\r\n") != "---":
        return {}, text
    closing_index = next(
        (index for index, line in enumerate(lines[1:], start=1)
         if line.rstrip("\r\n") == "---"),
        None,
    )
    if closing_index is None:
        return {}, text
    frontmatter = "".join(lines[1:closing_index])
    body = "".join(lines[closing_index + 1:]).strip()
    try:
        metadata = yaml.safe_load(frontmatter) or {}
    except yaml.YAMLError:
        metadata = {}
    if not isinstance(metadata, dict):
        metadata = {}
    return metadata, body

实现是逐行匹配独立成行的 --- 分隔符(rstrip("\r\n") 兼容 CRLF),而非 re.match(r"^---\n...") 这类正则——这一点有测试背书:tests/test_skill_loading.pytest_skill_frontmatter_requires_standalone_delimiters 验证了 ---not frontmatter 这种行首混排不会误判为 frontmatter,而 description: | 块标量里出现的 --- 行不会提前截断 frontmatter。解析失败(YAML 错误、解析结果不是 mapping)时统一回退为 {},整个文件按正文处理,不会让一个格式错误的技能文件弄崩启动流程。

目录输出:catalog()

catalog() 只输出名称和描述两列,这就是注入 system prompt 的全部内容:

- code-review: Perform thorough code reviews...
- pdf: Process PDF files...

空目录时返回 (no skills found)(见 s07_skill_loading/code.py)。

组装 system prompt

def build_system_prompt() -> str:
    return (
        f"You are a coding agent at {WORKDIR}. Use tools to solve tasks. "
        "Act, don't explain.\n\n"
        f"Skills available:\n{SKILL_LOADER.catalog()}\n\n"
        "Use load_skill to read the full instructions when a skill applies."
    )

这个函数把两部分拼成最终 system prompt:固定的 Agent 指令(工作目录、行为约束)+ 启动时扫描得到的技能目录,并用一句显式指令告诉模型「技能适用时先调 load_skill 读全文」——这是保证模型知道「目录只是索引、要用需加载」的关键提示词。SYSTEM = build_system_prompt() 在模块导入时执行一次(s07_skill_loading/code.py),之后每轮 agent_loop 都以同一份 SYSTEM 调用 API,因此目录部分在整个会话中保持恒定,不随工具调用增长。

load_skill 工具接线

加载全文:load()

def load(self, name: str) -> str:
    skill = self.skills.get(name)
    if skill:
        return skill["content"]
    available = ", ".join(self.skills) or "none"
    return f"Error: Unknown skill '{name}'. Available: {available}"

两个实现细节值得强调(原文档特别指出了这一点):

  • name查询启动时注册的注册表,不会被解释成文件路径——模型无法借这个工具读取任意文件,这是一个天然的越权防护;
  • 未知技能名不抛异常,而是返回带可用技能清单的错误字符串,模型读到 Error: Unknown skill 'xxx'. Available: code-review, pdf... 后可以自我纠正并改用正确名字。

工具注册与 Agent Loop

load_skill 与其他五个基础工具一起注册在 TOOLS 定义和 TOOL_HANDLERS 映射中(见 s07_skill_loading/code.py):

{"name": "load_skill", "description": "Load the full SKILL.md content by skill name.",
 "input_schema": {"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]}},
...
TOOL_HANDLERS = {
    ...
    "load_skill": SKILL_LOADER.load,
}

s07 的工具面刻意保持最小:bashread_filewrite_fileedit_fileglobload_skill,正好被测试 test_s07_exposes_only_base_tools_and_load_skilltests/test_skill_loading.py)锁死。

load_skill 返回后,无需任何特殊处理——既有的 Agent Loop 会像处理其他工具一样把结果追加为 tool_result 消息(s07_skill_loading/code.py):

results = []
for block in response.content:
    if block.type != "tool_use":
        continue
    output = execute_tool(block)
    results.append({
        "type": "tool_result",
        "tool_use_id": block.id,
        "content": output,
    })
messages.append({"role": "user", "content": results})

完整的 SKILL.md 就这样进入了上下文,成为模型后续几轮行动的「临时专家知识」。s07 还附带了 hooks(PreToolUse 权限拦截、PostToolUse 大输出告警等),但它们与本节主题无关,此处从略。

运行与验证

环境准备

依赖声明在 requirements.txt 中:anthropic>=0.25.0python-dotenv>=1.0.0pyyaml>=6.0。脚本通过 load_dotenv 读取 .env,要求其中配置模型标识;从源码看还需要环境变量 MODEL_IDos.environ["MODEL_ID"]s07_skill_loading/code.py),可选 ANTHROPIC_BASE_URL 指定 API 端点。

试一下

cd learn-claude-code
python s07_skill_loading/code.py

按文档建议依次输入这三个 prompt 观察行为差异:

  1. What skills are available? —— 模型应仅凭 system prompt 中的目录作答,不应先调用任何工具;
  2. Load the code-review skill and follow its instructions —— 应观察到一次 load_skill("code-review") 调用,且返回内容就是 skills/code-review/SKILL.md 的完整文本(安全检查清单、输出格式模板、常见反模式等);
  3. Review README.md and load the relevant skill first —— 模型应自行判断「相关技能是 code-review」,先 load_skill 再动手。

验证目标(原文档给出的检查点):system prompt 中只有技能目录;完整的 SKILL.md 内容只在 load_skill 调用之后才出现在消息列表中。

用测试用例做自动化验证

tests/test_skill_loading.py 不需要真实 API 就能验证上述行为(它用假的 anthropic/dotenv 模块加载课程脚本),其中最核心的用例 test_catalog_stays_small_and_load_skill_returns_the_full_file 构造了一个含 UNIQUE_FULL_INSTRUCTION 的临时技能,然后断言:

assert lesson.SKILL_LOADER.catalog() == (
    "- code-review: Review code for bugs, regressions, and missing tests."
)
assert "code-review" in lesson.SYSTEM
assert "UNIQUE_FULL_INSTRUCTION" not in lesson.SYSTEM   # 目录层不含正文
assert lesson.SKILL_LOADER.load("code-review") == manifest  # load 返回完整文件
assert lesson.TOOL_HANDLERS"load_skill" == manifest

即「目录层小、加载层全」这一核心契约被逐条钉死。另外 test_skill_frontmatter_falls_back_for_invalid_or_empty_metadatatests/test_skill_loading.py)验证了 frontmatter 缺失/非法时 name 回退目录名、description 回退正文首行的行为。本地可直接运行:

python -m pytest tests/test_skill_loading.py -v

机制的延伸:变体与在完整 harness 中的延续

本仓库里还存在同一思想的另一版实现 agents/s05_skill_loading.py,可以对比着看差异:它用正则解析 frontmatter、用 rglob 递归扫描、目录行可附带 [tags] 标签,且 load_skill 返回时用 <skill name="...">...</skill> 标签包裹正文——给模型一个显式的「这是一份技能文档」的边界标记。s07 教学版则换成逐行分隔符解析和单层 glob,逻辑更直白。两种变体的核心契约一致:load_skill 都只查注册表、拒绝未知名时列出可用技能。

该机制并未止步于教学章节。在集成版 harness s15_integrated_harness/code.py 中,load_skill 作为独立工具函数继续存在(第 726 行定义,第 2703 行注册进工具表),system prompt 里同样保留「Use load_skill(name) when a skill is relevant」这类指引——说明「目录先行 + 按需加载」是贯穿整个 learn-claude-code 课程线的知识加载基线。测试文件也会同时加载 s07 与 s15 两个脚本跑 frontmatter 用例(SKILL_LESSONS 元组,tests/test_skill_loading.py),确保两版实现行为一致。

从工程视角看,这套设计回答了「如何让 Agent 拥有可扩展的领域知识」:新增技能 = 新增一个 skills/<name>/SKILL.md 目录 + 写好 frontmatter,无需改任何代码;system prompt 体积只随技能数量线性增加(每条只多一行),而不随技能篇幅增长。

下一步

load_skill 加载的全文会作为 tool_result 留在 messages[] 里,随着工具调用不断累积,上下文会持续膨胀。s08 Context Compact 正是解决这个问题的:缩短较早的消息,为后续调用保留上下文空间,可继续阅读 s08_context_compact/README.md

登录后查看全文
热门项目推荐
相关项目推荐