乐于分享
好东西不私藏

Claude Code源码阅读:万字解析skill系统

Claude Code源码阅读:万字解析skill系统

写在前面

AIGC时代的《三年面试五年模拟》AI算法工程师求职面试秘籍独家资源:https://github.com/WeThinkIn/AIGC-Interview-Book

Rocky最新撰写10万字Stable Diffusion 3和FLUX.1系列模型的深入浅出全维度解析文章:https://zhuanlan.zhihu.com/p/684068402

AIGC算法岗/开发岗面试面经学习&交流社群(涵盖AI绘画、AI视频、大模型、AI多模态、AI Agent、数字人等AIGC面试干货资源)欢迎大家加入:


导读
本文深入解析了ClaudeCode开源项目中的Skill系统,该系统是一套可扩展的命令机制,允许用户和开发者创建可重复使用的工作流程。Skill本质上是一种特殊的PromptCommand,通过Markdown文件定义,支持参数化、权限控制和动态加载。
  • 给大家分享的依据是Claude code开源的第一版代码,即typescript的版本,并非后续有过修改的python版本,确保原汁原味,避免错漏。
  • 里面很多代码我是借助 AI 来阅读的,有些笔记也是 AI 生成的,大家注意鉴别。

目录

  • 概况
  • skill的分类
  • skill的创建
  • skill的加载
  • skill调用机制
  • skill的更新机制
  • 智能排序和推荐机制
  • 小结

概况

Claude-Code 的 Skill系统是一套可扩展的命令机制,允许用户和开发者创建可重复使用的工作流程。Skill 本质上是一种特殊的 Prompt Command,通过 Markdown 文件定义,支持参数化、权限控制和动态加载。

涉及到skill的部分,代码结构是这样的,包括skill的各种加载处理以及具体的使用。

```src/├── skills/                          # Skill 核心模块│   ├── bundledSkills.ts            # Bundled Skills 注册与管理│   ├── loadSkillsDir.ts            # 文件系统 Skill 加载器│   └── bundled/                    # 内置 Skills 实现│       ├── index.ts                # 初始化入口 (initBundledSkills)│       ├── skillify.ts             # /skillify 自动生成 Skill│       ├── verify.ts               # /verify 代码验证│       ├── updateConfig.ts         # /update-config 配置更新│       ├── keybindings.ts          # /keybindings-help 快捷键帮助│       ├── debug.ts                # /debug 调试工具│       ├── remember.ts             # /remember 记忆管理│       ├── simplify.ts             # /simplify 简化输出│       ├── batch.ts                # /batch 批量处理│       ├── stuck.ts                # /stuck 卡住检测│       ├── dream.ts                # /dream 记忆整理 (KAIROS)│       ├── claudeApi.ts            # Claude API 文档│       └── ...                     # 其他内置 Skills├── tools/SkillTool/                 # Skill Tool 实现│   ├── SkillTool.ts                # 主工具类 (validate/checkPermissions/call)│   ├── prompt.ts                   # Skill Listing 生成与预算控制│   └── ...├── commands.ts                      # Command 统一接口│   ├── getCommands()               # 获取所有 Commands│   ├── getSkillToolCommands()      # 获取 Skill Tool 可用 Commands│   └── getSlashCommandToolSkills() # 获取 Slash Command Skills├── utils/│   ├── attachments.ts              # Attachment 注入机制│   │   └── getSkillListingAttachments()  # Skill Listing 增量注入│   ├── hooks/│   │   └── skillImprovement.ts    # Skill 自动改进 Hook│   └── suggestions/│       └── skillUsageTracking.ts  # Skill 使用统计跟踪└── types/command.ts                 # Command 类型定义```

此处,我们主要关注skill相关流程主要是这些,启动阶段、运行推理阶段、Prompt的注入逻辑、动态发现skill。

启动阶段:  initBundledSkills() → registerBundledSkill() → bundledSkills[]  getCommands(cwd) → 加载文件系统 Skills → Commands[]运行时:  用户输入 "/skill-name args"    ↓  SkillTool.validateInput() → 检查 skill 是否存在    ↓  SkillTool.checkPermissions() → 检查权限规则    ↓  SkillTool.call() → 执行 skill    ├─ Inline 模式: processPromptSlashCommand() → 注入 prompt    └─ Fork 模式: executeForkedSkill() → 子代理隔离执行Prompt 注入:  getSkillListingAttachments()    ↓  formatCommandsWithinBudget() → 按 token budget 截断    ↓  注入到 System Reminder → 模型可见 skill listing动态发现:  文件操作 (Read/Write/Edit)    ↓  discoverSkillDirsForPaths() → 向上遍历查找 .claude/skills/    ↓  activateConditionalSkillsForPaths() → 激活匹配 paths 的 skills

以上为核心流程的简化总结,不追求绝对严谨,理解关键流程逻辑即可。

skill分类

一张表可以说明,各个等级都有各自的skill,可见skill并非是简单的一个文件夹里各种文件,而是在一个管理模式下,有多种理解和处理方法。

类型
loadedFrom 值
说明
存储位置
Bundled Skills
bundled
内置技能,编译到 CLI 中
src/skills/bundled/
User Skills
skills
用户自定义技能
~/.claude/skills/
Project Skills
skills
项目级技能
.claude/skills/

 (从 cwd 向上遍历)
Managed Skills
skills
组织管理技能
<managed_path>/.claude/skills/
Plugin Skills
plugin
插件提供的技能
插件目录
MCP Skills
mcp
MCP 服务器提供的远程技能
远程加载
Legacy Commands
commands_DEPRECATED
旧版命令格式
.claude/commands/

注意,在加载层面,这个内容是有先后顺序的,同名skill会被后加载的覆盖。

  1. Managed Skills(组织策略)
  2. User Skills(用户全局)
  3. Project Skills(项目级,从 cwd 向上遍历到 home)
  4. Additional Directories(--add-dir 指定)
  5. Legacy Commands(旧版 commands 目录)

skill的创建

skill的文件结构

Claude Code 的 Skill 依赖文件系统进行管理,典型的 Skill 文件结构如下。

```.claude/skills/<skill-name>/├── SKILL.md              # 必需:技能定义文件├── scripts/              # 可选:辅助脚本│   └── helper.sh└── schemas/              # 可选:JSON Schema 等    └── config.json```

文件名有如下要求。

  • 文件名:必须为 SKILL.md(大小写不敏感)
  • 目录名:作为 skill 的名称(如 commit-pr/SKILL.md → skill 名为 commit-pr
  • 命名空间:子目录用 : 分隔(如 code/review/SKILL.md → code:review

而在内部,大家最熟知的skill.md,也有他自己的格式约束,除了大家熟悉的name和description,还有其他约束字段,在 src/skills/loadSkillsDir.ts 的 parseSkillFrontmatterFields()里面可以看到。这里直接看看markdown的大概格式。

---name: my-skill                          # 可选:显示名称description: 技能的简短描述              # 必需:用于模型识别allowed-tools:                          # 可选:所需工具权限  - Read  - Write  - Bash(gh:*)when_to_use: >                          # 关键:告诉模型何时自动调用  Use when the user wants to...  Examples: 'trigger phrase 1', 'phrase 2'argument-hint: "[arg1] [arg2]"         # 可选:参数提示arguments:                              # 可选:参数列表  - arg1  - arg2context: fork                           # 可选:inline(默认)或 forkmodel: opus                             # 可选:模型覆盖effort: high                            # 可选:effort 级别user-invocable: true                    # 可选:是否允许用户直接调用(默认 true)disable-model-invocation: false         # 可选:禁止模型调用hooks:                                  # 可选:钩子配置  PostToolUse:    - tool: Write      hook: echo "File written"paths:                                  # 可选:条件激活路径  - "**/*.ts"  - "src/**/*.tsx"---# 技能标题详细描述技能的目的和使用方法。## Inputs`$arg1`: 第一个参数的说明`$arg2`: 第二个参数的说明## Goal清晰定义的成功标准和产出物。## Steps### 1. 第一步名称具体操作步骤。**Success criteria**: 如何判断此步骤完成。**Execution**: Task agent | Teammate | [human] | Direct(默认)**Artifacts**: 此步骤产生的数据(供后续步骤使用)。**Human checkpoint**: 何时需要用户确认。**Rules**: 硬性规则或约束。### 2. 第二步名称...

skill的创建

代码里给skill提供了3种新增skill的方案。

第一种也是最简单的一种,直接自己编写,新建一个文件夹和文件开始写就好了。这里用命令行写一个最简单的。

mkdir -p .claude/skills/my-skillcat > .claude/skills/my-skill/SKILL.md << 'EOF'---name: my-skilldescription: My custom skillwhen_to_use: Use when the user asks for X---# My SkillSteps...EOF

第二种,是借助/skillify来实现的,触发这个命令后,就会让系统根据提示开始自己总结prompt。

/skillify [可选:对流程的描述]

例如这样。

/skillify  # 不带参数,让模型自己分析/skillify "cherry-pick workflow"# 带描述提示

这个命令触发后,系统会收集以下信息。

  • Session Memory:从 getSessionMemoryContent() 获取会话记忆摘要
  • 用户消息历史:通过 getMessagesAfterCompactBoundary(context.messages) 获取压缩边界后的所有用户消息
  • 用户描述:/skillify如果提供了参数,会添加到 prompt 中作为额外提示

核心代码在src/skills/bundled/skillify.ts

async getPromptForCommand(args, context) {// 1. 获取 session memoryconst sessionMemory = (await getSessionMemoryContent()) ?? 'No session memory available.'// 2. 提取用户消息const userMessages = extractUserMessages(    getMessagesAfterCompactBoundary(context.messages)  )// 3. 构建用户描述块const userDescriptionBlock = args    ? `The user described this process as: "${args}"`    : ''// 4. 替换模板变量const prompt = SKILLIFY_PROMPT    .replace('{{sessionMemory}}', sessionMemory)    .replace('{{userMessages}}', userMessages.join('\n\n---\n\n'))    .replace('{{userDescriptionBlock}}', userDescriptionBlock)return [{ type'text', text: prompt }]}

收集以后,就会开始向大模型提交总结要求了。这个prompt里面会包含这些信息。内容很长,这里总结一下有哪些信息吧。

  • 上下文信息:session、用户消息历史、用户描述的信息。
  • 任务指令:分析、4轮问答收集信息(interview,使用AskUserQuestion工具而非纯文本提问)、生成 SKILL.md 文件、展示预览并请求确认。
  • interview细节。
    • 确认技能名称、描述、目标和成功标准
    • 展示高级步骤列表,确定参数、执行模式(inline/fork)、保存位置
    • 细化每个步骤的产出物、成功标准、人工检查点、并行性、硬性约束
    • 确认触发时机和短语,询问其他注意事项
  • markdown格式要求。就是按照上面的skill格式来输出。
  • 关键设计原则,大概有如下内容。
    • Success criteria 必需:每个步骤都必须有成功标准
    • when_to_use 关键字段:以 “Use when…” 开头,包含触发短语示例
    • 最小权限原则:使用精确的工具模式如 Bash(gh:*) 而非 Bash
    • 关注用户纠正:特别留意用户在会话中的修正和引导
    • 避免过度提问:简单流程不需要多轮采访
    • 禁止纯文本提问:所有问题必须通过 AskUserQuestion 工具

大模型就会按照上面的指令来完成skill构建的任务。大模型就会根据这个指令开始总结,这里有个流程,就是多轮问答采访用户,即interview,就是按照上面的指令来走的。这里会调用AskUserQuestion工具来询问。

// AskUserQuestion 允许模型向用户展示多个选项{  questions: [    {      question: "What should we name this skill?",      options: [        { label: "cherry-pick-pr", description: "Cherry-pick PR to release branch" },        { label: "hotfix-deploy", description: "Deploy hotfix to production" }      ]    }  ]}

这里值得注意的是,让大模型自动生成skill并不意味着做甩手掌柜,你也需要伴随着思考,并把自己的思考与需求跟系统/大模型说明白,自己也是需要非常明确的需求的。

第三种方式是编程注册(Bundled Skills),即在代码中通过 registerBundledSkill() 注册内置技能。

首先,定义 Bundled Skill,每个 bundled skill 都是一个 TypeScript 模块,导出一个 register 函数。

// src/skills/bundled/skillify.tsimport { registerBundledSkill } from'../bundledSkills.js'exportfunctionregisterSkillifySkill(): void{// 可选:环境检查if (process.env.USER_TYPE !== 'ant') {return  }  registerBundledSkill({    name: 'skillify',    description: "Capture this session's repeatable process into a skill.",    allowedTools: ['Read''Write''Edit''AskUserQuestion'],    userInvocable: true,    disableModelInvocation: true,    argumentHint: '[description]',async getPromptForCommand(args, context) {// 动态生成 promptreturn [{ type'text', text: '...' }]    },  })}

然后在 src/skills/bundled/index.ts 的 initBundledSkills() 中初始化,就支持调用了。

// src/skills/bundled/index.tsexportfunctioninitBundledSkills(): void{  registerUpdateConfigSkill()  registerKeybindingsSkill()  registerVerifySkill()  registerDebugSkill()  registerLoremIpsumSkill()  registerSkillifySkill()  // ← 在这里调用  registerRememberSkill()  registerSimplifySkill()  registerBatchSkill()  registerStuckSkill()// 条件注册:根据 feature flagif (feature('KAIROS')) {const { registerDreamSkill } = require('./dream.js')    registerDreamSkill()  }}

注册,是在registerBundledSkill ,核心逻辑位于 src/skills/bundledSkills.ts

// Internal registry for bundled skillsconst bundledSkills: Command[] = []exportfunctionregisterBundledSkill(definition: BundledSkillDefinition): void{const { files } = definitionlet skillRoot: string | undefinedlet getPromptForCommand = definition.getPromptForCommand// 1. 处理附加文件提取(如果有的话)if (files && Object.keys(files).length > 0) {    skillRoot = getBundledSkillExtractDir(definition.name)// Closure-local memoization: extract once per process.// Memoize the promise (not the result) so concurrent callers await// the same extraction instead of racing into separate writes.let extractionPromise: Promise<string | null> | undefinedconst inner = definition.getPromptForCommand    getPromptForCommand = async (args, ctx) => {// 首次调用时提取文件到磁盘      extractionPromise ??= extractBundledSkillFiles(definition.name, files)const extractedDir = await extractionPromiseconst blocks = await inner(args, ctx)// 如果提取成功,在 prompt 前添加 base directoryif (extractedDir === nullreturn blocksreturn prependBaseDir(blocks, extractedDir)    }  }// 2. 创建 Command 对象const command: Command = {type'prompt',    name: definition.name,    description: definition.description,    aliases: definition.aliases,    hasUserSpecifiedDescription: true,    allowedTools: definition.allowedTools ?? [],    argumentHint: definition.argumentHint,    whenToUse: definition.whenToUse,    model: definition.model,    disableModelInvocation: definition.disableModelInvocation ?? false,    userInvocable: definition.userInvocable ?? true,    contentLength: 0// Not applicable for bundled skills    source: 'bundled',    loadedFrom: 'bundled',    hooks: definition.hooks,    skillRoot,    context: definition.context,    agent: definition.agent,    isEnabled: definition.isEnabled,    isHidden: !(definition.userInvocable ?? true),    progressMessage: 'running',    getPromptForCommand,  // ← 可能已被包装  }// 3. 添加到全局注册表  bundledSkills.push(command)}

这里,regist和init的区别如下。

维度
registerBundledSkill() initBundledSkills()
职责
注册单个 skill
初始化所有 bundled skills
层级
底层 API(原子操作)
高层编排(批量调用)
调用者
各个 skill 模块(如 skillify.ts)
应用启动入口(main.tsx)
作用域
处理一个 skill 的定义 → Command 转换
按顺序调用所有 register 函数
类比
push()

 数组元素
init()

 初始化整个数组

另外有个细节,读写文件的skill内,似乎有一些安全校验措施,以写为例。

asyncfunctionwriteSkillFiles(dir: string, files: Record<stringstring>): Promise<void{// 1. 按父目录分组,减少 mkdir 调用const byParent = new Map<string, [stringstring][]>()for (const [relPath, content] of Object.entries(files)) {const target = resolveSkillFilePath(dir, relPath)  // 验证路径安全性const parent = dirname(target)const group = byParent.get(parent) || []    group.push([target, content])    byParent.set(parent, group)  }// 2. 并行创建目录和写入文件awaitPromise.all(    [...byParent].map(async ([parent, entries]) => {await mkdir(parent, { recursive: true, mode: 0o700 })  // 严格权限awaitPromise.all(entries.map(([p, c]) => safeWriteFile(p, c)))    })  )}// 安全写入:防止符号链接攻击const SAFE_WRITE_FLAGS = process.platform === 'win32'  ? 'wx'// Windows: write + exclusive  : fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | O_NOFOLLOWasyncfunctionsafeWriteFile(p: string, content: string): Promise<void{const fh = await open(p, SAFE_WRITE_FLAGS, 0o600)  // owner-onlytry {await fh.writeFile(content, 'utf8')  } finally {await fh.close()  }}// 路径验证:防止目录遍历攻击functionresolveSkillFilePath(baseDir: string, relPath: string): string{const normalized = normalize(relPath)if (    isAbsolute(normalized) ||    normalized.split(pathSep).includes('..') ||    normalized.split('/').includes('..')  ) {thrownewError(`bundled skill file path escapes skill dir: ${relPath}`)  }return join(baseDir, normalized)}

总结下来大概有这些。

  • 唯一临时目*:使用 per-process nonce 生成唯一目录名
  • 严格权限:目录 0o700,文件 0o600,仅 owner 可访问
  • 防符号链接攻击:O_NOFOLLOW | O_EXCL 标志
  • 路径遍历防:拒绝包含 .. 或绝对路径的文件
  • 原子写入:失败不重试,避免部分写入状态

这里也说一下Bundled Skills 的优势

  1. 编译时集成:直接打包到 CLI binary 中,无需额外安装
  2. 动态内容生成:getPromptForCommand 可以访问运行时上下文(session memory、用户消息等)
  3. 条件启用:支持 isEnabled() 回调和 feature flags
  4. 附加文件支持:可以携带参考文件,首次调用时自动提取
  5. 类型安全:TypeScript 类型检查确保定义完整
  6. 热重载友好:开发模式下修改代码后重启即可生效

skill的加载

定义好skill后,就到了实际的应用流了,第一步便是加载,要把skill的内容提前加载到内存,方便在查询和应用时能快速被发现和使用。

核心加载流程

核心的加载流程在 src/skills/loadSkillsDir.ts,负责从文件系统加载所有技能。

exportconst getSkillDirCommands = memoize(async (cwd: string): Promise<Command[]> => {// 1. 确定加载路径const userSkillsDir = join(getClaudeConfigHomeDir(), 'skills')const managedSkillsDir = join(getManagedFilePath(), '.claude''skills')const projectSkillsDirs = getProjectDirsUpToHome('skills', cwd)// 2. 并行加载各来源的技能const [managedSkills, userSkills, projectSkillsNested, ...] = awaitPromise.all([    loadSkillsFromSkillsDir(managedSkillsDir, 'policySettings'),    loadSkillsFromSkillsDir(userSkillsDir, 'userSettings'),    projectSkillsDirs.map(dir => loadSkillsFromSkillsDir(dir, 'projectSettings')),    ...  ])// 3. 去重(基于 realpath 解决符号链接问题)const deduplicatedSkills = deduplicateByRealPath(allSkillsWithPaths)// 4. 分离条件技能(带 paths 字段的)const unconditionalSkills = []const conditionalSkills = []for (const skill of deduplicatedSkills) {if (skill.paths && !activatedConditionalSkillNames.has(skill.name)) {      conditionalSkills.set(skill.name, skill)    } else {      unconditionalSkills.push(skill)    }  }return unconditionalSkills})

动态技能发现

除此以外,还支持动态的技能发现。

首先是文件操作Read/Write/Edit时,系统会检查文件路径上是否存在新的 .claude/skills/ 目录(src/utils/skills/skillChangeDetector.ts)。

exportasyncfunctiondiscoverSkillDirsForPaths(  filePaths: string[],  cwd: string,): Promise<string[]> {const newDirs: string[] = []for (const filePath of filePaths) {let currentDir = dirname(filePath)// 向上遍历到 cwd(不包含 cwd 本身)while (currentDir.startsWith(resolvedCwd + pathSep)) {const skillDir = join(currentDir, '.claude''skills')if (!dynamicSkillDirs.has(skillDir)) {        dynamicSkillDirs.add(skillDir)// 检查目录是否存在且未被 gitignoreif (await fs.stat(skillDir) && !(await isPathGitignored(currentDir, cwd))) {          newDirs.push(skillDir)        }      }      currentDir = dirname(currentDir)    }  }// 按深度排序(最深的优先)return newDirs.sort((a, b) => b.split(pathSep).length - a.split(pathSep).length)}

另外,带有 paths 字段的技能会在匹配的文件被操作时自动激活。(activateConditionalSkillsForPaths

exportfunctionactivateConditionalSkillsForPaths(  filePaths: string[],  cwd: string,): string[] {const activated: string[] = []for (const [name, skill] of conditionalSkills) {const skillIgnore = ignore().add(skill.paths)for (const filePath of filePaths) {const relativePath = relative(cwd, filePath)if (skillIgnore.ignores(relativePath)) {// 激活技能:从 conditionalSkills 移到 dynamicSkills        dynamicSkills.set(name, skill)        conditionalSkills.delete(name)        activatedConditionalSkillNames.add(name)        activated.push(name)break      }    }  }return activated}

此外,技能文件的变更,也是需要监听的。

// src/utils/skills/skillChangeDetector.tslet watcher: FSWatcher | null = nullexportfunctioninitSkillChangeDetection(): void{const skillsDirs = [    join(getClaudeConfigHomeDir(), 'skills'),    join(getManagedFilePath(), '.claude''skills'),    ...getProjectDirsUpToHome('skills', getCwd()),  ]  watcher = chokidar.watch(skillsDirs, {    ignored: /(^|[\/\\])\../// 忽略隐藏文件    persistent: true,  })  watcher.on('change', debounce(() => {    clearSkillCaches()// 通知其他模块清除缓存    skillsChanged.emit()  }, 500))}

skill listing机制

这是 Claude-Code 解决“skills 太多塞不进 prompt”的核心机制。

  1. 内存中全量加载:所有 skill 在启动时加载到内存(getCommands()
  2. Prompt 中按需注入:通过 token budget 控制注入到 prompt 的内容
  3. 增量式通知:首次会话只发送未发送过的 skills
  4. 智能过滤:实验性功能支持仅注入 bundled + MCP skills

整个流程是这样的。

启动时  ↓getCommands(cwd) → 加载所有 skills 到内存  ↓第一次 API 调用前  ↓getSkillListingAttachments() → 生成 skill_listing attachment  ↓formatCommandsWithinBudget() → 按预算截断描述  ↓注入到 System Reminder 消息  ↓后续调用  ↓只发送新增的 skills(增量更新)

具体地,先获取所有可用的skill。

// src/commands.tsexportconst getSkillToolCommands = memoize(async (cwd: string): Promise<Command[]> => {const allCommands = await getCommands(cwd)// 过滤出可用于 Skill Tool 的命令return allCommands.filter(cmd =>    cmd.type === 'prompt' &&    !cmd.disableModelInvocation &&  // 排除禁用模型调用的    cmd.source !== 'builtin' &&      // 排除内置命令    (      cmd.loadedFrom === 'bundled' ||           // Bundled Skills      cmd.loadedFrom === 'skills' ||            // User/Project Skills      cmd.loadedFrom === 'commands_DEPRECATED' || // Legacy Commands      cmd.hasUserSpecifiedDescription ||        // 有自定义描述的      cmd.whenToUse                              // 有 whenToUse 字段的    )  )})

然后生成Skill Listing Attachment

// src/utils/attachments.tsasyncfunctiongetSkillListingAttachments(  toolUseContext: ToolUseContext,): Promise<Attachment[]> {const cwd = getProjectRoot()const localCommands = await getSkillToolCommands(cwd)const mcpSkills = getMcpSkillCommands(appState.mcp.commands)let allCommands = mcpSkills.length > 0    ? uniqBy([...localCommands, ...mcpSkills], 'name')    : localCommands// 实验性过滤:如果启用了 skill search,只保留 bundled + MCPif (feature('EXPERIMENTAL_SKILL_SEARCH') && isSkillSearchEnabled()) {    allCommands = filterToBundledAndMcp(allCommands)  }// 增量更新:跟踪已发送的 skillsconst agentKey = toolUseContext.agentId ?? ''let sent = sentSkillNames.get(agentKey)if (!sent) {    sent = new Set()    sentSkillNames.set(agentKey, sent)  }// Resume 模式:跳过已存在于 transcript 中的 listingif (suppressNext) {    suppressNext = falsefor (const cmd of allCommands) {      sent.add(cmd.name)    }return []  }// 找出未发送的新 skillsconst newSkills = allCommands.filter(cmd => !sent.has(cmd.name))if (newSkills.length === 0) {return []  // 没有新 skills,不发送  }const isInitial = sent.size === 0// 是否是首次发送// 标记为已发送for (const cmd of newSkills) {    sent.add(cmd.name)  }// 按预算格式化const contextWindowTokens = getContextWindowForModel(model, betas)const content = formatCommandsWithinBudget(newSkills, contextWindowTokens)return [{type'skill_listing',    content,    skillCount: newSkills.length,    isInitial,  }]}

再然后, 按 Token Budget 截断描述,确保不会超出 token 限制。

// src/tools/SkillTool/prompt.ts// 预算配置exportconst SKILL_BUDGET_CONTEXT_PERCENT = 0.01// 1% 的上下文窗口exportconst CHARS_PER_TOKEN = 4exportconst DEFAULT_CHAR_BUDGET = 8_000  // 默认 8000 字符exportconst MAX_LISTING_DESC_CHARS = 250// 单个描述最大长度// 计算预算exportfunctiongetCharBudget(contextWindowTokens?: number): number{if (Number(process.env.SLASH_COMMAND_TOOL_CHAR_BUDGET)) {returnNumber(process.env.SLASH_COMMAND_TOOL_CHAR_BUDGET)  }if (contextWindowTokens) {returnMath.floor(      contextWindowTokens * CHARS_PER_TOKEN * SKILL_BUDGET_CONTEXT_PERCENT    )  }return DEFAULT_CHAR_BUDGET}// 格式化并截断exportfunctionformatCommandsWithinBudget(  commands: Command[],  contextWindowTokens?: number,): string{if (commands.length === 0return''const budget = getCharBudget(contextWindowTokens)// Step 1: 尝试使用完整描述const fullEntries = commands.map(cmd => ({    cmd,    full: formatCommandDescription(cmd),  // "- name: description"  }))const fullTotal = fullEntries.reduce((sum, e) => sum + stringWidth(e.full), 0) +                    (fullEntries.length - 1)  // 换行符if (fullTotal <= budget) {return fullEntries.map(e => e.full).join('\n')  }// Step 2: 分离 bundled skills(永不截断)和其他 skillsconst bundledIndices = new Set<number>()const restCommands: Command[] = []for (let i = 0; i < commands.length; i++) {const cmd = commands[i]!if (cmd.type === 'prompt' && cmd.source === 'bundled') {      bundledIndices.add(i)  // Bundled skills 保留完整描述    } else {      restCommands.push(cmd)    }  }// 计算 bundled skills 占用的空间const bundledChars = fullEntries.reduce((sum, e, i) => bundledIndices.has(i) ? sum + stringWidth(e.full) + 1 : sum,0  )const remainingBudget = budget - bundledCharsif (restCommands.length === 0) {return fullEntries.map(e => e.full).join('\n')  }// Step 3: 为非 bundled skills 计算最大描述长度const restNameOverhead = restCommands.reduce((sum, cmd) => sum + stringWidth(cmd.name) + 4,  // "- name: "0  ) + (restCommands.length - 1)const availableForDescs = remainingBudget - restNameOverheadconst maxDescLen = Math.floor(availableForDescs / restCommands.length)// Step 4: 极端情况处理if (maxDescLen < MIN_DESC_LENGTH) {// Non-bundled 只显示名称,bundled 保留描述return commands.map((cmd, i) =>      bundledIndices.has(i)        ? fullEntries[i]!.full        : `- ${cmd.name}`// 只有名称    ).join('\n')  }// Step 5: 正常截断return commands.map((cmd, i) => {if (bundledIndices.has(i)) return fullEntries[i]!.fullconst description = getCommandDescription(cmd)return`- ${cmd.name}${truncate(description, maxDescLen)}`  }).join('\n')}

这里大概的预算分配策略是这样的。

场景
示例
处理方式
总长度 ≤ 预算
10 个 skills,共 5000 字符
全部使用完整描述
轻微超出
50 个 skills,需截断 20%
非 bundled 截断描述
严重超出
200+ skills
Non-bundled 只显示名称
启用 skill search
实验性功能
只注入 bundled + MCP

这里还有一个和前文提到的动态技能发现很像但并不同的机制,增量更新机制。前者强调加载skill的时候会定时去查看是否有新的skill,而后者则是在skill listing过程中,只给大模型发送那些新增/原本因为被截断之类的原因而没发送的skill。

skill search

skill search是cc的一个实验性能力,可以说是我非常期待的一个模块,我在拿到代码第一时间就想去看看这部分怎么做了,然而,从代码来看,这功能应该的确存在,但代码内没有他的具体实现。

// src/utils/attachments.ts:95-102const skillSearchModules = feature('EXPERIMENTAL_SKILL_SEARCH')  ? {      featureCheck:require('../services/skillSearch/featureCheck.js'),      prefetch:require('../services/skillSearch/prefetch.js'),    }  : null

代码内提到了一些引用,都没有找到。

  • src/services/skillSearch/featureCheck.ts – Feature flag 检查
  • src/services/skillSearch/prefetch.ts – 两阶段发现机制
  • src/services/skillSearch/localSearch.ts – 本地搜索索引
  • src/services/skillSearch/remoteSkillState.ts – 远程技能状态管理
  • src/services/skillSearch/remoteSkillLoader.ts – 远程技能加载
  • src/services/skillSearch/telemetry.ts – 遥测数据记录
  • src/services/skillSearch/signals.ts – Discovery signal 类型
  • src/tools/DiscoverSkillsTool/prompt.ts – DiscoverSkills 工具

所以有关具体的实现我是展示不了了。我只能做一些我的猜测,或者是如果是我,我可能会做的方法。

  • 大概是个搜索的模式(我的老本行了,有些思维定式,但实际上就是这么个功能),从海量skill内,找到最适合当下任务的skill。
  • 第一步,根据当下问题,大模型先推导出可能会用到的功能项。
  • 第二步,根据大模型的推测,直接搜,搜的应该是description、when_to_use等相关字段的拼接。这个搜,可以是向量,也可以是字面,只要搜的准就行。
  • 第三步,把skill的关键内容返回回去,就可以开始做prompt注入了。

skill的调用机制

skill主要有3种方式调用。

  • 命令行触发。/my-skill arg1 arg2
  • 模型通过 Skill Tool 调用,这个模式和functioncall类似。
  • Agent 预加载,在创建子 Agent 时,可以在 frontmatter 中指定 agent 字段,Agent 启动时会预加载相关技能。

核心代码

这里还是得带上核心代码来讲。

SkillTool.call()是核心代码。位于 src/tools/SkillTool/SkillTool.ts

async call(  { skill, args },  context,  canUseTool,  parentMessage,  onProgress?,): Promise<ToolResult<Output>> {const commandName = skill.trim().replace(/^\//'')// 1. 记录使用统计  recordSkillUsage(commandName)// 2. 检查是否为 fork 模式const command = findCommand(commandName, commands)if (command?.type === 'prompt' && command.context === 'fork') {return executeForkedSkill(command, commandName, args, context, ...)  }// 3. 处理 inline 技能const processedCommand = await processPromptSlashCommand(    commandName,    args || '',    commands,    context,  )// 4. 返回新消息(技能内容已注入)return {    data: {      success: true,      commandName,      allowedTools: processedCommand.allowedTools,      model: processedCommand.model,    },    newMessages: processedCommand.messages,    contextModifier(ctx) {// 更新允许的工具和模型let modifiedContext = ctxif (allowedTools.length > 0) {        modifiedContext = updateAllowedTools(modifiedContext, allowedTools)      }if (model) {        modifiedContext = updateModel(modifiedContext, model)      }return modifiedContext    },  }}

processPromptSlashCommand处理 prompt 类型的 slash command(包括 Skill),将其转换为可发送给模型的消息。

exportasyncfunctionprocessPromptSlashCommand(  commandName: string,  args: string,  commands: Command[],  context: ToolUseContext,): Promise<SlashCommandResult{const command = findCommand(commandName, commands)// 获取技能的 prompt 内容const result = await command.getPromptForCommand(args, context)// 注册技能钩子if (command.hooks) {    registerSkillHooks(context.setAppState, sessionId, command.hooks, command.name)  }// 记录到 invokedSkills 状态(用于 compaction 保留)const skillPath = command.source ? `${command.source}:${command.name}` : command.name  addInvokedSkill(commandName, skillPath, result[0].text, agentId)return {    messages: [createUserMessage({ content: result })],    shouldQuery: true,    model: command.model,    effort: command.effort,    command,  }}

这里重点是,Command.getPromptForCommand(),每个技能 Command 对象的 getPromptForCommand 方法负责生成最终的 prompt。(这里解释一下command,skill是command的一种特殊情况,command是个通用接口,skill、plugin、bundled、mcp都属于command)

async getPromptForCommand(args, toolUseContext) {let finalContent = baseDir    ? `Base directory for this skill: ${baseDir}\n\n${markdownContent}`    : markdownContent// 1. 参数替换  finalContent = substituteArguments(finalContent, args, true, argumentNames)// 2. 变量替换if (baseDir) {const skillDir = process.platform === 'win32'      ? baseDir.replace(/\\/g'/'      : baseDir    finalContent = finalContent.replace(/\$\{CLAUDE_SKILL_DIR\}/g, skillDir)  }  finalContent = finalContent.replace(/\$\{CLAUDE_SESSION_ID\}/g    getSessionId()  )// 3. Shell 命令执行(非 MCP 技能)if (loadedFrom !== 'mcp') {    finalContent = await executeShellCommandsInPrompt(      finalContent,      toolUseContext,`/${skillName}`,      shell,    )  }return [{ type'text', text: finalContent }]}

fork机制

Fork 模式是将 Skill 在独立的子 Agent中执行,与主对话完全隔离。

这个是在skill的formatter中进行配置的。

context:fork

这里涉及如下流程

// 准备 Fork 上下文const { modifiedGetAppState, baseAgent, promptMessages, skillContent } =await prepareForkedCommandContext(command, args || '', context)// 创建隔离的子 Agent 上下文const subagentContext = {  ...parentContext,  readFileState: clone(parentContext.readFileState),  abortController: new AbortController(),  setAppState: () => {},  getAppState: modifiedGetAppState,  agentId: createAgentId(),}// 运行子 Agentforawait (const message of runAgent({  agentDefinition,  promptMessages,  toolUseContext: { ...context, getAppState: modifiedGetAppState },  canUseTool,  override: { agentId },})) {  agentMessages.push(message)}// 提取结果const resultText = extractResultText(agentMessages, 'Skill execution completed')// 清理finally {  clearInvokedSkillsForAgent(agentId)}

这个能力一般在代码审查类skill、部署skill、数据处理skill等场景中比较场景,不影响主流程。有如下技术要点。

  • 权限隔离:子 Agent 只能使用 allowedTools 中列出的工具
  • 状态隔离:子 Agent 的状态更新不会影响父级
  • 中止传播:父级中止时,子 Agent 也会中止
  • 进度报告:通过 onProgress 回调向 UI 报告进度

skill的更新机制

下面讲skill的更新和维护机制。

首先,最简单的,就是手动修改了,得益于前面提到的更新机制,此处我们更新文件,文件监听后就能生效,这个就不赘述了。

Skill Improvement

其次,我们来说些复杂的,就是 Skill Improvement 机制,他能自动检测用户对技能的修正建议并更新技能文件,大致流程如下。

  1. Hook 注册:在每次 API 查询后,注册 skill_improvement hook(PostSampling Hook)
  2. 触发检查:每 5 条用户消息检查一次(TURN_BATCH_SIZE = 5
  3. 对话分析:调用小模型分析最近的对话,检测用户的偏好、修正或新增需求
  4. 展示建议:在 UI 中展示改进建议,用户可选择”应用”或”忽略”
  5. 文件重写:用户确认后,调用 LLM 重写技能文件并保存

这个对话分析的prompt,大概是这样的。

System Prompt:

You detect user preferences and process improvements during skill execution. Flag anything the user asks for that should be remembered for next time.

User Prompt 结构:

You are analyzing a conversation where a user is executing a skill (a repeatable process).Your job: identify if the user's recent messages contain preferences, requests, or corrections that should be permanently added to the skill definition for future runs.<skill_definition>{当前技能的完整内容}</skill_definition><recent_messages>{最近的用户和助手消息,每条截断到500字符}</recent_messages>Look for:Requests to add, change, or remove steps: "can you also ask me X", "please do Y too", "don't do Z"Preferences about how steps should work: "ask me about energy levels", "note the time", "use a casual tone"Corrections: "no, do X instead", "always use Y", "make sure to..."Ignore:Routine conversation that doesn't generalize (one-time answers, chitchat)Things the skill already doesOutput a JSON array inside <updates> tags. Each item: {"section": "which step/section to modify or 'new step'", "change": "what to add/modify", "reason": "which user message prompted this"}.Output <updates>[]</updates> if no updates are needed.

总结一下,User Prompt 包含的内容:

  1. 任务说明:分析对话,找出应该永久添加到技能定义中的改进点

  2. 两个输入块:

    • <skill_definition>:当前技能的完整内容
    • <recent_messages>:最近的用户和助手消息(每条截断500字符)
  3. 检测目标(3类):

    • 步骤修改:”can you also ask me X”
    • 偏好设置:”ask me about energy levels”
    • 纠正指令:”no, do X instead”
  4. 忽略项(2类):

    • 不泛化的对话(一次性回答、闲聊)
    • 技能已实现的功能
  5. 输出格式:JSON数组包裹在 <updates> 标签中

    {"section""哪个部分需要修改","change""具体改什么","reason""哪条用户消息触发的"}
  6. 配置:使用 getSmallFastModel() 快速小模型

然后就会生成这样的指令。

<updates>[  {    "section": "Step 2: Cherry-pick the PR",    "change": "Add a human checkpoint before resolving merge conflicts",    "reason": "User said 'No, don't auto-resolve the conflicts. Let me handle them manually'"  },  {    "section": "Step 3: Push and Create PR",    "change": "Add success criteria: CI checks must pass",    "reason": "User said 'After cherry-picking, make sure to run the tests before pushing'"  }]</updates>

此后,当用户在 UI 中选择”应用”改进建议后,调用 applySkillImprovement() 函数,就会开始重写。重点还是把prompt讲讲。

System Prompt:

You edit skill definition files to incorporate user preferences. Output only the updated file content.

User Prompt 包含的内容:

  1. 任务说明:编辑技能文件,应用改进列表
  2. 两个输入块:
    • <current_skill_file>:当前 SKILL.md 的完整内容
    • <improvements>:改进列表,格式为 - section: change
  3. 5条规则:
    • 自然整合到现有结构
    • 保留 frontmatter(— 块)原样不变
    • 保持整体格式和风格
    • 不删除现有内容(除非明确要求替换)
    • 完整文件输出在 <updated_file> 标签中
  4. 配置参数:
    • model: getSmallFastModel() – 快速小模型
    • temperature: 0 – 确定性输出
    • thinkingConfig: disabled – 禁用思考过程
    • tools: [] – 不使用工具

智能排序与推荐机制

这算是一个小惊喜,cc内部有skill的智能排序和推荐机制,他会跟踪每个技能的使用情况,并在命令建议中实现智能排序和推荐。

使用统计跟踪

每次调用技能的时候,在 SkillTool.call() 中记录这个使用情况。

// src/tools/SkillTool/SkillTool.tsasync call({ skill, args }, context, ...) {const commandName = skill.trim().replace(/^\//'')// 记录使用统计  recordSkillUsage(commandName)// ... 执行技能}

然后,把他存储起来,以这个格式,是本地文件的存储。

{  skillUsage: {"my-skill": {      usageCount: 42,      // 使用次数      lastUsedAt: 1234567890000// 最后使用时间戳    },"another-skill": {      usageCount: 15,      lastUsedAt: 1234500000000    }  }}

有个细节,它是有防抖装置,60s内不重复记录。

// src/utils/suggestions/skillUsageTracking.tsconst SKILL_USAGE_DEBOUNCE_MS = 60_000  // 60秒const lastWriteBySkill = new Map<stringnumber>()exportfunctionrecordSkillUsage(skillName: string): void{const now = Date.now()const lastWrite = lastWriteBySkill.get(skillName)// 60秒内重复调用同一技能不重复记录if (lastWrite !== undefined && now - lastWrite < SKILL_USAGE_DEBOUNCE_MS) {return  }  lastWriteBySkill.set(skillName, now)  saveGlobalConfig(current => ({    ...current,    skillUsage: {      ...current.skillUsage,      [skillName]: {        usageCount: (current.skillUsage?.[skillName]?.usageCount ?? 0) + 1,        lastUsedAt: now,      },    },  }))}

使用情况的分数计算

另外,这个分数的计算也有说法。

exportfunctiongetSkillUsageScore(skillName: string): number{const config = getGlobalConfig()const usage = config.skillUsage?.[skillName]if (!usage) return0// 7天半衰期的指数衰减const daysSinceUse = (Date.now() - usage.lastUsedAt) / (1000 * 60 * 60 * 24)const recencyFactor = Math.pow(0.5, daysSinceUse / 7)// 最小衰减因子0.1,避免完全丢弃旧但常用的技能return usage.usageCount * Math.max(recencyFactor, 0.1)}
特性
说明
频率权重
使用次数越多,分数越高
时效性
7天半衰期,最近使用权重更高
保底机制
最少保留10%权重,不会完全消失
零起点
未使用过的技能分数为0

智能排序应用

这个分数主要用在排序上,这个排序有两个场景,一个是空搜索时的”最近使用”推荐,另一个是模糊搜索时的平局打破

首先是“最近使用”推荐,在src/utils/suggestions/commandSuggestions.ts

// 计算所有技能的使用分数const commandsWithScores = visibleCommands  .filter(cmd => cmd.type === 'prompt')  .map(cmd => ({    cmd,    score: getSkillUsageScore(getCommandName(cmd)),  }))  .filter(item => item.score > 0)  .sort((a, b) => b.score - a.score)  // 按分数降序排列// 取前5个最近使用的技能const recentlyUsed: Command[] = []for (const item of commandsWithScores.slice(05)) {  recentlyUsed.push(item.cmd)}

模糊排序时,可能会存在同排名的情况,就可以用频次分来调整排序。

// 预计算每个结果的使用分数const withMeta = searchResults.map(r => {const usage = r.item.command.type === 'prompt'    ? getSkillUsageScore(getCommandName(r.item.command))    : 0return { r, name, aliases, usage }})// 排序逻辑const sortedResults = withMeta.sort((a, b) => {// 优先级1:精确名称匹配const aExactName = a.name === queryconst bExactName = b.name === queryif (aExactName && !bExactName) return-1if (bExactName && !aExactName) return1// 优先级2:精确别名匹配// ...// 优先级3:前缀名称匹配// ...// 优先级4:前缀别名匹配// ...// 优先级5:Fuse 相似度分数const scoreDiff = (a.r.score ?? 0) - (b.r.score ?? 0)if (Math.abs(scoreDiff) > 0.1) {return scoreDiff  }// 优先级6:使用频率作为平局打破者,最后一步return b.usage - a.usage})

这个设计非常有小巧思,虽说并不复杂,还有打磨的空间,但是确实是挺有用的小设计。前文讲skill search没咋写,但是这个智能推荐确实是能起到不错的作用。

小结

读源码是个很磨人的事,读起来很慢,上周拖更了,不过读下来,作为skill的代表性项目,claude code在skill的处理上还是有大量的亮点的,收益颇丰。

  • 分层维护skill,支持优先级和覆盖。
  • 动态更新skill,支持监听更新。
  • 支持自动根据对话对skill进行更新维护。
  • 支持异步skill,也就是fork模式,进程维护这块还值得深挖。
  • 智能排序和推荐,有个意识也是不错的。

后续估计还会有一篇,讲讲他的推理机制,敬请期待。

推荐阅读

加入AIGCmagic社区知识星球

AIGCmagic社区里涵盖了海量的AIGC面试面经资源、内推招聘资讯、面试专业答疑、面试干货知识汇总、AIGC商业变现项目集合(AIGC、AI Agent、传统深度学习、自动驾驶、机器学习、计算机视觉、自然语言处理、具身智能、元宇宙、SLAM等)。

那该如何加入星球呢?很简单,我们只需要扫下方的二维码即可。与此同时,我们也重磅推出了知识星球2025年惊喜价:原价199元,前200名限量立减50!特惠价仅149元!(每天仅4毛钱)

时长:一年(从我们加入的时刻算起)

AIGC时代Rocky撰写的干货技术文章汇总分享!
Rocky在持续撰写AIGC干货技术文章并进行汇总(涵盖扩散模型核心原理、Stable Diffusion、FLUX、LoRA、ControlNet、ComfyUI、AI绘画框架、【三年面试五年模拟】AIGC算法工程师面试秘籍、Sora、AI Agent、大模型、Transformer、GAN、AIGC前沿技术深度行研报告等AI行业本质内容)
大家可以关注公众号WeThinkIn,并在后台 回复关键词Rocky的AIGC干货技术文章进行取用