Skip to content

Skills Loader 示例展示了如何基于 nextTurnParams.input 机制,构建自包含的技能加载工具。工具被调用时,会自动从文件系统读取对应 SKILL.md,将领域专属指令注入对话历史——后续轮次的模型无需任何额外配置即可获得这些能力。本文提供从基础单 skill 到多 skill 并行加载、带配置选项的可配置 skill、skill 目录发现工具的完整实现,并总结了幂等性检查、优雅降级、上下文保留、唯一标记四大生产级关键模式。

概述

Skills Loader 的核心思路:工具不只是返回结果,还通过 nextTurnParams.input 将自己的"领域知识"注入对话上下文,使后续轮次的模型天然具备该领域能力。

用户请求 → 模型调用 Skill 工具 → nextTurnParams 注入 SKILL.md 内容 → 后续轮次模型获得专属指令

前置准备

bash
pnpm add @openrouter/sdk zod

创建 skills 目录:

bash
mkdir -p ~/.claude/skills/pdf-processing
mkdir -p ~/.claude/skills/data-analysis
mkdir -p ~/.claude/skills/code-review

基础 Skills 工具

typescript
import { OpenRouter, tool } from '@openrouter/agent';
import { readFileSync, existsSync, readdirSync } from 'fs';
import path from 'path';
import { z } from 'zod';

const openrouter = new OpenRouter({
  apiKey: process.env.OPENROUTER_API_KEY,
});

const SKILLS_DIR = path.join(process.env.HOME || '~', '.claude', 'skills');

const listAvailableSkills = (): string[] => {
  if (!existsSync(SKILLS_DIR)) return [];
  return readdirSync(SKILLS_DIR, { withFileTypes: true })
    .filter((dirent) => dirent.isDirectory())
    .filter((dirent) => existsSync(path.join(SKILLS_DIR, dirent.name, 'SKILL.md')))
    .map((dirent) => dirent.name);
};

const skillsTool = tool({
  name: 'Skill',
  description: `加载专项技能,增强助手能力。
可用技能:${listAvailableSkills().join(', ') || '暂无'}
每个技能提供特定领域的指令和能力。`,

  inputSchema: z.object({
    type: z.string().describe("要加载的技能类型(如 'pdf-processing')"),
  }),

  outputSchema: z.string(),

  nextTurnParams: {
    input: (params, context) => {
      // 幂等性检查:同一 skill 不重复注入
      const skillMarker = `[Skill: ${params.type}]`;
      if (JSON.stringify(context.input).includes(skillMarker)) {
        return context.input;
      }

      // 读取 skill 文件
      const skillPath = path.join(SKILLS_DIR, params.type, 'SKILL.md');
      if (!existsSync(skillPath)) {
        return context.input; // 文件不存在则不修改上下文
      }

      const skill = readFileSync(skillPath, 'utf-8');
      const skillDir = path.join(SKILLS_DIR, params.type);

      // 追加到对话历史(保留已有内容)
      const currentInput = Array.isArray(context.input) ? context.input : [context.input];

      return [
        ...currentInput,
        {
          role: 'user',
          content: `${skillMarker}
Base directory for this skill: ${skillDir}

${skill}`,
        },
      ];
    },
  },

  execute: async (params, context) => {
    const skillMarker = `[Skill: ${params.type}]`;

    if (JSON.stringify(context?.turnRequest?.input || []).includes(skillMarker)) {
      return `Skill ${params.type} is already loaded`;
    }

    const skillPath = path.join(SKILLS_DIR, params.type, 'SKILL.md');
    if (!existsSync(skillPath)) {
      const available = listAvailableSkills();
      return `Skill "${params.type}" 不存在。可用技能:${available.join(', ') || '无'}`;
    }

    return `Launching skill ${params.type}`;
  },
});

使用方式

typescript
const result = openrouter.callModel({
  model: 'anthropic/claude-sonnet-4.5',
  input: '我需要处理一份 PDF 并从中提取表格',
  tools: [skillsTool],
});

const text = await result.getText();
// 模型将调用 Skill 工具加载 pdf-processing,
// 后续响应自动带有该技能的专属指令

Skill 文件示例

创建 ~/.claude/skills/pdf-processing/SKILL.md

markdown
# PDF 处理技能

你现在具备 PDF 处理能力。

## 可用工具
- `extract_text`:提取全文
- `extract_tables`:提取结构化表格
- `extract_images`:提取内嵌图片
- `split_pdf`:按页拆分

## 最佳实践
1. 处理前检查文件大小
2. 超过 50 页时分批处理
3. 扫描件可能需要 OCR
4. 跨页表格需跨页处理

## 输出格式
- 文字:纯文本或 Markdown
- 表格:JSON、CSV 或 Markdown 表格
- 图片:顺序命名的 PNG

## 错误处理
- 加密 PDF:请求密码
- OCR 失败:建议替代方案
- 提取出错:报告页码

多 Skill 并行加载

一次调用加载多个 skill:

typescript
const multiSkillLoader = tool({
  name: 'load_skills',
  description: '批量加载多个技能,适合复杂任务',

  inputSchema: z.object({
    skills: z.array(z.string()).describe('要加载的技能名称数组'),
  }),

  outputSchema: z.object({
    loaded: z.array(z.string()),
    failed: z.array(z.object({ name: z.string(), reason: z.string() })),
  }),

  nextTurnParams: {
    input: (params, context) => {
      let newInput = Array.isArray(context.input) ? context.input : [context.input];

      for (const skillName of params.skills) {
        const skillMarker = `[Skill: ${skillName}]`;

        if (JSON.stringify(newInput).includes(skillMarker)) continue;

        const skillPath = path.join(SKILLS_DIR, skillName, 'SKILL.md');
        if (!existsSync(skillPath)) continue;

        const skillContent = readFileSync(skillPath, 'utf-8');
        const skillDir = path.join(SKILLS_DIR, skillName);

        newInput = [
          ...newInput,
          {
            role: 'user',
            content: `${skillMarker}
Base directory: ${skillDir}

${skillContent}`,
          },
        ];
      }

      return newInput;
    },
  },

  execute: async ({ skills }) => {
    const loaded: string[] = [];
    const failed: Array<{ name: string; reason: string }> = [];

    for (const skill of skills) {
      const skillPath = path.join(SKILLS_DIR, skill, 'SKILL.md');
      if (existsSync(skillPath)) {
        loaded.push(skill);
      } else {
        failed.push({ name: skill, reason: 'Skill not found' });
      }
    }

    return { loaded, failed };
  },
});

// 模型可能自动调用:load_skills({ skills: ['pdf-processing', 'data-analysis'] })
const result = openrouter.callModel({
  model: 'anthropic/claude-sonnet-4.5',
  input: '我需要分析一份 PDF 报告并生成可视化图表',
  tools: [multiSkillLoader],
});

带配置选项的 Skill

支持自定义配置参数的 skill 加载器:

typescript
const configurableSkillLoader = tool({
  name: 'configure_skill',
  description: '带自定义配置加载技能',

  inputSchema: z.object({
    skillName: z.string(),
    options: z.object({
      verbosity: z.enum(['minimal', 'normal', 'detailed']).default('normal'),
      strictMode: z.boolean().default(false),
      outputFormat: z.enum(['json', 'markdown', 'plain']).default('markdown'),
    }).optional(),
  }),

  outputSchema: z.object({
    status: z.enum(['loaded', 'already_loaded', 'not_found']),
    message: z.string(),
    configuration: z.record(z.unknown()).optional(),
  }),

  nextTurnParams: {
    input: (params, context) => {
      const skillMarker = `[Skill: ${params.skillName}]`;
      if (JSON.stringify(context.input).includes(skillMarker)) {
        return context.input;
      }

      const skillPath = path.join(SKILLS_DIR, params.skillName, 'SKILL.md');
      if (!existsSync(skillPath)) return context.input;

      const skillContent = readFileSync(skillPath, 'utf-8');
      const options = params.options || {};

      const configHeader = `
## Skill 配置
- 详细程度:${options.verbosity || 'normal'}
- 严格模式:${options.strictMode || false}
- 输出格式:${options.outputFormat || 'markdown'}
`;
      const currentInput = Array.isArray(context.input) ? context.input : [context.input];

      return [
        ...currentInput,
        { role: 'user', content: `${skillMarker}\n${configHeader}\n${skillContent}` },
      ];
    },

    // 严格模式时降低 temperature
    temperature: (params, context) => {
      if (params.options?.strictMode) return 0.3;
      return context.temperature;
    },
  },

  execute: async ({ skillName, options }) => {
    const skillPath = path.join(SKILLS_DIR, skillName, 'SKILL.md');
    if (!existsSync(skillPath)) {
      return { status: 'not_found' as const, message: `Skill "${skillName}" 不存在` };
    }
    return {
      status: 'loaded' as const,
      message: `Skill "${skillName}" 已加载(含配置)`,
      configuration: options || {},
    };
  },
});

Skill 发现工具

列出当前所有可用的 skill:

typescript
const skillDiscoveryTool = tool({
  name: 'list_skills',
  description: '列出所有可用技能及其描述',

  inputSchema: z.object({
    category: z.string().optional().describe('按分类过滤'),
  }),

  outputSchema: z.object({
    skills: z.array(z.object({
      name: z.string(),
      description: z.string(),
      hasConfig: z.boolean(),
    })),
    totalCount: z.number(),
  }),

  execute: async ({ category }) => {
    const availableSkills = listAvailableSkills();
    const skills = [];

    for (const skillName of availableSkills) {
      const skillPath = path.join(SKILLS_DIR, skillName, 'SKILL.md');
      const content = readFileSync(skillPath, 'utf-8');
      const lines = content.split('\n').filter((l) => l.trim());
      const description = lines.find((l) => !l.startsWith('#')) || 'No description';
      const hasConfig = existsSync(path.join(SKILLS_DIR, skillName, 'config.json'));

      skills.push({ name: skillName, description: description.slice(0, 100), hasConfig });
    }

    return { skills, totalCount: skills.length };
  },
});

完整示例

组合所有工具:

typescript
import { OpenRouter, tool, stepCountIs } from '@openrouter/agent';

const result = openrouter.callModel({
  model: 'anthropic/claude-sonnet-4.5',
  input: `我有个复杂任务:
1. 先告诉我有哪些可用技能
2. 加载适合 PDF 分析的技能
3. 帮我从 report.pdf 中提取并分析数据`,
  tools: [skillDiscoveryTool, skillsTool, multiSkillLoader],
  stopWhen: stepCountIs(10),
});

const text = await result.getText();
console.log(text);

四大关键模式

1. 幂等性检查

注入前先检查标记,防止重复加载:

typescript
const marker = `[Skill: ${params.type}]`;
if (JSON.stringify(context.input).includes(marker)) {
  return context.input; // 已存在,直接返回
}

2. 优雅降级

skill 文件不存在时返回有用信息而非报错:

typescript
if (!existsSync(skillPath)) {
  return `Skill 不存在。可用技能:${listAvailableSkills().join(', ')}`;
}

3. 上下文保留

追加而非替换,不破坏已有对话历史:

typescript
const currentInput = Array.isArray(context.input)
  ? context.input
  : [context.input];
return [...currentInput, newMessage]; // 追加

4. 唯一标记

用唯一标记标识注入内容,使检测可靠:

typescript
const skillMarker = `[Skill: ${params.type}]`;
// 内容有明确标识,检测不依赖内容本身

常见问题

Q: skill 文件只有 Markdown,模型真的能理解吗?

A: 能。nextTurnParams.inputSKILL.md 内容作为 user 消息注入对话历史,模型在生成下一轮响应时会直接读取并遵循其中的指令,与在对话中直接发送指令效果相同。

Q: 多个 skill 同时加载会不会冲突?

A: 不会冲突,但可能存在指令叠加。每个 skill 独立注入到对话历史,模型会综合考虑所有已加载 skill 的指令。建议各 skill 的职责清晰分离。

Q: skills 目录的路径可以自定义吗?

A: 可以。修改 SKILLS_DIR 常量即可,也可以改成从环境变量读取,或直接传入参数。