首页
/ learn-claude-code s07 深入解析:技能按需加载(Skill Loading)——目录进 system prompt,完整 SKILL.md 只靠 load_skill 按需获取

learn-claude-code s07 深入解析:技能按需加载(Skill Loading)——目录进 system prompt,完整 SKILL.md 只靠 load_skill 按需获取

2026-09-06 15:20:14作者:管翌锬

本篇基于 s07 章节文档 与配套实现 s07_skill_loading/code.py 展开,讲解 learn-claude-code 中「技能(Skill)」知识加载机制:为什么把全部领域文档塞进 system prompt 是反模式、如何用「名称目录 + 按需读取」的两层结构替代它。读完你可以完整理解 SkillLoader 的扫描、frontmatter 解析、回退策略与安全边界,并能在本地跑通这个 300 多行的可运行 harness。

Skill Loading 总体架构:启动时扫描 skills/*/SKILL.md 生成目录进 system prompt,模型调用 load_skill 后才返回完整 SKILL.md

1. 问题:把全部规范文档塞进 system prompt

s07 所处的场景是:项目里有一套 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 组件;
  • 无关内容持续占用输入 token 和上下文窗口,留给代码、对话和工具结果的空间被压缩。

文档中的核心结论可以概括为 Harness 层的一句原则:知识加载应该是「先让模型知道有哪些技能,再按名称读取内容」,而不是「全部预载」。

2. 解决方案:两层加载架构

Skill 两层加载示意:Layer 1 元数据常驻 system prompt,Layer 2 全文经 tool_result 按需注入

启动时,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 可以看到这套设计的意图非常明确:

The system prompt contains a catalog of skill names and descriptions.
The model loads the full SKILL.md only when it calls load_skill.

    skills/                    Startup
    +------------------+       +------------------+
    | code-review/     | ----> | SkillLoader      |
    |   SKILL.md       |       | name + summary   |
    +------------------+       +--------+---------+
                                      v
                                 system prompt catalog

    LLM -- load_skill(name) --> full SKILL.md
     ^                              |
     +--------- tool_result --------+

即 Layer 1(常驻、廉价)只放元数据,Layer 2(按需、昂贵)放全文。这与同仓库早期讲义 agents/s05_skill_loading.py 中的表述一致:「Layer 1 (cheap): skill names in system prompt (~100 tokens/skill);Layer 2 (on demand): full skill body in tool_result」。

3. 技能目录结构与 SKILL.md 规范

每个技能是一个包含 SKILL.md 的目录。当前仓库 skills/ 下有四个真实技能:

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

SKILL.mdYAML frontmatter + Markdown 正文 两部分组成。以 skills/code-review/SKILL.md 为例,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.
---

正文则是完整的工作流说明(检查清单、扫描命令、性能检查项等,全文 150 行左右)。对比 skills/pdf/SKILL.md,其 frontmatter 用 description 一句话写清「做什么 + 何时使用」,正文给出 pdftotext、PyMuPDF、pandoc 等具体命令示例。skills/agent-builder/SKILL.md 则展示了**多行块标量(block scalar)**写法——description: | 下用缩进写多行触发条件与关键词,这正是测试用例专门覆盖的语法(见第 6 节)。

由此可以归纳 SKILL.md 的书写要点:

  • name:技能注册名,load_skill 的查询键;缺省时回退为目录名;
  • description:给模型做「选不选这个技能」决策用的触发描述,务必写清适用场景(如 "Use when user asks to review code..."),缺省时回退为正文首行;
  • 正文:完整的操作指令,模型加载后应当能直接照做。

4. 源码深潜:SkillLoader 的四个方法

SkillLoader 定义在 s07_skill_loading/code.py,构造时立即执行一次扫描:

SKILL_LOADER = SkillLoader(SKILLS_DIR)  # SKILLS_DIR = Path.cwd() / "skills"

4.1 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

几个实现细节值得注意:

  1. 独立的行分隔符:开闭 --- 必须是整行(只允许行尾 \r\n 差异),因此 ---not frontmatter 不会被误识别为 frontmatter;
  2. 块标量中的 --- 不截断description: | 下的缩进 --- 行(缩进后不位于行首为 --- 的语义由 rstrip("\r\n") == "---" 的行匹配决定,块标量内容行带缩进)会被保留在 frontmatter 内一起交给 yaml.safe_load 解析;
  3. YAML 解析失败不抛异常yaml.YAMLError 时回退为空 dict;解析结果不是 dict(如顶层是列表)同样回退为空 dict,正文原样返回。

4.2 scan:注册、回退与安全边界

def scan(self):
    self.skills.clear()
    if not self.skills_dir.exists():
        return
    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,
        }
  • 注册表结构self.skillsname -> {name, description, content} 的字典,content 保存的是 SKILL.md 全文(含 frontmatter),这正是 load_skill 稍后原样返回的内容;
  • 多级回退name 缺失时回退到目录名(manifest.parent.name);description 缺失时回退到正文首行,再用 lstrip("# ") 去掉 Markdown 标题符、" ".join(...split()) 折叠空白,保证目录里每个技能占干净的一行;
  • 安全边界manifest.resolve().is_relative_to(skills_root) 会拒绝解析后逃出 skills/ 根目录的条目——符号链接指向外部的 SKILL.md 会被跳过。这一行为由 tests/test_skill_loading.py 中的 linked-skill 用例显式验证(断言 "linked-skill" not in registry);
  • sorted(...) 保证扫描顺序确定,目录输出稳定。

4.3 catalog:只输出「名称 + 描述」

def catalog(self) -> str:
    if not self.skills:
        return "(no skills found)"
    return "\n".join(
        f"- {skill['name']}: {skill['description']}"
        for skill in self.skills.values()
    )

输出形如:

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

没有任何正文内容——这是「Layer 1 廉价」的关键。

4.4 build_system_prompt:目录如何进入 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."
    )

固定的 Agent 指令 + 扫描得到的技能目录,组成实际传给模型的 system prompt。末尾那句 "Use load_skill to read the full instructions when a skill applies" 是关键提示——它告诉模型目录只是索引,完整说明要靠工具获取。SYSTEM 在模块加载时构建一次(code.py#L136),随后在整个会话中保持不变。

5. load_skill 工具:name 是注册表键,不是文件路径

load_skill 与 bash、read_file 等基础工具一起注册在 TOOLS 中:

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

处理器直接映射到加载器的 load 方法(code.py#L212-L219):

TOOL_HANDLERS = {
    ...
    "load_skill": SKILL_LOADER.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}"

两个设计要点:

  1. name 用于查询启动时建立的注册表,不会被当作文件路径。这消除了「模型可以借 load_skill 读任意路径」的越权面——即使模型传一个伪造的路径字符串,dict.get 查不到就只会收到错误信息,而错误信息里还附上了可用技能列表,帮助模型自我纠正;
  2. 工具返回后走原有 Agent Loopagent_loopcode.py#L328-L356)在 stop_reason == "tool_use" 时逐个执行 tool_use 块,把输出包装成 tool_result 追加为 user 消息:
results.append({
    "type": "tool_result",
    "tool_use_id": block.id,
    "content": output,
})
messages.append({"role": "user", "content": results})

于是完整的 SKILL.md 以对话消息的形式进入上下文,而不是污染 system prompt——后续轮次模型都能「记得」已加载的技能全文,但下一会话重新开始时上下文又是干净的,实现真正的按会话按需付费(token 意义上)。

此外,load_skill 与其他工具一样会经过 PreToolUse/PostToolUse 钩子(权限检查、日志、大输出告警),见 code.py#L313-L325execute_tool。s07 的 TOOLS 列表恰好只含 6 个工具(bash、read_file、write_file、edit_file、glob、load_skill),这一点也有测试断言保证(tests/test_skill_loading.py#L96-L107)。

6. 测试如何验证「目录小而全文按需」

tests/test_skill_loading.py 用临时目录 + 伪造的 anthropic/dotenv 模块加载讲义模块,不依赖真实 API。核心用例 test_catalog_stays_small_and_load_skill_returns_the_full_file 断言了三层事实:

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   # 全文未进 system prompt
assert lesson.SKILL_LOADER.load("code-review") == manifest  # load 返回全文
assert lesson.TOOL_HANDLERS"load_skill" == manifest

即:system prompt 中只有目录行,UNIQUE_FULL_INSTRUCTION(模拟正文)绝不出现;而 load_skill 经工具处理器调用时返回与文件逐字节一致的 manifest

其余用例覆盖了健壮性边界:

  • test_skill_frontmatter_requires_standalone_delimiters---not frontmatter 不被识别;块标量 description: | 内含 --- 时仍正确解析(含 CRLF 变体);
  • test_skill_frontmatter_falls_back_for_invalid_or_empty_metadata:空 name:/description: 时回退目录名与正文首行;name: [bad](非字符串类型)时安全回退;符号链接技能被排除。

7. 运行与观察

依赖见 requirements.txtanthropic>=0.25.0python-dotenv>=1.0.0pyyaml>=6.0。运行时需要 MODEL_ID 环境变量(以及 ANTHROPIC_BASE_URL 等常规 API 配置)。

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

进入交互界面(s07 >> 提示符,输入 q 退出)后,文档建议依次尝试这些 prompt:

  1. What skills are available?
  2. Load the code-review skill and follow its instructions
  3. Review README.md and load the relevant skill first

观察点:

  • 首轮 system prompt 中是否只有技能目录(四个技能的 name + description);
  • 调用 load_skill 后消息流中是否出现完整的 SKILL.md 内容(作为 tool_result);
  • 加载 code-review 技能后再让模型评审,它是否真的照技能正文的检查清单执行安全、正确性、性能、可维护性各节的检查。

8. 设计要点回顾与后续演进

s07 机制可以归纳为四条工程经验:

  1. 索引与内容分离:常驻上下文只放「名称 + 触发描述」,全文延迟到 load_skill 调用时刻,token 成本从 O(全部技能全文) 降为 O(命中技能全文);
  2. 元数据回退链name → 目录名,description → 正文首行,保证即使 frontmatter 残缺,技能也能以可用形态出现在目录中;
  3. 查询键而非路径load_skill(name) 查注册表而非读文件,天然规避路径穿越,且错误响应自带可用列表;
  4. 结果走标准 tool_result 通道:不修改 system prompt,不引入新的消息类型,完全复用 s01 建立的 Agent Loop。

顺着这条线,s07 也暴露了它的下一课:随着工具调用增加(load_skill 返回的全文本身也可能很长),messages[] 会积累较早的文件内容和工具结果。下一章节 s08 Context Compact 的主题就是缩短较早的消息,为后续调用保留上下文空间——按需加载解决了「不该进的内容不进入」,而 compact 解决「进入后如何回收」。

(本文基于 learn-claude-code 仓库 s07 章节,代码文件路径与测试断言均以当前仓库实际内容为准。)

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