learn-claude-code s07 深入解析:技能按需加载(Skill Loading)——目录进 system prompt,完整 SKILL.md 只靠 load_skill 按需获取
本篇基于 s07 章节文档 与配套实现 s07_skill_loading/code.py 展开,讲解 learn-claude-code 中「技能(Skill)」知识加载机制:为什么把全部领域文档塞进 system prompt 是反模式、如何用「名称目录 + 按需读取」的两层结构替代它。读完你可以完整理解 SkillLoader 的扫描、frontmatter 解析、回退策略与安全边界,并能在本地跑通这个 300 多行的可运行 harness。
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. 解决方案:两层加载架构
启动时,SkillLoader 扫描 skills/*/SKILL.md,读取 YAML frontmatter 中的 name 和 description,把这份**目录(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.md 由 YAML 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
几个实现细节值得注意:
- 独立的行分隔符:开闭
---必须是整行(只允许行尾\r\n差异),因此---not frontmatter不会被误识别为 frontmatter; - 块标量中的
---不截断:description: |下的缩进---行(缩进后不位于行首为---的语义由rstrip("\r\n") == "---"的行匹配决定,块标量内容行带缩进)会被保留在 frontmatter 内一起交给yaml.safe_load解析; - 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.skills是name -> {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}"
两个设计要点:
name用于查询启动时建立的注册表,不会被当作文件路径。这消除了「模型可以借 load_skill 读任意路径」的越权面——即使模型传一个伪造的路径字符串,dict.get查不到就只会收到错误信息,而错误信息里还附上了可用技能列表,帮助模型自我纠正;- 工具返回后走原有 Agent Loop。
agent_loop(code.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-L325 的 execute_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.txt:anthropic>=0.25.0、python-dotenv>=1.0.0、pyyaml>=6.0。运行时需要 MODEL_ID 环境变量(以及 ANTHROPIC_BASE_URL 等常规 API 配置)。
cd learn-claude-code
python s07_skill_loading/code.py
进入交互界面(s07 >> 提示符,输入 q 退出)后,文档建议依次尝试这些 prompt:
What skills are available?Load the code-review skill and follow its instructionsReview README.md and load the relevant skill first
观察点:
- 首轮 system prompt 中是否只有技能目录(四个技能的 name + description);
- 调用
load_skill后消息流中是否出现完整的SKILL.md内容(作为tool_result); - 加载 code-review 技能后再让模型评审,它是否真的照技能正文的检查清单执行安全、正确性、性能、可维护性各节的检查。
8. 设计要点回顾与后续演进
s07 机制可以归纳为四条工程经验:
- 索引与内容分离:常驻上下文只放「名称 + 触发描述」,全文延迟到
load_skill调用时刻,token 成本从 O(全部技能全文) 降为 O(命中技能全文); - 元数据回退链:
name→ 目录名,description→ 正文首行,保证即使 frontmatter 残缺,技能也能以可用形态出现在目录中; - 查询键而非路径:
load_skill(name)查注册表而非读文件,天然规避路径穿越,且错误响应自带可用列表; - 结果走标准 tool_result 通道:不修改 system prompt,不引入新的消息类型,完全复用 s01 建立的 Agent Loop。
顺着这条线,s07 也暴露了它的下一课:随着工具调用增加(load_skill 返回的全文本身也可能很长),messages[] 会积累较早的文件内容和工具结果。下一章节 s08 Context Compact 的主题就是缩短较早的消息,为后续调用保留上下文空间——按需加载解决了「不该进的内容不进入」,而 compact 解决「进入后如何回收」。
(本文基于 learn-claude-code 仓库 s07 章节,代码文件路径与测试断言均以当前仓库实际内容为准。)
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00