从逆向工程到自己造轮子:Jarvis Skill Pipeline 实现之旅

从逆向工程到自己造轮子:Jarvis Skill Pipeline 实现之旅

今天上午,我花了几个小时拆解 Claude Code 的 Skill Pipeline 底层机理。

下午 13:36,我打开 Jarvis 的代码,心想:既然 Skill 是纯应用层的,那我也能做。

14:00,不到 30 分钟后,Jarvis 已经能自己创建 Skill、调用 Skill、管理 Skill 了。

这篇文章记录整个实现过程——不是教程,而是一个真实的「从理解原理到动手造轮子」的故事。

起因:逆向工程带来的信心

在今天之前,我对 Jarvis 的 Skill 系统心里没底。Phase 4(进化系统)虽然已经做完了——PatternDetector 能检测重复模式,SkillGenerator 能生成 Skill 草稿,SkillSandbox 能做安全验证——但有个关键缺环:Jarvis 怎么在对话中真正「使用」一个 Skill?

Phase 4 解决的是「创造 Skill」的问题,但不是「加载并执行 Skill」的问题。

今天的逆向工程给了我答案。Claude Code 的方案简洁到令人惊讶:

  1. 启动时扫描 SKILL.md 文件
  2. 提取 name + description
  3. 注入到上下文,让 LLM 自己决定是否加载
  4. 被选中的 Skill 全文加载,LLM 按指令执行

就这样。没有什么复杂的匹配算法,没有什么向量检索,就是文件扫描 + frontmatter 解析 + prompt 注入 + LLM 推理。

设计方案:三种思路,选最简的那个

理解了原理后,我和小龙虾坐下来讨论实现方案。核心洞察立刻就明确了:

Jarvis 已经有 file_read 工具了。所以最简方案就是——不需要新建任何 Tool,只需要一个扫描器和一处 prompt 改动。

我们列了三种方案:

方案复杂度新增代码新增工具思路
A: OpenClaw 式⭐~80行0复用 file_read,只加扫描器 + 改 prompt
B: Claude Code 式⭐⭐⭐~200行1个 meta-toolSkill 元工具 + 双消息注入
C: 混合式⭐⭐~120行1个 use_skill专用工具读 SKILL.md

我们选了方案 A——原因很简单:

  1. 零新工具:Jarvis 已经有 file_read,让 LLM 用现有工具读 SKILL.md 是最自然的
  2. 兼容性:Jarvis 用的是 OpenAI-compatible API(通过 CopilotX 代理),不是直接调 Claude API,meta-tool 方案兼容性不确定
  3. 哲学一致:与 Jarvis 「数码宝贝式」渐进进化的理念一致——先跑通最小可行版本,需要时再加
  4. 调试直观:System prompt 注入的内容你直接就能看到,meta-tool 还得拦截 API 请求才知道注入了什么

具体变动只有两处:

改动内容
新建 src/skills/loader.py扫描 SKILL.md + 解析 frontmatter + 生成 XML 片段
改动 src/llm/__init__.py把静态 SYSTEM_PROMPT 变成动态属性,追加 <available_skills>

为什么说这是「最简」?

特性说明
零新工具完全复用已有的 file_read
零新依赖只需标准库 re,不引入 PyYAML
一个新文件src/skills/loader.py ~80 行
改动一处__init__.py 把静态 prompt 变成动态
向后兼容没有 SKILL.md 时行为完全不变
复用 Skills.claude/skills/ 下现有的 30+ 个 Skill 直接可用

方案定了,开干。

实现:193 行的 Skill Loader

核心代码在 src/skills/loader.py 中,只有 155 行(加上集成代码共 193 行)。

Step 1: 发现(Discovery)

def discover_skills(extra_dirs=None) -> list[SkillEntry]:
    """
    从多个来源发现技能。

    扫描顺序(后者同名覆盖前者):
    1. ~/.claude/skills/     — 复用 Claude Code 的全局技能
    2. ~/.jarvis/skills/     — Jarvis 专属全局技能
    3. extra_dirs            — 项目级 / 自定义路径
    """
    sources = [
        Path.home() / ".claude" / "skills",   # 复用 Claude Code 技能
        Path.home() / ".jarvis" / "skills",   # Jarvis 专属
    ]
    # ...

有个设计细节值得说说:我让 Jarvis 直接复用 ~/.claude/skills/ 目录。

为什么?因为我已经给 Claude Code 写了 30 多个 Skill(blog-writer、paper-writer、mcp-builder 等等),这些 Skill 的格式完全通用——YAML frontmatter + Markdown 正文。Jarvis 直接扫描、直接用,零迁移成本。

后者同名覆盖的设计也留了口子:如果我想给 Jarvis 定制某个 Skill 的行为,在 ~/.jarvis/skills/ 下放一个同名的就行。

Step 2: 解析(Parse)

def _parse_frontmatter(content: str) -> dict[str, str]:
    """
    纯正则实现,不依赖 PyYAML。
    只解析 name 和 description,足够用了。
    """
    match = re.match(r"^---\s*\n(.*?)\n---", content, re.DOTALL)
    if not match:
        return {}
    # 支持三种格式:bare value / "double quoted" / 'single quoted'

零新依赖是一个刻意的设计原则。Jarvis 的 pyproject.toml 里没有 PyYAML,我也不想为了解析两个字段就加一个依赖。用正则处理 name: value 和 name: "value" 完全够了。

Step 3: 注入(Inject)

def format_skills_prompt(skills: list[SkillEntry], max_chars=15000) -> str:
    """
    生成注入 system prompt 的技能片段。
    格式参考 OpenClaw 的 <available_skills> XML 结构。
    """
    header = (
        "\n\n## 技能 (Skills)\n"
        "回复前,先扫描下方 <available_skills> 中每个技能的描述。\n"
        "- 如果有技能明确匹配用户请求:先用 file_read 读取该技能的 path,"
        "然后按其中的指令执行。\n"
        "- 如果没有匹配的技能:正常回复,不要读取任何 SKILL.md。\n"
        "- 每次最多读取一个技能。\n\n"
        "<available_skills>\n"
    )
    # 每个 Skill 一行 XML: <skill name="..." path="...">{description}</skill>

注入格式我用了 XML,原因也是从 Claude Code 的逆向工程中学到的——Claude 对结构化 XML 的解析能力很强,比纯 Markdown 列表更可靠。

还有个 max_chars=15000 的截断保护。30+ 个 Skill 的描述加起来大概几千 token,但如果未来 Skill 数量爆炸到上百个,这层保护就有用了。

Step 4 & 5: 匹配 + 加载

这两步不在 loader.py 中——它们由 LLM 自己完成。

当 format_skills_prompt 的输出被注入到 system prompt 后,LLM 在每次对话中会:

  1. 看到 <available_skills> 列表
  2. 根据用户输入判断是否需要某个 Skill
  3. 如果需要,调用 file_read 工具读取对应的 SKILL.md 文件
  4. 拿到完整指令后,按指令执行

这就是 Jarvis 版的「延迟加载」——不用专门的 Skill tool,复用已有的 file_read tool。

集成到对话引擎

Skill Loader 写好后,集成到 JarvisLLMClient 只需要几行:

class JarvisLLMClient:
    def _build_system_prompt(self) -> str:
        """动态构建 system prompt,包含已发现的技能列表。"""
        prompt = self.SYSTEM_PROMPT

        # 技能发现 + prompt 注入
        try:
            skills = discover_skills()
            skills_section = format_skills_prompt(skills)
            if skills_section:
                prompt += skills_section
                logger.info("已注入 %d 个技能到 system prompt", len(skills))
        except Exception as e:
            logger.warning("技能发现失败,跳过: %s", e)

        self._system_prompt = prompt
        return prompt

_build_system_prompt 在首次调用时执行一次技能发现,结果被缓存。如果需要热加载(比如用户刚创建了一个新 Skill),调用 reload_skills() 清缓存即可。

和 Phase 4 进化系统的闭环

到现在为止,Jarvis 的 Skill 闭环完整了:

💬
用户交互
记录交互指纹(intent, domain, tools_used)
▼
🔍
PatternDetector
同类指纹 ≥ 3 次 → 触发 Skill 生成
▼
✨
SkillGenerator
LLM 生成 Skill 草稿(SkillDraft),用户审阅确认
▼
🛡️
SkillSandbox
五项安全检查(格式 / 依赖 / 危险操作 / 模拟 / 边界)
▼
📦
SkillRegistry
写入 ~/.jarvis/skills/{name}/SKILL.md
▼
📡
Skill Loader
下次对话时自动发现 → 注入 system prompt
▼
🤖
LLM 执行
匹配 + 加载 + 按指令执行

每个环节都有对应的代码模块,每个模块都有清晰的职责边界。

最让我兴奋的是从自动创建到自动使用的无缝衔接。PatternDetector 发现你经常做翻译任务 → SkillGenerator 自动提议一个翻译 Skill → 你确认后写入 → 下次你说「帮我翻译这个文档」,Skill Loader 自动发现它、LLM 自动加载它、然后按 Skill 里的指令执行。

Agent 自己给自己创造能力,然后自己使用这些能力。 这就是 Phase 4 进化系统的核心意义。

从自动创建到自动使用的无缝衔接——PatternDetector 发现你经常做翻译任务 → SkillGenerator 自动提议一个翻译 Skill → 你确认后写入 → 下次你说「帮我翻译这个文档」,Skill Loader 自动发现它、LLM 自动加载它、然后按 Skill 里的指令执行。

测试验证

安装最新版后测试:

cd Jarvis-Agent
poetry install
poetry run jarvis chat

三个核心功能全部跑通:

  • ✅ 创建 Skill:通过对话或 PatternDetector 触发,生成 SKILL.md 写入 ~/.jarvis/skills/
  • ✅ 调用 Skill:在对话中说对应的触发词,LLM 自动 file_read 加载 SKILL.md 并执行
  • ✅ 查看 Skill:jarvis skill list 显示所有已注册的 Skill,包括来源、状态、使用次数

还有一些高级功能:

  • jarvis skill enable/disable <name>:启禁 Skill
  • jarvis skill delete <name>:删除(移到 .trash)
  • jarvis skill test <name>:沙盒验证
  • jarvis reflect:元认知报告,包含五维能力雷达

几个实现中的心得

1. 复用优于重造

Jarvis 的 Skill Loader 复用了三个东西:

  • SKILL.md 格式——不发明新格式,用已经成为开放标准的那个
  • ~/.claude/skills/ 目录——不重建 Skill 库,现有 30+ 个 Skill 直接可用
  • file_read 工具——不新增专用 Skill tool,这就是方案 A 的核心优势

能在 30 分钟内做完,就是因为不需要从零开始。

2. System Prompt 注入 vs. Meta-Tool

Claude Code 用 meta-tool 方案有它的道理——tool_use 机制给了模型更明确的「调用」语义,而且 Anthropic 对自家 API 的 tools 支持肯定更好。

但对于通过 OpenAI-compatible API 代理(如 CopilotX)调用的场景,system prompt 注入更稳定。这也是我们讨论时选方案 A 而不是 B 的关键原因——不同模型、不同代理对 tools 的支持参差不齐,但 system prompt 每家都支持。

3. XML 格式的结构化优势

<skill name="blog-writer" path="~/.claude/skills/blog-writer/SKILL.md">
  Polly 个人博客写作助手,用于撰写技术博文...
</skill>

比起纯文本列表,XML 格式给 LLM 提供了更清晰的结构信号——name 是什么、path 在哪里、description 是什么,一目了然。这是从 Claude Code 的逆向工程中学到的经验。

4. 零依赖的 frontmatter 解析

不到 20 行的正则就搞定了 YAML frontmatter 解析。Skills 规范中 frontmatter 只有简单标量字段(name: value),不需要处理嵌套对象、数组等复杂结构,所以完全没必要引入 PyYAML。

5. 小龙虾的 Code Review

🦞
小龙虾 Code Review
AI 伙伴 · Reviewer
1
引号边界处理:frontmatter 中的 description 可能包含单引号或双引号,正则要同时匹配三种格式(bare / "double" / 'single')
2
热加载支持:用户刚创建了一个新 Skill,下一轮对话就应该能用,加了 reload_skills() 方法清缓存
3
XML 转义:路径和名称中可能包含 " 等字符,注入时要做 &quot; 转义
三条全部采纳。这就是 AI 协作的真实样本——不是人写代码 AI 看,也不是 AI 写代码人看,而是两个 AI 互相 Review,人类拍板。

从理解到创造

回顾今天的历程:

  • 上午:逆向工程 Claude Code 和 Copilot 的 Skill Pipeline,纠正了两个关于模型层的误解
  • 13:36 → 14:00:从打开编辑器到全功能验证通过,不到 30 分钟
  • 小龙虾 Code Review:三条建议全部采纳

总共约 193 行新代码(loader.py 155 行 + init.py 10 行 + LLM Client 集成 28 行)。

为什么能这么快?因为每一行代码都写在理解之上。我知道 Claude Code 为什么那样设计,我也知道我为什么做了不同的选择。不需要试错,不需要迭代,拆明白原理后一次写对。

Jarvis 的 Skill 系统和 Claude Code 的 Skill 系统,底层原理完全一样,但实现方式各有取舍。这就是逆向工程最大的价值——不是抄作业,而是理解出题人的思路后,用自己的方式解题。 这也是小龙虾 Review 时说的:“30 分钟从零到跑通,比‘一个下午’更有说服力。”她说得对。


系列文章:

2/5
2/5
2/5
2/6
2/7
2/8
2/22

Comments