跳到正文

技能系统

给Agent专业知识的天真方式是把它粘贴到系统提示词里。“这是我们的授权约定。以下是我们的数据库模式。以下是测试策略。这是部署笔记。”

这对一两个包裹来说是有效的。到五岁时,系统提示词长达一万五千token,你每调用一次都要为它们付费,Agent则在翻找,寻找适用于今天任务的唯一一颗子弹。

技能在渐进披露中也能发挥同样的作用。名字和一句话描述永远存在系统提示词(廉价)。完整内容都存放在软盘的 markdown 文件里,只有Agent要求时才会加载(也很便宜,但只有在需要时才会加载)。

学习成果

src/skills.tsskills/<name>/SKILL.md文件中发现技能,在系统提示词中显示其名称和描述,并提供一个按需返回完整内容的loadSkill工具。

快速路径

  1. 创建一个skills/目录,里面有一两个技能文件夹,每个文件夹包含SKILL.md
  2. 解析每个SKILL.md的前置内容以获取姓名和描述
  3. 在系统提示词列表中添加skills部分,包含姓名和描述
  4. 添加一个loadSkill(name)工具,返回全价

动手练习 11.1

实施技能发现和按需加载。

要求:

  1. 定义一个Skill形状(namedescriptionpath
  2. 写一个扫描<dir>/<name>/SKILL.md并解析前置内容的discoverSkills(dirs: string[])
  3. 按名字去重复。第一个目录获胜,因此项目本地技能可以覆盖全局
  4. buildSystemPrompt中添加一个# Skills部分,列出每个技能的名称和一行描述
  5. 添加一个loadSkill工具,可以接收一个name并返回完整的降价内容

实现提示:

  • Frontmatter 解析不需要库。---标记之间的两行切片对于name:description:字段就足够了
  • 限制加载技能内容,防止一个大技能上下文窗口爆(同样适用模块5的上限)
  • 系统提示词部分应简短。每个技能一行是合适的密度。仅提供姓名和描述

技能形态

ts
import { readdirSync, existsSync, readFileSync } from "node:fs";
import { join } from "node:path";

export interface Skill {
  name: string;
  description: string;
  path: string;
}

function parseFrontmatter(md: string): { description?: string } {
  if (!md.startsWith("---")) return {};
  const end = md.indexOf("\n---", 3);
  if (end < 0) return {};
  const block = md.slice(3, end);
  const descLine = block.split("\n").find((l) => l.startsWith("description:"));
  return {
    description: descLine?.replace("description:", "").trim().replace(/^['"]|['"]$/g, ""),
  };
}

export function discoverSkills(dirs: string[]): Skill[] {
  const skills: Skill[] = [];
  const seen = new Set<string>();

  for (const dir of dirs) {
    if (!existsSync(dir)) continue;
    for (const entry of readdirSync(dir)) {
      const path = join(dir, entry, "SKILL.md");
      if (existsSync(path) && !seen.has(entry)) {
        seen.add(entry);
        const content = readFileSync(path, "utf-8");
        const { description } = parseFrontmatter(content);
        skills.push({
          name: entry,
          description: description ?? "(no description)",
          path,
        });
      }
    }
  }

  return skills;
}

dirs是阵列,因为真正的Harness会在多个地方出现。项目本地技能(工作目录中)优先考虑。全局技能(在用户主目录中)填充剩余部分。

他们在提示词中浮出水面

ts
export interface PromptContext {
  // ...existing fields
  skills?: { name: string; description: string }[];
}

// Inside buildSystemPrompt, after Guardrails:
if (ctx.skills?.length) {
  const lines = ctx.skills
    .map((s) => `- ${s.name}: ${s.description}`)
    .join("\n");
  sections.push(`
# Skills
The following skills are available. Call \`loadSkill\` with the name to get full content.
${lines}`);
}

Agent每条调用都会看到技能名称和一句简短的描述。只有当Agent决定加载完整内容时,完整内容才会进入上下文。

装载工具

ts
import { tool } from "ai";
import { z } from "zod";
import { readFileSync } from "node:fs";
import type { Skill } from "./skills";

export function createLoadSkillTool(skills: Skill[]) {
  const MAX_SKILL_CHARS = 4000;
  const byName = new Map(skills.map((s) => [s.name, s]));

  return tool({
    description: `Load the full content of a skill.
WHEN TO USE: the task touches a domain you have a skill for (auth, db, testing,
  deployment, etc.). Check the # Skills section in your instructions.
WHEN NOT TO USE: tasks unrelated to any available skill.
DO NOT USE FOR: tasks where the skill name is not in the listed skills.`,
    inputSchema: z.object({
      name: z.string().describe("Skill name as listed in the Skills section"),
    }),
    execute: async ({ name }) => {
      const skill = byName.get(name);
      if (!skill) return `Unknown skill: ${name}`;
      const content = readFileSync(skill.path, "utf-8");
      return content.length > MAX_SKILL_CHARS
        ? content.slice(0, MAX_SKILL_CHARS) + `\n... (truncated at ${MAX_SKILL_CHARS} chars)`
        : content;
    },
  });
}

上限很重要。一项已经写到一万五千字的技能,不应该在一Agent 调用 loadSkill就毁掉上下文窗口。截断消息会让模型知道如果需要重新加载并加偏移(这里没实现,但很容易添加)。

接入主流程

ts
import { discoverSkills } from "./src/skills";

const skillDirs = [
  join(cwd, "skills"),
  join(process.env.HOME ?? "", ".harness", "skills"),
];
const skills = discoverSkills(skillDirs);

const tools = {
  // ...everything else
  loadSkill: createLoadSkillTool(skills),
};

const instructions = buildSystemPrompt({
  // ...existing context
  skills: skills.map((s) => ({ name: s.name, description: s.description })),
});

先规划技能,再做全局规划。姓名和描述见提示词。内容通过工具按需加载。

为什么要“名字进,内容出”

数据说明了一切。五个技能,每个一千字,大约相当于五千个技能token永远加到每个调用上。五个技能加上一句话描述,总共一百token。模型仍然知道这些技能的存在。Agent根据任务决定加载哪些。只有有原因,完整内容才会进入上下文。

这与第五模块中的预防优先清理学科相同,应用于知识层而非工具输出层。

**注意:模型必须提出问题**

模型不会自动加载技能。系统提示词给它们命名,工具也在表面上。模型还需要决定是否调用 loadSkill。把这当作取回路径,而不是保证。注意你的游戏过程:如果模型从未加载明显有用的技能,你的技能描述需要更犀利的钩子。

动手试试

把一个示例技能放进skills/auth-patterns/SKILL.md

markdown
---
description: Patterns and pitfalls for adding authentication to this project
---
# Auth Patterns

This project uses NextAuth with JWT sessions. Key files:

- `lib/auth.ts`: NextAuth config
- `middleware.ts`: route protection
...

执行一个应该能触发该技能的任务:

bash
bun run index.ts . "Add OAuth login to this project. Check the auth-patterns skill first."

你应该看到name: "auth-patterns"的Agent 调用 loadSkill,然后继续使用加载的内容。

运行一个无关的任务,确认Agent没有加载任何内容:

bash
bun run index.ts . "What's the syntax for a TypeScript const assertion?"

没有技能负担。系统提示词提到技能存在;模型知道当它们不适用时,不要去追求它们。

bash
npx tsc --noEmit

提交

bash
git add src/skills.ts src/tools.ts src/system.ts index.ts skills/
git commit -m "feat(skills): progressive-disclosure skill loading"

完成标准

  • [ ] discoverSkills扫描目录并解析前置文件
  • [ ] 系统提示词列出技能并用一句话描述
  • [ ] loadSkill工具按需返回完整内容,但有上限
  • [ ] 当名称冲突时,项目-本地技能覆盖全局技能
  • [ ] 命名技能的任务触发loadSkill;无关任务则不行
  • [ ] npx tsc --noEmit

**注意:分段目标载荷**

现在loadSkill还原整个文件。对于五千字的技能来说,这很浪费,因为Agent只需要一个章节。通过添加一个可选的section: string参数,只返回请求的标题及其内容。现在模型可以加载loadSkill({ name: "auth-patterns", section: "OAuth flow" })了。你在哪里划清“技能作为文档”和“技能作为一个小型可搜索语料库”之间的界限?

参考实现

请参见上方完整代码块。练习方案就是应用到你的文件上的同一套代码。

非官方简体中文翻译 · 原课程来自 Vercel Academy