learn-claude-code 中的技能按需加载机制:Skill 目录 + load_skill 的 Harness 层实现
在 learn-claude-code("Bash is all you need" 风格的 nano Claude Code 式 agent harness)中,s07 章节解决的核心问题是:如何让模型知道"有哪些技能可用",又不在每次 LLM 调用时把全部技能正文塞进上下文。本文以 s07_skill_loading/README.ja.md 的完整内容为主干,结合 s07_skill_loading/code.py 源码与 tests/test_skill_loading.py 测试用例,讲清 SkillLoader 的扫描、frontmatter 解析、目录注入 system prompt、load_skill 按需返回全文的完整链路。读完后你能掌握:如何为自己的 agent 设计"目录常驻、正文按需"的知识加载层,并理解其中每一个防御性细节(YAML 解析失败回退、符号链接防护、名称回退策略)背后的原因。
问题背景:把所有规范写死在 system prompt 的代价
文档给出的场景很典型:一个项目里同时有 React 组件规范、SQL 风格指南、API 设计文档。最直接的写法是把它们全部拼进 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()
)
这样 Agent 确实"读得到"所有规范,但代价明确:
- 三份文档全文在每次 LLM 调用时都会随 system prompt 发送给模型,无论当前任务是否需要;
- 任务如果只是改 React 组件,SQL 风格指南和 API 设计文档只是白白消耗输入 token 与上下文窗口,挤压代码、对话和 tool result 的可用空间。
这就是 s07 要解决的 Harness 层问题——知识加载(Knowledge loading):先向模型展示存在哪些技能,需要时再按名字加载全文。
解决方案:目录常驻 system prompt,正文按需进入 tool_result
核心设计可以压缩成一张表(原文档表格完整保留):
| 内容 | 在模型输入中的位置 | 加入时机 |
|---|---|---|
| 技能名称与描述 | system prompt | 启动时 |
完整 SKILL.md |
tool_result |
调用 load_skill 时 |
对应到 s07_skill_loading/code.py 的实现:
- 启动时
SkillLoader扫描skills/*/SKILL.md,解析 YAML frontmatter 中的name与description,把目录(catalog)注入 system prompt; - 当模型判断需要完整指令时,调用
load_skill(name); - 返回的完整
SKILL.md内容作为tool_result追加到消息列表,由既有的 Agent Loop 继续处理。
由于 SYSTEM = build_system_prompt() 在模块加载时就已计算完成(见 code.py L136),整个会话的 system prompt 只包含"固定指令 + 技能目录",正文只在模型主动加载后才出现在消息历史中。
技能目录结构:每个技能就是一个含 SKILL.md 的目录
仓库中真实的技能目录如下(与原文档一致):
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..."),正文则是完整的评审清单、输出格式模板、常见漏洞模式和评审命令。skills/pdf/SKILL.md、skills/mcp-builder/SKILL.md、skills/agent-builder/SKILL.md 遵循同样的约定。值得注意的是,description 都刻意写成"功能说明 + Use when ..."的触发条件句式——这是为了让模型仅凭目录就能判断"这个技能是否与当前任务相关",是 catalog 设计能否成立的关键。
源码深潜:SkillLoader.scan() 的解析与防御逻辑
原文档给出的 scan() 片段在完整实现中(code.py L82-L106)多了一个前置检查:skills 目录不存在时直接返回,保证脚本在空仓库中也能启动。逐段拆解其防御逻辑:
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,
}
这里有几个值得单独说明的细节,全部有对应测试用例佐证:
sorted(...)保证目录稳定:glob 结果排序后遍历,同一目录结构下 catalog 输出顺序可复现;is_relative_to(skills_root)是符号链接防护:manifest.resolve()会把符号链接解析到真实路径,若SKILL.md是指向skills/之外的软链(或路径穿越),resolve()后的路径不再是skills_root的子路径,直接被continue跳过。tests/test_skill_loading.py L147-L158 显式构造了linked-skill/SKILL.md -> ../outside-skill.md的软链并断言"linked-skill" not in registry;- name 的三级取值:frontmatter 中是字符串的
name(strip 后使用)→ 否则回退为目录名(manifest.parent.name)→ 类型不对(比如 YAML 解析出 list)则同样落到目录名。测试 test_skill_frontmatter_falls_back_for_invalid_or_empty_metadata 验证了name: [bad]这类非法类型时的回退行为; - description 的三级取值:frontmatter 字符串 → 正文第一行(通常是 Markdown 一级标题)→ 清理。
" ".join(... .lstrip("# ").split())会把多行/多空格压缩成单行,并剥掉标题前缀#。测试确认:frontmatter 为空时description回退为正文首行Body description;正文为空的empty-skill得到空描述;类型非法的typed-fallback回退为Typed fallback。
parse_frontmatter:独立分隔符与 YAML 安全加载
scan() 只是调用方,真正解析 frontmatter 的是静态方法 parse_frontmatter(code.py L58-L80):
@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
两个容易踩坑的点在这里被显式处理,并有测试锁定行为:
- 必须是无行内内容的独立
---行:lines[0].rstrip("\r\n") != "---"意味着---not frontmatter这种行不会被当作开头,整段文本原样作为 body 返回(测试 test_skill_frontmatter_requires_standalone_delimiters 断言parse_frontmatter("...") == ({}, invalid_opening)); - 块标量内部的
---不会误判为结尾:查找闭合分隔符时逐行比对去尾后的整行内容,YAML 块标量(description: |)内部的缩进行---不等于---,因此多行 description 可以包含---。测试用block_scalar样例(含 CRLF 变体)验证description == "before\n---\nafter\n"且body == "# Body"; yaml.safe_load失败或结果非 dict 时回退空字典:frontmatter 损坏不会让扫描崩溃,而是整个文件按"无元数据"处理,再走 name/description 的目录名/首行回退逻辑。
catalog() 与 load():目录瘦身与按名检索
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()
)
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}"
-
catalog()只输出name: description列表,正文永远不进入 system prompt。原文档给出的样例输出即来自真实技能:- code-review: Perform thorough code reviews... - pdf: Process PDF files... -
load(name)是注册表检索而非文件路径解释——name只查启动时构建的self.skills字典,模型无法借它读取任意路径,这保持了 s03 权限层建立的"工作区内、受控访问"约束; -
未知技能名返回带可用列表的错误信息,这是一个"自我纠偏友好"的设计:模型下一轮可以直接从
Available: ...里挑正确的名字重试,而不需要额外的 list 工具。
测试锁定的核心不变量
test_catalog_stays_small_and_load_skill_returns_the_full_file(tests/test_skill_loading.py L67-L93) 用一个带 UNIQUE_FULL_INSTRUCTION 标记的临时技能同时断言了四层事实:
catalog()输出恰好是- code-review: Review code for bugs, regressions, and missing tests.(多行块标量 description 被压成单行);UNIQUE_FULL_INSTRUCTION不在SYSTEM中——正文确实没进 system prompt;SKILL_LOADER.load("code-review")返回的是完整原文(含 frontmatter);TOOL_HANDLERS"load_skill"与load()等价——工具链路直通。
另一个测试 test_s07_exposes_only_base_tools_and_load_skill 则锁定了本章节的工具面就是 bash, read_file, write_file, edit_file, glob, load_skill 六个,确认 s07 相对前几章只新增了 load_skill 一个工具。
集成方式:工具注册、执行与 Agent Loop
load_skill 在 code.py L208-L209 声明为普通工具,schema 只有一个必填的 name: string:
{"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(code.py L212-L219) 中直接绑定 SKILL_LOADER.load。由于它返回的是字符串,执行链路完全复用通用 Agent Loop:agent_loop(code.py L328-L356)把模型返回的 tool_use 交给 execute_tool,execute_tool 先触发 PreToolUse hooks(权限钩子只拦截 bash 危险命令与越界文件路径,load_skill 不在拦截名单内),再调用 handler,最后把输出包成 {"type": "tool_result", "tool_use_id": ..., "content": output} 追加为一条 user 消息。也就是说:技能正文是借 tool_result 通道进入上下文的,占用的是消息历史而不是 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 指令、技能目录、"何时调用 load_skill"的行为提示三者在模块级拼好,随每次 client.messages.create(..., system=SYSTEM, ...) 发送。从结构上看,目录规模与启动时 skills/ 目录内容成正比:技能越多,常驻开销越大,但每份技能只付"一行"的价格。
动手运行
运行方式(继承原文档"试してみよう"一节):
cd learn-claude-code
python s07_skill_loading/code.py
环境依赖来自 code.py L40-L47:load_dotenv(override=True) 加载 .env;必须提供 MODEL_ID 环境变量(MODEL = os.environ["MODEL_ID"]),可选提供 ANTHROPIC_BASE_URL 以指向兼容 Anthropic API 的端点(注意代码在设置 ANTHROPIC_BASE_URL 时会主动 pop 掉 ANTHROPIC_AUTH_TOKEN)。skills/ 目录默认取 Path.cwd() / "skills",所以要在仓库根目录运行才能扫到内置的 4 个技能。
原文档建议的 3 个验证 prompt:
What skills are available?— 模型应能直接从 system prompt 目录回答,无需任何工具调用;Load the code-review skill and follow its instructions— 观察load_skill调用后完整的 skills/code-review/SKILL.md 以tool_result形式出现;Review README.md and load the relevant skill first— 验证模型能否依据 description 自主判断该加载哪个技能。
验证要点:system prompt 里只有目录;完整 SKILL.md 只在 load_skill 调用之后才出现。
扩展视角:该机制在完整 harness 中的延续
从源码结构看,s07 的模式在最终整合版 s15_integrated_harness/code.py 中被保留并扩展:scan_skills() 用相同的 frontmatter 解析与目录名回退逻辑填充 SKILL_REGISTRY(L700-L715),load_skill() 作为工具返回注册表内容(L726-L731),而 system prompt 改为每轮由 assemble_system_prompt() 动态重建,技能目录在其中与 memory catalog、MCP 服务器列表并列注入(L775-L794)。测试文件 tests/test_skill_loading.py 也确实对 s07 与 s15 两份实现做同一组 frontmatter/回退断言,说明"目录常驻、正文按需"是被跨版本锁定的不变量。
小结
s07 在 learn-claude-code 的渐进式教学序列中承担"知识加载"这一 Harness 层:用 frontmatter 解析把每个技能的"名片"(name + description)常驻 system prompt,把"说明书"(完整 SKILL.md)留给 load_skill 按需通过 tool_result 送达,并以注册表检索、软链防护、解析失败回退保证该机制在真实目录下的健壮性。理解了这一层,后续章节里 s08 Context Compact 要解决的"消息历史越积越长"问题(tool_result 中的技能正文也会累积)就有了直接的前因——这也是原文档"次へ"一节的衔接点。
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 StartedRust0623
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