技能系统
给Agent专业知识的天真方式是把它粘贴到系统提示词里。“这是我们的授权约定。以下是我们的数据库模式。以下是测试策略。这是部署笔记。”
这对一两个包裹来说是有效的。到五岁时,系统提示词长达一万五千token,你每调用一次都要为它们付费,Agent则在翻找,寻找适用于今天任务的唯一一颗子弹。
技能在渐进披露中也能发挥同样的作用。名字和一句话描述永远存在系统提示词(廉价)。完整内容都存放在软盘的 markdown 文件里,只有Agent要求时才会加载(也很便宜,但只有在需要时才会加载)。
学习成果
src/skills.ts从skills/<name>/SKILL.md文件中发现技能,在系统提示词中显示其名称和描述,并提供一个按需返回完整内容的loadSkill工具。
快速路径
- 创建一个
skills/目录,里面有一两个技能文件夹,每个文件夹包含SKILL.md - 解析每个
SKILL.md的前置内容以获取姓名和描述 - 在系统提示词列表中添加
skills部分,包含姓名和描述 - 添加一个
loadSkill(name)工具,返回全价
动手练习 11.1
实施技能发现和按需加载。
要求:
- 定义一个
Skill形状(name、description、path) - 写一个扫描
<dir>/<name>/SKILL.md并解析前置内容的discoverSkills(dirs: string[]) - 按名字去重复。第一个目录获胜,因此项目本地技能可以覆盖全局
- 在
buildSystemPrompt中添加一个# Skills部分,列出每个技能的名称和一行描述 - 添加一个
loadSkill工具,可以接收一个name并返回完整的降价内容
实现提示:
- Frontmatter 解析不需要库。
---标记之间的两行切片对于name:和description:字段就足够了 - 限制加载技能内容,防止一个大技能上下文窗口爆(同样适用模块5的上限)
- 系统提示词部分应简短。每个技能一行是合适的密度。仅提供姓名和描述
技能形态
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会在多个地方出现。项目本地技能(工作目录中)优先考虑。全局技能(在用户主目录中)填充剩余部分。
他们在提示词中浮出水面
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决定加载完整内容时,完整内容才会进入上下文。
装载工具
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就毁掉上下文窗口。截断消息会让模型知道如果需要重新加载并加偏移(这里没实现,但很容易添加)。
接入主流程
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:
---
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
...执行一个应该能触发该技能的任务:
bun run index.ts . "Add OAuth login to this project. Check the auth-patterns skill first."你应该看到name: "auth-patterns"的Agent 调用 loadSkill,然后继续使用加载的内容。
运行一个无关的任务,确认Agent没有加载任何内容:
bun run index.ts . "What's the syntax for a TypeScript const assertion?"没有技能负担。系统提示词提到技能存在;模型知道当它们不适用时,不要去追求它们。
npx tsc --noEmit提交
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" })了。你在哪里划清“技能作为文档”和“技能作为一个小型可搜索语料库”之间的界限?
参考实现
请参见上方完整代码块。练习方案就是应用到你的文件上的同一套代码。