SKILLS 和 渐进式披露 是 A 家最早提出来的方案,也是 OpenClaw 火了后大家一直讨论的哪个技能好用很核心的强依赖的实现逻辑。
如果把 Claude Code 的 skills 理解成一堆 prompt 文件,后面的很多设计都解释不通。
从其源码实现来看,会发现它在解决的核心问题是:怎么让模型保留足够强的技能召回能力,同时又不把常驻上下文撑爆。
这件事说穿了就是五个字:渐进式披露。
大概的逻辑是:
-
先告诉模型「系统里存在 skills 机制」。 -
再告诉它「当前有哪些 skill 名称和简短说明」。 -
等它真的决定调用某个 skill 时,再把正文、权限、hooks、模型覆盖、附加工具权限这些重内容展开。 -
如果某些 skill 还和路径、目录、文件类型绑定,那就继续往后拖,拖到模型真的碰到对应文件时再激活。
这是一个优雅且干净的工程化设计。它没有发明一套复杂到难以维护的 skill runtime,也没有把所谓智能寄托在黑盒检索器上,而是先把「披露成本」这件事控制住。
我们按工程实现往下拆:
-
skill 在系统里到底被建模成什么 -
多来源 skill 是怎么统一装配的 -
渐进式披露具体分了哪几层 -
条件激活和动态发现是怎么接进文件操作链路的 -
inline 和 fork 两条执行路径分别解决什么问题 -
这套设计真正适合什么场景,代价又是什么 -
如果要在自己的 Agent 里复刻,最短落地路径应该怎么走
一、先看 skills 在系统里被建模成什么
Claude Code 里,skill 最终会被统一建模成 Command,而且类型是 prompt。
最核心的构造函数是 createSkillCommand:
return {
type: 'prompt',
name: skillName,
description,
hasUserSpecifiedDescription,
allowedTools,
argumentHint,
argNames: argumentNames.length > 0 ? argumentNames : undefined,
whenToUse,
version,
model,
disableModelInvocation,
userInvocable,
context: executionContext,
agent,
effort,
paths,
contentLength: markdownContent.length,
isHidden: !userInvocable,
progressMessage: 'running',
userFacingName(): string {
return displayName || skillName
},
source,
loadedFrom,
hooks,
skillRoot: baseDir,
async getPromptForCommand(args, toolUseContext) {
...
return [{ type: 'text', text: finalContent }]
},
}
这段代码说明有几个关键点:
-
skill 不是特殊 runtime object,而是 prompt command -
skill 本体是 getPromptForCommand()生成的一组文本 block -
skill 可以带: -
allowedTools -
model -
effort -
paths -
hooks -
context: inline | fork
-
-
skill 的调用结果,不是「执行一段脚本」,而是把 skill 展开成后续对话消息,或者 fork 成子代理执行
如果我们自己做 Agent,建议参考。skill 不要单独发明一套 DSL runtime,直接把它抽象成「可延迟展开的 prompt 命令」就够了。
二、skills 的来源有哪几类
skills 并不只来自一个目录。getSkills() 会把多个来源统一聚合。[commands.ts] commands.ts#L353-L398
const [skillDirCommands, pluginSkills] = await Promise.all([
getSkillDirCommands(cwd)...
getPluginSkills()...
])
const bundledSkills = getBundledSkills()
const builtinPluginSkills = getBuiltinPluginSkillCommands()
然后 loadAllCommands() 再把这些东西和 workflow/plugin/内建命令一起合并。[commands.ts] commands.ts#L445-L469
也就是说,skills 的来源至少有:
-
bundled skills -
磁盘上的 /skills/ -
plugin skills -
builtin plugin skills -
兼容旧 /commands/目录加载进来的 prompt commands
SkillTool 根本不需要知道 skill 来自哪里。只要最后是 prompt command,就能走统一调用路径。
三、skills 的「渐进式披露」分 5 层
1)第一层:系统提示只声明「技能机制存在」
系统提示里不会把所有 skill 正文直接塞进去。它只给一个能力声明,告诉模型:
-
用户说 /<skill-name>,其实是在指 skill -
可以用 SkillTool去执行 -
不要乱猜,只能调用列出来的那些
这段在 [prompts.ts] prompts.ts#L353-L401:
hasSkills
? `/<skill-name> (e.g., /commit) is shorthand for users to invoke a user-invocable skill. When executed, the skill gets expanded to a full prompt. Use the ${SKILL_TOOL_NAME} tool to execute them. IMPORTANT: Only use ${SKILL_TOOL_NAME} for skills listed in its user-invocable skills section - do not guess or use built-in CLI commands.`
: null
这一步只暴露了机制,没有暴露内容。
2)第二层:只披露 skill 名称和短描述
真正给模型看的 skill 列表,是通过 getSkillToolCommands() 过滤出来的。[commands.ts] commands.ts#L561-L580
return allCommands.filter(
cmd =>
cmd.type === 'prompt' &&
!cmd.disableModelInvocation &&
cmd.source !== 'builtin' &&
(
cmd.loadedFrom === 'bundled' ||
cmd.loadedFrom === 'skills' ||
cmd.loadedFrom === 'commands_DEPRECATED' ||
cmd.hasUserSpecifiedDescription ||
cmd.whenToUse
),
)
这段有两个要点:
-
只有 prompt命令才能进 skill 列表 -
并不是所有 prompt command 都自动暴露,至少得满足可描述性要求
也就是说,可执行集合和对模型披露集合不是完全相同的。
Claude Code 在这里收了一刀,避免模型看到一堆没有描述、无法判断用途的技能。
3)第三层:列表本身还要走预算裁剪
skill 列表不是全量原文塞进 prompt,而是按预算压缩过的。核心逻辑在 [prompt.ts] tools/SkillTool/prompt.ts#L20-L171。
最关键的常量:
export const SKILL_BUDGET_CONTEXT_PERCENT = 0.01
export const DEFAULT_CHAR_BUDGET = 8_000
export const MAX_LISTING_DESC_CHARS = 250
以及格式化逻辑:
return `- ${cmd.name}: ${getCommandDescription(cmd)}`
和预算裁剪:
if (fullTotal <= budget) {
return fullEntries.map(e => e.full).join('\n')
}
如果超预算,就会:
-
bundled skills 尽量保留完整描述 -
其它 skills 截断 description -
极端情况下退化成只发 - skill-name
这就是很典型的渐进式披露:先给最小可用索引,不给正文。
4)第四层:列表还是增量下发,不是每轮全量重发
技能列表通过 skill_listing attachment 发给模型。发送逻辑在 [attachments.ts] utils/attachments.ts#L2669-L2752。
核心逻辑:
const newSkills = allCommands.filter(cmd => !sent.has(cmd.name))
...
for (const cmd of newSkills) {
sent.add(cmd.name)
}
...
return [
{
type: 'skill_listing',
content,
skillCount: newSkills.length,
isInitial,
},
]
这个 sentSkillNames 机制说明:
-
第一次发的是初始批次 -
后面只发新增的 skill -
resume 之后还会 suppress,避免重复污染上下文
然后 messages.ts 会把它包成系统提醒。[messages.ts] utils/messages.ts#L3763-L3772
return wrapMessagesInSystemReminder([
createUserMessage({
content: `The following skills are available for use with the Skill tool:\n\n${attachment.content}`,
isMeta: true,
}),
])
很多 Agent 会每轮把所有 tools / skills 全量重发,Claude Code 显然在认真控 token。 当然,如果技能不多,也可以直接全量发,不要过早优化。
5)第五层:真正的 skill 内容延迟到调用时才展开
直到调用 SkillTool,skill 的真实正文才会通过 command.getPromptForCommand() 生成。[SkillTool.ts] utils/processUserInput/processSlashCommand.tsx#L869-L920
这里才会发生:
-
$ARGUMENTS替换 -
${CLAUDE_SKILL_DIR}替换 -
${CLAUDE_SESSION_ID}替换 -
markdown 内嵌 shell 执行 -
hooks 注册 -
附加权限 attachment 注入 -
invoked skill 记录
换句话说,skill 的重内容、重权限、重上下文副作用,都是按需加载。
四、除了延迟加载,它还做了「条件激活」
这也是渐进式披露的重要一层,而且很多人会漏掉。
1)带 paths frontmatter 的 skill,不会启动即暴露
getSkillDirCommands() 里会把 skill 分成两类:[loadSkillsDir.ts] loadSkillsDir.ts#L771-L803
if (
skill.type === 'prompt' &&
skill.paths &&
skill.paths.length > 0 &&
!activatedConditionalSkillNames.has(skill.name)
) {
newConditionalSkills.push(skill)
} else {
unconditionalSkills.push(skill)
}
然后 conditional skills 被先放进 conditionalSkills map,而不是直接进入模型可见集合。
这意味着:
-
你定义了某个 skill 只适用于 *.tsx -
它不会在项目启动时就干扰所有任务 -
只有模型真的碰到匹配文件时,这个 skill 才会被激活
2)激活时机挂在文件操作上
FileRead / FileWrite / FileEdit 三个工具里,都有两步副作用:
-
发现上层目录里的 .claude/skills -
激活匹配当前文件路径的 conditional skills
比如 FileReadTool:[FileReadTool.ts] /tools/FileReadTool/FileReadTool.ts#L575-L591
const newSkillDirs = await discoverSkillDirsForPaths([fullFilePath], cwd)
...
addSkillDirectories(newSkillDirs).catch(() => {})
...
activateConditionalSkillsForPaths([fullFilePath], cwd)
对应的激活实现是 [activateConditionalSkillsForPaths] skills/loadSkillsDir.ts#L997-L1058:
const skillIgnore = ignore().add(skill.paths)
...
if (skillIgnore.ignores(relativePath)) {
dynamicSkills.set(name, skill)
conditionalSkills.delete(name)
activatedConditionalSkillNames.add(name)
}
这一步非常像条件规则系统,而不是纯静态注册。
效果就是:技能集合会随着你读写哪些文件而变化。
五、动态发现本身也是渐进式披露的一部分
除了 path-conditional activation,Claude Code 还支持目录级动态发现。
1)启动时只加载一部分 skill 目录
getSkillDirCommands() 启动时会加载:
-
managed -
user -
project dirs -
additional dirs -
legacy commands
但它不会把所有嵌套目录里的 .claude/skills 一次性全扫出来。[loadSkillsDir.ts] skills/loadSkillsDir.ts#L638-L804
2)当模型碰到某个文件时,再向上走目录树找嵌套 skill
discoverSkillDirsForPaths() 会从当前文件的父目录开始,一路往上走到 cwd,查找 .claude/skills。[loadSkillsDir.ts] skills/loadSkillsDir.ts#L861-L915
while (currentDir.startsWith(resolvedCwd + pathSep)) {
const skillDir = join(currentDir, '.claude', 'skills')
...
await fs.stat(skillDir)
...
newDirs.push(skillDir)
}
而且还做了两个非常实用的约束:
-
已检查过的目录不会重复 stat -
gitignored 目录里的 skills 不会静默加载
这个设计让:
技能跟着你进入子目录而出现,不跟整个仓库一起一次性曝光。
六、SkillTool 的调用链,实际上分 inline 和 fork 两条路
这是技能系统和普通 slash command 最大的不同之一。
1)调用前校验
SkillTool.validateInput() 会做:
-
去掉前导 / -
检查 skill 是否存在 -
检查是否 disableModelInvocation -
检查是否为 prompt类型
见 [SkillTool.ts] tools/SkillTool/SkillTool.ts#L355-L430
关键逻辑:
const commands = await getAllCommands(context)
const foundCommand = findCommand(normalizedCommandName, commands)
...
if (foundCommand.type !== 'prompt') {
return {
result: false,
message: `Skill ${normalizedCommandName} is not a prompt-based skill`,
}
}
2)权限检查
SkillTool.checkPermissions() 很细,除了 allow / deny 规则,还会对「只有安全属性的 skill」自动放行。[SkillTool.ts] /tools/SkillTool/SkillTool.ts#L433-L579
这个设计的意义是:
-
简单 declarative skill 不必每次都弹权限 -
带额外风险属性的 skill 要 ask user
3)inline skill:展开成后续对话消息
默认分支会走 processPromptSlashCommand()。[SkillTool.ts] tools/SkillTool/SkillTool.ts#L635-L644
getMessagesForPromptSlashCommand() 干的事情很丰富:[processSlashCommand.tsx] utils/processUserInput/processSlashCommand.tsx#L827-L920
-
command.getPromptForCommand(args, context)得到真正 skill 正文 -
注册 hooks -
addInvokedSkill()记录 skill 内容,供 compact 时恢复 -
从 skill 文本里再抽 attachment -
增加 command_permissionsattachment -
生成一批 messages
返回结构里最关键的是:
return {
messages,
shouldQuery: true,
allowedTools: additionalAllowedTools,
model: command.model,
effort: command.effort,
command
}
也就是说,inline skill 的本质是:
把 skill 变成一段新的上下文和权限修饰,然后让主对话继续跑。
4)fork skill:交给子代理跑,再把结果归还
如果 skill frontmatter 里声明 context === 'fork',就走 executeForkedSkill()。[SkillTool.ts] tools/SkillTool/SkillTool.ts#L622-L633
它会:
-
构造子代理上下文 -
runAgent() -
收集 agent messages -
抽取结果文本 -
最终返回 { status: 'forked', agentId, result }
见 [executeForkedSkill] /tools/SkillTool/SkillTool.ts#L122-L290
这一步说明 Claude Code 已经把 skill 分成两类:
-
知识/流程模板型 skill:inline 展开 -
工作委派型 skill:fork 子代理执行
这个值得学一下。不是所有 skill 都应该展开在主上下文里。
七、结果返回逻辑
为什么它也算渐进式披露的一部分?
1)inline skill 的 tool_result
很轻
mapToolResultToToolResultBlockParam() 对 inline skill 的返回只是:
content: `Launching skill: ${result.commandName}`
见 [SkillTool.ts] tools/SkillTool/SkillTool.ts#L857-L862
也就是说,tool_result 本身不承载 skill 的全部结果。
真正有价值的内容在 newMessages 里,已经被送回主会话继续推理。
2)fork skill 的 tool_result
直接带最终结果
fork skill 返回的是:
content: `Skill "${result.commandName}" completed (forked execution).\n\nResult:\n${result.result}`
见 [SkillTool.ts] tools/SkillTool/SkillTool.ts#L848-L855
这是因为 fork skill 已经在独立上下文里把工作做完了,主线程要拿的是总结结果。
所以在 Claude Code 里,skill 结果返回不是单一模式,而是:
-
inline:返回「已加载 skill」,真正内容进主对话 -
fork:返回「子代理执行结果」
这也是一种披露控制。
不同执行语义,对结果暴露方式也不同。
八、如何简要实现
一个新 Agent,如何简要实现 skills 的发现、召回、调用、结果返回?
一个够用、够短、能落地的最小设计,不追求和 Claude Code 一模一样,但核心思路一致。
1)第一步:统一 skill 数据结构
最小结构建议这样:
type Skill = {
name: string
description: string
whenToUse?: string
contentLoader: (args: string, ctx: AgentContext) => Promise<string>
allowedTools?: string[]
model?: string
effort?: 'low' | 'medium' | 'high'
context?: 'inline' | 'fork'
paths?: string[]
}
-
contentLoader允许延迟展开 -
context决定 inline/fork -
paths支持条件激活 -
allowedTools/model/effort支持 skill 级上下文修饰
这和 Claude Code 的 createSkillCommand() 思路是一致的。[loadSkillsDir.ts] skills/loadSkillsDir.ts#L270-L401
2)第二步:启动时只加载「索引」,不要加载正文
最简做法:
-
扫描 skills 目录 -
解析 frontmatter -
只把 name / description / whenToUse / paths / context放进 registry -
skill 正文不要此时进 prompt
示意:
async function loadSkillIndex(skillDirs: string[]): Promise<Skill[]> {
const skills: Skill[] = []
for (const dir of skillDirs) {
for (const skillFile of await listSkillFiles(dir)) {
const raw = await readFile(skillFile, 'utf8')
const { frontmatter, content } = parseFrontmatter(raw)
skills.push({
name: basename(dirname(skillFile)),
description: String(frontmatter.description ?? ''),
whenToUse: frontmatter.when_to_use ? String(frontmatter.when_to_use) : undefined,
paths: Array.isArray(frontmatter.paths) ? frontmatter.paths : undefined,
context: frontmatter.context === 'fork' ? 'fork' : 'inline',
contentLoader: async () => content,
})
}
}
return skills
}
这个阶段要学 Claude Code 的不是目录细节,而是索引和正文分离。
3)第三步:做一个「未发送 skill 集合」
这是渐进式披露的核心。
维护一个 session 级状态:
type SkillDisclosureState = {
sentSkillNames: Set<string>
}
每轮只发送新的:
function getNewSkillListings(skills: Skill[], sent: Set<string>): Skill[] {
const fresh = skills.filter(s => !sent.has(s.name))
for (const s of fresh) sent.add(s.name)
return fresh
}
然后把它格式化成短列表,而不是全文:
function formatSkillListing(skills: Skill[]): string {
return skills.map(s => `- ${s.name}: ${s.description}`).join('\n')
}
这对应 Claude Code 的 sentSkillNames + skill_listing attachment 方案。
4)第四步:把文件操作接成动态发现触发器
如果你也想要「技能跟着目录出现」,最小版本就是:
-
用户或模型读/写/改文件时 -
从文件父目录往上走到 cwd -
看有没有 .agent/skills或.claude/skills -
找到新目录就加载 skill index
示意:
async function discoverSkillDirsForFile(filePath: string, cwd: string): Promise<string[]> {
const dirs: string[] = []
let current = dirname(filePath)
while (current.startsWith(cwd + sep)) {
const candidate = join(current, '.agent', 'skills')
if (await exists(candidate)) dirs.push(candidate)
const parent = dirname(current)
if (parent === current) break
current = parent
}
return dirs
}
Claude Code 的现成参考是 [discoverSkillDirsForPaths] skills/loadSkillsDir.ts#L861-L915。
5)第五步:做条件激活,而不是启动时全暴露
如果 skill 定义里有 paths,就不要一开始暴露。
等碰到匹配文件时再激活:
function activatePathScopedSkills(
pending: Skill[],
touchedFiles: string[],
): { active: Skill[]; remaining: Skill[] } {
const active: Skill[] = []
const remaining: Skill[] = []
for (const skill of pending) {
if (!skill.paths || skill.paths.length === 0) {
active.push(skill)
continue
}
const matched = touchedFiles.some(file => matchAny(file, skill.paths!))
if (matched) active.push(skill)
else remaining.push(skill)
}
return { active, remaining }
}
这就是 Claude Code conditionalSkills -> activateConditionalSkillsForPaths() 的最小复刻。
6)第六步:调用 skill 时才真正加载正文
不要提前把 skill 正文塞到 prompt。
调用时再做:
async function invokeSkill(
skill: Skill,
args: string,
ctx: AgentContext,
): Promise<SkillInvocationResult> {
const prompt = await skill.contentLoader(args, ctx)
if (skill.context === 'fork') {
const result = await runSubAgent({
prompt,
allowedTools: skill.allowedTools,
model: skill.model,
effort: skill.effort,
})
return { mode: 'fork', result }
}
return {
mode: 'inline',
newMessages: [
{ role: 'user', content: `[SKILL:${skill.name}]` },
{ role: 'user', content: prompt, meta: true },
],
allowedTools: skill.allowedTools,
model: skill.model,
effort: skill.effort,
}
}
这就是 Claude Code SkillTool.call() 的最小骨架。[SkillTool.ts] tools/SkillTool/SkillTool.ts#L581-L863
7)第七步:结果返回必须分 inline 和 fork
直接照 Claude Code 的语义分两种:
inline
-
返回一个轻 tool_result: Launching skill: xxx -
真正内容通过 newMessages回到主对话继续推理
fork
-
返回最终结果摘要 -
子代理对话不污染主上下文
示意:
type SkillInvocationResult =
| {
mode: 'inline'
newMessages: Message[]
allowedTools?: string[]
model?: string
effort?: string
}
| {
mode: 'fork'
result: string
}
这一步是很多新 Agent 最容易偷懒的地方。
要么所有 skill 都 inline,主上下文爆炸;要么所有 skill 都 fork,失去细粒度引导。
九、小结
「skills 的渐进式披露」其实就是 Claude Code 在控制 prompt 成本和能力密度时最典型的设计之一。它真正解决的问题不是「怎么找到一个 skill」,而是「怎么在不把上下文撑爆的前提下,让模型知道自己有技能可用」。
它背后的思路:
-
先给索引 -
再给局部集合 -
再给真实正文 -
最后才给执行结果
这是一个很像搜索引擎的设计:摘要、点击、展开、消费,而不是把整本书扔给你。
以上。