本文继续深入 AI Agent 项目的核心设计,介绍 Skills 动态加载系统------一个让 Agent 在运行时按需加载领域知识的可扩展架构。
1. 引言:为什么需要 Skills 系统?
做过 AI Agent 的都知道,系统提示词(System Prompt)是最关键的"魔法调料"。但这碗调料有个尴尬的问题:要么太少,要么太多。
一个通用 Agent 什么都会一点,但什么都不精。把所有领域知识都塞进系统提示词?上下文窗口有限,token 消耗爆炸,而且每次对话无关的知识也会跟着进来。
这个项目给出的方案是 Skills 动态加载系统------把领域知识打包成独立的 Skill,Agent 在运行时按需加载。核心设计哲学是:
不要给 Agent 所有知识,只给它找到知识的路径。
整体架构如下:
scss
┌─────────────────────────────────────────────────────┐
│ 聊天请求 (每轮) │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────┐ │
│ │ chat.service.ts │ │
│ │ discoverSkills() → 扫描文件系统 │ │
│ │ 返回 SkillMetadata[](仅 name + description)│ │
│ └──────────────────┬───────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────┐ │
│ │ chat.agent.ts │ │
│ │ prepareCall(): │ │
│ │ system prompt = 基础指令 + buildSkillsPrompt │ │
│ │ → 注入可用 Skills 列表 │ │
│ └──────────────────┬───────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────┐ │
│ │ ToolLoopAgent 循环 │ │
│ │ │ │
│ │ Agent: "用户问 Python 相关, │ │
│ │ 我要先加载 python-developer skill" │ │
│ │ → 调用 loadSkill("python-developer") │ │
│ │ │ │
│ │ loadSkill 工具执行: │ │
│ │ ├─ 读取 SKILL.md │ │
│ │ ├─ stripFrontmatter() 去掉 YAML │ │
│ │ └─ 返回 body 内容给 Agent │ │
│ │ │ │
│ │ Agent 收到技能指令后继续任务 │ │
│ └──────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘
2. Skill 的格式约定
每个 Skill 就是一个包含 SKILL.md 的目录,放在配置的 AGENTS_DIR 下:
objectivec
~/.agents/skills/
├── python-developer/
│ ├── SKILL.md
│ └── scripts/
│ └── lint.sh
├── react-optimizer/
│ ├── SKILL.md
│ └── templates/
│ └── component.tsx
└── data-analyst/
└── SKILL.md
SKILL.md 的格式借鉴了静态站点生成器的 Frontmatter 惯例:
markdown
---
name: python-developer
description: Python 开发专家,擅长代码审查、性能优化和最佳实践
---
# Python Developer Skill
## 核心原则
- 遵循 PEP 8 编码规范
- 优先使用标准库,第三方依赖需评估必要性
- ...
## 代码审查要点
1. 类型注解覆盖率需 > 90%
2. 函数复杂度(McCabe)不超过 10
3. ...
这种设计有两个关键点:
- 元数据与内容分离 :Frontmatter 中的
name和description用于 Skill 发现和列表展示,不占用系统提示词的 token 预算 - 按需加载 :
SKILL.md的正文内容只有在 Agent 显式调用loadSkill时才加载,不会预先注入
3. Skill 发现机制
每次聊天开始时,系统都会重新扫描 AGENTS_DIR,发现可用的 Skill。
typescript
export async function discoverSkills(): Promise<SkillMetadata[]> {
const agentsDir = path.resolve(os.homedir(), env.AGENTS_DIR);
const skills: SkillMetadata[] = [];
const seenNames = new Set<string>();
let entries: string[];
try {
entries = await readdir(agentsDir);
} catch {
// 目录不存在,自动创建并设置 700 权限
await mkdir(agentsDir, { recursive: true });
await chmod(agentsDir, 0o700);
return skills;
}
for (const entryName of entries) {
const skillDir = path.join(agentsDir, entryName);
const skillFile = path.join(skillDir, 'SKILL.md');
// 跳过非目录项
const dirStat = await stat(skillDir);
if (!dirStat.isDirectory()) continue;
// 读取 SKILL.md
const content = await readFile(skillFile, 'utf-8');
// 解析 YAML frontmatter
const frontmatter = parseFrontmatter(content);
// 跳过重名
if (seenNames.has(frontmatter.name)) continue;
skills.push({
name: frontmatter.name,
description: frontmatter.description,
path: skillDir,
});
}
return skills;
}
设计要点:
- Fail-Safe 创建 :目录不存在时自动创建并设置
0o700权限,防止无权限问题 - 去重保护 :
seenNames集合防止同名 Skill 被重复发现 - 容错跳过 :某个目录没有
SKILL.md或 Frontmatter 格式不对,其他 Skill 不受影响
Frontmatter 解析器没有用 YAML 库,而是手写了一个轻量解析:
typescript
export function parseFrontmatter(content: string) {
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
// 按行解析 key: value
const frontmatter: Record<string, string> = {};
for (const line of match[1].split('\n')) {
const colonIndex = line.indexOf(':');
const key = line.slice(0, colonIndex).trim();
const value = line.slice(colonIndex + 1).trim();
frontmatter[key] = value;
}
return { name: frontmatter.name, description: frontmatter.description };
}
为什么不用 js-yaml?因为只需要解析 name 和 description 两个字段,一个 indexOf(':') 就够了。引入第三方库反而增加了依赖体积和攻击面。
4. 系统提示词注入
发现 Skill 后,buildSkillsPrompt 将它们格式化为系统提示词的一部分:
typescript
export function buildSkillsPrompt(skills: SkillMetadata[]): string {
if (skills.length === 0) return '';
const skillsList = skills.map((s) => `- ${s.name}: ${s.description}`).join('\n');
return `
## Skills
Use the \`loadSkill\` tool to load a skill when the user's request
would benefit from specialized instructions.
Available skills:
${skillsList}
`;
}
这段提示词在 chat.agent.ts 的 prepareCall 中拼接:
typescript
prepareCall: ({ options, ...settings }) => {
return {
...settings,
instructions: `${settings.instructions}\n\n${buildSkillsPrompt(options.skills)}`,
experimental_context: { chatId: options.chatId, metadata: options.metadata },
};
},
这里有个重要的设计决策 :拼接的是 instructions 而非 system 提示词。在 Vercel AI SDK 中,instructions 是动态注入的------它在每次 prepareCall 时重新生成,而 system 提示词在 Agent 构造函数中就固定了。这意味着 Agent 的基座身份("你是一名数据科学家")是固定的,但可用的技能列表可以随每次请求动态变化。
5. loadSkill 工具
当 Agent 决定需要某个领域的专业知识时,它调用 loadSkill 工具:
typescript
loadSkill: tool({
description: '加载 skill 以获取 specialized instructions',
inputSchema: z.object({
name: z.string().describe('The skill name to load'),
}),
execute: async ({ name }) => {
const skills = await discoverSkills();
const skill = skills.find(
(s) => s.name.toLowerCase() === name.toLowerCase()
);
if (!skill) {
return { error: `Skill '${name}' not found` };
}
const skillFile = path.join(skill.path, 'SKILL.md');
const content = await readFileSync(skillFile, 'utf-8');
const body = stripFrontmatter(content);
return {
skillDirectory: skill.path,
content: body, // 核心:去掉 frontmatter 后的正文
};
},
}),
这个工具的设计原则是"最简接口":
- 输入 :只需要
name一个字段 - 输出 :只返回
content(正文)和skillDirectory(路径引用) - 匹配:不区分大小写,降低 Agent 的调用门槛
- 容错:未找到时返回错误字符串,Agent 可以据此调整行为
stripFrontmatter 负责去掉 YAML Frontmatter,只留下纯指令文本:
typescript
export function stripFrontmatter(content: string): string {
const match = content.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/);
return match ? content.slice(match[0].length).trim() : content.trim();
}
Strip 后的内容就是纯粹的领域指令,作为工具执行结果返回给 Agent,进入 Agent 的上下文。
6. Skills 管理 API
光有程序化的加载还不够,还需要一套管理接口。Skills Module 提供了三个 RESTful 接口:
POST /api/skills/upload --- 上传 Skill
markdown
用户上传 my-skill.zip
→ multer 接收文件(内存存储,10MB 限制)
→ fixFileNameEncoding 修复中文编码问题
→ validateRequest 校验文件数组
→ skills.service.uploadSkills
uploadSkills 流程:
1. adm-zip 读取内存中的 ZIP 文件
2. 检查 ZIP 中是否包含 SKILL.md(硬性要求)
3. 从文件名派生 skill 目录名:my-skill.zip → my-skill
4. sanitizeFileName 清理非法字符
5. 如果同名已存在,先删除(覆盖升级)
6. 解压到 {AGENTS_DIR}/{skillName}/
文件名校验(sanitizeFileName)防止路径穿越攻击:
typescript
export function sanitizeFileName(name: string): string {
return name.replace(/[^a-zA-Z0-9_-]/g, '_');
}
中文编码修复:浏览器上传时文件名可能按 Latin1 编码传入,需要尝试解码为 UTF-8:
typescript
function fixFileNameEncoding(file: Express.Multer.File): void {
const buffer = Buffer.from(file.originalname, 'latin1');
const decoded = buffer.toString('utf8');
if (!decoded.includes('�') && /[\u4e00-\u9fa5]/.test(decoded)) {
file.originalname = decoded;
}
}
POST /api/skills --- 删除所有 Skills
typescript
export async function deleteAllSkills(): Promise<SkillDeleteResult[]> {
const entries = await fs.readdir(targetDir, { withFileTypes: true });
const skillDirs = entries.filter((entry) => entry.isDirectory());
for (const dir of skillDirs) {
const skillDirPath = path.join(targetDir, dir.name);
await fs.rm(skillDirPath, { recursive: true, force: true });
deleted.push({ name: dir.name, path: skillDirPath, size });
}
}
GET /api/skills/download --- 打包下载
用 archiver 将整个 Skills 目录流式压缩:
typescript
export function downloadSkillsStream(): PassThrough {
const archive = archiver('zip', { zlib: { level: 9 } });
archive.directory(targetDir, false);
archive.pipe(passThrough);
archive.finalize();
return passThrough;
}
选择流式(PassThrough)而不是先写入磁盘再发送,避免了临时文件管理和磁盘 IO 开销。
7. 完整数据流
scss
用户发送消息
│
▼
chat.service.ts
├─ discoverSkills() → 扫描 ~/.agents/skills/
│ 返回 [SkillMetadata]
│
├─ callOptionsSchema.parse({ skills, chatId, metadata })
│
└─ createAgentUIStream({ agent: chatAgent, ... })
chat.agent.ts: prepareCall()
│
├─ instructions = 基础系统提示
│ + buildSkillsPrompt(skills)
│
└─ 注入 experimental_context
ToolLoopAgent 循环
│
├─ [思考] 用户需要 Python 代码审查
│
├─ Agent 调用 loadSkill("python-developer")
│ ├─ discoverSkills() 再次扫描
│ ├─ 匹配名称
│ ├─ 读取 SKILL.md → stripFrontmatter
│ └─ 返回正文内容
│
├─ [思考] 根据 Skill 指令执行审查
│
├─ 调用 readFile/runCommand 等工具
│
└─ [输出] 反馈审查结果给用户
两个 discoverSkills 调用 :一次在 chat.service.ts 中(构建可用列表),一次在 loadSkill 工具执行时(获取实际内容)。为什么不是复用结果?因为用户可能在上一次列表构建后上传了新的 Skill,第二次扫描确保获取最新状态。
8. 热重载与无重启部署
这个设计中有一个隐形的优点:无需重启服务器。
传统的做法是在应用启动时扫描一次 Skills 并缓存起来,但这样每次上传新 Skill 后都要重启才能生效。这个项目选择在每次聊天请求 和每次 loadSkill 调用时重新扫描文件系统。
代价是增加了磁盘 IO(扫描目录),但 Skills 目录通常只有十几个子目录,这几十毫秒的开销相对于 LLM 的推理延迟(秒级)而言可以忽略。
scss
场景:运维上传了一个新的 sql-optimizer skill
→ 上传 POST 返回成功
→ 用户发起新对话
→ discoverSkills() 扫描到新目录
→ Agent 在系统提示词中看到 sql-optimizer
→ Agent 按需调用 loadSkill("sql-optimizer")
→ 技能立即生效,零停机
9. 为什么选 ZIP 而不是 Git 或直接上传目录?
| 方式 | 优点 | 缺点 |
|---|---|---|
| ZIP 上传 | 简单,浏览器原生支持,单个文件传输 | 需要解压校验 |
| Git 仓库同步 | 版本控制,协作方便 | 需要 Git 权限,部署复杂 |
| 目录映射 | 开发时直接修改 | 无法在管理界面上传 |
对于这个项目,选择 ZIP 的理由是:
- 自包含 :一个 ZIP 包就是一个完整的 Skill,包含
SKILL.md和配套脚本 - 浏览器友好 :Vue 3 前端通过
<input type="file">就能上传 - 可校验 :上传时可以检查 ZIP 是否包含
SKILL.md,提前拒绝无效包 - 可分发:ZIP 本身就是 Skill 的分发格式
10. 设计原则总结
| 原则 | 体现 |
|---|---|
| 按需加载 | 只注入技能名称和描述,正文在 Agent 请求时才加载 |
| 热加载 | 每次请求重新扫描文件系统,上传即可生效 |
| 简单接口 | loadSkill 只接受 name,返回 content |
| Fail-Safe | 目录不存在自动创建,SKILL.md 缺失自动跳过 |
| 可审计 | 所有操作有结构化日志,OpenAPI 文档自动生成 |
| 最小依赖 | Frontmatter 解析手写,不引入 js-yaml |
11. 与子智能体系统的配合
Skills 系统和子智能体(Subagent)系统是互补关系:
- Skills:垂直注入领域知识,让 Agent 更"懂行"
- Subagent:水平扩展并行能力,让 Agent 更"高效"
典型场景:
yaml
用户: "分析这个项目的 Python 代码质量并修复问题"
主 Agent:
→ 发现需要 python-developer skill
→ 调用 loadSkill("python-developer") 获取代码规范
→ 创建子任务:
├── 子 Agent A: 检查 src/ 目录(携带 python-developer 指令)
├── 子 Agent B: 检查 tests/ 目录
└── 子 Agent C: 检查 setup.py 和 requirements.txt
→ 汇总结果,输出整改报告
12. 踩坑记录
Frontmatter 解析器的换行符差异
开发时发现一个坑:SKILL.md 在不同操作系统上换行符不同(\n vs \r\n)。正则用了 \r?\n 来处理:
typescript
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
文件名编码的 Latin1/UTF-8 问题
浏览器上传中文文件名时,Chrome 会按 Latin1 编码发送。fixFileNameEncoding 尝试解码,如果结果包含 �(非法字符)则保持原样。这个方案不是 100% 完美(存在无法区分 Latin1 编码和有效 UTF-8 的情况),但在实践中覆盖了 95% 以上的场景。
同名覆盖的安全边界
上传同名 Skill 时,现有目录会被先删除再解压。这里有一个竞态条件(两个请求同时上传同名文件),但对于个人/小团队项目,这个风险的优先级较低。
13. 总结
Skills 动态加载系统是这个 AI Agent 项目中扩展性的核心。通过将领域知识和 Agent 逻辑解耦,实现了:
- 插件化扩展:任何人都可以创建一个 Skill ZIP 包上传,不需要修改代码
- 零成本知识管理:Agent 只加载它需要的知识,无关知识不占用上下文
- 运行时热更新:上传即生效,不需要重启服务
- 简单一致的接口:一个 SKILL.md 文件、一个 loadSkill 工具、三个 REST 端点
如果把 AI Agent 比作一个开发者,那么 Skills 就是它随时可以翻开的手册------不需要把所有手册背下来,但知道哪本手册放在哪里、什么时候该翻开它。
关联阅读:
- AI Agent 沙箱双层防护体系:从权限过滤到内核隔离的完整实现 --- 本文姊妹篇,介绍安全沙箱设计
- 手写一个 AI Agent 全栈项目:从沙箱执行到子智能体的完整实现 --- 项目架构总览