HARNESS 105

Skill Loading

"用到什么知识,临时加载什么知识"

不要让 Agent 背着整本百科全书走路。
让它知道有哪些书,用到哪本再翻开哪本。

1

问题:System Prompt 不是免费午餐

从 s01 到 s04,system prompt 一直都很短——"You are a coding agent. Use tools." 就一句。但在真实的 Agent 应用中,你需要让 Agent 懂很多东西:Git 工作流、测试规范、代码审查清单、API 设计指南、安全扫描规则、部署流程、文档风格……

如果全部塞进 system prompt

假设你有 10 个技能,每个的详细指南约 2000 token。全部塞进 system prompt:10 × 2000 = 20,000 token

system prompt 是每次 API 调用都发送的。Agent 一次任务平均调用 10 次 API → 10 × 20,000 = 200,000 token 消耗在这些技能知识上。

每次调用里,用户实际任务只占约 3,000 token。system prompt 占了 20,000 token——87% 的上下文窗口被"可能用得上"但"这次没用"的知识占据。

三个具体问题

① 浪费钱:每 1,000 token 都计费。10 次调用 × 20k = 200k token 浪费在无关知识上。如果 Claude Opus 的价格是 $15/百万 input token,这就是 $3 的无谓开销。

② 挤压有效上下文:system prompt 占得多 → 留给对话历史、文件内容、工具结果的空间就少。长任务更容易触发上下文溢出。

③ 稀释注意力:system prompt 里的每一条信息都在争夺 LLM 的注意力。20,000 token 的 system prompt 里,真正相关的 2,000 token 被淹没在 18,000 token 的噪声中。就像在 10 页纸里找 1 段话。

核心矛盾:知识越多 → Agent 越能干,但知识越多 → system prompt 越大 → 成本越高、上下文越挤、注意力越散。我们需要一个让知识和成本脱钩的机制。
2

两层加载:把"目录"和"正文"分开

s05 的核心洞见来自日常经验:你去图书馆,不是把所有书翻开摆在桌上。你先看目录卡片(便宜),找到需要的书,再翻开那本(贵)。两层加载就是把 system prompt 变成"目录卡片",把 skill 正文变成"书"。

LAYER 1 — 始终在 system prompt

Skill 名称 + 一句话描述

Skills available:
  - git: Git workflow helpers
  - test: Testing best practices
  - review: Code review checklist
  - api: REST API design guidelines
  - security: Security scanning rules
  ...
💰 成本:~100 token/skill × 10 = ~1,000 token
LAYER 2 — 模型调用 load_skill 时才注入

完整 Skill 正文(tool_result)

<skill name="review">
  ## Code Review Checklist
  ### 1. Naming
  - Variables: snake_case
  - Classes: PascalCase
  ### 2. Error Handling
  ...
💰 成本:~2,000 token/skill,但只在用到时加载 1 次

同一个 10-skill 场景,成本对比

方案System Prompt每次调用的有效上下文
全塞进 system20,000 token被严重压缩
两层加载~1,000 token95% 的空间留给任务

而且这个节省随 skill 数量线性放大:20 个 skill → 全塞 40,000 token vs 两层 ~2,000 token。skill 越多,两层模式的优势越大。

设计哲学:第一层让你知道有什么(awareness),第二层让你获得详情(deep knowledge)。两个完全不同的成本等级,承载两种完全不同的信息需求。
3

案例:有 Skill Loading 和没有的区别

任务:"帮我做一次代码审查,检查 auth.py 的安全性和可读性"。假设项目有 8 个 skill:git, test, review, api-design, security, deploy, doc, cicd。

场景 A:没有两层加载,全塞 system prompt

每次 API 调用,system prompt 携带 8 个 skill × 2000 token = 16,000 token。

第 1 轮:LLM 收到 system(16k 包含全部 skill)+ 用户任务 + auth.py 内容(2k)→ 共 18k+ token。
LLM 需要在 16k 的 system prompt 里找到 review 和 security 的指令——它们占了 system 的 4k/16k = 25%,被其余 12k 无关内容包围。

第 3 轮:对话历史已经累积了前几轮的工具结果,加上 16k 的 system prompt → 上下文窗口更紧张。

结果:LLM 可能漏掉 review skill 里的某些检查项——不是它不努力,是它的注意力被 system prompt 里的 deploy 和 cicd 指令稀释了。

场景 B:有两层加载,按需注入

【第 1 轮 API 调用】
system: "You are a coding agent... Skills: git, test, review, api, security, deploy, doc, cicd" (~800 token)
user: "帮我做代码审查,检查 auth.py"

assistant: tool_use load_skill("review") & load_skill("security")
【第 2 轮 API 调用】
user: tool_result <skill name="review"> 完整审查清单 </skill> (2,000 token)
user: tool_result <skill name="security"> 完整安全检查 </skill> (2,000 token)

assistant: 现在有了详细的审查清单,开始逐项检查 auth.py...

总成本:第 1 轮 system(800) + 第 2 轮 system(800) + load_skill 结果(4,000) = 5,600 token

相比全塞模式的 16,000 token/轮 × 5 轮 = 80,000 token,节省了 93%。而且 LLM 的注意力聚焦在 review 和 security 上,不被 deploy 和 cicd 干扰。

💡 两层加载为什么有效?不是技术原因,是认知原因
人类专家也是这样工作的——你不会在脑子里同时激活所有知识。你根据当前任务,从记忆里调取相关的知识。Skill Loading 就是让 LLM 拥有这种"按需调取"的能力。Layer 1 告诉它有哪些可选,Layer 2 让它取到详情。
4

Skill 文件格式:YAML Frontmatter + Markdown

s05 选择了最通用的文件格式:Markdown 文件 + YAML 头部元数据。不需要数据库、不需要 API、不需要配置中心——就是文件夹里的 .md 文件

目录结构:一个目录 = 一个 Skill

skills/ ├── git/ │   └── SKILL.md ← 文件名固定为 SKILL.md ├── test/ │   └── SKILL.md ← 目录名 = skill 名 ├── review/ │   └── SKILL.md ← SkillLoader 递归扫描 rglob("SKILL.md") └── security/    └── SKILL.md

文件名统一为 SKILL.md,目录名作为默认的 skill 名称。这样一眼就能看出每个 skill 是什么,不需要打开文件。

文件内容:YAML 头 + Markdown 正文

# skills/review/SKILL.md 的完整内容: --- name: review ← 可选:覆盖目录名作为 skill 名 description: >- ← Layer 1 用的描述文本 Code review checklist covering naming, error handling, performance, security, and readability. tags: quality, review, checklist ← 可选标签,可用于分类/搜索 --- ← --- 以下是正文,模型调用 load_skill 时才获取 → # Code Review Checklist ## 1. Naming Conventions - Variables and functions use snake_case - Classes use PascalCase - Constants use UPPER_SNAKE_CASE ## 2. Error Handling - All exceptions are either caught with specific handlers or documented in the function's docstring - Never use bare `except:` without specifying exception types ## 3. Performance - No O(n²) loops on potentially large datasets - Database queries are not inside loops (N+1 problem)

为什么选择这个格式?

① 人可读:Markdown 是最通用的文档格式。非程序员也能写、能改。

② 机器可解析:YAML frontmatter 有成熟的解析库。正则表达式 ^---\n(.*?)\n---\n(.*) 一行拆开。

③ 无依赖:不需要数据库、不需要 API、不需要额外的服务。文件系统就是存储。

④ 版本控制友好:.md 文件放进 git 仓库,改动有历史、有 diff、有 code review。

⑤ 动态加载:加一个新 skill 就是新建一个目录和文件。重启 Agent,SkillLoader 自动扫描到它。不需要改任何代码。

5

SkillLoader 类:扫描、解析、提供两个接口

SkillLoader 是整个 s05 的核心类。它只有 ~50 行代码,但做了四件事:扫描文件 → 解析 frontmatter → 提供描述列表 → 提供完整内容。

① 扫描:递归查找所有 SKILL.md

def _load_all(self): 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)}

rglob("SKILL.md") 是 Python 的递归 glob——它会遍历 skills/ 下的所有子目录,找到每一个 SKILL.md。sorted() 保证加载顺序稳定(按目录名排序)。

如果 YAML 里没有 name 字段,默认用目录名作为 skill 名。这意味着最简情况下,你只需要创建一个目录并在里面放一个 SKILL.md,不需要写 YAML。

② 解析:一行正则拆开 YAML 头和正文

def _parse_frontmatter(self, text): match = re.match(r"^---\n(.*?)\n---\n(.*)", text, re.DOTALL) if not match: return {}, text # 没有 frontmatter → 全文作为正文 meta = yaml.safe_load(match.group(1)) or {} return meta, match.group(2).strip()

正则 ^---\n(.*?)\n---\n(.*) 解释:

  • ^--- — 匹配文件开头的三个横线
  • (.*?) — 捕获 YAML 内容(非贪婪,到下一个 --- 就停)
  • --- — 匹配第二个三个横线
  • (.*) — 捕获剩余所有内容 = 正文

re.DOTALL. 也能匹配换行符,这样正文里的多行内容能被完整捕获。

如果文件没有 frontmatter(不以 --- 开头),match 为 None → 返回空元数据 + 全文作为正文。这样最简单的情况下,一个纯 Markdown 文件也能作为 skill 加载,只是没有 Layer 1 的描述信息。

③ 两个接口:get_descriptions() 和 get_content(name)

这是 SkillLoader 对外暴露的两个方法,分别对应 Layer 1 和 Layer 2:

# Layer 1:返回所有 skill 的简短描述(约 100 token/skill) def get_descriptions(self) -> str: for name, skill in self.skills.items(): desc = skill["meta"].get("description", "No description") yield f" - {name}: {desc}" # Layer 2:返回指定 skill 的完整正文(约 2000 token/skill) def get_content(self, name: str) -> str: skill = self.skills.get(name) if not skill: return f"Error: Unknown skill '{name}'" return f"<skill name=\"{name}\">\n{skill['body']}\n</skill>"

注意 get_content<skill> XML 标签包裹正文。这不是必须的,但它有两个好处:① 让 LLM 清楚地知道"这是 skill 的边界";② 如果一次加载了多个 skill,标签帮助区分它们。

6

Layer 1 & Layer 2 在运行时怎么协作

Layer 1:System Prompt 动态生成

s05 的 system prompt 不再是写死的字符串,而是一个 f-string,嵌入了 SKILL_LOADER.get_descriptions() 的返回值:

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

启动时,SkillLoader 扫描完 skills/ 目录后,这行代码运行一次。system prompt 里包含的就是当前所有 skill 的名称和一句话描述。

加新 skill 的效果:在 skills/deploy/SKILL.md 放一个新文件 → 重启 Agent → system prompt 自动多一行 - deploy: Deployment workflow and checklist。没有改任何 Python 代码。

Layer 2:load_skill 工具 — 和读文件一样的机制

load_skill 在 dispatch map 里就是一行。它和 read_file 在机制上完全一样——都是 "LLM 调用 → handler 执行 → 结果作为 tool_result 注入 messages":

TOOL_HANDLERS = { "bash": ..., "read_file": lambda **kw: run_read(kw["path"], kw.get("limit")), "load_skill": lambda **kw: SKILL_LOADER.get_content(kw["name"]), # ↑ 和 read_file 一样的 dispatch 模式。handler 不同,机制相同。 }

这意味着什么?LLM 不需要知道 skill 是"特殊的"。对它来说,load_skill("review")read_file("auth.py") 是一样的——调一个工具,拿到一段文本,基于这段文本继续思考。这是 s05 最优雅的地方:知识注入和文件读取共用同一套机制。

跟读数据流:一次完整的 load_skill 过程

假设用户说"帮我审查 auth.py"。LLM 看到 system prompt 里有 skill 列表,决定需要 review skill:

LLM 的 tool_use block:
{"type": "tool_use", "name": "load_skill", "input": {"name": "review"}}

Harness dispatchTOOL_HANDLERS.get("load_skill")(name="review")

SkillLoader.get_content("review"):从 self.skills["review"] 取出 body → 包裹在 <skill> 标签中 → 返回字符串

注入到 messages 的 tool_result:
{"type": "tool_result", "tool_use_id": "toolu_001",
 "content": "<skill name=\"review\">\n# Code Review Checklist\n## 1. Naming..."}

下一轮 API 调用:这条 tool_result 作为 role: "user" 的消息出现在 messages 末尾。LLM 看到了完整的 review 清单。

LLM 继续工作:现在有了详细指南,它开始逐项检查 auth.py——命名规范 ✓、错误处理 ✓、性能问题 ✓。

7

代码拆解 & s04 vs s05

A

SkillLoader 类(~50 行,新增)

递归扫描 skills/ 目录下的 SKILL.md 文件 → 正则解析 YAML frontmatter → 存入内存字典。提供两个方法:get_descriptions() 返回轻量目录(用于 system prompt),get_content(name) 返回完整正文(用于 tool_result)。

最精妙的设计:如果文件没有 frontmatter,也能加载——将全文作为正文,元数据为空,目录名作为 skill 名。零配置即可用。

B

动态 System Prompt(改动 ~5 行)

从写死的字符串变成 f-string 嵌入 SKILL_LOADER.get_descriptions()。加 skill 不改代码——只改文件系统。这呼应了 s05 的核心哲学:配置和数据应该放在文件里,而不是代码里。

C

load_skill 工具(dispatch map 加 1 行)

和 s02/s03/s04 的每一个工具注册方式完全相同。handler 是 SKILL_LOADER.get_content(name),返回的文本作为 tool_result 注入 messages。知识注入和文件读取使用同一套 tool_result 管道——这是 s05 最优雅的架构选择。

组件s04s05
System Prompt静态字符串(~50 token)动态生成,嵌入 skill 目录(~1000 token)
知识管理skills/*/SKILL.md,文件系统即知识库
新增类SkillLoader(扫描+解析+提供接口)
新增工具task(子 agent)load_skill(按需知识)
知识注入路径Layer 1: system prompt / Layer 2: tool_result
可扩展性改代码加工具加文件加 skill,不改代码
Agent Loop完全不变
8

从 s05 带走的三个核心认识

01

知识分层,按热度对待

热知识(有什么可用的)放 system prompt——始终可见、成本低。冷知识(具体怎么做)放 tool_result——用到才加载、成本高但精准。

02

配置优于代码

skill 是 Markdown 文件,不是 Python 代码。加 skill 不需要改代码、不需要重新部署逻辑。文件系统就是配置中心。

03

复用同一套管道

知识注入(load_skill)和文件读取(read_file)共用 tool_result 机制。不给知识搞特殊通道——统一的工具调用管道让架构保持简洁。

9

落地案例:Skill Loading 在真实 AI 产品中的应用

📂 Claude Code 的 Skill 系统

Claude Code 的 .claude/skills/ 目录和 s05 的 skills/ 目录是同一个设计。你在 Claude Code 里用的 /code-review/simplify 等 slash command,底层就是 SKILL.md 文件被 SkillLoader 加载。

Claude Code 在每次对话开始时,会自动扫描 skills 目录,把 skill 名称列表放进 system prompt(Layer 1)。当你提到相关任务时,Claude 会自动加载对应的 skill 全文(Layer 2)。你写的每一个 SKILL.md,都直接参与了 Claude 的决策过程。

🔌 MCP Server 的工具注册

MCP(Model Context Protocol)Server 连接时,先发送一个轻量的"工具清单"(名称 + schema ≈ Layer 1)。工具被调用时才返回完整输出(≈ Layer 2)。原理和 s05 完全一致——只是 MCP 的两层是"工具 schema vs 工具执行结果",s05 的两层是"skill 元数据 vs skill 正文"。

🧠 RAG 的"确定性版本"

RAG(检索增强生成)让 LLM 从知识库中模糊搜索相关内容。s05 的 Skill Loading 可以看作"确定性 RAG"——LLM 精确知道要加载哪个 skill,直接取全文,不做模糊检索。

在你的 Agent 项目中:结构化知识(检查清单、工作流、模板)用 s05 模式。非结构化知识(历史文档、FAQ、知识库文章)用 RAG。两者互补,不是替代。

核心迁移:设计 Agent 的知识系统时,问自己三个问题:① 哪些知识是"几乎所有任务都要用"的?→ 塞 system prompt。② 哪些是"特定场景才用"的?→ 做成 skill,用到才加载。③ 哪些是"不知道什么时候会用到"的?→ 用 RAG 检索。三层知识架构,各司其职。

s05 解决了知识注入的成本问题,但 Agent 对话还是会越来越长。
s06:怎么在上下文满了的时候,优雅地"腾地方"?

s06: Context Compact →