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面试干货资源)欢迎大家加入:

-
给大家分享的依据是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并非是简单的一个文件夹里各种文件,而是在一个管理模式下,有多种理解和处理方法。
|
|
|
|
|
|---|---|---|---|
|
|
bundled |
|
src/skills/bundled/ |
|
|
skills |
|
~/.claude/skills/ |
|
|
skills |
|
.claude/skills/
|
|
|
skills |
|
<managed_path>/.claude/skills/ |
|
|
plugin |
|
|
|
|
mcp |
|
|
|
|
commands_DEPRECATED |
|
.claude/commands/ |
注意,在加载层面,这个内容是有先后顺序的,同名skill会被后加载的覆盖。
-
Managed Skills(组织策略) -
User Skills(用户全局) -
Project Skills(项目级,从 cwd 向上遍历到 home) -
Additional Directories( --add-dir指定) -
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 === null) return 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() |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
push()
|
init()
|
另外有个细节,读写文件的skill内,似乎有一些安全校验措施,以写为例。
asyncfunctionwriteSkillFiles(dir: string, files: Record<string, string>): Promise<void> {// 1. 按父目录分组,减少 mkdir 调用const byParent = new Map<string, [string, string][]>()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 的优势
-
编译时集成:直接打包到 CLI binary 中,无需额外安装 -
动态内容生成: getPromptForCommand可以访问运行时上下文(session memory、用户消息等) -
条件启用:支持 isEnabled()回调和 feature flags -
附加文件支持:可以携带参考文件,首次调用时自动提取 -
类型安全:TypeScript 类型检查确保定义完整 -
热重载友好:开发模式下修改代码后重启即可生效
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”的核心机制。
-
内存中全量加载:所有 skill 在启动时加载到内存( getCommands()) -
Prompt 中按需注入:通过 token budget 控制注入到 prompt 的内容 -
增量式通知:首次会话只发送未发送过的 skills -
智能过滤:实验性功能支持仅注入 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 === 0) return''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')}
这里大概的预算分配策略是这样的。
|
|
|
|
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
这里还有一个和前文提到的动态技能发现很像但并不同的机制,增量更新机制。前者强调加载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 机制,他能自动检测用户对技能的修正建议并更新技能文件,大致流程如下。
-
Hook 注册:在每次 API 查询后,注册 skill_improvementhook(PostSampling Hook) -
触发检查:每 5 条用户消息检查一次( TURN_BATCH_SIZE = 5) -
对话分析:调用小模型分析最近的对话,检测用户的偏好、修正或新增需求 -
展示建议:在 UI 中展示改进建议,用户可选择”应用”或”忽略” -
文件重写:用户确认后,调用 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 包含的内容:
-
任务说明:分析对话,找出应该永久添加到技能定义中的改进点
-
两个输入块:
-
<skill_definition>:当前技能的完整内容 -
<recent_messages>:最近的用户和助手消息(每条截断500字符) -
检测目标(3类):
-
步骤修改:”can you also ask me X” -
偏好设置:”ask me about energy levels” -
纠正指令:”no, do X instead” -
忽略项(2类):
-
不泛化的对话(一次性回答、闲聊) -
技能已实现的功能 -
输出格式:JSON数组包裹在
<updates>标签中{"section": "哪个部分需要修改","change": "具体改什么","reason": "哪条用户消息触发的"} -
配置:使用
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 包含的内容:
-
任务说明:编辑技能文件,应用改进列表 -
两个输入块: -
<current_skill_file>:当前 SKILL.md 的完整内容 -
<improvements>:改进列表,格式为- section: change -
5条规则: -
自然整合到现有结构 -
保留 frontmatter(— 块)原样不变 -
保持整体格式和风格 -
不删除现有内容(除非明确要求替换) -
完整文件输出在 <updated_file>标签中 -
配置参数: -
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<string, number>()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)}
|
|
|
|---|---|
| 频率权重 |
|
| 时效性 |
|
| 保底机制 |
|
| 零起点 |
|
智能排序应用
这个分数主要用在排序上,这个排序有两个场景,一个是空搜索时的”最近使用”推荐,另一个是模糊搜索时的平局打破
首先是“最近使用”推荐,在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(0, 5)) { 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毛钱)
时长:一年(从我们加入的时刻算起)


夜雨聆风