首页
/ learn-claude-code 技能加载机制解析:两层注入实现 Agent 的按需知识加载

learn-claude-code 技能加载机制解析:两层注入实现 Agent 的按需知识加载

2026-09-05 20:42:54作者:卓艾滢Kingsley

本文基于 learn-claude-code 仓库的 docs/en/s05-skill-loading.md 文档展开,完整讲解其「两层技能注入(Two-layer Skill Injection)」设计:如何在系统提示词中仅保留廉价的技能目录,而将完整的领域知识通过 tool_result 按需注入上下文。读完后你将理解 SkillLoader 的完整实现链路(扫描 SKILL.md → 解析 YAML frontmatter → 生成目录 → 注册 load_skill 工具),并能掌握在自己 Agent 项目中复用该模式的实操方法。

Skill Loading 两层注入架构总览

1. 问题:把领域知识塞进系统提示词有多贵

learn-claude-code 是一个「从 0 到 1 手写类 Claude Code agent harness」的教学仓库,其核心理念是 Bash is all you need。在课程序列中,s05 位于工具使用(s02)与子代理(s04)之后,专门解决领域知识注入这一 Harness 层问题。

原文档给出的动机非常直白:你希望 Agent 遵循领域特定的工作流——git 约定、测试模式、代码审查清单。最朴素的做法是把它们全部写进系统提示词,但:

  • 系统提示词在每一次 LLM 调用时都会完整发送;
  • 10 个技能 × 每个约 2000 tokens = 20,000 tokens 的固定开销,而其中绝大多数内容与当前任务无关;
  • 这些 tokens 挤占了本应留给代码、对话历史和工具结果的上下文窗口空间。

原文档用一句话概括了设计哲学:

"Load knowledge when you need it, not upfront" —— 通过 tool_result 注入,而不是塞进系统提示词。

learn-claude-code 将其归类为 Harness 层(Harness layer)的「On-demand knowledge」:领域专业知识由模型主动索要时才加载。

2. 方案:两层注入(Layer 1 目录 + Layer 2 全文)

docs/en/s05-skill-loading.md 用一张 ASCII 图阐明了两层结构:

System prompt (Layer 1 -- always present):
+--------------------------------------+
|| You are a coding agent.              |
|| Skills available:                    |
||   - git: Git workflow helpers        |  ~100 tokens/skill
||   - test: Testing best practices     |
+--------------------------------------+

When model calls load_skill("git"):
+--------------------------------------+
|| tool_result (Layer 2 -- on demand):  |
|| <skill name="git">                   |
||   Full git workflow instructions...  |  ~2000 tokens
||   Step 1: ...                        |
|| </skill>                             |
+--------------------------------------+

两层各自承担不同角色:

内容 进入模型输入的方式 加载时机
Layer 1 技能名称 + 描述(元数据) 系统提示词 启动时
Layer 2 技能完整正文SKILL.md body) tool_resultload_skill 的返回) 模型按需调用时

这个设计的精髓在于成本与频次匹配:Layer 1 每次调用都发送,所以必须便宜(约 100 tokens/技能);Layer 2 只在模型判断"这个技能与当前任务相关"时才进入上下文,加载一次即沉淀在消息历史中供后续轮次引用。模型因此"知道有哪些技能(cheap)",并在"相关时加载它们(expensive)"。

3. SKILL.md 规范:目录即技能,frontmatter 即元数据

3.1 目录约定

每个技能是一个目录,内含一个 SKILL.md 文件,其头部是 YAML frontmatter:

skills/
  pdf/
    SKILL.md       # ---\n name: pdf\n description: Process PDF files\n ---\n ...
  code-review/
    SKILL.md       # ---\n name: code-review\n description: Review code\n ---\n ...

SkillLoader 扫描所有 SKILL.md,并以目录名作为技能标识符(frontmatter 中的 name 若缺失则回退到目录名)。

仓库中实际存在 4 个可直接运行的技能,与文档描述一一对应:

技能 文件 用途(来自 frontmatter description)
pdf skills/pdf/SKILL.md 处理 PDF 文件:提取文本、创建、合并文档
code-review skills/code-review/SKILL.md 带安全/性能/可维护性分析的系统化代码审查流程
agent-builder skills/agent-builder/SKILL.md 为任意领域设计与构建 AI Agent
mcp-builder skills/mcp-builder/SKILL.md 构建 MCP (Model Context Protocol) 服务器

值得注意的是,agent-builder 技能的 frontmatter 使用了 YAML 多行块(description: |),其中甚至内嵌了触发关键词("Keywords: agent, assistant, autonomous...")——这实际是在教模型如何决定调用 load_skill:描述越精确,按需加载的命中率越高。技能正文则是完整的领域 SOP,例如 skills/code-review/SKILL.md 包含了安全/正确性/性能/可维护性/测试五大类审查清单、npm audit / pip-audit 等扫描命令,以及标准化的审查输出模板。

4. SkillLoader 源码解剖

4.1 扫描与 frontmatter 解析

docs/en/s05-skill-loading.md 给出的 SkillLoader 骨架在仓库中对应可执行文件 agents/s05_skill_loading.py,其实现细节如下:

# agents/s05_skill_loading.py (L59-L104)
class SkillLoader:
    def __init__(self, skills_dir: Path):
        self.skills_dir = skills_dir
        self.skills = {}
        self._load_all()

    def _load_all(self):
        if not self.skills_dir.exists():
            return
        for f in sorted(self.skills_dir.rglob("SKILL.md")):
            text = f.read_text()
            meta, body = self._parse_frontmatter(text)
            name = meta.get("name", f.parent.name)
            self.skills[name] = {"meta": meta, "body": body, "path": str(f)}

    def _parse_frontmatter(self, text: str) -> tuple:
        """Parse YAML frontmatter between --- delimiters."""
        match = re.match(r"^---\n(.*?)\n---\n(.*)", text, re.DOTALL)
        if not match:
            return {}, text
        try:
            meta = yaml.safe_load(match.group(1)) or {}
        except yaml.YAMLError:
            meta = {}
        return meta, match.group(2).strip()

与文档骨架相比,源码补充了几个健壮性细节:

  1. 目录不存在时静默降级if not self.skills_dir.exists(): return),Agent 退化为"无技能"模式而不是崩溃;
  2. sorted(rglob("SKILL.md")) 保证技能加载顺序确定,系统提示词中的目录顺序在多次启动间稳定;
  3. frontmatter 解析失败不抛异常——正则不匹配或 YAML 语法错误时回退为 ({}, text),整个文件被当作正文,技能名回退为目录名;
  4. 注册表中除了 meta/body 还保留了 path 字段,便于调试时定位技能来源文件。

frontmatter 的两个核心字段有明确的回退链:

  • name:缺失 → 用目录名(f.parent.name);
  • description:缺失 → 显示 "No description"(s05 版本)。

4.2 两层内容的生产方法

# agents/s05_skill_loading.py (L85-L104)
def get_descriptions(self) -> str:
    """Layer 1: short descriptions for the system prompt."""
    if not self.skills:
        return "(no skills available)"
    lines = []
    for name, skill in self.skills.items():
        desc = skill["meta"].get("description", "No description")
        tags = skill["meta"].get("tags", "")
        line = f"  - {name}: {desc}"
        if tags:
            line += f" [{tags}]"
        lines.append(line)
    return "\n".join(lines)

def get_content(self, name: str) -> str:
    """Layer 2: full skill body returned in tool_result."""
    skill = self.skills.get(name)
    if not skill:
        return f"Error: Unknown skill '{name}'. Available: {', '.join(self.skills.keys())}"
    return f"<skill name=\"{name}\">\n{skill['body']}\n</skill>"

两个方法分别对应两层:

  • get_descriptions()(Layer 1):把目录渲染成 - name: description 列表。源码额外支持 frontmatter 的 tags 字段,会以 [tags] 形式追加,便于模型做更细粒度的相关性判断。
  • get_content()(Layer 2):把正文包进 <skill name="...">...</skill> 标签后返回。这里有一个值得借鉴的错误处理设计——未知技能名不会静默失败,而是返回带可用技能清单的错误信息(Error: Unknown skill 'x'. Available: a, b, c),模型看到后可以自我纠正并改用正确的名字,形成闭环。

4.3 Layer 1 注入系统提示词

# agents/s05_skill_loading.py (L110-L114)
SYSTEM = f"""You are a coding agent at {WORKDIR}.
Use load_skill to access specialized knowledge before tackling unfamiliar topics.

Skills available:
{SKILL_LOADER.get_descriptions()}"""

系统提示词在模块导入时即构建完成(SKILL_LOADER 是模块级单例,见 agents/s05_skill_loading.py#L107),因此每次 client.messages.create 调用(agents/s05_skill_loading.py#L188-L193agent_loop)都会携带这份目录。以仓库自带 4 个技能为例,Layer 1 的开销就是 4 行 - name: description——这正是"~100 tokens/skill"量级的直观体现。

5. load_skill 工具:Layer 2 只是"另一个工具处理器"

原文档强调:Layer 2 is just another tool handler.s05 中,load_skillbashread_file 等基础工具完全平级:

# agents/s05_skill_loading.py (L166-L185)
TOOL_HANDLERS = {
    "bash":       lambda **kw: run_bash(kw["command"]),
    "read_file":  lambda **kw: run_read(kw["path"], kw.get("limit")),
    "write_file": lambda **kw: run_write(kw["path"], kw["content"]),
    "edit_file":  lambda **kw: run_edit(kw["path"], kw["old_text"], kw["new_text"]),
    "load_skill": lambda **kw: SKILL_LOADER.get_content(kw["name"]),
}

TOOLS = [
    # ...bash / read_file / write_file / edit_file...
    {"name": "load_skill", "description": "Load specialized knowledge by name.",
     "input_schema": {"type": "object", "properties": {"name": {"type": "string", "description": "Skill name to load"}}, "required": ["name"]}},
]

agent_loop 中,load_skill 的执行路径与任何其他工具相同:模型输出 tool_use block → 循环在 TOOL_HANDLERS 中按 block.name 查表 → 结果包成 {"type": "tool_result", "tool_use_id": block.id, "content": ...} 追加回消息列表。也就是说,技能加载没有引入任何新的协议或循环分支,它复用了 s02 建立的通用工具调度机制——这是该设计工程成本极低的根本原因。

从消息流角度看,一次技能加载后的上下文形如:

system:      You are a coding agent... Skills available:
            - code-review: Perform thorough code reviews...
            - pdf: Process PDF files...
user:        I need to do a code review -- load the relevant skill first
assistant:   [tool_use: load_skill {name: "code-review"}]
user:        [tool_result: <skill name="code-review"> ...完整 SKILL.md 正文... </skill>]
assistant:   (依据技能正文中的 checklist 执行审查)

6. 从 s04 到 s05:变更对照

原文档给出了与前一课的组件级 diff,这是理解渐进式 Harness 构建的关键:

Component Before (s04) After (s05)
Tools 5 (base + task) 5 (base + load_skill)
System prompt Static string + skill descriptions
Knowledge None skills/*/SKILL.md files
Injection None Two-layer (system + result)

对照 docs/en/s04-subagent.md 所在课程即可验证:s04 引入的 task 工具(派生子代理)在 s05 中被替换为 load_skill,工具总数保持 5 个不变——每节课只增量引入一个机制。

7. 演进:s07 版本 SkillLoader 的健壮化改造

同一机制在仓库的课程重排版 s07_skill_loading/code.py 中得到了显著强化,对比 s07_skill_loading/README.md 可以清晰看到"从原型到可投用"的工程化路径:

7.1 frontmatter 解析:正则 → 逐行状态机

s05 的正则 ^---\n(.*?)\n---\n(.*) 有一个隐患:当 description 使用 YAML 块标量(description: |)且块内容中恰好出现 --- 行时,正则可能误判结束边界。s07 的 parse_frontmatter 改为逐行扫描,要求首行严格等于 ---(排除 ---not frontmatter 这类误报),并寻找第一个"整行恰好是 ---"的闭合行——缩进的 --- 不会被视为闭合符,因此块标量内的 --- 被正确保留在 description 值中。

7.2 描述回退链的三级降级

scan() 中的处理:

name = raw_name.strip() if isinstance(raw_name, str) else ""
name = name or manifest.parent.name                      # 回退 1:目录名
description = (raw_description.strip()
               if isinstance(raw_description, str) else "")
description = description or body.split("\n", 1)[0]       # 回退 2:正文首行
description = " ".join(str(description).lstrip("# ").split())  # 归一化空白与标题前缀

frontmatter 无 description 时,取正文第一行(通常是 H1 标题,剥离 # 前缀后作为描述),保证目录中不出现 "No description" 这种对模型毫无信息的条目。

7.3 符号链接防护

s07 的扫描增加了边界检查:manifest.resolve().is_relative_to(skills_root) 确保被解析的 SKILL.md 真实位于 skills/ 目录下,拒绝通过符号链接指向目录外部的文件——防止外部内容借技能目录逃逸进模型上下文。

7.4 load 返回完整原文而非仅 body

s07 的 load() 直接返回 content(含 frontmatter 的完整 SKILL.md 原文),而 s05 的 get_content() 只返回剥离后的 body 并额外包裹 <skill> 标签。测试用例 tests/test_skill_loading.py 明确断言了 s07 的行为:SKILL_LOADER.load("code-review") == manifest(全文原样返回)。

8. 测试证据:两层注入的可验证行为

tests/test_skill_loading.py 为两层注入提供了自动化验证(通过伪造 anthropic/dotenv 模块并临时切换工作目录加载 lesson 模块):

  1. 目录保持小而全文按需返回L67-L93):断言 SYSTEM 中包含技能名 code-review,但不包含正文标记 UNIQUE_FULL_INSTRUCTION——直接证明了 Layer 1 / Layer 2 的分离;同时验证 TOOL_HANDLERS"load_skill" 返回完整 manifest;
  2. 工具面收敛L96-L107):s07 只暴露 bash / read_file / write_file / edit_file / glob / load_skill 六个工具,技能机制不引入额外工具;
  3. frontmatter 边界鲁棒性L110-L144):覆盖 ---not frontmatter 误报、块标量内嵌 ---(含 CRLF 换行)、空/非法 frontmatter 回退到正文首行、非 mapping 元数据(- not / - a mapping)降级为 {},以及符号链接技能被拒绝加载。

这些测试把文档中的"设计意图"钉成了"实现事实",是理解两层机制最可靠的仓库内证据。

9. 运行与验证

9.1 环境准备

agents/s05_skill_loading.py 的启动逻辑看,运行需要:

  • Python 依赖:anthropicpython-dotenvpyyaml(见 requirements.txt);
  • 环境变量:MODEL_ID(必填,缺失会直接抛 KeyError);
  • 可选:ANTHROPIC_BASE_URL——设置后代码会主动弹出 ANTHROPIC_AUTH_TOKENL49-L50),以适配需要 api_key 鉴权的自建/代理端点;
  • 工作目录需包含 skills/ 目录(SKILLS_DIR = WORKDIR / "skills"),因此必须在仓库根目录运行。

9.2 启动与观察

cd learn-claude-code
python agents/s05_skill_loading.py

进入交互式 REPL 后,按原文档给出的 4 个提示词依次验证:

  1. What skills are available? —— 验证模型能"看见"Layer 1 目录(此时上下文里应只有 4 行 name/description);
  2. Load the agent-builder skill and follow its instructions —— 触发 load_skill("agent-builder"),观察完整正文以 tool_result 形式进入消息历史;
  3. I need to do a code review -- load the relevant skill first —— 验证模型能根据任务语义自主选择技能;
  4. Build an MCP server using the mcp-builder skill —— 验证加载后的领域知识(MCP SDK 用法、模板代码)能实际驱动后续的工具调用。

每次工具调用时 REPL 会打印 > load_skill: 前 200 字符的输出预览(agents/s05_skill_loading.py#L205-L206),可以直接肉眼确认 Layer 2 的内容确实进入了上下文。仓库同时提供重排版入口 python s07_skill_loading/code.py(见 s07_skill_loading/README.md),两者机制相同,可对照观察健壮性差异。

10. 设计要点与复用清单

docs/en/s05-skill-loading.md 与两份源码、测试实现合并来看,这套按需知识加载机制可以提炼为 5 条可复用原则:

  1. 元数据与正文分离存储SKILL.md = frontmatter(name/description/tags)+ 正文,目录名即兜底标识符——新增一个技能零代码改动,只需放一个目录;
  2. 目录进系统提示词,正文进 tool_result:用"每次必读"与"按需才读"两种通道分别承载高频低成本与低频高价值内容;
  3. 加载机制复用通用工具调度load_skill 只是 TOOL_HANDLERS 表中的一个条目,不新增循环逻辑、不新增协议;
  4. 错误信息要可自纠:未知技能名返回 Error: Unknown skill 'x'. Available: ... 而不是空串,让模型在下一轮自我修正;
  5. 解析永远降级不崩溃:目录缺失、YAML 非法、frontmatter 缺失都走回退路径(空目录/目录名/正文首行),并辅以符号链接边界检查防止上下文污染。

对正在构建自己的 agent harness 的开发者而言,这套模式的最小可行形态就是:一个 SkillLoader 类(扫描 + 解析 + 两个查询方法)、一个系统提示词模板、一个 load_skill 工具定义——s05agents/s05_skill_loading.py 的核心相关代码(L58-L114、L166-L185)不到 100 行,即可作为独立模块移植到任意基于 Anthropic Messages API 的 Agent 项目中。

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