Claude Code Skill 机制深度解析:一个 Skill 的完整生命周期
📍 导航指南
根据你的背景,选择合适的阅读路径:
- 🤔 刚接触 Skill? → 第一部分:Skill 是什么 - 理解 Skill 的本质和与 Tool 的区别
- 🗺️ 想看完整流程? → 第二部分:工作机制全景 - 从文件到调用的完整时序
- 🔍 想深入细节? → 第三至七部分 - 五个关键阶段的源码级分析
- 💡 想提炼设计思想? → 第八部分:设计思想总结 - 六大设计原则与架构启示
目录
第一部分:Skill 是什么 🤔
第二部分:工作机制全景 🗺️
第三部分:加载阶段 📂
第四部分:首轮对话 💬
第五部分:Model 调用 Skill 🎯
第六部分:内容注入 📝
第七部分:压缩后恢复 🔄
第八部分:设计思想总结 💡
- Skill 内容是 UserMessage,不是 SystemPrompt
- Model 是主动调用者,不是被动接收者
- 渐进式加载的四个阶段
- sentSkillNames 的粒度是 agentId
- 压缩后不重置 sentSkillNames
- 条件激活 + 动态发现的组合
- 给开发者的启示
第一部分:Skill 是什么 🤔
Skill 的本质
Skill 的本质:一个可被 model 调用的 Markdown 指令包。
它不是一段可执行代码,而是封装了某个任务完整工作方法的模块。一个 skill 包含:
- Markdown 指令内容:工作流程、检查清单、最佳实践
- 元数据(frontmatter):描述、触发条件、路径规则
- 参数系统:
${1},${2}等占位符,调用时替换 - 内联 shell:
!命令`` 语法,执行动态内容 - 变量替换:
${CLAUDE_SKILL_DIR},${CLAUDE_SESSION_ID}等 - 附属文件(可选):配置、模板等
当 skill 被调用时,系统把处理后的内容注入到对话里,model 读到这些指令后按照指令执行。
关键点:调用者可以是用户(手动输入 /skill-name),也可以是 model 自己(看到 skill 列表后自主决定调用)。两种方式底层走的是同一套流程——都通过 SkillTool 把调用转化成 Markdown 指令注入对话。这是 skill 和传统命令的本质区别——model 有主动权。
Skill vs Tool
从调用方式看,Skill 也是通过 Skill tool 调用的,表面上"就是一个工具"。但 SkillTool 不是"功能执行器",而是"指令注入器"——这是核心区别。
执行流程对比:
标准 Tool(如 Bash):
Model 调用 → 系统执行代码 → 返回结果 → Model 继续
Skill(如 systematic-debugging):
Model 调用 → SkillTool 注入 Markdown 指令到对话 → Model 读到指令 → Model 自己执行
维度对比:
| 维度 | 标准工具(Tool) | Skill |
|---|---|---|
| 本质 | 确定性的可执行代码 | Markdown 指令包 |
| SkillTool 的角色 | N/A | 只是"加载器",把 Markdown 注入对话 |
| 执行主体 | 系统代为执行,返回结果 | model 读到指令后自己执行 |
| 用户可扩展性 | ❌ 需要改源码并编译 | ✅ 写 .md 文件放到 .claude/skills/ |
| 生命周期 | 启动时静态注册 | 运行时动态发现、按需激活 |
| Token 消耗 | 相对固定 | 渐进式:最高单次 5k,支持压缩恢复 |
| 核心作用 | 扩展 model 的"手"(物理操作能力) | 扩展 model 的"脑"(工作方法论) |
实际意义:你可以写一个 my-code-review.md,立刻就能用。但你写不了一个新的 MyBashTool——那需要改 Claude Code 源码。Skill 是用户可编程的"方法论库",Tool 是系统内置的"能力库"。
小结:Skill 是 Markdown 指令包,不是可执行代码。它通过注入提示词的方式告诉 model "该怎么做",而不是代替 model 执行任务。Skill 和 Tool 的本质区别在于:Tool 扩展能力,Skill 扩展方法论。
第二部分:工作机制全景 🗺️
在深入每个环节之前,先建立完整的流程框架。一个 skill 从文件到被 model 使用,经历五个关键阶段。
三个核心问题
Skill 机制要解决三个根本问题:
- 发现问题:model 怎么知道有哪些 skill 可用?
- 调用问题:model 决定用某个 skill,怎么找到它、执行它?
- 持久化问题:压缩后 skill 内容会丢失吗?
完整时序
【启动阶段】第三部分
加载 skill 文件 → 解析出名字和描述 → 筛选出符合规范的
↓
【首轮对话】第四部分
分析用户消息,推荐相关 skill → 把完整 skill 列表告诉 model
↓
【Model 调用并注入】第五-六部分
Model 通过 SkillTool 选择某个 skill → 系统找到对应的 Markdown 文件
→ 读取并处理文件内容 → 把最终文本作为消息注入对话
↓
Model 在下一轮读到 skill 指令 → 按指令执行任务
↓
【压缩恢复】第七部分
压缩时历史清空 → 从记录中重建 skill 内容 → 重新注入
已发送列表保持不变(避免重复)
sequenceDiagram
autonumber
participant FS as 文件系统
participant Loader as Skill 加载器
participant Agent as Agent 核心
participant Model as Claude 模型
Note over Loader,FS: 启动阶段(仅读 frontmatter)
Loader->>FS: 扫描 5 个目录
FS-->>Loader: skill 文件列表
Loader->>Loader: 解析 name / description,过滤不合规的
Note over Agent,Model: 首轮对话
Agent->>Model: 注入 skill_listing attachment(可用 skill 列表)
Note over Agent,Model: Model 主动调用
Model->>Agent: tool_use(name:"skill", input.skill:"commit")
Agent->>Loader: getPromptForCommand(args)
activate Loader
Note over Loader: 参数替换 / 变量替换 / 内联 shell 执行
Loader-->>Agent: 完整 Markdown 文本
deactivate Loader
Agent->>Agent: addInvokedSkill() 写入全局 state
Agent->>Model: isMeta:true UserMessage 注入
Model->>Model: 读到指令,按 skill 执行任务
Note over Agent,Model: 压缩触发
Agent->>Agent: createSkillAttachmentIfNeeded()<br/>从 invokedSkills 按时间戳重建
Agent->>Model: invoked_skills attachment(上限 25k tokens)
设计原则
这五个阶段背后有几个一以贯之的设计原则:
- 渐进式投递:不是一次性 dump 所有 skill,而是按需、增量、事件驱动
- Token 预算意识:listing 8k、单 skill 5k、总计 25k,每个数字都是精心权衡
- Agent 隔离:sentSkillNames、invokedSkills 都是 per-agent,子 agent 有独立状态
- 性能优先:prefetch 与 streaming 并行,用户感知不到 discovery 延迟
小结:Skill 的完整生命周期分为五个阶段:加载(发现文件)→ 首轮对话(告知 model)→ Model 调用(选择使用)→ 内容注入(指令进入对话)→ 压缩恢复(持久化保证)。核心设计原则是渐进式、Token 预算管理、Agent 隔离。
接下来,逐节展开每个阶段的实现细节。
第三部分:加载阶段 📂
核心问题
启动时要解决两个问题:
- 去哪找 skill 文件:用户可能在多个地方存放 skill
- 是否所有 skill 都对 model 可见:加载 ≠ 可用,中间有过滤
扫描 5 个位置
启动时按优先级依次扫描,用 realpath 去重,同一文件不加载两次:
| 优先级 | 目录 | 说明 |
|---|---|---|
| 1 | getManagedSkillsDir() | 系统托管(官方 skill,禁用内联 shell) |
| 2 | getUserSkillsDir() → ~/.claude/skills/ | 用户全局 skill |
| 3 | getProjectSkillsDir() → .claude/skills/ | 项目级 skill |
| 4 | getAdditionalSkillDirs() | 配置文件额外指定的目录 |
| 5 | getLocalCommandsDir() | 旧版 commands 目录(向下兼容) |
解析出名字和描述
每个 skill 文件被读取并解析,从 Markdown frontmatter 提取关键信息:
---
name: create
description: Create git commits following conventional commit format
when_to_use: When you need to commit changes
---
解析为 Command 对象:
{
name: 'commit', // 从文件路径推导
description: 'Create git commits...', // 从 frontmatter 读取
whenToUse: 'When you need to...', // 从 frontmatter 读取
getPromptForCommand: async (args) => { // 被调用时加载完整 Markdown
// 参数替换、变量替换、内联 shell
}
}
关键:此时只读取了 frontmatter(名字、描述),完整的 Markdown 正文要等 model 调用时才通过 getPromptForCommand 加载。这就是渐进式加载——启动阶段只准备"目录",不加载"全文"。
条件激活的底层机制:基于目录/文件的动态发现
条件 skill 并不在启动时全部加载,而是通过**动态附件(Dynamic Attachment)**机制按需激活。当用户的当前工作目录或操作文件匹配特定模式时,系统实时扫描对应目录下的 skill 文件,将其追加到当前 agent 的可用列表中。
例如,以下 frontmatter 配置会让一个 skill 仅在 packages/database/ 目录下被激活:
---
name: db-migration
description: Generate and run database migrations
when_to_use: when working in packages/database/
paths: ["packages/database/**"]
---
每当用户 cd 进入该目录或其子目录,或在其中读写文件时,系统会触发 getDynamicSkillAttachments() 扫描匹配的 skill,并将新 skill 的名称与描述通过 skill_listing attachment 注入对话。model 随后就能自主调用它。
整个流程是事件驱动的:目录切换 / 文件变更 → 路径模式匹配 → 解析 skill → 增量发送 listing,用户和 model 都无需感知加载过程,实现了真正的"上下文感知 skill"。
过滤规则
getSkillToolCommands 过滤出 model 可见的 skill:
return commands.filter(cmd =>
cmd.type === 'prompt' &&
!cmd.disableModelInvocation &&
cmd.source !== 'builtin' &&
(cmd.whenToUse != null || cmd.hasUserSpecifiedDescription)
)
关键:没有 description 或 whenToUse 的 skill,model 看不到。
小结:加载阶段扫描 5 个目录,解析 frontmatter 提取名字和描述,过滤出标注清楚用途的 skill。此时只加载"目录",不加载"全文",这是渐进式加载的第一步。
第四部分:首轮对话 💬
核心问题
加载完 skill 后,model 不会自动知道它们的存在。需要有机制把"可用 skill 列表"告诉 model。
核心机制
<system-reminder>
The following skills are available for use with the Skill tool:
- commit: Create git commits following conventional commit format
- systematic-debugging: Use when encountering any bug or test failure
- ...
</system-reminder>
这是一个动态注入的 attachment,不是 system prompt 的一部分。
首轮发送完整列表,后续只发新增的:系统用 sentSkillNames 记录"已发送过哪些 skill",避免重复发送浪费 token。Model 能看到的 skill = 对话上下文里所有 skill_listing attachment 的累积。
小结:首轮对话通过
system-reminder的skill_listingattachment 把可用 skill 列表告诉 model。后续只发送新增的 skill,已发送的通过sentSkillNames记录避免重复。
第五部分:Model 调用 Skill 🎯
核心问题
Model 决定用某个 skill 后,系统怎么找到它、加载它的完整内容?
SkillTool 的完整调用链
Model 通过 SkillTool 发出调用请求(标准 tool_use 格式):
{
"type": "tool_use",
"name": "skill",
"input": { "skill": "commit", "args": "--amend" }
}
name 固定是 "skill"(SkillTool 的名称),input.skill 是具体 skill 名称。
SkillTool 收到请求后,调用层级如下:
SkillTool.call()
└─ findCommand() // 按名精确匹配
└─ fork? executeForkedSkill()
else processPromptSlashCommand()
└─ getPromptForCommand() // 读取完整 Markdown
└─ addInvokedSkill() // 写全局 state
└─ createUserMessage({ isMeta: true })
逐步拆解:
-
findCommand("commit", commands)— 精确名称匹配。找不到直接报错,不猜测相似名称。 -
fork 分叉 — 如果 skill 的 frontmatter 设置了
context: fork,走executeForkedSkill(),在独立子 agent 中执行,不影响主对话上下文。默认走内联路径。 -
getPromptForCommand(args)— 这里才真正读取 Markdown 文件全文。在这之前,系统只知道 skill 的名字和描述。读取完成后执行参数替换、变量替换、内联 shell。 -
addInvokedSkill()— 把 skill 名称、路径、处理后的内容写入全局 state,key 是agentId:skillName。不是给当下用的,是为压缩后恢复准备的。 -
createUserMessage({ isMeta: true })— 把 skill 内容包装成一条 isMeta 消息(可以理解为"系统塞给 model 的小纸条,用户在界面上看不到")注入对话流。Model 下一轮就能读到。
SkillTool 的本质:把 model 的一个"工具调用"(tool_use)转化成一段"Markdown 指令注入"(isMeta UserMessage)。Model 发出的是命令动作,SkillTool 把它转换成提示词,再交还给 model 执行。这是 skill 机制的核心转换点。
小结:Model 通过 SkillTool 发出调用请求,系统通过
findCommand精确匹配找到 skill,调用getPromptForCommand加载完整内容,注册到invokedSkills,最后以 isMeta UserMessage 注入对话。SkillTool 的本质是把"工具调用"转化成"指令注入"。
第六部分:内容注入 📝
问题
skill 找到了,内容处理好了,怎么让 model 在下一轮能读到这些指令?直接修改 system prompt?还是别的方式?
isMeta UserMessage
skill 内容被包装成一条特殊的 UserMessage 注入到对话流里:
createUserMessage({ content: finalContent, isMeta: true })
isMeta: true 标记这条消息是系统内部注入的——可以理解为"系统塞给 model 的小纸条":用户界面上看不到,但 model 在下一轮对话时完整可见。
整个注入链路:
getPromptForCommand(args, context)
↓
substituteArguments() // ${1}, ${2} 参数替换
↓
变量替换 // ${CLAUDE_SKILL_DIR}, ${CLAUDE_SESSION_ID}
↓
executeInlineShellCommands() // !`命令` 内联 shell 执行
// 仅用户/项目级 skill 开放,系统托管 skill 禁用
↓
addInvokedSkill() // 注册到全局 state(key: agentId:skillName)
↓
isMeta UserMessage 注入到对话
addInvokedSkill:为什么要注册?
export function addInvokedSkill(skillName, skillPath, content, agentId = null) {
const key = `${agentId ?? ''}:${skillName}` // per-agent key
STATE.invokedSkills.set(key, {
skillName, skillPath, content,
invokedAt: Date.now(), // 时间戳,压缩时排序用
agentId,
})
}
注册的目的不是当下,是为了压缩后恢复——这是下一节的核心。
小结:Skill 内容通过 isMeta UserMessage 注入对话,经过参数替换、变量替换、内联 shell 执行等处理。
addInvokedSkill把处理后的内容注册到全局 state,为压缩后恢复做准备。
第七部分:压缩后恢复 🔄
问题
Claude Code 的 context 压缩会清空历史对话(保留摘要)。
压缩触发条件:当对话上下文 token 占用达到阈值时(通常是模型窗口的 70-80%),系统自动触发压缩,将早期消息摘要化。用户也可通过 /compact 命令手动触发。
skill 内容是以 isMeta UserMessage 形式存在对话历史里的,压缩后这条消息消失了——model 忘了它调用过什么 skill,忘了 skill 里的指令。
这个问题在长对话中非常实际:用户调用了 systematic-debugging,跑了几十轮之后触发压缩,下一轮 model 按什么规则继续调试?
invoked_skills attachment
压缩触发后,createSkillAttachmentIfNeeded 从 invokedSkills state(一直保留,不随压缩清除)里重建 skill 内容:
export function createSkillAttachmentIfNeeded(agentId?: string) {
const invokedSkills = getInvokedSkillsForAgent(agentId)
if (invokedSkills.size === 0) return null
// 按最近调用时间排序,优先保留最近用的
let usedTokens = 0
const skills = Array.from(invokedSkills.values())
.sort((a, b) => b.invokedAt - a.invokedAt)
.map(skill => ({
name: skill.skillName,
content: truncateToTokens(skill.content, 5_000), // 单 skill 上限
}))
.filter(skill => {
const tokens = roughTokenCountEstimation(skill.content)
if (usedTokens + tokens > 25_000) return false // 总预算上限
usedTokens += tokens
return true
})
return createAttachmentMessage({ type: 'invoked_skills', skills })
}
Token 预算:
- 单个 skill:最多 5,000 tokens(超出部分截断,保留开头的指令)
- 所有 skill 合计:最多 25,000 tokens
- 超出总预算的 skill 直接丢弃(最久未用的先丢)
为什么压缩后不重置 sentSkillNames?
如果重置,下一轮会重新发一次完整 skill_listing(约 4k token)。但这不只是 token 的问题——更深层的原因是认知负载:压缩后 context window 已经被清空,此时让 model 重新面对全量 skill 列表,不如让它直接聚焦在 invoked_skills(正在使用中的指令内容)上。后者信息密度更高,model 不需要再做一次"从列表中选哪个"的判断。
小结:压缩触发后,
createSkillAttachmentIfNeeded从invokedSkillsstate 重建内容,生成invoked_skillsattachment。单个 skill 最多 5k tokens,总计最多 25k tokens,按时间戳排序优先保留最近使用的。压缩后不重置sentSkillNames,避免重复发送列表和增加认知负载。
第八部分:设计思想总结 💡
如果说 Tool 是 Claude 的"手",直接操作环境;那么 Skill 就是它的"工作手册"——一套被精心编排、可在需要时即时查阅的操作规程。这一系列设计决策,正是为了在 Token 预算、响应速度与智能体自主性之间找到最佳平衡。
经过七个部分的分析,现在可以回答:Claude Code 的 skill 机制为什么这么设计?这些设计决策背后的权衡是什么?
1. Skill 内容是 UserMessage,不是 SystemPrompt
决策:skill 内容通过 isMeta: true 的 UserMessage 注入,而不是写进 system prompt。
为什么:
- 时机控制:system prompt 在每轮都存在,无法控制"何时出现"。UserMessage 可以精确控制在哪一轮注入,实现按需加载。
- 动态性:system prompt 在对话开始时固定,skill 是运行时动态选择的。如果 skill 内容在 system prompt 里,就失去了"model 自主选择 skill"的能力。
- token 效率:只有被调用的 skill 内容才进入上下文,未用的 skill 不占用 token。
这个决策让 skill 从"启动时配置"变成了"运行时工具"。
2. Model 是主动调用者,不是被动接收者
决策:把 skill 列表发给 model,让 model 通过 SkillTool 自主调用,而不是系统预判"用户意图 → 自动注入对应 skill"。
为什么:
- 灵活性:model 可以根据对话上下文、当前任务阶段、已有信息动态决定是否需要 skill,比规则匹配更智能。
- 可组合性:model 可以在一次对话中调用多个 skill、按顺序调用、甚至根据前一个 skill 的结果决定下一个。
- 可扩展性:新增 skill 只需要加文件,不需要修改调用逻辑——model 自动"学会"用新 skill。
这是 Claude Code skill 和传统 macro/snippet 系统的本质区别:从"预设规则触发"到"智能体自主决策"。
3. 渐进式加载的四个阶段
决策:启动过滤 → 首轮全量 → 后续增量 → 按需激活,而不是"启动时一次性发完所有 skill"。
为什么:
- Token 压力:100+ skill 的完整列表可能超过 20k token,每轮都发会耗尽预算。
- 信息过载:model 一次看到所有 skill,难以找到当前相关的。渐进式加载让 model 在每个时刻只看到"当前上下文相关"的 skill。
- 性能:prefetch 与 streaming 并行,discovery 不阻塞主流程。
- 适应性:动态发现让 monorepo、插件系统等场景自然工作。
这个设计体现了"lazy evaluation"思想:不提前计算不需要的东西。
4. sentSkillNames 的粒度是 agentId
决策:Map<agentId, Set<string>>,每个 agent 独立追踪已发送的 skill 集合。
为什么:
- 信息完整性:子 agent 不会因为主线程已发送而漏掉 skill 列表。每个执行单元有独立的知识状态。
- 隔离性:主线程用了 100 个 skill,子 agent 只看到自己需要的 5 个,不会被噪声淹没。
- 正确性:避免"主线程发过、子 agent 以为自己知道但实际没见过"的幽灵状态。
这是分布式系统里"每个节点独立状态"的经典设计。
5. 压缩后不重置 sentSkillNames
决策:压缩触发后,invokedSkills state 重建内容注入,但 sentSkillNames 保持不变。
为什么:
- Token 优化:
invoked_skillsattachment 已经覆盖了最重要的内容(25k token 预算),重新发 skill_listing(4k token)是纯浪费。 - 信息不丢失:model 从
invoked_skills里读到了具体指令内容,比 skill 列表更有价值。 - 一致性:避免压缩前后 model 看到的信息"先详细后简略"的割裂感。
这个决策是"最小化冗余"原则的体现。
6. 条件激活 + 动态发现的组合
决策:skill 不只是"启动时加载的静态列表",还有两种运行时激活机制。
为什么:
- 上下文相关性:数据库迁移 skill 只有在碰到 migration 文件时才有意义,平时不应该占据 model 的注意力。
- 可扩展性:monorepo 的每个子项目可以有自己的 skill,不需要全局注册。
- 避免预配置负担:不需要在启动时"猜测"用户会用到哪些目录,运行时自动发现。
这是"event-driven"架构在 skill 加载上的应用。
给开发者的启示
如果你在构建类似的 agent harness,这些设计值得参考:
- 指令包 > 可执行代码:让 model 自主选择、组合工具,而不是硬编码调用逻辑
- 渐进式 > 一次性:按需加载、增量更新,在 token 和信息质量之间找平衡
- 隔离 > 共享:每个执行单元独立状态,避免状态污染
- 并行 > 串行:把耗时操作(discovery)和主流程(streaming)并行,用户感知不到延迟
- token 预算是一等公民:每个数字(8k/5k/25k)都是权衡的结果,不是随意拍脑袋
小结:Claude Code 的 skill 机制体现了六大设计原则:Skill 内容作为 UserMessage 而非 SystemPrompt(时机控制)、Model 主动调用而非被动接收(智能决策)、渐进式加载(lazy evaluation)、per-agent 状态隔离(分布式设计)、压缩后不重置列表(最小化冗余)、条件激活+动态发现(event-driven)。这些原则共同构建了一个高效、灵活、可扩展的 skill 系统。
关键源码路径
| 文件 | 作用 |
|---|---|
src/skills/loadSkillsDir.ts | 五级目录扫描、createSkillCommand、条件 skill 激活 |
src/commands.ts | getSkillToolCommands 过滤逻辑 |
src/utils/processUserInput/processSlashCommand.tsx | findCommand 精确匹配 |
src/attachments.ts | getSkillListingAttachments、sentSkillNames、getDynamicSkillAttachments |
src/bootstrap/state.ts | addInvokedSkill、invokedSkills state |
src/compact.ts | createSkillAttachmentIfNeeded、压缩后恢复逻辑 |
src/query.ts | prefetch 启动与收集(Turn-0 vs Inter-turn 差异) |
附录:最小可用 Skill 完整案例
下面演示一个完整 skill 的生命周期:从文件创建、触发调用、内容注入,到压缩恢复。
1. 创建 skill 文件
在项目根目录下创建 .claude/skills/conventional-commit.md:
---
description: Create git commits following conventional commit format
when_to_use: When you need to commit staged changes
---
# Conventional Commit Skill
Follow these steps to create a commit:
1. Run `git diff --staged` to review staged changes.
2. Determine the type: feat, fix, docs, refactor, test, chore, etc.
3. Write a commit message in the format: `type(scope): description`
4. If the user provided a specific instruction (`${1}`), prioritize it.
5. Run `git commit -m "<message>"` and confirm success.
2. 启动加载
启动 Claude Code 后,loadSkillsDir 扫描到该文件,解析出:
name:conventional-commit(从文件名推导)description:Create git commits following conventional commit formatwhenToUse:When you need to commit staged changes
过滤检查通过,进入可用列表。
3. 首轮对话:model 发现 skill
用户说"帮我提交代码"。首轮 system-reminder 注入 skill_listing:
The following skills are available for use with the Skill tool:
- conventional-commit: Create git commits following conventional commit format
- ...
Model 看到后决定调用。
4. Model 调用 Skill
Model 发出工具调用:
{
"type": "tool_use",
"name": "skill",
"input": { "skill": "conventional-commit", "args": "" }
}
5. 内容注入
SkillTool 执行 getPromptForCommand,读取完整 Markdown(此时无参数替换和内联 shell),包装为 isMeta: true 的 UserMessage 注入对话。Model 在下一轮读到指令,开始逐步执行。
6. 压缩恢复
假设对话很长触发了压缩。压缩后历史清空,但 invokedSkills state 中仍有记录。createSkillAttachmentIfNeeded 重建 invoked_skills attachment,将完整指令(截断到 5k tokens)重新注入上下文,model 可以继续按规范提交。
本文源码基于 Claude Code 源码分析,完整源码笔记见 learn-claude-code/notes/claude-code-skill在queryloop中的实现.md
附录:Claude Code 核心组件速查 🔧
在阅读本文时,你会频繁遇到这些 Claude Code 内部组件。这里提供快速参考,帮助理解它们在 Skill 机制中的角色。
Attachment(附件)
是什么:Claude Code 在对话中注入的结构化元数据,不显示在用户界面,但 model 可见。
典型用途:
skill_listingattachment:可用 skill 列表invoked_skillsattachment:压缩后恢复的 skill 内容system-reminderattachment:动态注入的系统提示
为什么需要它:
- 与 system prompt 分离,可以动态增删
- 支持分类管理不同类型的上下文信息
- 不占用用户可见的对话历史
在 Skill 中的作用:
- 首轮对话通过
skill_listingattachment 告知 model 可用的 skill - 压缩后通过
invoked_skillsattachment 恢复已调用的 skill 内容
agentId(Agent 标识符)
是什么:每个执行单元(主 agent 或子 agent)的唯一标识符,格式通常是 UUID。agentId 通常在 agent 创建时由系统随机生成(基于 UUID v4),子 agent 会获得全新的独立标识。
为什么需要它:
- 支持多 agent 并发:主线程和多个子 agent 同时工作
- 状态隔离:每个 agent 有独立的 skill 列表、已调用记录
- 正确性保证:避免"主线程发过、子 agent 没收到"的状态不一致
在 Skill 中的作用:
sentSkillNames的 key:Map<agentId, Set<skillName>>invokedSkills的 key:${agentId}:${skillName}- 子 agent 创建时分配新 agentId,继承空的 skill 状态
实际场景:
主 agent (agentId: abc-123)
└─ 已发送 skill: [commit, debug, test]
└─ 已调用 skill: [debug]
子 agent (agentId: def-456)
└─ 已发送 skill: [] // 独立状态,需要重新发送列表
└─ 已调用 skill: []
Compaction(上下文压缩)
是什么:当对话历史超过 token 限制时,Claude Code 自动触发的上下文清理机制。
工作原理:
- 保留最近 N 轮对话
- 将早期对话压缩成摘要(summary)
- 清空中间的详细历史
影响范围:
- ✅ 保留:system prompt、最近对话、全局 state(invokedSkills)
- ❌ 清空:历史对话中的 isMeta UserMessage(包括 skill 内容)
为什么 Skill 需要关心压缩:
- Skill 内容以 isMeta UserMessage 形式存在对话历史中
- 压缩后这些消息消失,model 会忘记它正在使用的 skill 指令
- 解决方案:从
invokedSkillsstate 重建invoked_skillsattachment
压缩触发时机:
- Token 达到阈值(通常是 context window 的 70-80%)
- 用户手动触发压缩
Skill 的压缩恢复策略:
// 压缩前
对话历史: [user1, assistant1, isMeta(skill内容), user2, assistant2, ...]
invokedSkills state: { "abc:debug": {content: "...", invokedAt: 123} }
// 压缩后
对话历史: [summary, user_recent, assistant_recent, ...]
invokedSkills state: { "abc:debug": {content: "...", invokedAt: 123} } // 保留
invoked_skills attachment: [{ name: "debug", content: "..." }] // 重建并注入
isMeta UserMessage
是什么:一种特殊的用户消息,标记 isMeta: true。
特性:
- Model 可见:在下一轮对话时完整可见
- 用户不可见:不显示在用户界面的对话历史中
- 可以理解为"系统塞给 model 的小纸条"
与普通 UserMessage 的区别:
| 维度 | 普通 UserMessage | isMeta UserMessage |
|---|---|---|
| 来源 | 用户输入 | 系统内部注入 |
| 用户可见 | ✅ 显示在界面 | ❌ 隐藏 |
| Model 可见 | ✅ 完整可见 | ✅ 完整可见 |
| 典型用途 | 用户问题、指令 | Skill 内容、系统指令 |
在 Skill 中的作用:
- Skill 的完整 Markdown 内容以 isMeta UserMessage 形式注入
- Model 在下一轮读到这些指令,按指令执行任务
- 压缩时会被清空,需要通过 attachment 恢复
代码示例:
// Skill 内容注入
createUserMessage({
content: skillMarkdownContent,
isMeta: true // 用户看不到,但 model 读得到
})
State(全局状态)
是什么:Claude Code 在整个会话期间持久化的全局变量,不随对话压缩而清空。
典型内容:
invokedSkills:已调用的 skill 记录sentSkillNames:已发送给各 agent 的 skill 列表- 其他系统状态(工作目录、环境变量等)
为什么需要 State:
- 对话历史会被压缩清空,但某些信息需要持久化
- State 是"对话之外的记忆"
State vs 对话上下文:
| 维度 | 对话上下文 | State |
|---|---|---|
| 存储位置 | 对话历史 | 全局变量 |
| 压缩影响 | 会被清空/摘要 | 不受影响 |
| Model 可见 | 直接可见 | 需要通过 attachment 注入 |
| 典型用途 | 对话内容、消息流 | 持久化状态、元数据 |
在 Skill 中的作用:
// 调用时写入 State
STATE.invokedSkills.set('abc:debug', {
skillName: 'debug',
content: '...',
invokedAt: Date.now()
})
// 压缩后从 State 读取
const skills = STATE.invokedSkills.get('abc:debug')
// 重建 invoked_skills attachment