learn-claude-code-s05_skill_loading.py

原址

s05_skill_loading.py 讲的是:给 AI Coding Agent 增加"按需加载技能"的能力

核心思想是:不要把所有专业知识一次性塞进 system prompt,而是先只放技能名称和简介,等模型需要时再通过 load_skill 工具加载完整技能内容。 文件开头注释明确写了这个"两层注入"设计:第 1 层把技能名和简介放进 system prompt,第 2 层在模型调用 load_skill("pdf") 时返回完整技能正文。(GitHub)

这个文件解决的问题

普通 Agent 如果把所有规则、工具说明、PDF 处理方法、代码审查规范、MCP 构建指南都直接写进 system prompt,会有几个问题:

  1. prompt 变长,浪费 token

  2. 模型注意力被稀释

  3. 不相关知识也会干扰当前任务

  4. 技能越多,系统提示词越膨胀

所以这个文件实现了一个更像 Claude Code / Cursor / Agent Skill 的机制:

平时只告诉模型:"我有哪些技能";

真正需要时,模型自己调用 load_skill,再把完整说明加载进上下文。

这就是注释里说的:"Don't put everything in the system prompt. Load on demand." (GitHub)

整体执行流程

可以理解成这 6 步:

objectivec 复制代码
启动程序
  ↓
扫描 skills/ 目录下所有 SKILL.md
  ↓
解析每个 SKILL.md 的 YAML frontmatter
  ↓
把技能名称 + 简介塞进 system prompt
  ↓
用户提出任务
  ↓
模型判断是否需要某个技能,需要就调用 load_skill(name)
  ↓
load_skill 返回完整技能说明
  ↓
模型根据完整技能继续完成任务

目录结构设计

文件注释里规定了技能目录大概长这样:(GitHub)

objectivec 复制代码
skills/
  pdf/
    SKILL.md
  code-review/
    SKILL.md

每个技能一个目录,每个目录里有一个 SKILL.md

比如仓库里确实有这些技能目录:agent-buildercode-reviewmcp-builderpdf。(GitHub)

SKILL.md 的结构通常是:

yaml 复制代码
---
name: pdf
description: Process PDF files...
---

# PDF Processing Skill

具体操作说明......

比如 skills/pdf/SKILL.md 里就有 name pdfdescription Process PDF files...,后面才是完整的 PDF 处理说明。(GitHub)

核心代码解释

1. 初始化环境

ini 复制代码
load_dotenv(override=True)

if os.getenv("ANTHROPIC_BASE_URL"):
    os.environ.pop("ANTHROPIC_AUTH_TOKEN", None)

WORKDIR = Path.cwd()
client = Anthropic()
MODEL = os.environ["MODEL_ID"]
SKILLS_DIR = WORKDIR / "skills"

这段做了几件事:

代码

作用

load_dotenv(override=True)

读取 .env 文件里的环境变量

ANTHROPIC_BASE_URL 判断

如果使用自定义 base url,就移除 ANTHROPIC_AUTH_TOKEN

WORKDIR = Path.cwd()

当前运行目录作为工作区

client = Anthropic()

初始化 Anthropic 客户端

MODEL = os.environ["MODEL_ID"]

从环境变量读取模型名

SKILLS_DIR = WORKDIR / "skills"

默认从当前目录下的 skills/ 加载技能

这些初始化代码位于文件中部,负责准备模型客户端、模型 ID 和技能目录。(GitHub)

2. SkillLoader:技能加载器

这是本文件最关键的类。

ruby 复制代码
class SkillLoader:
    def __init__(self, skills_dir: Path):
        self.skills_dir = skills_dir
        self.skills = {}
        self._load_all()

它的作用是:启动时扫描 skills/ 目录,把所有技能读进内存。
self.skills 是一个字典,用来保存所有技能。每个技能大概会被存成这样:

css 复制代码
{
  "pdf": {
    "meta": {...},
    "body": "...完整技能内容...",
    "path": "skills/pdf/SKILL.md"
  }
}

SkillLoader 会在初始化时调用 _load_all(),扫描 skills_dir 下面所有 SKILL.md 文件。(GitHub)

3. _load_all():扫描所有技能文件

python 复制代码
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)}

这段逻辑很重要:

步骤

含义

if not self.skills_dir.exists()

如果没有 skills/ 目录,就直接返回

rglob("SKILL.md")

递归查找所有叫 SKILL.md 的文件

f.read_text()

读取技能文件内容

_parse_frontmatter(text)

拆分 YAML 元信息和正文

meta.get("name", f.parent.name)

优先用 frontmatter 里的 name,没有就用目录名

self.skills[name] = ...

存入技能字典

也就是说,技能名可以来自两种地方:

makefile 复制代码
name: pdf

或者来自目录名:

bash 复制代码
skills/pdf/SKILL.md
      ↑
     pdf

相关扫描和存储逻辑在 SkillLoader._load_all() 里。(GitHub)

4. _parse_frontmatter():解析 YAML 头部

python 复制代码
match = re.match(r"^---\n(.*?)\n---\n(.*)", text, re.DOTALL)

这行正则的意思是:

yaml 复制代码
从文件开头开始匹配:

---
这里是 YAML 元信息
---
这里是正文

如果匹配不到 frontmatter:

arduino 复制代码
return {}, text

也就是:没有元信息,整个文件都当正文。

如果匹配到了:

csharp 复制代码
meta = yaml.safe_load(match.group(1)) or {}
return meta, match.group(2).strip()

也就是:

部分

作用

match.group(1)

YAML frontmatter

match.group(2)

技能正文

yaml.safe_load(...)

把 YAML 字符串转成 Python 字典

.strip()

去掉正文首尾空白

所以 SKILL.md 实际被拆成了两部分:

css 复制代码
meta:name、description、tags 等短信息
body:完整技能说明

这就是后面"两层加载"的基础。(GitHub)

5. get_descriptions():生成第 1 层技能简介

python 复制代码
def get_descriptions(self) -> str:
    """Layer 1: short descriptions for the system prompt."""

这个方法生成放进 system prompt 的内容。

它不会返回完整技能正文,只返回类似这样的短描述:

less 复制代码
 - pdf: Process PDF files...
 - code-review: Perform thorough code reviews...

代码里会读取每个技能的:

ini 复制代码
desc = skill["meta"].get("description", "No description")
tags = skill["meta"].get("tags", "")

然后拼成一行文本。(GitHub)

这就是第 1 层:

只告诉模型有哪些技能,以及每个技能大概干什么。

6. get_content():生成第 2 层完整技能内容

python 复制代码
def get_content(self, name: str) -> str:
    """Layer 2: full skill body returned in tool_result."""

这个方法就是 load_skill 背后的真正逻辑。

如果技能不存在:

kotlin 复制代码
return f"Error: Unknown skill '{name}'. Available: ..."

如果技能存在:

swift 复制代码
return f"<skill name=\"{name}\">\n{skill['body']}\n</skill>"

也就是说,当模型调用:

scss 复制代码
load_skill("pdf")

工具会返回:

ini 复制代码
<skill name="pdf">
完整 PDF 处理说明......
</skill>

这就是第 2 层:

模型真正需要某个技能时,才把完整说明注入上下文。

相关逻辑在 get_content() 中。(GitHub)

System Prompt 是怎么组装的?

ini 复制代码
SKILL_LOADER = SkillLoader(SKILLS_DIR)

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()}"""

这段就是把技能简介注入系统提示词。

注意:这里注入的是 get_descriptions(),不是 get_content()

所以 system prompt 里只有:

diff 复制代码
你是一个 coding agent
遇到陌生任务时可以用 load_skill 加载专业知识
当前可用技能:
- pdf: ...
- code-review: ...

不会一开始就把 PDF 的详细命令、代码审查清单、MCP 构建流程全部塞进去。(GitHub)

工具系统:比前面章节多了 load_skill

这个文件仍然保留了普通 Agent 工具:

工具

作用

bash

执行 shell 命令

read_file

读取文件

write_file

写文件

edit_file

替换文件中的指定文本

load_skill

按名称加载技能正文

工具处理函数在 TOOL_HANDLERS 里注册,其中 load_skill 对应:

objectivec 复制代码
"load_skill": lambda **kw: SKILL_LOADER.get_content(kw["name"])

也就是:模型调用 load_skill,实际执行的是 SKILL_LOADER.get_content(name)。(GitHub)

load_skill 和普通工具有什么区别?

这是这个文件最值得理解的点。

普通工具是"做事"的:

复制代码
bash        → 执行命令
read_file   → 读文件
write_file  → 写文件
edit_file   → 改文件

load_skill 不是直接做事,而是"给模型补知识"的:

复制代码
load_skill → 返回一段专业操作说明,让模型变得更会做某类任务

所以它更像是:

复制代码
知识工具 / 上下文注入工具 / 按需说明书加载器

举个例子:

用户说:

复制代码
帮我处理这个 PDF

模型看到 system prompt 里有:

arduino 复制代码
- pdf: Process PDF files...

于是模型可以先调用:

json 复制代码
{
  "name": "pdf"
}

然后 load_skill 返回完整 PDF 技能,比如如何用 pdftotextPyMuPDFreportlabpandoc 等处理 PDF。skills/pdf/SKILL.md 里确实包含读取、创建、合并、拆分 PDF 的具体操作说明。(GitHub)

safe_path():限制文件路径不能逃出工作区

python 复制代码
def safe_path(p: str) -> Path:
    path = (WORKDIR / p).resolve()
    if not path.is_relative_to(WORKDIR):
        raise ValueError(f"Path escapes workspace: {p}")
    return path

这个函数用于保护 read_filewrite_fileedit_file 这些文件操作。

它会把用户传入的路径拼到当前工作区,然后解析成绝对路径。

如果最终路径不在 WORKDIR 里面,就抛错。

比如:

bash 复制代码
正常:src/main.py
危险:../../etc/passwd

../../etc/passwd 解析后会逃出工作区,所以会被拦截。相关路径保护逻辑在 safe_path() 中。(GitHub)

run_bash():执行命令,但做了简单危险命令拦截

scss 复制代码
dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]
if any(d in command for d in dangerous):
    return "Error: Dangerous command blocked"

它会拦截一些明显危险命令,比如:

bash 复制代码
rm -rf /
sudo
shutdown
reboot
> /dev/

然后用:

ini 复制代码
subprocess.run(..., shell=True, cwd=WORKDIR, timeout=120)

执行命令,最多跑 120 秒。输出会截断到 50000 字符。(GitHub)

不过这里要注意:这是教学版,不是生产级沙箱。

原因是:

  1. 它用了 shell=True

  2. 危险命令只靠字符串黑名单拦截

  3. safe_path() 只保护文件读写工具,不保护 bash 命令

  4. bash 理论上仍然能执行很多未被黑名单覆盖的危险操作

所以这个文件更适合学习 Agent 架构,不适合直接拿来跑不可信输入。

agent_loop():核心 Agent 循环

ini 复制代码
def agent_loop(messages: list):
    while True:
        response = client.messages.create(
            model=MODEL,
            system=SYSTEM,
            messages=messages,
            tools=TOOLS,
            max_tokens=8000,
        )

这里和前面的 Agent Loop 思路一样:

  1. 把历史消息 messages 发给模型

  2. 带上 system

  3. 带上 tools

  4. 等模型回复

  5. 如果模型要调用工具,就执行工具

  6. 把工具结果塞回 messages

  7. 继续循环

  8. 直到模型不再调用工具

判断是否继续的关键是:

kotlin 复制代码
if response.stop_reason != "tool_use":
    return

如果模型不是因为调用工具而停止,就说明它已经给出最终回答了,循环结束。(GitHub)

工具调用结果怎么回传给模型?

go 复制代码
results.append(
    {
        "type": "tool_result",
        "tool_use_id": block.id,
        "content": str(output),
    }
)

messages.append({"role": "user", "content": results})

这段会把工具执行结果包装成 Anthropic API 需要的 tool_result 格式,再作为一条新的 user message 追加到历史消息里。(GitHub)

所以完整交互像这样:

scss 复制代码
用户:帮我处理 PDF
模型:我要调用 load_skill("pdf")
程序:执行 load_skill,拿到 PDF 技能说明
程序:把技能说明作为 tool_result 返回给模型
模型:读完技能说明后,再继续完成 PDF 任务

命令行入口

ini 复制代码
if __name__ == "__main__":
    history = []
    while True:
        query = input("\033[36ms05 >> \033[0m")

这部分让脚本变成一个命令行聊天程序。

执行后会看到类似:

复制代码
s05 >>

然后你输入问题,程序把你的输入追加到 history

bash 复制代码
history.append({"role": "user", "content": query})
agent_loop(history)

最后从 history[-1]["content"] 里拿出模型回复并打印。(GitHub)

和前面几个文件的关系

你前面看过的文件可以串起来理解:

文件

重点

s01_agent_loop.py

最小 Agent Loop:用户输入 → 模型回复

s02_tool_use.py

加入工具调用:模型可以调用 bash / read / write 等工具

s03_todo_write.py

加入任务规划 / Todo 管理

s04_subagent.py

加入子 Agent:主 Agent 可以把任务委托出去

s05_skill_loading.py

加入技能加载:模型可以按需加载专业知识

s05 的本质升级是:

复制代码
从"能调用工具"
升级到
"能根据任务加载专业操作手册"

也就是:

ini 复制代码
Agent = LLM + Tools + Memory/Context + Planning + Skills

这里的 Skills 不是模型本身的能力,而是外部维护的一组专业说明书。

这个文件最核心的设计思想

可以总结成一句话:

sql 复制代码
System Prompt 只放索引,完整知识按需加载。

更具体一点:

ini 复制代码
skills/ 目录 = 技能库
SKILL.md frontmatter = 技能索引
SKILL.md body = 完整技能说明
get_descriptions() = 注入 system prompt 的轻量索引
load_skill() = 按需读取完整技能
tool_result = 把技能正文塞回模型上下文

这个设计很像:

复制代码
书架目录 + 按需取书

不是一开始把所有书都摊在桌子上,而是先告诉模型:

复制代码
我有 PDF 技能、代码审查技能、MCP 构建技能......

等模型真的要处理 PDF 时,再把 PDF 那本"说明书"拿出来。

最后用一句大白话理解

s05_skill_loading.py 就是在教你做一个更聪明的 AI Coding Agent:

它不再把所有知识都写死在 system prompt 里,而是把知识拆成一个个 SKILL.md 文件;启动时只加载技能简介,真正用到时再通过 load_skill 工具把完整技能说明拿出来。这样既省 token,又让 Agent 可以扩展出很多专业能力。

相关推荐
Double@加贝2 天前
Cursor 的基础配置使用说明
cursor·ai工具
qcx232 天前
【系统学AI】21 AI产品定位:April Dunford方法在AI红海中的应用
人工智能·claude·cursor·定价·ai native
MattTian3 天前
Cursor配置教程
cursor
Cyning3 天前
AI 编程可闭环协作 · 卷二:技术图谱——让 Agent 先看地图再动手
ai编程·cursor
huangfuyk5 天前
前端使用Cursor编辑器方面遇到的问题及注意细节
前端·编辑器·ai编程·cursor
kyriewen8 天前
推行AI写代码一年后,Code Review变成了新的加班理由
前端·ai编程·cursor
柯倪8 天前
写了个AI插件,用以改善ai忘了做过什么,以及日常的一些小问题
ai编程·cursor
行走__Wz8 天前
cursor报错:Your Cursor installation appears to be corrupt. Please reinstall.
cursor·cursor报错
糖梨8 天前
Windows 下 Cursor 变量跳转的 WSL2 + clangd 方案 —— 跨平台 Linux C++ 开发环境搭建踩坑实录
c++·跨平台·wsl·clangd·cursor