1. 从一次 Skill 不生效说起:OpenClaw SKILL.md 加载链路到底卡在哪
如果你在本地跑 OpenClaw,大概率遇到过这种场景:明明把SKILL.md放进了~/.openclaw/workspace/skills/目录,Frontmatter 也照着示例写了,可模型就是"看不见"这个技能,或者用户输入/feishu-doc毫无反应。更迷惑的是,日志里既没有报错,也没有任何加载提示,仿佛这个文件根本不存在。
这类问题的根源,几乎都出在 Skill 系统的加载链路上。OpenClaw 的 Skill 不是"放进去就生效"的简单机制,它要经过目录发现 → Frontmatter 解析 → 运行时资格校验 → 注册进快照 → 注入 Prompt五个环节,任何一环被过滤掉,技能都会静默消失。而 OpenClaw Skill 系统工作流程(代码层分析)的价值,就是把这五个环节拆开,让你知道每一步在检查什么、失败时该看哪个文件。
我试过在 Windows 和 macOS 上分别复现这套链路,发现最容易踩的坑集中在两处:一是 Frontmatter 的requires.env声明了环境变量但实际没注入,导致evaluateRuntimeEligibility直接返回 false;二是openclaw.json里配了allowBundled白名单,却没把自定义技能加进去,结果被isBundledSkillAllowed拦下。这两个问题都不会抛异常,只会让技能"人间蒸发"。
这篇文章面向需要在本地复现 Skill 调度行为的开发者,我会给出可复制的目录结构、Frontmatter 字段配置示例,以及通过日志与断点验证 Skill 被正确加载与触发的具体动作。核心检索词是 OpenClaw Skill 加载链路、SKILL.md 解析、Frontmatter 元数据读取,适合已经跑通 OpenClaw 基础环境、想深入理解扩展机制的人。读完之后,你应该能自己定位"技能为什么不生效",而不是靠反复重启碰运气。
2. 前置准备:TaoToken 接入与 OpenClaw 运行环境搭建
在拆解加载链路之前,得先把运行环境准备好。OpenClaw 本身是本地 Agent 运行时,但它需要调用大模型来完成技能匹配和工具调用决策,所以你得有一个可用的模型接入点。这里我用 TaoToken 作为模型网关,它兼容 OpenAI 风格的接口,配置起来比较直接。
先说清楚 TaoToken 是什么:它是一个模型 API 聚合服务,提供统一的 Base URL 和 Key,让你在 OpenClaw 里通过一份配置就能切换不同模型。对 Skill 系统调试来说,这点很关键——因为技能是否被触发,取决于模型能不能正确读取注入的<available_skills>列表并生成 Tool Call,所以你需要一个稳定的模型端点来观察行为。
第一步,拿到 API Key。访问 https://taotoken.net/api-keys 创建密钥,注意这个页面是控制台的一部分,创建后 Key 只显示一次,记得立刻保存。如果你还没注册,先走 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 完成账号初始化。
第二步,确认 OpenClaw 的模型配置位置。OpenClaw 的模型接入通常写在openclaw.json的models或providers段里,不同版本字段名略有差异。你需要把 Base URL 指向https://taotoken.net/api(注意 API 地址不带 UTM 参数),Key 填上一步创建的密钥,Model ID 填你实际要用的模型名。
第三步,准备 Skill 目录。OpenClaw 会从多个路径搜索技能,优先级从高到低大致是:
| 搜索路径 | 用途 | 典型场景 |
|---|---|---|
~/.openclaw/workspace/skills/ | 用户自定义技能 | 自己写的业务技能 |
~/.openclaw/workspace/.agents/skills/ | 工作区技能 | 项目级共享技能 |
~/.openclaw/skills/ | 系统配置技能 | 全局安装的技能 |
~/.agents/skills/ | 用户主目录技能 | 跨工作区复用 |
plugins/*/skills/ | 插件提供的技能 | 第三方插件附带 |
我建议调试阶段统一放在~/.openclaw/workspace/skills/下,因为它的加载日志最完整,出问题时最容易定位。目录结构长这样:
~/.openclaw/workspace/skills/ └── feishu-doc/ └── SKILL.md注意每个技能必须是独立子目录,SKILL.md放在子目录根下。如果你直接把SKILL.md丢在skills/根目录,loadSkillsFromDirSafe()递归查找时虽然能找到文件,但loadSingleSkillDirectory()会用父目录名作为技能名,容易和预期不符。
环境准备好之后,先别急着写复杂技能。用一个最小可用的SKILL.md验证链路是否通,比一上来就配依赖、配环境变量要高效得多。下一节我会给出完整的 Frontmatter 配置和对应的openclaw.json片段。
3. 可复制配置:SKILL.md Frontmatter 与 openclaw.json 完整片段
这一节是全文的核心操作部分。我会给出一个能直接跑通的SKILL.md,然后逐字段解释它在加载链路里被谁读取、做什么校验。你可以把下面的内容原样复制到~/.openclaw/workspace/skills/feishu-doc/SKILL.md。
--- name: feishu-doc description: Feishu document read/write operations. Activate when user mentions Feishu docs, cloud docs, or docx links. user-invocable: true disable-model-invocation: false primaryEnv: FEISHU_APP_ID os: ["win32", "darwin", "linux"] requires: bins: ["node"] env: ["FEISHU_APP_ID"] install: - kind: node package: feishu-sdk --- # Feishu Doc Skill 当用户提到飞书文档、云文档或 docx 链接时,使用本技能读取或写入文档内容。 ## 使用方式 用户输入 `/feishu-doc <docx_url>` 时,直接调用 feishu_doc 工具。这段 Frontmatter 里,每个字段都有明确的消费方:
name和description会被parseFrontmatter()解析后写入 Skill 对象,最终由formatSkillsForPrompt()转成 XML 注入模型上下文。description尤其重要,模型就是靠它判断"这个任务该不该用这个技能",所以描述里要写清楚触发条件。
user-invocable: true决定用户能否用/feishu-doc这种斜杠命令直接调用。如果设成 false,用户手动输入命令会被忽略,但模型仍可能自动调用(取决于disable-model-invocation)。
disable-model-invocation: false表示允许模型自动调用。如果你写了一个只该由用户显式触发的危险操作技能,把它设成 true。
os字段由evaluateRuntimeEligibility()检查,只有当前系统在列表里,技能才会被注册。这个检查在shouldIncludeSkill()里执行,失败时静默跳过。
requires.bins和requires.env是依赖检查。bins会调用hasBinary()检查可执行文件是否存在,env会检查process.env或技能配置里的env注入。这里声明了FEISHU_APP_ID,意味着你必须通过配置或环境变量提供它,否则技能不加载。
install段声明安装方式,kind: node对应normalizeSafeNpmSpec()校验包名合法性。支持的 kind 包括node、brew、go、uv、download,分别对应不同的安装器。
接下来是openclaw.json的配置片段。这个文件通常在工作区根目录或~/.openclaw/下:
{ "skills": { "entries": { "feishu-doc": { "enabled": true, "env": { "FEISHU_APP_ID": "cli_xxxxxxxxxxxx" } } }, "allowBundled": ["feishu-doc"], "limits": { "maxSkillsInPrompt": 150, "maxSkillsPromptChars": 18000 } } }这里有几个关键点。entries.feishu-doc.enabled为 true 是必须的,如果设成 false,shouldIncludeSkill()第一步就返回 false。env字段是运行时注入的环境变量,它会和process.env一起被hasEnv检查,所以即使系统环境里没有FEISHU_APP_ID,只要这里配了,技能依然能通过校验。
allowBundled是 bundled 技能白名单。如果你用的是 OpenClaw 内置技能,必须把技能名加进这个数组,否则isBundledSkillAllowed()会拦截。自定义技能通常不受这个限制,但为了保险,建议统一加上。
limits控制注入 Prompt 的技能数量和字符上限。maxSkillsInPrompt: 150意味着最多 150 个技能会被写进<available_skills>,超出部分会被截断。maxSkillsPromptChars: 18000是字符上限,如果你的技能描述写得太长,可能挤掉后面的技能。调试阶段建议把这两个值调小,比如 10 和 2000,这样日志里更容易看清哪些技能被注入了。
如果你用的是插件提供的技能,还需要在插件的manifest.json里声明技能路径:
{ "id": "feishu", "skills": ["skills/feishu-doc", "skills/feishu-drive"] }resolvePluginSkillDirs()会读取这个 manifest,检查插件激活状态,然后把技能目录加入搜索路径。注意插件技能只有在插件被激活时才会加载,如果插件被禁用,技能也不会出现。
配置写完后,别急着启动。先用一个简单的 Node 脚本验证 Frontmatter 能否被正确解析,这样能把"解析失败"和"校验失败"两类问题分开。下一节我会给出验证请求的具体命令和预期结果。
4. 验证请求:用日志与断点确认 Skill 被正确加载与触发
配置写好了,接下来要验证链路是否真的走通。OpenClaw 的 Skill 加载是静默的,所以你需要主动打开日志或打断点。我推荐三个层次的验证:文件解析层、注册层、Prompt 注入层。
第一层:验证 Frontmatter 解析。写一个最小脚本,直接调用解析逻辑。如果你有 OpenClaw 源码,可以这样:
const { parseFrontmatter } = require('./src/agents/skills/frontmatter'); const fs = require('fs'); const raw = fs.readFileSync( process.env.HOME + '/.openclaw/workspace/skills/feishu-doc/SKILL.md', 'utf8' ); const fm = parseFrontmatter(raw); console.log(JSON.stringify(fm, null, 2));预期输出里应该包含name: "feishu-doc"、description、user-invocable: true等字段。如果name是 undefined,说明 YAML 分隔符---没写对,或者字段缩进有问题。Frontmatter 必须是文件开头第一行就是---,前面不能有空行或 BOM。
第二层:验证技能注册。在loadSingleSkillDirectory()里打断点,或者加一行日志:
function loadSingleSkillDirectory(params) { const skillFilePath = path.join(params.skillDir, "SKILL.md"); console.log('[skill-loader] trying:', skillFilePath); const raw = readSkillFileSync({ /* ... */ }); const frontmatter = parseFrontmatter(raw); console.log('[skill-loader] parsed name:', frontmatter.name); // ... }启动 OpenClaw 后,如果日志里出现了[skill-loader] trying: .../feishu-doc/SKILL.md,说明目录发现成功。如果紧接着出现parsed name: feishu-doc,说明解析成功。如果只有 trying 没有 parsed,问题在解析环节。
第三层:验证资格校验。在shouldIncludeSkill()里加日志:
export function shouldIncludeSkill(params) { const { entry, config, eligibility } = params; const skillKey = resolveSkillKey(entry.skill, entry); const skillConfig = resolveSkillConfig(config, skillKey); if (skillConfig?.enabled === false) { console.log('[skill-filter] disabled:', skillKey); return false; } if (!isBundledSkillAllowed(entry, allowBundled)) { console.log('[skill-filter] not in allowBundled:', skillKey); return false; } const eligible = evaluateRuntimeEligibility({ /* ... */ }); console.log('[skill-filter] eligibility for', skillKey, ':', eligible); return eligible; }这段日志能直接告诉你技能被哪个条件拦下了。如果看到not in allowBundled,就去openclaw.json里补白名单;如果eligibility是 false,检查os、requires.bins、requires.env三项。
第四层:验证 Prompt 注入。在formatSkillsForPrompt()里打印结果:
export function formatSkillsForPrompt(skills) { if (skills.length === 0) return ""; const lines = [ "\n\nThe following skills provide specialized instructions for specific tasks.", "Use the read tool to load a skill's file when the task matches its description.", "", "<available_skills>", ]; for (const skill of skills) { lines.push(" <skill>"); lines.push(` <name>${escapeXml(skill.name)}</name>`); lines.push(` <description>${escapeXml(skill.description)}</description>`); lines.push(` <location>${escapeXml(skill.filePath)}</location>`); lines.push(" </skill>"); } lines.push("</available_skills>"); const result = lines.join("\n"); console.log('[skill-prompt] injected skills count:', skills.length); console.log('[skill-prompt] preview:', result.slice(0, 500)); return result; }如果这里打印的skills.length是 0,说明前面的过滤把所有技能都拦掉了。如果数量对但模型不调用,问题在模型侧——可能是description写得不够明确,模型没识别出任务匹配。
第五层:验证实际触发。启动 OpenClaw 后,在对话里输入一个明确匹配技能描述的任务,比如"帮我读一下这个飞书文档 https://xxx.feishu.cn/docx/xxx"。观察日志里是否出现 Tool Call,工具名是否是feishu_doc。如果模型生成了调用但执行失败,检查dispatch.toolName和argMode配置。
实测下来,这套五层验证能把 90% 的 Skill 不生效问题定位到具体环节。剩下的 10% 通常是热更新没生效——refresh.ts用 chokidar 监听文件变化,但awaitWriteFinish有防抖延迟,改完文件后等一两秒再试。
5. 本篇常见错排查:401、local proxy failed、reading choices 与 OAuth 报错对照
调试 Skill 链路时,报错往往不直接指向 Skill 本身,而是模型接入层的问题。因为技能触发依赖模型生成 Tool Call,如果模型请求失败,你会误以为是技能没加载。这一节我把几类高频报错和对应的排查方向列出来。
401 Unauthorized。这个最常见,通常是 TaoToken 的 Key 没配对,或者 Base URL 写错了。检查openclaw.json里的模型配置,确认 Base URL 是https://taotoken.net/api,Key 是https://taotoken.net/api-keys里创建的那一串。注意 Key 不要有多余空格,也不要误用了其他服务的 Key。如果 Key 刚创建,确认账号状态正常。
local proxy failed / connection refused。这个报错说明 OpenClaw 尝试连接模型端点时被拒绝。可能原因有三个:一是 Base URL 端口写错,二是本地网络策略拦截,三是模型服务临时不可用。先确认https://taotoken.net/api能正常访问,再检查 OpenClaw 的 provider 配置里有没有多余的代理设置。如果你在openclaw.json里配了proxy字段,先注释掉试试。
reading 'choices' 报错。这个错误通常出现在解析模型响应时,response.choices是 undefined。根因是模型返回的 JSON 结构不符合 OpenAI 格式,或者请求根本没成功但代码继续往下解析了。排查方向:先看原始响应体,确认返回的是标准 chat completion 格式。如果用的是非 OpenAI 兼容的模型,需要在 provider 配置里加适配层。另外,如果max_tokens设得太小,模型可能返回空响应,也会触发这个错误。
OAuth 相关报错。如果你用的是需要 OAuth 的模型服务,报错可能出现在 token 刷新环节。检查openclaw.json里的 OAuth 配置,确认client_id、client_secret、refresh_token都在有效期内。OAuth token 过期后不会自动刷新,需要重新授权。如果你不确定,先用 API Key 方式接入,排除 OAuth 变量。
技能加载了但模型不调用。这类问题不报错,但技能形同虚设。排查三步:第一,确认formatSkillsForPrompt()输出的 XML 里确实有这个技能;第二,检查description是否写清楚了触发条件,模型需要明确的语义线索;第三,确认disable-model-invocation不是 true。如果技能是user-invocable: true但disable-model-invocation: true,只有用户手动输入斜杠命令才会触发,模型自动调用会被禁止。
热更新不生效。改完SKILL.md后技能行为没变,通常是 watcher 没触发。refresh.ts里awaitWriteFinish.stabilityThreshold默认有几百毫秒延迟,改完等两秒再试。如果还是不行,检查resolveWatchTargets()返回的路径是否包含你的技能目录。有些编辑器保存时会先写临时文件再重命名,chokidar 可能捕获不到change事件,这时手动重启 OpenClaw 最稳妥。
路径逃逸警告。日志里出现warnEscapedSkillPath说明resolveContainedSkillPath()检测到技能文件不在允许的根目录内。这通常是符号链接导致的——你的技能目录是个 symlink,指向了根目录之外。解决办法是把技能文件实际复制到根目录内,或者调整rootRealPath配置。
排查时有个原则:先确认模型接入正常,再确认技能加载正常,最后确认触发正常。顺序反了会把模型问题误判成技能问题,浪费大量时间。
6. 继续深入:从 Skill 加载到 Coding Plan 的实践路径
把加载链路跑通之后,你会发现 OpenClaw 的 Skill 系统真正的价值在于可扩展性。每个SKILL.md就是一个独立的能力单元,通过 Frontmatter 声明依赖和权限,通过目录约定被自动发现,通过 Prompt 注入被模型感知。这套设计让你可以在不改动 OpenClaw 核心代码的前提下,持续增加新能力。
如果你打算把这套机制用在长期编码或 Agent 场景里,建议下一步做两件事。一是把常用技能整理成模板,固定 Frontmatter 字段规范,避免每次手写出错;二是把模型接入配置标准化,用统一的 Base URL 和 Key 管理,减少环境切换成本。TaoToken 的 Coding Plan 适合这种长期使用场景,你可以在 https://taotoken.net/coding-plan 查看具体方案,它针对编码类任务做了优化,配合 OpenClaw 的技能调度会比较顺手。
调试过程中如果遇到模型响应格式问题,可以用模型对话页面快速验证接口是否正常,地址是 https://taotoken.net/chat。这个页面能直接看到原始响应,比在 OpenClaw 里翻日志要快。接入文档在 https://taotoken.net/doc,里面有各语言的调用示例和字段说明,配置openclaw.json时可以参考。
最后提醒一点:Skill 的description字段值得反复打磨。它不是给人看的注释,而是模型判断"是否调用"的唯一依据。描述里写清楚触发关键词、适用场景、输入输出格式,模型调用的准确率会明显提升。我见过太多技能因为描述写得太笼统,导致模型要么不调用,要么乱调用。把描述当成 Prompt 的一部分来写,这个习惯能省下大量排查时间。