Skills 动态加载系统:让 AI Agent 按需获取领域知识

本文继续深入 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. ...

这种设计有两个关键点:

  1. 元数据与内容分离 :Frontmatter 中的 namedescription 用于 Skill 发现和列表展示,不占用系统提示词的 token 预算
  2. 按需加载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?因为只需要解析 namedescription 两个字段,一个 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.tsprepareCall 中拼接:

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 的理由是:

  1. 自包含 :一个 ZIP 包就是一个完整的 Skill,包含 SKILL.md 和配套脚本
  2. 浏览器友好 :Vue 3 前端通过 <input type="file"> 就能上传
  3. 可校验 :上传时可以检查 ZIP 是否包含 SKILL.md,提前拒绝无效包
  4. 可分发: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 逻辑解耦,实现了:

  1. 插件化扩展:任何人都可以创建一个 Skill ZIP 包上传,不需要修改代码
  2. 零成本知识管理:Agent 只加载它需要的知识,无关知识不占用上下文
  3. 运行时热更新:上传即生效,不需要重启服务
  4. 简单一致的接口:一个 SKILL.md 文件、一个 loadSkill 工具、三个 REST 端点

如果把 AI Agent 比作一个开发者,那么 Skills 就是它随时可以翻开的手册------不需要把所有手册背下来,但知道哪本手册放在哪里、什么时候该翻开它。


关联阅读

项目源码github.com/oliyg/expre...

相关推荐
赤龙ERP1 小时前
赤龙一周观察 · 6月第2周
大数据·人工智能·ai·erp
weedsfly1 小时前
Sass 代码复用完全指南:从变量到模块化
前端
qq_291579251 小时前
霍客引擎与电商图片AI:智能视觉营销的新范式
人工智能
JGDT_1 小时前
ERP重塑与未来趋势:SAP的实践及大一统格局(上)
大数据·人工智能·安全·架构·开源
张拭心1 小时前
Android 17 新特性:后台音频交互限制加强
android·前端
洛星核1 小时前
CrewAI 安装、使用方法详细全解
人工智能·github·人机交互·ai编程·agi·智能体
chen_zn951 小时前
RLinf复现RECAP(一):从轨迹回报到优势标签
人工智能·强化学习·具身智能·vla
神奇小汤圆1 小时前
Vector Graph RAG 开源!一套向量数据库同时搞定语义检索+RAG多跳
后端
小高学习java1 小时前
事务的边界问题,如何判断数据回滚时机。
java·数据库·后端