OpenClaw源码学习 | 技能加载、技能裁剪和路径压缩
技能约束的配置
可以通过~/.openclaw/openclaw.json文件配置技能相关的限制。其中maxCandidatesPerRoot和maxSkillsLoadedPerSource为技能加载阶段的约束。maxSkillsInPrompt和maxSkillsPromptChars为构造技能部分的系统提示词阶段的约束。maxSkillFileBytes为模型决定读取技能说明文件SKILL.md阶段的约束。
// ~/.openclaw/openclaw.json
{
skills:{
limits:{
maxCandidatesPerRoot:300,//扫描 skills 根目录时的最大子目录数量,,默认300
maxSkillsLoadedPerSource:200,//从单个技能源(如 bundled、managed、workspace 等)最多加载的技能数量
maxSkillsInPrompt:150,//注入到 system prompt 中的最多技能数量,用于控制 prompt 长度
maxSkillsPromptChars:30000, //注入到 system prompt 中技能块的最大字符数预算
maxSkillFileBytes:256000//单个 SKILL.md 文件的最大字节数,超出则跳过该技能
}
}
}
如上所示,如果在配置文件添加了这些约束,src/agents/skills/workspace.ts文件的resolveSkillsLimits会加载这些配置值,否则返回默认值。
// src/agents/skills/workspace.ts
functionresolveSkillsLimits(config?: OpenClawConfig): ResolvedSkillsLimits{
const limits = config?.skills?.limits;
return {
maxCandidatesPerRoot: limits?.maxCandidatesPerRoot ?? DEFAULT_MAX_CANDIDATES_PER_ROOT,
maxSkillsLoadedPerSource:
limits?.maxSkillsLoadedPerSource ?? DEFAULT_MAX_SKILLS_LOADED_PER_SOURCE,
maxSkillsInPrompt: limits?.maxSkillsInPrompt ?? DEFAULT_MAX_SKILLS_IN_PROMPT,
maxSkillsPromptChars: limits?.maxSkillsPromptChars ?? DEFAULT_MAX_SKILLS_PROMPT_CHARS,
maxSkillFileBytes: limits?.maxSkillFileBytes ?? DEFAULT_MAX_SKILL_FILE_BYTES,
};
}
技能按照优先级顺序加载
loadSkillEntries方法则负责从技能目录中加载技能。OpenClaw会从多个来源读取技能,这些来源包括:
-
bundled:内置技能,随OpenClaw安装时内置的技能,目录在openclaw安装目录下,例如~/.nvm/versions/node/v22.17.1/lib/node_modules/openclaw/skills -
extra:额外的技能,这个目录可以通过~/openclaw/openclaw.json进行配置,如果没有配置则忽略。你可以通过下列方式配置一个额外技能目录。
//~/openclaw/openclaw.json
skills: {
load: {
extraDirs: ["~/Projects/agent-scripts/skills"],
},
}
-
managed:托管技能,技能目录为~/openclaw/skills。 -
agents-skills-personal:个人技能,技能目录为~/.agents/skills。 -
agents-skills-project:项目技能,技能目录为~/workspace/.agents/skills。 -
workspace:工作区技能,技能目录为~/workspace/。
其中上面的~/workspace是通过配置文件agents.agent[x].workspace字段进行配置的。最常用的三个来源是内置技能、托管技能和工作区技能。内置技能是全局的,托管技能单个用户共享的、而工作区技能则是每个agent独有的。
loadSkillEntries函数会按照以下代码中的顺序依次从各个技能目录加载技能列表后合并。
// src/agents/skills/workspace.ts
const merged = new Map<string, Skill>();
// Precedence: extra < bundled < managed < agents-skills-personal < agents-skills-project < workspace
for (const skill of extraSkills) {
merged.set(skill.name, skill);
}
for (const skill of bundledSkills) {
merged.set(skill.name, skill);
}
for (const skill of managedSkills) {
merged.set(skill.name, skill);
}
for (const skill of personalAgentsSkills) {
merged.set(skill.name, skill);
}
for (const skill of projectAgentsSkills) {
merged.set(skill.name, skill);
}
for (const skill of workspaceSkills) {
merged.set(skill.name, skill);
}
可见,如果不同技能目录下出现重名,后加载的会覆盖前加载的,也即技能加载是按照以下优先级来处理:extra < bundled < managed < agents-skills-personal < agents-skills-project < workspace。 从不同技能目录加载目录的逻辑是一样的,这是通过在loadSkillEntries内调用loadSkills函数实现的,需要注意的是,这个函数从指定目录加载时需要满足maxCandidatesPerRoot和maxSkillsLoadedPerSource配置的约束。
// src/agents/skills/workspace.ts
const limits = resolveSkillsLimits(opts?.config);
const loadSkills = (params: { dir: string; source: string }): Skill[] =>{
// 需要满足单个根目录可加载总技能数约束 maxCandidatesPerRoot
const resolved = resolveNestedSkillsRoot(params.dir, {maxEntriesToScan: limits.maxCandidatesPerRoot,
});
...
const loadedSkills: Skill[] = [];
for (const name of limitedChildren) {
...
// 超过 maxSkillsLoadedPerSource 限制就不再加载了
if (loadedSkills.length >= limits.maxSkillsLoadedPerSource) {
break;
}
}
// 最后再执行一次截断
if (loadedSkills.length > limits.maxSkillsLoadedPerSource) {
return loadedSkills.slice().sort((a, b) => a.name.localeCompare(b.name)).slice(0, limits.maxSkillsLoadedPerSource);
}
return loadedSkills;
}
技能裁剪和路径压缩
resolveWorkspaceSkillPromptState函数负责加载技能列表,并执行技能裁剪和路径压缩等工作,这两项工作的目的也是进一步减少系统提示词中技能部分的长度从而节省模型上下文。
// src/agents/skills/workspace.ts
functionresolveWorkspaceSkillPromptState(
workspaceDir: string,
opts?: WorkspaceSkillBuildOptions,
): {
eligible: SkillEntry[];
prompt: string;
resolvedSkills: Skill[];
} {
// 调用loadSkillEntries函数加载技能列表(opts?.entries说明也可能是用的预加载条目)
const skillEntries = opts?.entries ?? loadSkillEntries(workspaceDir, opts);
...
//执行 技能截断
const { skillsForPrompt, truncated } = applySkillsPromptLimits({
skills: resolvedSkills,
config: opts?.config,
});
const truncationNote = truncated
? `⚠️ Skills truncated: included ${skillsForPrompt.length} of ${resolvedSkills.length}. Run \`openclaw skills check\` to audit.`
: "";
// 技能路径压缩
const prompt = [
remoteNote,
truncationNote,
formatSkillsForPrompt(compactSkillPaths(skillsForPrompt)),
].filter(Boolean).join("\n");
return { eligible, prompt, resolvedSkills };
}
从上面代码可以看到:applySkillsPromptLimits负责技能截断;compactSkillPaths负责路径压缩。
技能裁剪
技能裁剪主要是根据配置中要求的最多技能数量maxSkillsInPrompt和系统提示词中技能章节最多占用的字节数maxSkillsPromptChars数量约束。来对技能列表中的技能条目进行裁剪。简单来说,就是要限制总技能条目。
首先,applySkillsPromptLimits函数直接按照maxSkillsInPrompt约束直接筛选前maxSkillsInPrompt条技能,多余的技能直接忽略。
// src/agents/skills/workspace.ts
functionapplySkillsPromptLimits(params: { skills: Skill[]; config?: OpenClawConfig }): {
skillsForPrompt: Skill[];
truncated: boolean;
truncatedReason: "count" | "chars" | null;
} {
const limits = resolveSkillsLimits(params.config);//要求的技能总数上线
const total = params.skills.length; // 传入的技能总数
// 先按数量上限截取技能
const byCount = params.skills.slice(0, Math.max(0, limits.maxSkillsInPrompt));
let skillsForPrompt = byCount;
...
}
maxSkillsPromptChars是要限制技能在系统提示词中所在的字节数。applySkillsPromptLimits函数内部先做了一个fits函数来计算指定的技能列表是否满足约束。
// src/agents/skills/workspace.ts
const fits = (skills: Skill[]): boolean => {
const block = formatSkillsForPrompt(skills);
return block.length <= limits.maxSkillsPromptChars;
};
然后使用二分查找法,从skillsForPrompt列表中找出满足maxSkillsPromptChars约束的最大技能的索引lo,然后从skillsForPrompt中截取前lo个技能。
// src/agents/skills/workspace.ts
// 二分查找法查看从哪个位置删除尾部的技能 O(log n) 时间复杂度,避免了逐项尝试的 O(n²) 开销
if (!fits(skillsForPrompt)) {
let lo = 0;
let hi = skillsForPrompt.length;
while (lo < hi) {
const mid = Math.ceil((lo + hi) / 2);
if (fits(skillsForPrompt.slice(0, mid))) {
lo = mid;
} else {
hi = mid - 1;
}
}
// 最终 lo 是满足字符预算的最大技能数量
skillsForPrompt = skillsForPrompt.slice(0, lo);
truncated = true;
truncatedReason = "chars";
}
return {skillsForPrompt, truncated, truncatedReason };
现在,上面返回的skillsForPrompt就是直接用来构造系统提示词技能章节的列表。系统提示词中技能章节内容如下:
## Skills (mandatory),
Before replying: scan <available_skills><description> entries.
- If exactly one skill clearly applies: read its SKILL.md at <location> with read, then follow it.
- If multiple could apply: choose the most specific one, then read/follow it.
- If none clearly apply: do not read any SKILL.md.
Constraints: never read more than one skill up front; only read after selecting.
- When a skill drives external API writes, assume rate limits: prefer fewer larger writes, avoid tight one-item loops, serialize bursts when possible, and respect 429/Retry-After.
<available_skills>
<skill>
<name>doubao-image-generator</name>
<description>豆包图像生成 - 根据提示词生成高质量图片</description>
<location>/Users/alice/.bun/.../skills/doubao-image-generator/SKILL.md</location>
</skill>
<skill>
<name>tavily</name>
<description>AI-optimized web search via Tavily API. Returns concise, relevant results for AI agents.</description>
<location>/Users/alice/.bun/.../skills/tavily/SKILL.md</location>
</skill>
...
</available_skills>
路径压缩
观察上述每个技能列表的<location>内容,技能的SKILL.md的完整路径的前缀部分可以进一步压缩已节省系统提示词大小。compactSkillPaths函数将技能文件路径中的用户主目录前缀替换为 ~,以减少注入 system prompt 时的 token 消耗。 模型能够理解 ~ 的展开,且 read 工具会将 ~ 解析为实际的主目录路径。每个技能路径大约可节省 5–6 个 token,按照上面maxSkillsInPrompt默认值为150,累计约能节省 750–900 个 token。
// src/agents/skills/workspace.ts
functioncompactSkillPaths(skills: Skill[]): Skill[] {
const home = os.homedir();
if (!home) return skills;
const prefix = home.endsWith(path.sep) ? home : home + path.sep;
return skills.map((s) => ({
...s,
filePath: s.filePath.startsWith(prefix) ? "~/" + s.filePath.slice(prefix.length) : s.filePath,
}));
}
夜雨聆风