Skip to content

Skill 系统架构

Skill 系统是 claude-code-java 中最具设计巧思的模块 —— 它让 LLM 学会「按剧本演出」。

从生活类比开始

想象你是一个剧团导演(LLM),面对各种演出需求。

没有 Skill 的情况:观众说"演一出莎士比亚",你只能靠自己的记忆即兴发挥,质量参差不齐。

有 Skill 的情况:助理递给你一份详细的「演出手册」(Skill),里面写着角色设定、台词要点、舞台指示。你按手册演出,质量稳定可控。

这就是 Skill 的本质 —— 它不是代码插件,而是一份给 LLM 的「工作手册」

一个核心认知

这是理解 Skill 系统最重要的一句话

Skill 就是 Command。 具体来说,Skill 是 type='prompt' 的 Command,它复用了整个 Command 基础设施(注册、发现、路由、执行)。Skill 不是一个独立的概念。

在代码中的体现:

概念对应的类说明
Command(命令)Command 接口所有命令的顶层抽象
Skill(技能)PromptCommandtype=PROMPT 的 Command
内置命令Repl 中硬编码type=BUILTIN,如 /help、/exit

整体架构

虚线表示当前版本未实现,预留给未来扩展。

Skill 的 5 大来源

来源加载方式路径当前状态
Bundled启动时代码注册resources/skills/待实现(P1)
DiskSkillLoader 扫描 SKILL.md~/.claude-code-java/skills/已实现
MCPMCP 服务器动态提供通过 McpManager待实现(P2)
Plugin内置/市场插件插件目录待实现(P3)
动态发现模型操作文件时向上遍历中间路径的 skills/待实现(P3)

当前版本实现了最核心的 Disk 来源,它覆盖了 90% 的使用场景。

Skill 的数据结构

一个 Skill 在内存中由 PromptCommand 表示:

java
PromptCommand {
    name = "simplify"                          // ← 命令名,也是 /simplify 的触发词
    description = "审查代码质量和效率"            // ← 注入 system-reminder,LLM 据此判断是否匹配
    source = DISK                               // ← 来源(决定覆盖优先级)
    skillDir = /project/.claude-code-java/skills/simplify/
    context = "inline"                          // ← 执行模式:inline 或 fork
    allowedTools = ["Read", "Bash"]             // ← 激活时免审批的工具
    rawContent = "你是一个代码审查专家..."        // ← 提示词正文(未渲染)
    disableModelInvocation = false              // ← 是否禁止 LLM 自动调用
    userInvocable = true                        // ← 是否出现在 / 菜单中
}

它来自 SKILL.md 文件的解析:

markdown
---
description: 审查代码质量和效率
allowed-tools:
  - Read
  - Bash
context: inline
---

你是一个代码审查专家...
请检查 $ARGUMENTS 中指定的文件...

两条触发路径

Skill 的触发有两条完全不同的路径,但最终都汇聚到 SkillTool:

为什么路径 A 不在 Repl 中直接执行 Skill?

因为 Skill 的本质是「提示词」,需要 LLM 来解读和执行。Repl 只负责发现用户想调用 Skill,然后交给 AgentLoop,让 LLM 通过标准的工具调用链路来加载 Skill。这样两条路径的执行逻辑完全一致。

渐进式上下文加载

Skill 系统采用渐进式加载策略,精打细算地使用上下文窗口:

阶段加载内容token 开销
始终在上下文name + description 列表约 100 token/skill
按需加载完整 SKILL.md 正文约 500-5000 token
从不加载未被触发的 Skill 正文0
系统提示词 = 基础 system prompt
            + <system-reminder>
                - simplify: 审查代码质量        ← 始终在上下文(~100 token)
                - commit: 创建 git commit       ← 始终在上下文(~100 token)
              </system-reminder>

当 LLM 调用 SkillTool("simplify") 时:
  → 才加载 SKILL.md 完整内容(~2000 token)
  → 注入到对话中

为什么不把所有 Skill 的完整内容都放进系统提示?

上下文窗口是稀缺资源。如果你有 20 个 Skill,每个 2000 token,那就占掉了 40000 token —— 几乎是上下文窗口的 1/5。渐进式加载确保只有「真正被使用的 Skill」才占用上下文。

Inline vs Fork 执行模式

对比维度Inline(默认)Fork
上下文共享主对话独立子 Agent
Token 预算与主对话共享有自己的预算(maxTurns=30)
历史可见性LLM 能看到之前的对话看不到主对话历史
结果处理直接在当前对话继续文本结果返回主对话
适用场景大多数 Skill独立分析、搜索、审计等任务型 Skill
当前支持已实现已实现

类比:

  • Inline = 在当前函数里直接写代码(共享局部变量)
  • Fork = 调用一个新函数(有自己的作用域,返回值回传)

Fork 模式的实现架构

Fork 模式通过 ForkExecutor 类实现,执行流程如下:

关键设计决策

  • 子 Agent 的 commandRegistry = null:从根本上杜绝递归 fork
  • 共享 PermissionManager:用户在主对话批准的操作,子 Agent 不再重复询问
  • 共享 ClaudeApiClient:复用同一个 HTTP 连接
  • 独立 ConversationHistory:子 Agent 的中间过程不污染主对话上下文

什么 Skill 适合 Fork?

类型模式特征例子
指令型inline告诉 LLM "怎么做"pdf、commit、claude-api
任务型fork交给子 Agent 独立完成代码搜索、依赖分析、安全审计

在 SKILL.md 中声明 context: fork 即可启用 Fork 模式:

yaml
---
description: 分析项目依赖
context: fork
allowed-tools:
  - Read
  - Glob
  - Grep
---
请分析当前项目的依赖文件...

与 Tool 系统的关系

初学者容易混淆 Skill 和 Tool,它们通过 SkillTool 产生了桥接:

SkillTool 是两个 Registry 之间的桥梁

  • 它是一个 Tool(注册在 ToolRegistry 中)
  • 它的 execute() 方法内部调用 CommandRegistry
  • LLM 通过标准的 tool_use 机制触发它

关键设计亮点

设计说明
统一抽象Skill = PromptCommand = Command,复用全套基础设施
双向触发用户 /name + LLM 自动匹配,两条路径汇聚到 SkillTool
渐进加载只有 description 始终在上下文,正文按需加载
优先级覆盖Bundled < Disk(用户级) < Disk(项目级),方便定制
三层权限deny/allow 规则 → 安全属性白名单 → 交互式询问

思考题

  1. 如果两个 Skill 的 description 很相似(比如都是"代码审查"),LLM 会怎么选择?你能想到什么优化方案?
  2. 当前 Skill 列表在启动时加载一次。如果用户在运行中添加了新的 SKILL.md,需要重启才能生效。如何实现「热加载」?
  3. Fork 模式中,子 Agent 看不到主对话历史。这在什么场景下是优势?什么场景下是劣势?

下一步

理解了 Skill 系统的整体架构后,让我们看看 CommandRegistry 命令注册中心 的具体实现。

基于 MIT 许可发布