learn-claude-code 技能加载机制解析:两层注入实现 Agent 的按需知识加载
本文基于 learn-claude-code 仓库的 docs/en/s05-skill-loading.md 文档展开,完整讲解其「两层技能注入(Two-layer Skill Injection)」设计:如何在系统提示词中仅保留廉价的技能目录,而将完整的领域知识通过 tool_result 按需注入上下文。读完后你将理解 SkillLoader 的完整实现链路(扫描 SKILL.md → 解析 YAML frontmatter → 生成目录 → 注册 load_skill 工具),并能掌握在自己 Agent 项目中复用该模式的实操方法。
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_result(load_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()
与文档骨架相比,源码补充了几个健壮性细节:
- 目录不存在时静默降级(
if not self.skills_dir.exists(): return),Agent 退化为"无技能"模式而不是崩溃; sorted(rglob("SKILL.md"))保证技能加载顺序确定,系统提示词中的目录顺序在多次启动间稳定;- frontmatter 解析失败不抛异常——正则不匹配或 YAML 语法错误时回退为
({}, text),整个文件被当作正文,技能名回退为目录名; - 注册表中除了
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-L193 的 agent_loop)都会携带这份目录。以仓库自带 4 个技能为例,Layer 1 的开销就是 4 行 - name: description——这正是"~100 tokens/skill"量级的直观体现。
5. load_skill 工具:Layer 2 只是"另一个工具处理器"
原文档强调:Layer 2 is just another tool handler. 在 s05 中,load_skill 与 bash、read_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 模块):
- 目录保持小而全文按需返回(L67-L93):断言
SYSTEM中包含技能名code-review,但不包含正文标记UNIQUE_FULL_INSTRUCTION——直接证明了 Layer 1 / Layer 2 的分离;同时验证TOOL_HANDLERS"load_skill"返回完整 manifest; - 工具面收敛(L96-L107):s07 只暴露
bash / read_file / write_file / edit_file / glob / load_skill六个工具,技能机制不引入额外工具; - frontmatter 边界鲁棒性(L110-L144):覆盖
---not frontmatter误报、块标量内嵌---(含 CRLF 换行)、空/非法 frontmatter 回退到正文首行、非 mapping 元数据(- not/- a mapping)降级为{},以及符号链接技能被拒绝加载。
这些测试把文档中的"设计意图"钉成了"实现事实",是理解两层机制最可靠的仓库内证据。
9. 运行与验证
9.1 环境准备
从 agents/s05_skill_loading.py 的启动逻辑看,运行需要:
- Python 依赖:
anthropic、python-dotenv、pyyaml(见 requirements.txt); - 环境变量:
MODEL_ID(必填,缺失会直接抛KeyError); - 可选:
ANTHROPIC_BASE_URL——设置后代码会主动弹出ANTHROPIC_AUTH_TOKEN(L49-L50),以适配需要api_key鉴权的自建/代理端点; - 工作目录需包含
skills/目录(SKILLS_DIR = WORKDIR / "skills"),因此必须在仓库根目录运行。
9.2 启动与观察
cd learn-claude-code
python agents/s05_skill_loading.py
进入交互式 REPL 后,按原文档给出的 4 个提示词依次验证:
What skills are available?—— 验证模型能"看见"Layer 1 目录(此时上下文里应只有 4 行 name/description);Load the agent-builder skill and follow its instructions—— 触发load_skill("agent-builder"),观察完整正文以tool_result形式进入消息历史;I need to do a code review -- load the relevant skill first—— 验证模型能根据任务语义自主选择技能;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 条可复用原则:
- 元数据与正文分离存储:
SKILL.md= frontmatter(name/description/tags)+ 正文,目录名即兜底标识符——新增一个技能零代码改动,只需放一个目录; - 目录进系统提示词,正文进 tool_result:用"每次必读"与"按需才读"两种通道分别承载高频低成本与低频高价值内容;
- 加载机制复用通用工具调度:
load_skill只是TOOL_HANDLERS表中的一个条目,不新增循环逻辑、不新增协议; - 错误信息要可自纠:未知技能名返回
Error: Unknown skill 'x'. Available: ...而不是空串,让模型在下一轮自我修正; - 解析永远降级不崩溃:目录缺失、YAML 非法、frontmatter 缺失都走回退路径(空目录/目录名/正文首行),并辅以符号链接边界检查防止上下文污染。
对正在构建自己的 agent harness 的开发者而言,这套模式的最小可行形态就是:一个 SkillLoader 类(扫描 + 解析 + 两个查询方法)、一个系统提示词模板、一个 load_skill 工具定义——s05 版 agents/s05_skill_loading.py 的核心相关代码(L58-L114、L166-L185)不到 100 行,即可作为独立模块移植到任意基于 Anthropic Messages API 的 Agent 项目中。
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