S05 Skill 加载详解笔记

基于 s05_skill_loading.py 源码逐行分析,配合 s05-skill-loading.md 设计思路。


一、问题:所有知识塞进 system prompt 太浪费

s04 的 agent 已经能派子 agent 出去干活了,但还有一个问题没解决:领域知识放哪里?

假设你希望 agent 遵循特定的工作流——git commit 规范、代码审查清单、PDF 处理流程、MCP server 构建指南。最直接的做法是把这些全写进 system prompt:

你是一个编程 agent。
Git 规范:第一条...第二条...(500 tokens)
测试模式:AAA 模式...mock 策略...(800 tokens)
代码审查:检查项一...检查项二...(600 tokens)
PDF 处理:先用 pdftotext...再提取表格...(1200 tokens)
...

10 个 skill,每个 500-2000 token,加起来就是 上万 token 常驻在 system prompt 里。问题是:用户这次只想处理一个 PDF,其他 9 个 skill 的 token 全浪费了。更糟的是,大部分 API 对 system prompt 也计费——你为没用的知识付了全价

二、解决方案:两层架构,按需加载

s05 的方案很优雅:

第一层(便宜):system prompt 里只放 skill 的名字和一句话描述
第二层(按需):模型调用 load_skill("xxx") 时,完整内容才以 tool_result 注入

System prompt:
+--------------------------------------+
| You are a coding agent.              |
| Skills available:                    |
|   - pdf: Process PDF files...        |  ← Layer 1: 只有名字和描述,~100 tokens/skill
|   - code-review: Review code...      |
+--------------------------------------+

当模型调用 load_skill("pdf"):
+--------------------------------------+
| tool_result:                         |
| <skill name="pdf">                   |
|   完整的 PDF 处理流程...              |  ← Layer 2: 完整 body,~2000 tokens
|   步骤一:...                        |      但只在实际用到时才加载
|   步骤二:...                        |
| </skill>                             |
+--------------------------------------+

核心思想:“Don’t put everything in the system prompt. Load on demand.” — 别把所有东西塞进系统提示词,用到什么加载什么。


三、和 s04 相比,多了什么?

组件 s04 s05
知识管理 SkillLoader:从 skills/*/SKILL.md 加载
system prompt 静态字符串 + 动态注入 skill 名称列表
新工具 task load_skill(按需加载完整内容)
分层设计 两层注入(prompt 层 + tool_result 层)

s04 引入了子 agent 来隔离上下文,s05 则是在不隔离的情况下减少上下文浪费——两个互补策略。


四、Skill 文件长什么样?

4.1 目录结构

skills/
  pdf/
    SKILL.md          ← YAML frontmatter + Markdown body
  code-review/
    SKILL.md
  agent-builder/
    SKILL.md

每个 skill 是一个目录,目录名是 skill 的标识。目录下必须有一个 SKILL.md 文件。

4.2 SKILL.md 格式

---
name: pdf
description: Process PDF files - extract text, fill forms, merge documents
tags: [document, conversion]
---

# PDF Processing

## Step 1: Extract text
Use `pdftotext input.pdf -` to extract text content.

## Step 2: Extract tables
Use `pdfplumber` or `tabula-py` for table extraction.
...

上半部分是 YAML frontmatter(两行 --- 之间的内容)——包含元数据:name(skill 名)、description(一句话描述,会出现在 system prompt 里)、tags(可选标签)。

下半部分是 Markdown body——skill 的完整指令,模型调用 load_skill 后才会看到。

初学者注意:YAML 是一种人类可读的数据格式,用缩进表示层级关系。--- 是 YAML 文档的分隔符。Python 读取时用 yaml.safe_load() 把文本解析成 dict。


五、SkillLoader 类:skill 的扫描、解析、分发

5.1 初始化与扫描

class SkillLoader:
    def __init__(self, skills_dir: Path):
        self.skills_dir = skills_dir
        self.skills = {}            # {name: {meta: {...}, body: "...", path: "..."}}
        self._load_all()            # 初始化时自动扫描所有 skill 文件

创建实例时立刻调用 _load_all() 扫描整个 skills/ 目录。所有 skill 在程序启动时一次性加载到内存,后续 get_content() 只是读字典——不需要每次访问硬盘

5.2 _load_all() — 递归发现所有 SKILL.md

def _load_all(self):
    if not self.skills_dir.exists():
        return                        # skills/ 目录不存在就静默跳过,不报错
    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)  # 优先用 frontmatter 里的 name,
        self.skills[name] = {                    # 没有就用目录名
            "meta": meta,
            "body": body,
            "path": str(f)
        }

几个关键细节:

  • rglob("SKILL.md"):递归搜索所有子目录。rglobPath 的方法,类似 **/*.md 通配。
  • sorted(...):按文件名排序,确保每次加载顺序一致——避免 skill 列表顺序随机变化干扰模型。
  • name 的取值优先级:frontmatter 里写了 name: xxx → 用它;没写 → 用父目录名(如 skills/pdf/SKILL.mdname = "pdf")。
  • 不存在不报错skills/ 目录没有时不崩溃,agent 照样跑,只是 get_descriptions() 返回 "(no skills available)"。这种"可选的增强"设计贯穿整个项目。

5.3 _parse_frontmatter() — 分离元数据和正文

def _parse_frontmatter(self, text: str) -> tuple:
    match = re.match(r"^---\n(.*?)\n---\n(.*)", text, re.DOTALL)
    if not match:
        return {}, text                # 没有 frontmatter → 整个文本当 body
    try:
        meta = yaml.safe_load(match.group(1)) or {}
    except yaml.YAMLError:
        meta = {}                      # YAML 格式坏了也不崩溃,降级为空字典
    return meta, match.group(2).strip()

正则 ^---\n(.*?)\n---\n(.*) 把文件切成两段:group(1) 是 frontmatter(YAML),group(2) 是正文(Markdown)。re.DOTALL. 匹配换行符——否则 .*? 只能匹配一行。

容错设计值得注意:YAML 解析失败返回空字典,正则不匹配返回原文本作为 body。无论输入多差,函数都不会崩。对 agent 来说,这意味着用户可以随意写 SKILL.md——格式不对最多导致 skill 不可用,不会让整个程序挂掉。

5.4 get_descriptions() — 第一层:给 system prompt 的简介

def get_descriptions(self) -> str:
    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)

输出效果:

  - pdf: Process PDF files - extract text, fill forms, merge documents [document, conversion]
  - code-review: Review code for bugs, style, and security issues [quality, security]

这段文本被嵌入 system prompt,模型看到的只是"有哪些 skill 以及一句话描述"。每个 skill 大约占 100 tokens——10 个 skill 才 1000 tokens,比全部塞进去省了 10-20 倍。

5.5 get_content() — 第二层:返回完整内容

def get_content(self, name: str) -> str:
    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>"

模型调 load_skill("pdf") → 返回用 <skill> 标签包裹的完整 body。<skill> 标签不是 HTML,是一个语义标记——帮助模型区分"这是系统注入的 skill 指令"和"对话中的其他文本"。和 s03 的 <reminder> 是同一思路。


六、工具分发与 system prompt 注入

6.1 load_skill 工具定义

核心字段:

  • name:skill 名称,必填。模型从 system prompt 里看到了可用列表,调用时指定要加载哪个。

6.2 分发

TOOL_HANDLERS = {
    # ...s04 的 5 个工具...
    "load_skill": lambda **kw: SKILL_LOADER.get_content(kw["name"]),
}

load_skill 和其他工具完全平权。模型的视角是:我看到了一个叫 “pdf” 的 skill → 我需要处理 PDF → 我先调 load_skill("pdf") 获取完整指令 → 然后按指令执行。skill 的 body 在 tool_result 里出现在 messages 最新位置,模型注意力最强。

6.3 System prompt 的动态注入

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

注意这行:“Use load_skill…before tackling unfamiliar topics” — 告诉模型:遇到不熟悉的领域,先加载 skill 再看。这就是那个"使用规范":工具 schema 告诉你 怎么调(传 name 参数),system prompt 告诉你 什么时候调(遇到陌生领域先加载)。


七、完整流程走读

假设 skills/ 下有一个 pdf skill,用户说:“帮我把 report.pdf 转成文本”。

第 1 轮

  1. 模型收到 system prompt,里面写着:
    Skills available:
      - pdf: Process PDF files - extract text...
    
  2. 模型看到 “pdf”,任务又涉及 PDF,判断需要加载 skill。
  3. 模型调用 load_skill("pdf")

第 2 轮

  1. SKILL_LOADER.get_content("pdf") 返回:
    <skill name="pdf">
    # PDF Processing
    Step 1: Use pdftotext input.pdf - to extract text.
    Step 2: For tables, use pdfplumber.
    </skill>
    
  2. 这段内容作为 tool_result 追加到 messages——出现在上下文最新位置,模型注意力最强。
  3. 模型读到完整指令,知道第一步该用 pdftotext

第 3 轮

  1. 模型调用 bash: pdftotext report.pdf -,得到文本输出。
  2. 任务完成。

关键洞察:上 1 万 token 的 skill body 只在第 2 轮存在于上下文中。第 1 轮和第 3 轮里就没有。如果把它塞进 system prompt,它会占用每一轮的 token 预算——不管用不用。


八、设计洞察

8.1 懒加载:让昂贵的东西可选

skill 的完整内容很贵(2000+ tokens),但不是每次对话都需要。s05 的方案等价于编程中的"懒加载"(lazy loading)——只在实际访问时把完整数据装入内存(上下文)。system prompt 里放名字,相当于放了一个"指针",解引用由模型决定。

8.2 文本即接口

skill 文件没有任何自定义格式——YAML frontmatter + Markdown body。任何人可以用任何编辑器创建 skill,不需要懂 Python。降低贡献门槛就是提高系统可扩展性

8.3 容错贯穿始终

_parse_frontmatter 的三层降级:正则不匹配 → ({}, text);YAML 解析失败 → {};name 没有 → 用目录名。无论输入多差,函数不崩。这个模式在 skill 系统里尤其重要——skill 是用户写的,用户会犯错。

8.4 信号与内容的分离

system prompt 里放的是信号(“这个 skill 存在,它大概干什么”),tool_result 里放的是内容(“具体怎么做”)。信号帮助模型做路由决策(“我需要这个 skill 吗?”),内容帮助模型做执行决策(“怎么操作 pdftotext?”)。把两者分开是对 token 预算的最优分配。

8.5 能力扩展不侵入循环

加一个 skill 不需要改 agent_loop。SkillLoader 扫描文件系统,load_skill 只是 dispatch map 里又一个条目。这延续了 s02 以来的一贯哲学:扩展能力 = 加工具 + 注册 handler,循环永远不变

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐