首页
/ learn-claude-code 中的技能按需加载机制:Skill 目录 + load_skill 的 Harness 层实现

learn-claude-code 中的技能按需加载机制:Skill 目录 + load_skill 的 Harness 层实现

2026-09-04 10:37:13作者:范靓好Udolf

在 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 解析失败回退、符号链接防护、名称回退策略)背后的原因。

s07 技能加载总览:启动时扫描 skills 目录生成 catalog,模型调用 load_skill 后以 tool_result 返回完整 SKILL.md

问题背景:把所有规范写死在 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 的实现:

  1. 启动时 SkillLoader 扫描 skills/*/SKILL.md,解析 YAML frontmatter 中的 namedescription,把目录(catalog)注入 system prompt;
  2. 当模型判断需要完整指令时,调用 load_skill(name)
  3. 返回的完整 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.mdskills/mcp-builder/SKILL.mdskills/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 标记的临时技能同时断言了四层事实:

  1. catalog() 输出恰好是 - code-review: Review code for bugs, regressions, and missing tests.(多行块标量 description 被压成单行);
  2. UNIQUE_FULL_INSTRUCTION 不在 SYSTEM 中——正文确实没进 system prompt;
  3. SKILL_LOADER.load("code-review") 返回的是完整原文(含 frontmatter);
  4. 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_skillcode.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_loopcode.py L328-L356)把模型返回的 tool_use 交给 execute_toolexecute_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-L47load_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:

  1. What skills are available? — 模型应能直接从 system prompt 目录回答,无需任何工具调用;
  2. Load the code-review skill and follow its instructions — 观察 load_skill 调用后完整的 skills/code-review/SKILL.mdtool_result 形式出现;
  3. 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_REGISTRYL700-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 中的技能正文也会累积)就有了直接的前因——这也是原文档"次へ"一节的衔接点。

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