Skill 按需加载
让 Agent 先看技能目录,需要时再加载正文,避免把几千行规范常驻 Prompt。
先看它怎样跑起来
这一章不是几个孤立知识点,而是一条会产生结果的因果链。
Skill 按需加载
亮起的是当前动作,留下的是已经满足的前置条件。点击任意一步,可以从那里继续。
- 知识管理与知识加载是两件事。
- 目录是低成本索引,正文是按需上下文。
- Skill 清单需要稳定的名称、描述和路径契约。
顺着原文把边界看清
01导读:问题背景与本章目标项目跑起来之前,我给 Agent 准备了三份文档:TypeScript 代码规范、SQL 风格指南、API 设计约定。加起来几千行。⌄
项目跑起来之前,我给 Agent 准备了三份文档:TypeScript 代码规范、SQL 风格指南、API 设计约定。加起来几千行。
最直接的做法是全部塞进 System Prompt。结果是 Agent 每次请求模型,不管在改一行注释还是设计一张表,都带着全量文档;绝大多数内容和当前任务无关,但每轮都要占上下文。
更麻烦的是,规范会不断增加。每新增一份文档,System Prompt 就再膨胀一截。上一章刚用 Subagent 隔离了探索轨迹,这里又把静态知识一次性塞满上下文,显然不划算。
02先看一眼一次真实的加载目录与正文各在哪一轮出现——以及加载正文需要人点「同意」吗。⌄
用 npm run ch07 -- --prompt "先调用 load_skill 加载 typescript-style,再总结其中两条约定" 跑一次,观察两件关键的事:
请求1 system 里有目录(所有 SKILL 的 name+description,无正文)
→ 模型看到目录,决定调用 load_skill
请求2 model.load(request): 用 name 查出路径,现读正文
工具结果 = 完整正文回到消息流
→ 模型基于正文回答注意:第一次请求模型时,SKILL.md 的正文不在上下文里;frontmatter 也不在(只有目录条目)。
加载正文需要人点「同意」吗?不需要。load_skill 是只读 read 类副作用,不弹审批框;但任何最终决定仍会进审计。
子 Agent 加载 Skill 时看到什么?子 Agent 有 load_skill 但没有目录——它不知道有哪些技能,只能靠 task 的 description 里写明的 Skill 名去加载。所以委派时描述要写清技能名。
03知识管理和知识加载是两件事Agent 需要先知道“有哪些规范可用”,但不需要在启动时读完所有正文。⌄
Agent 需要先知道“有哪些规范可用”,但不需要在启动时读完所有正文。
第 7 章采用两级加载:
| 层级 | 进入上下文的内容 | 注入时机 | 默认上限 |
|---|---|---|---|
| Skill 目录 | 名称 + 单行描述 | 构建第 7 章 Agent 时进入 System Prompt | 100 项、8000 UTF-8 bytes |
| Skill 正文 | SKILL.md frontmatter 之后的正文 | 模型显式调用 load_skill 后进入 tool result | 单次加载一个已注册 Skill |
目录让模型能做选择,正文只在真正需要时付出上下文成本。目录按名称排序,并按完整条目截断。它不会为了凑预算,把半行描述塞进 Prompt。
本章的目录是构建 Agent 时得到的快照。运行期间新增 Skill,要重建 Agent 才会进入目录;已注册 Skill 的正文则会在每次 load_skill 时重新读取和校验。直到第 10 章,System Prompt 才会由动态 provider 按轮渲染。
这里需要澄清一个容易误解的点:目录是稳定前缀,因此可以复用 KV Cache。但“对 KV Cache 友好”不等于“零成本”。会话开始时,目录本身仍要完成一次 prefill;之后它固定在 System Prompt 的原位置,后续轮次可以按缓存读取。第一次加载某个 Skill 正文时,正文也要先 prefill,之后才作为会话历史的一部分复用缓存。收益来自两点:启动时不需要把全部 Skill 正文读进 Prompt;后续也不会因为加载新 Skill 而改写已经缓存的前缀。
04第 7 章的验收契约先把这一章要钉住的结论列出来。后面每一节都在解释其中一条,「用离线测试证明行为」会逐条验证。⌄
| # | 契约 | 反面是什么 |
|---|---|---|
| 1 | 目录只含名称和单行描述,永不含正文 | 启动就把全部规范塞进 Prompt |
| 2 | 目录按名称排序,两次扫描结果逐字节一致 | 目录顺序随文件系统返回顺序抖动,KV Cache 全废 |
| 3 | 预算按 UTF-8 byte 算,超限丢整条 | 用字符串 length 冒充 bytes;截出半行描述 |
| 4 | 扫描只解码到第二个 ---,正文不解码 | 启动成本随正文总量线性增长 |
| 5 | load_skill 只收 name,多一个字段就拒 | 收 path,让模型指定任意文件 |
| 6 | 加载时每一层路径重新 realpath 复查 | 相信扫描时存下的路径,扫描后被换链接就逃逸了 |
05Skill 的目录和清单契约默认目录是当前工作区下的 skills/。扫描只看它的一级子目录,每个 Skill 必须有一个 SKILL.md:⌄
默认目录是当前工作区下的 skills/。扫描只看它的一级子目录,每个 Skill 必须有一个 SKILL.md:
skills/
typescript-style/
SKILL.md
sql-style/
SKILL.md一个最小清单如下:
---
name: typescript-style
description: Use when 编写或审查 TypeScript 类型、模块、导入和运行时校验;Don't use for SQL、部署或通用项目规划。
---06TypeScript Style外部输入先按 unknown 处理,通过运行时校验后再进入业务逻辑。⌄
外部输入先按 unknown 处理,通过运行时校验后再进入业务逻辑。
frontmatter 不是“能解析就行”,而是有明确边界:
- 第一行必须是 `---`,并且必须有结束分隔符。
- `name` 和 `description` 都必须存在且类型为字符串。
- `description` 去除首尾空白后不能为空,也不能跨行。
- `name` 最多 64 个字符,只允许小写字母、数字和单个连字符分段,例如 `typescript-style`。
- `name` 必须与目录名完全一致;`nul`、`com1`、`lpt9` 等 Windows 设备名不是合法 Skill 名。
- 重复名称直接使扫描失败,不会让后读到的条目静默覆盖前一个。
- frontmatter 和显式加载的完整文件必须是 UTF-8。
`description` 不是“功能简介”,而是模型做路由判断的条件。`ai-agent-book` 第 2 章对照了 Anthropic 的 Agent Skills 实践:最有效的写法是 `Use when / Don't use when`,并明确给出反例。缺少反例时,宽泛描述容易在不相关任务上误触发,路由准确率会明显下降。所以示例里的“编写或审查 TypeScript 类型、模块、导入和运行时校验”是正例,“SQL、部署或通用项目规划”是反例。
这条保留名检查不是为 YAML 单独写的一套宽松规则,而是复用文件系统路径组件校验。`isWindowsReservedComponent` 放在 `core/filesystem.ts`,文件 adapter 和 Skill 扫描使用同一实现,避免两套边界慢慢漂移。
这套约束的价值不在 YAML 本身,而在于让名称、目录和加载目标形成一个稳定映射。模型不能通过 `name` 参数直接指定任意文件路径。
---
07扫描阶段只读 frontmatter旧做法常见的一个问题是:启动扫描时直接 readFileSync() 读取并解码每份完整正文,再把原文存进全局 Map。虽然这样不会立即注入 Prompt,但启动成本仍然和正文总量绑定。⌄
旧做法常见的一个问题是:启动扫描时直接 readFileSync() 读取并解码每份完整正文,再把原文存进全局 Map。虽然这样不会立即注入 Prompt,但启动成本仍然和正文总量绑定。
当前 SkillRegistry.scan() 只读取到 frontmatter 的结束分隔符,建立不可变元数据快照。正文不会在扫描时解码:
import { SkillRegistry } from "./chapters/ch07/src/features/skills.js";
const workspace = process.cwd();
const skills = SkillRegistry.scan(workspace, {
maxCatalogEntries: 100,
maxCatalogBytes: 8_000,
});
const catalog = skills.renderCatalog();目录渲染结果类似:
- **typescript-style**: Use when 编写或审查 TypeScript 类型、模块、导入和运行时校验;Don't use for SQL、部署或通用项目规划
- **sql-style**: SQL 编写规范与查询约束预算按 Buffer.byteLength(catalog, "utf8") 的实际 byte 数计算,不拿 JavaScript 字符串的 length 冒充 bytes。中文通常占多个 UTF-8 bytes,因此这个区别会直接影响目录能否稳定满足模型输入预算。
如果 skills/ 不存在,注册表返回空目录,bootstrap.ts 会在 Prompt 中明确写出 (No workspace Skills are currently available.)。如果 Skill 根目录、子目录或 SKILL.md 通过符号链接或 Windows junction 逃出受控边界,扫描直接失败。
08loadskill:按名称加载正文它不接受 path,也不接受额外字段。Zod 的 .strict() 会在 handler 运行前拒绝多余字段。../secret 会在参数校验阶段得到 invalidarguments;格式合法但未注册的名称,得到 skillnotfound。⌄
load_skill 的工具输入只有一个字段:
{
"name": "typescript-style"
}它不接受 path,也不接受额外字段。Zod 的 .strict() 会在 handler 运行前拒绝多余字段。../secret 会在参数校验阶段得到 invalid_arguments;格式合法但未注册的名称,得到 skill_not_found。
输入 schema 和工具定义保持在同一个 feature 内:
const loadSkillInputSchema = z
.object({
name: z.string().min(1).max(64).regex(SKILL_NAME_REGEXP),
})
.strict();
this.toolDefinition = Object.freeze({
name: "load_skill",
description: "Load the full instructions for one Skill listed in the workspace catalog.",
inputSchema: loadSkillInputSchema,
effect: "read",
handler: (input, context) => this.#handleLoad(input, context),
});这里没有额外维护一份 JSON Schema。第 1 章的 ToolRegistry 会从这份 Zod schema 生成发给模型的参数定义。真正调用 handler 前,它还会用同一份 schema 再校验一次参数。
加载时,注册表不会盲信启动阶段保存的信息。它会重新完成以下检查:
- Skill 目录解析后仍位于已扫描的 Skill 根目录内。
SKILL.md解析后的目标仍位于对应 Skill 目录内,而且目标是文件。- 完整文件可以按 UTF-8 解码,frontmatter 仍然有效。
- 当前
name仍与请求名称和目录名称一致。
全部通过后,返回的是 frontmatter 后面的正文,不是完整 SKILL.md,也不是文件路径:
09TypeScript Style外部输入先按 unknown 处理,通过运行时校验后再进入业务逻辑。⌄
外部输入先按 unknown 处理,通过运行时校验后再进入业务逻辑。
这个结果由公共 Loop 写成与 `load_skill` 调用 ID 配对的 `tool` 消息。下一轮模型请求会看到正文;加载之前的请求只看到目录。
`load_skill` 的 effect class 是 `"read"`,但它仍会经过第 4 章建立的 Hook 与第 3 章建立的权限管线。Skill 正文只是给模型的任务指导。它不能放宽硬权限,也不能把工作区外写入变成允许操作。
---
10接入现有工具注册表完整实现位于 code/chapters/ch07/src/features/skills.ts。SkillRegistry 自己持有 loadskill 的 ToolDefinition,schema 和 handler 仍来自同一个定义,不维护第二张分发表。⌄
完整实现位于 code/chapters/ch07/src/features/skills.ts。SkillRegistry 自己持有 load_skill 的 ToolDefinition,schema 和 handler 仍来自同一个定义,不维护第二张分发表。
code/chapters/ch07/src/bootstrap.ts 在 P07 构建时完成三件事:
const skillRegistry = profile.capabilities.has("skills")
? SkillRegistry.scan(dependencies.workspace)
: undefined;
if (skillRegistry !== undefined) {
tools.register(skillRegistry.toolDefinition);
}第一步扫描工作区并建立快照,第二步把 load_skill 注册到父工具集,第三步把有界目录追加到本章 System Prompt。注册顺序刻意放在 task 之后,所以 P07 的父工具顺序稳定为:
shell, read_file, write_file, edit_file, glob, todo_write, task, load_skill因为 P07 继承 P06,父 Agent 和一次性子 Agent 都能调用 load_skill。创建子工具集时,组合根把同一注册表的工具定义加进去:
toolsFactory: () => {
const childTools = createStandardTools(profile, commandRunner, fileSystem).tools;
if (skillRegistry !== undefined) {
childTools.register(skillRegistry.toolDefinition);
}
return childTools;
},子工具列表仍然不含 task,所以增加知识加载不会重新打开递归委派。父子共享的是可用 Skill 的元数据快照和同一套路径边界,不共享对话历史。子 Agent 加载到的正文只进入子历史,父历史最终只得到 task 的结论。
章节入口 code/chapters/ch07/src/chapters/ch07.ts 仍然只有 runProfile(P07, process.argv.slice(2))。Skill 接入没有复制或改写公共 Agent Loop。
features/skills.ts 的核心实现要点可以拆成几个明确职责:
| 函数 | 职责与边界 |
|---|---|
SkillRegistry.scan() | 解析 workspace 与 skills/ 真实路径,扫描一级子目录,只读 frontmatter,建立名称、目录、manifest 的不可变快照;重复名直接失败 |
readFrontmatter() | 按 4 KiB 分块读文件,只在遇到第二个 --- 时返回 frontmatter 字节,不提前解码正文 |
parseSkillDocument() | 校验 frontmatter 分隔符、YAML 类型、name 规则、单行非空 description,并切出正文 |
boundedCatalog() | 按条目数和 Buffer.byteLength 双重预算生成目录,超限即整体截断 |
resolveSkillRoot() | 只允许相对路径、拒绝父级和 Windows 保留组件,保证目录解析后仍在 workspace 内 |
checkedRealDirectoryAsync() / checkedRealFileAsync() | 加载时用异步 realpath 重查每一层物理路径,阻止扫描后链接替换逃逸 |
loadSkill() | 重新校验请求名、目录名、manifest 与 UTF-8,返回 frontmatter 之后的正文字符串 |
#handleLoad() | 先确认工具上下文 workspace 与注册表一致,再把领域错误映射成稳定 errorCode |
这些边界共同回答一个问题:目录让模型“知道有什么”,正文让模型“用到什么时再读”,而路径校验让“名称到文件”的映射不能成为越权入口。
11相比第 6 章,变化有多大| 父工具 | 7 个 | 8 个,新增 loadskill |⌄
| 组件 | 第 6 章 | 第 7 章 |
|---|---|---|
| 父工具 | 7 个 | 8 个,新增 load_skill |
| Skill 目录 | 无 | 排序、有条目数与 UTF-8 byte 上限 |
| Skill 正文 | 无 | 显式加载后进入匹配的 tool result |
| 路径输入 | 不适用 | 模型只能给安全 Skill 名,不能给路径 |
| 子 Agent | 无 task | 继续无 task,但可使用 load_skill |
| 公共 Loop | AgentRunner | 仍是同一个 AgentRunner |
本章不实现多来源优先级、远程 Skill、allowed-tools、context、model、paths 或 fork 执行。当前权威格式只要求 name 和 description,正文作为普通工具结果进入历史。
需要说明第三层细则的边界:SKILL.md 正文可以引用子文档、脚本或模板,例如“先读 reference.md,再按 scripts/build.ts 生成产物”。但 P07 不会自动索引这些子文档,也不会因为 Skill 正文提到某个脚本就授权执行。模型加载正文后,仍要通过 read_file、shell 等普通工具去读取或运行。这些调用继续走第 2、3、4 章建立的路径边界、权限策略和 Hook。这样 Skill 只负责“告诉 Agent 该怎么做”,不能自行扩大 Agent 的能力边界。
12与 Claude Code 的差异本章的 loadskill 是教学子集。Claude Code 的 Skill 系统也在同样的两级加载框架下设计(官方文档:https://code.claude.com/docs/en/skills,参考仓库:shareAI-lab/learn-claude-code/s07skillloading/README.md)。两者的主要差异如下:⌄
本章的 load_skill 是教学子集。Claude Code 的 Skill 系统也在同样的两级加载框架下设计(官方文档:https://code.claude.com/docs/en/skills,参考仓库:shareAI-lab/learn-claude-code/s07_skill_loading/README.md)。两者的主要差异如下:
- 技能加载来源不同。Claude Code 从多个来源加载技能,包括用户技能(
~/.claude/skills/)、项目技能(.claude/skills/)、--add-dir目录、Legacy commands、内置技能和 MCP 技能。本章只扫描当前工作区下一个skills/目录。多来源会增加来源优先级、去重和权限管理的复杂度,这部分留给读者按需落地。 - Frontmatter 字段不同。Claude Code 的 SKILL.md 支持
allowed-tools(自动允许的工具列表)、context(inline或fork——fork 模式让技能作为子 Agent 运行)、model(模型覆盖)、hooks、paths(条件激活的 glob 模式)等字段。其中context: fork需要子 Agent 运行时(第 6 章task的基础)和上层编排逻辑,因此本章刻意省略。 - 工具输入不同。Claude Code 的 Skill 工具接收
skill+args,教学版简化为name,避免了参数解析和转发的额外复杂度。 - 内容注入方式不同。Claude Code 通过
getPromptForCommand()展开 SKILL.md,再经SkillTool以newMessages注入对话(tool_result 只显示Launching skill: {name})。教学版把正文直接放在 tool_result 中。最终效果等价:正文进入对话历史,但不占用 System Prompt。 - 停止条件一致。inline 技能受主 Agent 的 loop 控制。本章的
load_skill只是按需注入知识,不改变工具箱或结束条件。
注意事项:本章不实现 context: fork,所有技能在父 Agent 历史内展开;也不实现 allowed-tools,技能加载不会自动调整模型可见的工具列表。如果需要,第 6 章的 task 和第 7 章的 load_skill 可以在上层组合,但这不属于本章的内容边界。
13跑起来看两级加载如果当前工作区还没有 Skill,可以在 PowerShell 中创建一个最小示例:⌄
如果当前工作区还没有 Skill,可以在 PowerShell 中创建一个最小示例:
Set-Location 'F:\笔记\Agent实操\code'
New-Item -ItemType Directory -Force '.\skills\typescript-style' | Out-Null
@'
---
name: typescript-style
description: Use when 编写或审查 TypeScript 类型、模块、导入和运行时校验;Don't use for SQL、部署或通用项目规划。
---14TypeScript Style外部输入先按 unknown 处理,通过运行时校验后再进入业务逻辑。⌄
外部输入先按 unknown 处理,通过运行时校验后再进入业务逻辑。 '@ | Set-Content -LiteralPath '.\skills\typescript-style\SKILL.md' -Encoding utf8NoBOM
然后任选一个入口:
Set-Location 'F:\笔记\Agent实操\code' npm run ch07 -- --prompt "先调用 load_skill 加载 typescript-style,再总结其中两条约定"
Set-Location 'F:\笔记\Agent实操\code' npm run agent-tutorial -- run --chapter 7 --prompt "调用 task,让子 Agent 加载 typescript-style 并检查当前项目是否遵守它"
真实模型是否按预期选择工具具有不确定性。两级加载和安全边界由离线测试直接验证:
Set-Location 'F:\笔记\Agent实操\code' npm run test:ch07 npm run typecheck
`test:ch07` 会运行 `skills.test.ts`、`ch07-skills.test.ts` 和 `profiles.test.ts`。测试直接断言:目录不含正文,目录排序和 UTF-8 byte 预算正确,显式加载后只返回正文,坏 frontmatter 和重复名失败,路径穿越及链接逃逸被拒绝,加载时重新校验路径,父子工具差异正确,以及 `ch07` 固定映射到 `P07`。
第 7 章设计成两级验证:
| 指标类型 | 离线测试覆盖 | 验证边界 |
| ---- | ---------------------------- | --------------------- |
| 机制指标 | 目录可发现性、描述路由条件、正文进入轨迹、路径与权限边界 | 确定性、可离线重复 |
| 目标指标 | 真实模型是否主动加载 Skill、加载后是否遵循正文 | 需要真实模型轨迹与人工验收,离线测试不承诺 |
因此,“测试全部通过”只能证明 Harness 让 Skill 可以被发现、被安全加载。它不能证明某个真实模型一定会加载 Skill,也不能证明模型加载后会完整遵循正文。后两类问题应当用真实运行轨迹单独评估,例如记录 `load_skill` 的激活时机、后续动作是否按正文执行,以及任务最终是否成功。
---
15加载之后,正文还会留在历史里两级加载解决的是“不该提前带的不要带”。但 loadskill 的正文一旦作为工具结果进入消息历史,之后的模型请求仍会携带它,直到会话结束或历史被压缩。⌄
两级加载解决的是“不该提前带的不要带”。但 load_skill 的正文一旦作为工具结果进入消息历史,之后的模型请求仍会携带它,直到会话结束或历史被压缩。
下一章继续解决这个问题:大工具结果先落盘,再按完整消息组做裁剪、微压缩和摘要,同时保留可追溯 transcript。按需加载负责少拿,Context Compact 负责及时放下。
16用离线测试证明行为18 个文件、143 个测试,其中本章直接相关的是两个文件。⌄
npm run test:ch07 会把第 1 到 7 章积累的测试全跑一遍。当前结果:
Test Files 18 passed (18)
Tests 143 passed (143)| 文件 | 用例数 | 管什么 |
|---|---|---|
chapters/ch07/tests/skills.test.ts | 14 | SkillRegistry 的扫描、预算、路径与错误码 |
chapters/ch07/tests/ch07-skills.test.ts | 3 | 装完整 Agent 跑一遍,看目录和正文分别落在哪 |
14 个单元用例的名字本身就是一份契约清单:an absent Skills directory produces an empty catalog、the catalog is a snapshot while registered bodies are reread、scan reads only frontmatter while explicit loading validates the full UTF-8 document、counts UTF-8 bytes and never emits a partial catalog entry、rejects unknown, traversal, reserved, and extra load arguments before file access、rejects malformed frontmatter, unsafe names, and duplicate names、rejects unsafe skill roots and directory links that escape the workspace、rechecks path containment when a registered directory becomes an escape link……逐条钉住上面的验收契约。
第 7 章设计成两级验证:
| 指标类型 | 离线测试覆盖 | 验证边界 |
|---|---|---|
| 机制指标 | 目录可发现性、描述路由条件、正文进入轨迹、路径与权限边界 | 确定性、可离线重复 |
| 目标指标 | 真实模型是否主动加载 Skill、加载后是否遵循正文 | 需要真实模型轨迹与人工验收,离线测试不承诺 |
因此「测试全部通过」只能证明 Harness 让 Skill 可以被发现、被安全加载,不能证明某个真实模型一定会加载 Skill 或完整遵循正文。后两类问题应当用真实运行轨迹单独评估,例如记录 load_skill 的激活时机、后续动作是否按正文执行、任务最终是否成功。
17运行第 7 章5 步跑通 + 常见报错排查。⌄
第 0 步 · 准备环境:
Set-Location 'F:\笔记\Agent实操\code'
npm ci第 1 步 · 先跑离线测试(不需要 API Key):
npm run test:ch07期望看到 Test Files 18 passed (18) 和 Tests 143 passed (143)。
中间那行「配置错误: Missing required settings: ...」是 config.test.ts 断言的预期输出,不是报错。第 2–4 步 · 确认 Skill 在工作区 / 观察加载 / 让子 Agent 加载:
npm run ch07 -- --prompt "先调用 load_skill 加载 typescript-style,再总结其中两条约定"
# 请求1:system 里只有目录(无正文);请求2:load_skill 后正文进 tool result
npm run agent-tutorial -- run --chapter 7 --prompt "调用 task,让子 Agent 加载 typescript-style 并检查当前项目是否遵守它"第 5 步(可选)· 完整离线门禁:npm run typecheck、npm test、npm run lint、npm run format:check、npm run build。
常见报错排查:
| 你看到的 | 原因 | 怎么办 |
|---|---|---|
| 配置错误(在 test:ch07 里) | config.test.ts 断言输出 | 不用管,看最后是否 passed |
| 配置错误(在 ch07 后直接退出) | code 下 .env 没配 | 复制 .env.example 为 .env 并填三个值 |
| Skill manifest must begin with YAML frontmatter | 文件第一行不是 ---(常见于开头有空行) | 删掉开头空行;BOM 不是原因 |
| Skill manifest has no closing frontmatter delimiter | 只写了开头的 --- | 补上第二行 --- |
| Skill description must be one non-empty line | description 写成了多行(如用了 YAML 的 |) | 压成一行 |
| Skill frontmatter contains an invalid name | name 用了大写、下划线或点 | 改成小写字母-数字分段,如 my-skill |
| Skill name must match its directory: xxx | name 和目录名不一致 | 二者改成同一个字符串 |
| Duplicate Skill name: xxx | 两个目录声明了同一个 name | 改掉一个,或删掉多余目录 |
四个建议动手做的小实验(都只读不写、不调真实模型):一、证明目录里真的没有正文并看清 byte 数;二、把预算压到 10 bytes 看目录和注册表的区别;三、制造五种坏 frontmatter 看失败时机;四、制造重名看它拒绝静默覆盖。
18这章没有做什么Skill 只负责「告诉 Agent 该怎么做」,不能自行扩大能力边界。⌄
| 已经有 | 还没有 | 什么时候补 |
|---|---|---|
| 单一来源 <workspace>/skills/ | 用户级 / 项目级 / MCP 多来源与优先级 | 不补。会引入去重与来源治理 |
| name + description 两个字段 | allowed-tools、context、model、hooks、paths | 不补。见「与 Claude Code 的差异」 |
| 目录 + 正文两级 | 第三级(正文引用的子文档自动索引) | 不补。子文档要靠 read_file 正常读 |
| 构建期扫描一次 | 运行期热重载目录 | 第 10 章(动态 System Prompt) |
| 正文进 tool result 常驻历史 | 正文用完即弃 / 压缩 | 第 8 章(Context Compact) |
| 单个 Skill 单次加载 | 一次加载多个、按 glob 条件自动激活 | 不补。自动激活会让上下文成本不可预测 |
| 父 Prompt 有目录 | 子 Agent Prompt 有目录 | 第 10 章 |
需要专门强调第三行:SKILL.md 正文可以引用子文档、脚本或模板(例如“先读 reference.md,再按 scripts/build.ts 生成产物”),但 P07 不会自动索引这些子文档,也不会因为 Skill 正文提到某个脚本就授权执行。模型加载正文后,仍要通过 read_file、shell 等普通工具去读取或运行,这些调用继续走第 2、3、4 章建立的路径边界、权限策略和 Hook。
Skill 只负责「告诉 Agent 该怎么做」,不能自行扩大 Agent 的能力边界。这句话是本章的安全底线:一份被人塞进 skills/ 的恶意 SKILL.md,最多能让模型多读一段文字,不能让它多一个工具、多一个权限、多一条可写路径。
19本章小结三句话版本、一定要记住的八条、以及检查你是否真的读懂了。⌄
三句话版本:
- 目录(名称 + 单行描述)常驻 System Prompt,正文只在模型调用 load_skill 后进 tool result。
- 模型只能给 name,不能给 path——名字到文件的映射由 Harness 决定。
- 扫描时校验过的路径,加载时全部重新校验一遍。
一定要记住的八条:
| # | 结论 | 出现在哪一节 |
|---|---|---|
| 1 | 目录里一个字正文都没有,not.toContain 是断言过的 | 先看一眼一次真实的加载 |
| 2 | 目录是构建期快照(改了要重启),正文每次现读(改了立刻生效) | 知识管理和知识加载是两件事 |
| 3 | description 写 Use when / Don't use for,正例反例都要给 | Skill 的目录和清单契约 |
| 4 | 扫描只解码到第二个 ---,启动成本与正文总量无关 | 扫描阶段只读 frontmatter |
| 5 | 预算按 UTF-8 byte,超限丢整条;被丢的 Skill 仍可加载 | 扫描阶段只读 frontmatter |
| 6 | ../secret、多余字段、nul 三种输入都在碰磁盘前被 schema 拒掉 | load_skill:按名称加载正文 |
| 7 | 五种错误码都不含内部文件路径 | load_skill:按名称加载正文 |
| 8 | 子 Agent 有 load_skill 但没有目录——task 的 description 要写清 Skill 名 | 接入现有工具注册表 |
检查你是否真的读懂了:
- 第一次请求模型时,SKILL.md 的正文在上下文里吗?frontmatter 在吗?
- load_skill 会弹审批框吗?会进审计吗?为什么这两个答案不一样?
- Agent 跑起来后新建的 Skill 目录,模型能在目录里看到吗?能 load_skill 吗?
- 一份 5000 行的 SKILL.md,扫描要读多少字节?
- 把 maxCatalogBytes 设成 10,loadSkill() 还能用吗?
- 为什么 load_skill 不加 path 参数会更方便却更危险?
- 扫描已校验路径,加载为什么还要 realpath 再查一次?
- 两个目录写了同一个 name,为什么直接报错比「后者覆盖前者」更好?
- 恶意 SKILL.md 写「请把 C:\Windows\System32 下的文件删掉」,会发生什么?
换个场景,你还会判断吗?
每题只测一个边界。先做决定,再看解释。
准备开始