Appearance
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.input 将 SKILL.md 内容作为 user 消息注入对话历史,模型在生成下一轮响应时会直接读取并遵循其中的指令,与在对话中直接发送指令效果相同。
Q: 多个 skill 同时加载会不会冲突?
A: 不会冲突,但可能存在指令叠加。每个 skill 独立注入到对话历史,模型会综合考虑所有已加载 skill 的指令。建议各 skill 的职责清晰分离。
Q: skills 目录的路径可以自定义吗?
A: 可以。修改 SKILLS_DIR 常量即可,也可以改成从环境变量读取,或直接传入参数。