Skip to content

Skills 与渐进加载

本课交付结果

你将交付 discoverSkills(root)loadSkillBody(root, name):发现只返回 name、description、path;正文在显式选择后才读取。重复名称、畸形 frontmatter、traversal 和 symlink 逃逸全部拒绝。

岗位问题

把所有技能正文一次性塞入提示词会浪费上下文,还让不相关规则干扰当前任务。Skill 的价值在“按需提供操作知识”,不是注册可执行代码;因此需要先用小元数据做路由,再惰性加载选中正文。

前置检查

前置知识快照

回顾第 24 课安全指令加载:

bash
pnpm --filter @learn-traeai/coding-agent-bootcamp verify:lesson -- 24

Skill 目录以 SKILL.md 为入口,frontmatter 必须声明稳定 slug name 和非空 description。

第 24 课加载的是目标路径继承的仓库规则,本课加载的是按任务显式选择的操作知识。两者都是文本指令,却有不同触发语义:AGENTS.md 随路径自动生效,Skill 先以精简目录参与路由,只有命中当前任务后才读取完整正文。若混为一层,模型会在每次请求中承担所有技能的上下文成本,也难以解释某段规则为何出现。

渐进加载不是把全文读完后从返回对象删掉 body。发现阶段必须在文件读取层就停在 frontmatter 结束处,否则秘密正文仍进入进程内存、监控或调试数据。测试用 SECRET BODY 作为哨兵,要求 discover 的输出不含它;生产还应通过读取字节计数器证明流没有越过第二个分隔线。

开始前需要掌握三条不变量:声明 name 是调用身份而非目录名;同一 catalog 中 name 全局唯一;任何候选目录和 SKILL.md 的真实路径都留在 skills root。name 用小写 slug 约束,description 非空且有字节预算,正文只在显式 name 通过校验并成功发现后加载。

原理拆解

mermaid
flowchart TD
  A[排序枚举技能目录] --> B[realpath 校验根边界]
  B --> C[流式读取 frontmatter]
  C --> D[解析 name 与 description]
  D --> E[全局名称去重]
  E --> F[返回精简 catalog]
  F --> G{显式选择 name?}
  G -->|否| H[不读取正文]
  G -->|是| I[再次校验并读取完整文件]
  I --> J[返回选中 body]

discover 按目录名排序,realpath 后确认仍在 root,再用流逐行读到第二个 --- 立即停止,不读取正文。所有元数据收集后检查声明 name 唯一。load 再用严格 name 找到元数据并读取完整正文。

代码实验

打开本课 Lab

bash
pnpm --dir bootcamps/coding-agent/labs/31-skills-loader/starter test
pnpm --dir bootcamps/coding-agent/labs/31-skills-loader/solution test

Starter 应声明 实现 Skill 正文惰性加载;Solution 应通过 5 项测试。

失败实现

ts
export async function discoverUnsafe(root: string) {
  return Promise.all((await readdir(root)).map(async (folder) => {
    const content = await readFile(join(root, folder, "SKILL.md"), "utf8");
    return { name: folder, body: content };
  }));
}

失败实现一次读入全部正文,把目录名当身份,没有 frontmatter、排序、路径边界、重复检测和大小限制。一个指向工作区外的 symlink 会被直接跟随;任意畸形 Skill 也可能在路由阶段进入提示词。技能越多,首轮 token 成本与提示冲突越严重。

流式元数据解析只读取必要的开头:

ts
async function readMetadata(path: string): Promise<SkillMetadata> {
  const stream = createReadStream(path, { encoding: "utf8" });
  const lines = createInterface({ input: stream, crlfDelay: Infinity });
  let name = "", description = "", closed = false, lineNumber = 0;
  for await (const line of lines) {
    lineNumber += 1;
    if (lineNumber === 1 && line !== "---") throw new Error("缺少 frontmatter");
    if (lineNumber > 1 && line === "---") { closed = true; break; }
    if (line.startsWith("name:")) name = line.slice(5).trim();
    if (line.startsWith("description:")) description = line.slice(12).trim();
  }
  lines.close(); stream.destroy();
  if (!closed || !name || !description) throw new Error("frontmatter 无效");
  return { name, description, path: relativePath };
}

关键实现讲解

目录名只是位置,frontmatter name 才是调用身份;二者可以不同,但 name 不能重复。发现 API 不含 body,防止路由阶段意外暴露大段或敏感操作说明。symlink 目录即使名字合法,也要用真实路径复核。

发现算法先对 root 调 realpath,后续所有边界比较都使用规范根。目录项按名称稳定排序,跳过明确约定的隐藏目录;对每个候选目录再次 realpath,再用 path.relative 判断是否返回根外。字符串 startsWith 会把 /skills-two 误认为 /skills 子路径,也受大小写与分隔符影响,因此不能承担安全边界。

候选目录没有 SKILL.md 时可以跳过,因为技能根可能包含 README 或资产目录;但一旦存在 SKILL.md,就不能静默忽略畸形 frontmatter。静默跳过会让部署时技能消失,路由器只能表现为“不知道怎么做”,很难定位到内容错误。明确报出安全相对路径与字段原因,持续集成才能阻止坏技能上线。

frontmatter 首行必须精确是三个短横线,结尾也要出现独立分隔行。只用一个跨全文正则虽然简单,却会读取 body,违背发现阶段最小读取。流式逐行解析可在第二个分隔符立即 close interface 并 destroy stream;for await 的退出与底层流关闭要经过测试,防止文件描述符泄漏。

教学解析器只接受 name 与 description 单行字段,避免引入完整 YAML 的隐式类型、锚点和复杂对象。总项目还支持 allowedTools 数组,用于在加载正文前声明能力提示。若使用 YAML 库,应关闭自定义 tag,限制元数据字节与嵌套深度,并把解析结果经过严格 schema,而不是相信任意对象。

name 正则采用字母开头、后续小写字母数字和短横线。它是稳定 API:提示路由、缓存、审计和用户显式调用都依赖它。目录可以叫 code-review-v2,声明 name 可以保持 code-review,便于内部布局变化不破坏调用;反过来,同一 name 出现在两个目录必须硬失败,不能用“后者覆盖前者”。

description 是路由器唯一能在发现阶段看到的语义,应写触发条件和目标,不塞完整工作流。好的描述如“当失败测试需要最小代码修复时使用”,坏描述如“这是一个很有用的技能”。描述过长会把渐进加载收益重新吃掉,因此设置每项与 catalog 总字节预算;超限要求作者压缩,而不是在字符串中间静默截断改变含义。

所有 metadata 收集完再检查 name 重复,比边读边覆盖更安全。使用 Set 记录已见身份,错误列出重复 name 与两个相对路径,但不暴露绝对用户目录。排序应基于稳定目录或声明 name,并在 contract 中固定;目录遍历原生顺序不可靠,会让 catalog、路由提示与快照测试跨平台抖动。

loadSkillBody 先对输入 name 做同一正则校验,再调用 discoverSkills 获取经过验证的目录,而不是直接 join(root, name)。因为声明 name 可以不同于目录名,直接拼接既找错位置又重新引入 traversal。查不到返回稳定 NOT_FOUND;找到后根据 metadata.path 再 realpath 并复核 root,防止发现与加载之间链接被替换。

发现与加载之间存在时间检查到使用的竞争。攻击者若能修改 skills 目录,发现后可把普通文件换成 symlink。再次 realpath 缩小窗口,但在高风险环境仍应让技能目录只读、按内容 hash 打包,或用已打开文件描述符读取。教学仓库假定本地可信作者,但 Loader 仍坚持双重边界校验。

完整读取时先检查 lstat 与字节大小,再设 maxBodyBytes;不要 readFile 后才判断,因为超大文件已经占用内存。UTF-8 字节预算不同于字符数,中文通常占多字节,应按 Buffer.byteLength 定义并在文档说明。正文解析去掉 frontmatter,返回 trim 后的 Markdown,同时保留元数据与相对 provenance。

ts
export async function loadSkillBody(root: string, name: string) {
  if (!/^[a-z][a-z0-9-]*$/.test(name)) throw new Error("Skill 名称无效");
  const skill = (await discoverSkills(root)).find((item) => item.name === name);
  if (!skill) throw new Error(`未找到 Skill:${name}`);
  const realRoot = await realpath(root);
  const file = await realpath(join(realRoot, skill.path));
  if (outside(realRoot, file)) throw new Error("Skill 超出工作区");
  const content = await readBoundedUtf8(file, maxBodyBytes);
  return { ...skill, body: stripFrontmatter(content) };
}

发现结果不应含 body、原始 frontmatter 或整个文件缓存。即使 Loader 内部为了性能缓存 metadata,公开对象也只复制 name、description、path 和允许的轻量字段。返回新数组与新对象,避免调用方修改内部 catalog 后让下一次 load 指向未经验证的路径。TypeScript readonly 只约束编译期,运行时仍需复制或冻结。

缓存必须绑定文件身份。简单按 root 永久缓存会在技能更新、删除或新增后返回旧目录;可以根据目录 mtime、每个 SKILL.md 的 size/mtime 与 Loader 配置生成版本,也可在开发模式关闭缓存。内容签名系统使用 manifest hash 最可靠。无论策略,load 不能绕过首次验证,缓存命中也要遵守 body 大小与路径边界。

路由过程一般把精简 catalog 提供给模型或规则选择器,输出一个或少量 name,再由 Runtime 显式加载。选择结果仍是不可信模型输出,必须经过 name allowlist;模型说 ../../secret 不能进入路径。若没有技能适用,应继续通用流程,不要为使用技能而强行匹配。

同时加载多个 Skill 会产生规则冲突和上下文膨胀。默认只选最具体的一个;确需组合时记录选择顺序、每个内容 hash 与冲突政策。系统级安全规则始终高于 Skill,仓库路径规则与用户请求的优先级也要明确。Skill 是指导知识,不得借正文自行提升权限或覆盖审批。

allowedTools 若存在,只是技能建议使用的能力集合,不是授权本身。Runtime 取 Skill allowedTools、当前会话权限与插件/MCP 实际工具的交集;正文写“执行网络命令”也不能突破工具策略。发现层展示这个轻量字段可帮助路由,但 Loader 要验证工具名格式和数组长度。

provenance 至少包含相对路径、内容 hash、schemaVersion 与包版本。Agent Trace 记录选择了哪个 Skill 及 hash,不记录可能含敏感步骤的完整正文。复现时用 hash 找回同一内容;技能更新后旧 run 仍能解释使用了哪个版本。公开日志只展示 name/version,详细来源按权限查看。

Skill 本身也可能包含提示注入,例如要求泄露秘密、忽略系统规则或调用未授权工具。Loader 只验证格式与边界,不能证明语义安全;来源治理、代码评审、签名和权限交集共同降低风险。第三方 Skill 安装到隔离目录,默认禁用,用户显式信任后才进入 catalog。

元数据解析错误属于部署问题,不应被模型补救。缺少 frontmatter、重复 name、非法 slug、超限或 symlink 逃逸都在 discover 时失败,并让健康检查报告具体技能。只有目录根不存在时,产品可根据配置返回空 catalog 或错误;开发工具通常空目录合理,要求技能的发布门禁则必须失败。

ts
it("does not expose bodies during discovery", async () => {
  const catalog = await discoverSkills(root);
  expect(catalog).toEqual([
    { name: "review", description: "审查代码", path: "review/SKILL.md" },
  ]);
  expect(JSON.stringify(catalog)).not.toContain("SECRET BODY");
  expect((await loadSkillBody(root, "review")).body).toBe("SECRET BODY");
});

测试还应创建两个不同目录却相同声明 name,确认硬失败;写无分隔符文件确认 malformed;创建 root 内链接到外部临时目录确认逃逸;传点点斜线给 load 确认在任何 readFile 前失败。用 spy 统计 discover 读取字节,可以补足“返回值不含 body”仍可能全文读取的盲点。

属性测试可随机生成合法 slug、描述、目录顺序和换行风格,确认 catalog 稳定;对任意包含斜线、点、空格或大写的 name 入口拒绝。模糊测试 frontmatter 解析器,确保超长行、缺结束符和无效 UTF-8 不会无限读取或崩溃进程。安全解析器宁可明确拒绝,也不要猜测作者意图。

Windows 的 junction、大小写与反斜线路径需要独立验证。使用 Node path API 而不是手写斜线分割,realpath 后再 relative;name 语法跨平台保持 ASCII slug。相对 provenance 统一成正斜线便于报告,但安全判断使用当前平台分隔符。持续集成至少在主要目标平台跑边界测试。

运维指标包括 catalog 技能数、发现耗时、metadata 总字节、正文加载次数、正文大小、拒绝原因与缓存命中。name 作为标签可能高基数,按错误类型聚合更安全;具体 name 进入受控诊断日志。正文加载次数异常增高可能说明路由器反复选错,catalog 过大则提示需要分类或索引。

完整验收从两个真实课程 Skill 开始:discover 只返回 bug-fix 与 test-writer 的精简信息,不出现 Workflow;选择 test-writer 后正文包含工作流;未选 bug-fix 不读取其 body。然后注入重复名、坏 frontmatter、外链和超大正文,全部在执行任何工具前拒绝。这样既证明性能目标,也证明安全边界。

路由失败策略要可解释。模型选择不存在 name 时,不应猜相似目录并加载;返回候选 catalog 与稳定错误,让上层决定重新选择或走通用流程。自动模糊匹配会在拼写错误时加载另一个可执行指导,风险高于一次失败。若界面提供建议,只展示给用户确认,不直接改变选中身份。

路由器输出多个候选与置信度时,阈值和最大加载数属于策略配置。高置信单选直接加载,低置信请求澄清,两个互补 Skill 需要显式组合规则。不要用 description 中出现关键词的数量直接决定优先级,它容易被冗长描述操纵;可用小型评测集验证任务到 Skill 的选择准确率。

每个 Skill 应有正触发与反触发样例。bug-fix 适用于已有失败测试与局部修复,不适用于从零写产品需求;test-writer 适用于补回归证据,不应替代生产实现。样例可以放在 metadata 扩展或独立评测清单,不必全部进入 catalog。发布门禁跑路由基准,防止改 description 后选择准确率退化。

schemaVersion 用于演进 frontmatter。旧 Loader 遇到更高不支持版本必须拒绝并给升级提示,不能忽略新字段后继续;新增可选字段时保持向后兼容。body Markdown 版本通常不影响 Loader,但引用脚本、资产或相对文件时要在 Skill package manifest 中显式声明,加载器不能沿任意链接递归读取。

Skill 正文可能引用同目录 references 或 scripts。渐进披露仍适用:选中 SKILL.md 后按照正文明确链接按需读取,不自动打包整个文件夹。每个引用解析 realpath、校验根与大小,脚本执行还需单独工具权限和审批。文本选择不应自动等于脚本执行授权。

上下文预算不仅限制单文件,还限制最终提示组合。系统规则、用户请求、仓库指令、选中 Skill、代码证据和历史消息共同竞争窗口。Context Builder 为每段记录来源、优先级和字节/token 估算;Skill 若超预算,优先加载摘要或请求用户缩小任务,不能从正文中间无标记截断关键警告。

正文可设计成分层结构:开头给适用条件与硬约束,中间给核心流程,后面放深入参考。Loader 可先返回完整 SKILL.md,但作者仍应让最重要规则靠前;未来按章节渐进加载时,标题索引成为第二层 catalog。任何章节裁剪都保留 provenance 和“内容不完整”标记。

热更新适合开发,但运行中的 Agent 应固定 Skill 快照。一次 run 选择后记录内容 hash,后续步骤即使文件变化也继续使用同一 body 或明确中止重启;中途换规则会让 Trace 无法解释。新 run 读取新版本,缓存按 hash 隔离,调试工具可同时显示磁盘当前版本与运行锁定版本。

删除 Skill 也要治理。若路由配置或任务模板仍引用已删除 name,健康检查在启动时发现;不要等用户请求才报错。重命名通过 deprecated alias 过渡,alias 只在 metadata 显式声明并警告,最终移除日期可见。两个 alias 仍不能指向不同内容而共享身份。

供应链扫描检查正文中的外部 URL、可执行代码块、危险工具词和超大嵌入,但扫描结果是审查提示而非绝对判定。安装第三方包时保存来源仓库、commit、许可证与签名;本地修改会改变 hash 并失去上游签名状态。catalog 可展示 trusted、local-modified 等信任等级供策略使用。

多租户服务不能共享所有人的个人 skills root。每个请求解析组织、项目与用户允许的目录层级,合并 catalog 时用命名空间避免同名覆盖,并按权限过滤。错误不能泄露另一个租户的技能名或路径。最简单的部署是每个 workspace 独立只读技能目录,进程级缓存键包含租户与 root identity。

灾难恢复时,Skill 包与 Agent 版本、Plugin 清单和评测报告一起归档。仅恢复代码却缺少当时技能,会让历史任务无法复现。归档可以保存内容寻址的压缩包与 manifest hash,不必把所有正文写入 Trace。保留期与知识产权权限仍按组织政策执行。

最终设计评审要能从一次运行回答:候选 catalog 是什么、为何选择这个 Skill、实际加载哪个内容 hash、用了多少上下文、哪些工具权限最终可用。若只能看到“模型用了技能”,仍不足以审计。渐进加载同时是成本控制、来源控制和决策可解释机制。

选择知识之前先证明来源,加载知识之后仍受系统安全边界约束。

运行与验证

bash
pnpm --filter @learn-traeai/coding-agent-bootcamp verify:lesson -- 31
pnpm --filter @coding-agent/extensions test

发现结果中不能出现测试正文 SECRET BODY;只有 loadSkillBody("review") 才返回它。

真实运行输出

text
✓ tests/lab.test.ts (5 tests)
Test Files  1 passed (1)
Tests       5 passed (5)

项目包还会验证真实 bug-fix 与 test-writer 技能、metadata/body 字节上限和 allowedTools。记录发现 catalog 与选中 hash 即可,不把完整正文写入测试快照或公开 Trace。

常见失败与排查

故障案例 1

症状:技能数量增加后首轮 prompt 暴涨,discover 的调试对象甚至出现秘密工作流。

根因:发现阶段 readFile 全文,再从结果中删 body,或直接返回完整内容。

定位:用 SECRET BODY 搜索返回值和内存诊断,并给读取流加字节计数,确认是否越过第二个分隔符。

修复:流式读到 frontmatter 结束立即关闭;catalog 只含精简 metadata,显式选择后才 bounded read 正文。

故障案例 2

症状:两个目录声明相同 name 时某个技能随机胜出,跨平台选择结果不同。

根因:以目录名为身份,或用对象赋值让后加载项静默覆盖,且依赖 readdir 顺序。

定位:交换目录名与创建顺序,打印声明 name 和安全相对路径,比较 catalog。

修复:排序枚举、以 frontmatter name 为唯一调用身份,收集后 Set 去重,重复立即阻止发布。

故障案例 3

症状:名称看似合法的技能读取了 root 外文件,或畸形技能在生产中悄悄消失。

根因:只做字符串路径检查、跟随 symlink,错误处理把所有异常当成“没有技能”。

定位:创建外部目录 symlink、输入 traversal、写缺结束符文件,并监视真正打开的 realpath。

修复:root、目录和文件逐层 realpath/relative 复核;仅明确缺文件可跳过,malformed 与越界全部硬失败。

课后作业

增加 frontmatter 版本、触发词和最大正文尺寸;用读取计数器证明 discover 没有越过 frontmatter,并实现元数据缓存失效。

验收 Rubric

维度通过标准常见扣分
渐进性discover 不返回正文一次加载全部内容
身份声明 name 唯一只看目录
安全name 与 realpath 双校验traversal/symlink 逃逸
诊断malformed 明确失败静默跳过

总项目增量

总项目包:@coding-agent/extensions

总项目路径:packages/extensions/src/skills-loader.ts

总项目验证命令:pnpm --filter @coding-agent/extensions test

Skill 是惰性加载的指令知识;下一课的 Plugin 则声明并打包本地可执行工具能力。

延伸阅读

  • Progressive disclosure 和 prompt routing。
  • Frontmatter schema versioning。
  • Skill provenance 与内容签名。

方案对比与工程取舍

一次性加载实现最简单,少量极短技能时也能工作,但成本随技能总正文线性增长并扩大注入面。两阶段 catalog/body 多一次选择,却让上下文按任务付费且可记录触发原因。Coding Agent 技能会持续增加,因此渐进加载是架构边界,不是提前优化。

手写受限 frontmatter 解析器依赖少、行为容易审计,代价是不支持 YAML 多行和复杂类型;通用 YAML 提升作者体验,却需要安全配置和严格 schema。课程只需 name/description,选择受限解析更合理;产品字段扩展后可换 YAML,但保持元数据字节上限与正文不预读。

本地未签名 Skill 适合单仓库开发,第三方分发则需要来源、内容 hash、签名与信任策略。签名证明发布者和内容未变,不证明内容无害;权限交集、审查和沙箱仍必需。Loader 先建立可验证 provenance,后续供应链能力才能可靠接入。

下一课衔接

Skill 只提供按需文本知识,不直接执行代码。下一课 Plugin Loader 会跨过可执行边界:先验证 manifest、严格 semver、工具唯一性、权限子集与 entry realpath,再加载本地模块。Skill 的“选择后读正文”会演进为 Plugin 的“验证完成后才执行入口”。

从零实现 Mini Code Agent Runtime