learn-claude-code 技能按需加载机制:SkillLoader、SKILL.md 与 load_skill 工具实现解析
本篇围绕 learn-claude-code 教程的 s07 章节(s07_skill_loading/README.md)展开,讲清楚 Agent harness 中「技能目录先行、全文按需加载」这一知识注入机制的设计动机、SkillLoader 扫描与 frontmatter 解析细节、build_system_prompt 与 load_skill 工具的接线方式,并给出可复制的运行步骤、测试用例级验证方法,以及该机制在完整 harness(s15)中的延续。读完后你能在自己的 Agent 项目里独立实现一套两层式技能加载系统。
问题:把所有规范塞进 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 中的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 用 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,
}
从源码结构看,这里有几处精心设计:
- 安全边界:
manifest.resolve().is_relative_to(skills_root)会拒绝符号链接指向skills/之外的文件。测试 tests/test_skill_loading.py 中专门构造了linked-skill(软链到 skills 目录外部的文件)并断言它不会进入注册表; - name 兜底:frontmatter 缺
name或非字符串时,回退为目录名(manifest.parent.name),保证技能总能以稳定名字被引用; - description 兜底:缺
description时取正文第一行,并做lstrip("# ")去标题符号、压缩空白,避免目录中出现# Title这种原始 Markdown 形态; - 注册表结构:每个技能存
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.py 的 test_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 的工具面刻意保持最小:bash、read_file、write_file、edit_file、glob、load_skill,正好被测试 test_s07_exposes_only_base_tools_and_load_skill(tests/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.0、python-dotenv>=1.0.0、pyyaml>=6.0。脚本通过 load_dotenv 读取 .env,要求其中配置模型标识;从源码看还需要环境变量 MODEL_ID(os.environ["MODEL_ID"],s07_skill_loading/code.py),可选 ANTHROPIC_BASE_URL 指定 API 端点。
试一下
cd learn-claude-code
python s07_skill_loading/code.py
按文档建议依次输入这三个 prompt 观察行为差异:
What skills are available?—— 模型应仅凭 system prompt 中的目录作答,不应先调用任何工具;Load the code-review skill and follow its instructions—— 应观察到一次load_skill("code-review")调用,且返回内容就是 skills/code-review/SKILL.md 的完整文本(安全检查清单、输出格式模板、常见反模式等);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_metadata(tests/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。
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 StartedRust0625
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