字节笔记本
2026年8月28日
OpenRouter SDK Skills Loader:按任务装技能
Claude Code 把技能做成「任务对上了再读 SKILL.md」。自己用 OpenRouter 接任意模型时,也可以照这个套路:给 Agent SDK 加一个 Skills Loader 工具,模型觉得需要某项能力,就调工具;下一轮对话里,技能全文已经在上下文里。官方例子在 Skills Loader,底层机制是 nextTurnParams。
它解决什么问题
技能文件本身不难写。难的是:
- 不要一上来把几十份 SKILL.md 全塞进系统提示。
- 模型一旦选中某个技能,后续轮次要一直带着这份说明,别下一轮又丢了。
- 同一技能不要加载两次,上下文里堆重复标记。
- 这套逻辑要跟具体模型解耦:换 Claude、换 Gemini、换开源模型,加载方式不变。
OpenRouter 的做法是把「加载技能」封装成一个 tool()。execute 只负责回报「已启动 / 找不到 / 已经装过」;真正把文件写进下一轮输入的,是 nextTurnParams.input。工具执行完、模型还没看到下一轮之前,SDK 会按工具数组顺序跑这些函数,改 input、instructions、甚至 temperature。
包名用现在的 Agent SDK:
npm install @openrouter/agent zod密钥走环境变量 OPENROUTER_API_KEY。平台接口(列模型、查额度)在 @openrouter/sdk;多轮工具循环、callModel、nextTurnParams 在 @openrouter/agent。
技能目录先放好
例子默认扫 ~/.claude/skills//SKILL.md,跟 Claude Code 本机目录兼容。你完全可以改成项目里的 .agents/skills,或自己的 ./skills。约定只有一条:每个技能一个目录,里面有 SKILL.md。
mkdir -p ~/.claude/skills/pdf-processing
mkdir -p ~/.claude/skills/data-analysis
mkdir -p ~/.claude/skills/code-reviewpdf-processing 目录下的 SKILL.md 写流程,不要写某次接口的死参数:
# PDF Processing
处理 PDF 时按这个顺序来。
## 可用动作
- extract_text:抽全文
- extract_tables:抽表,输出 JSON 或 markdown 表
- extract_images:抽内嵌图
- split_pdf:按页切开
## 注意
1. 先看文件大小。超过约 50 页就分段,不要一次读完。
2. 扫描件可能要 OCR。
3. 表格可能跨页,合并时带上页码。
4. 加密 PDF 先要密码,不要当成损坏文件。如果走 Agent Skills 的 frontmatter,description 写成「什么时候该用」。Loader 这边主要靠工具的 description 把可用技能名告诉模型,目录扫描结果会拼进这段话。
最小可用的 Skill 工具
下面是官方例子的骨架,注释按实际踩坑补了几句。
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((d) => d.isDirectory())
.filter((d) => existsSync(path.join(SKILLS_DIR, d.name, "SKILL.md")))
.map((d) => d.name);
};
const skillsTool = tool({
name: 'Skill',
description: `Load a specialized skill to enhance the assistant capabilities.
Available skills: ${listAvailableSkills().join(", ") || "none configured"}
Each skill provides domain-specific instructions and capabilities.`,
inputSchema: z.object({
type: z.string().describe("The skill type to load (e.g. pdf-processing)"),
}),
outputSchema: z.string(),
nextTurnParams: {
input: (params, context) => {
const skillMarker = `[Skill: ${params.type}]`;
if (JSON.stringify(context.input).includes(skillMarker)) {
return context.input;
}
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}" not found. Available skills: ${available.join(", ") || "none"}`;
}
return `Launching skill ${params.type}`;
},
});调用时把这个工具丢进 callModel。模型看到「处理 PDF、抽表」,会先调 Skill({ type: "pdf-processing" }),下一轮才开始真正干活:
const result = openrouter.callModel({
model: 'anthropic/claude-sonnet-4.5',
input: 'I need to process a PDF and extract tables from it',
tools: [skillsTool],
});
const text = await result.getText();model 字段换成 OpenRouter 上别的模型也行,技能注入不绑某一家。这是它跟「只在 Claude Code 里生效的技能目录」最大的差别。
执行顺序,别搞反
一次工具调用的顺序是固定的:
- 模型产出 tool call。
- 所有工具的 execute 跑完。
- 按 tools 数组顺序跑每个工具的 nextTurnParams。
- 改过的参数交给下一轮模型。
所以 execute 里读到的 context.input 还是本轮的,技能正文还没进去。去重标记要在 nextTurnParams 里查,也要在 execute 里查,两边各挡一层:一边避免重复注入,一边让模型听到「已经装过了」而不是再调一次。
nextTurnParams 能改的不只是消息列表。常见字段:
| 字段 | 适合干什么 |
|---|---|
| input | 把 SKILL.md 追加进对话 |
| instructions | 往系统提示追加短约定 |
| model | 复杂任务升级到更强模型 |
| temperature | 严格模式压低随机性 |
| maxOutputTokens | 技能需要长输出时放宽上限 |
改的时候只动需要动的字段。官方建议:不需要改就返回 undefined,不要把整个 context 再展开一遍。
一次装多个、带配置、先列清单
复杂任务经常要两个技能一起上,比如「读 PDF 报告再做图」。用数组参数,工具名可以叫 load_skills,入参是 skills: string[]。nextTurnParams 里循环每个名字:已有标记就跳过,文件不存在也跳过,否则追加一条带 [Skill: name] 标记的 user 消息。execute 返回 loaded 和 failed 两份列表,方便模型决定要不要改用别的技能。
需要「严格模式 / 输出格式」时,把 options 写进 inputSchema,在 nextTurnParams 里拼一段配置头,同时按 strictMode 调 temperature。发现类工具(list_skills)只 execute、不改下一轮参数:读每个 SKILL.md 的首段当简介,有 config.json 就标一下。完整拼装见官方 Complete Example,记得加 stopWhen: stepCountIs(10),避免模型在「列技能 → 加载 → 干活」之间空转。
四条写工具时的规矩
官方例子反复强调这几条,漏一条就会在多轮里出怪问题:
- 幂等。 用稳定标记,比如 [Skill: pdf-processing],注入前先 JSON.stringify(context.input).includes(marker)。已存在就原样返回。
- 缺文件要软。 execute 返回「找不到,可用:…」,不要抛未捕获异常把整轮打崩。
- 只追加,不替换。 nextTurnParams.input 必须基于现有 context.input 展开。直接 return 一段新数组,等于把用户原话和已加载的技能清掉。
- 标记可读。 标记既是去重键,也是给人看日志时的锚点。不要用随机 UUID。
路径不要写死成字面量 ~/.claude/skills/name/SKILL.md 再交给 readFileSync。波浪号不会自动展开,要用 path.join(process.env.HOME, ...)。文档里有一处示例用了字面量波浪号,那是示意,复制到生产会读失败。
和 Claude Code / .agents/skills 怎么分工
三套东西经常一起出现,别混成一个概念:
| 层 | 谁在跑 | 作用 |
|---|---|---|
| .agents/skills 或 ~/.claude/skills | 文件约定 | 技能正文放哪,团队共享 |
| Claude Code / Cursor / Codex 自己的 Skills | 那个产品的运行时 | 产品内部按 description 激活 |
| OpenRouter Skills Loader | 你的 callModel 循环 | 任意模型都能按需把同一份 SKILL.md 读进上下文 |
仓库里的技能仍然建议只维护一份,优先 .agents/skills。Loader 的 SKILLS_DIR 指过去即可。OpenRouter 自己也维护了一套可安装技能:OpenRouterTeam/skills,覆盖 TypeScript SDK、create-agent-tui、create-headless-agent、模型查询、图像和语音接口。那些是「教代理怎么用 OpenRouter」的技能;本文说的 Loader 是「在你自己的 agent 里加载任意技能」的运行时。两边可以叠:用官方 skill 脚手架出一个 harness,再在生成的项目里接 Loader。
落地检查
- 装的是 @openrouter/agent,密钥在环境变量里,没有写进仓库。
- SKILLS_DIR 用 HOME 拼出来,每个技能目录里确实有 SKILL.md。
- 工具 description 里带上当前可用技能名,模型才能选对 type。
- nextTurnParams.input 先查标记再追加,execute 对已加载和找不到都有明确返回。
- 多技能、发现工具、主 Skill 工具一起挂上时,设了 stopWhen。
- 换一个非 Anthropic 的模型跑同一句「抽 PDF 表格」,确认仍会先调 Skill 再干活。
做完这些,技能就不再绑死在某一个编码产品里。文件还是那些 SKILL.md,换模型只换 callModel 的 model 字段。