dodo-agent

Agent Skills到底是如何实现的?

如果你用过 Claude Code 这种类似的 Code Cli,那么一定知道他们都有强大的 Skills 技能系统。Claude Code 启动时会自动加载 /.claude/skills/ 目录下的技能,当用户提出某个专业领域的…

TL;DR

如果你用过 Claude Code 这种类似的 Code Cli,那么一定知道他们都有强大的 Skills 技能系统。Claude Code 启动时会自动加载 /.claude/skills/ 目录下的技能,当用户提出某个专业领域的…

如果你用过 Claude Code 这种类似的 Code Cli,那么一定知道他们都有强大的 Skills 技能系统。Claude Code 启动时会自动加载 ~/.claude/skills/ 目录下的技能,当用户提出某个专业领域的问题时,Claude Code 会自动加载对应的技能指令,按照技能中的指引来完成任务。比如你想让它帮你做一个 PPT,它会加载 PPT 技能,按照技能中的工作流一步步完成。 Anthropics 官方在 GitHub 上开源了一系列 Skills(https://github.com/anthropics/skills),涵盖了 PPT 制作、代码审查、技术画图、安全扫描等多个领域。这些 Skills 本质上就是文件目录,包含了专业领域的知识、工作流程和操作指引。 那么问题来了,能不能在我们的 dodo-agent 中 web 端实现类似 Claude Code 的 Skills 能力? 答案当然是可以的。我们这节课就要手搓一套 Agent Skills 系统,并把它集成到 ReactAgent 架构中。 在之前的课程中,我们已经基于 ReactAgent 实现了 WebSearchReactAgent、FileReactAgent,它们各自集成了不同的工具。而 SkillsReactAgent 的目标是把 Skills、搜索、文件系统、Bash 等多种能力整合到一起,通过 Skills 系统让 Agent 具备按需获取专业领域知识的能力。 其中 ReactAgent 的轮次调度、流式输出、工具调用等基础机制在前面的课程中已经详细讲过,这里不再赘述,直接聚焦 Skills 的实现。

Skills 架构概览

核心设计思路

Skills 系统采用两阶段加载策略:

第一阶段(轻量):启动时扫描技能目录 → 提取元数据(名称+描述) → 格式化为系统提示词
第二阶段(按需):LLM 判断需要某个技能 → 调用 read_skill 工具 → 加载完整技能内容

为什么要分两个阶段?因为一个技能的完整内容可能有几千甚至上万 token。以官方的 PPT 技能为例, https://github.com/anthropics/skills/tree/main/skills/pptxSKILL.md 文件包含了从安装 python-pptx 到创建演示文稿、添加图表、应用主题等完整的工作流指引,内容非常长。如果把所有技能的完整内容都塞进系统提示词,会带来三个问题: 1. Token 浪费:每次对话都携带全部技能内容,但大部分技能根本用不到 2. 干扰 LLM 判断:过多的上下文信息会让 LLM 的注意力分散,降低决策准确度 3. 响应延迟:系统提示词越长,模型的首 token 响应时间越慢 所以两阶段加载的本质是:用最少的 Token 告诉 LLM 有哪些技能可用,等 LLM 真正需要时再加载完整内容。

包结构

Skills 模块的代码组织如下:

agent
├──
└── manual
    ├──
    ├──
    ├── config
    │   └──
    ├── model
    │   ├──
    │   └──
    ├── registry
    │   ├──
    │   ├──
    │   └──
    └── tool
        └──

接下来逐层拆解。

技能元数据:SkillMetadata

核心思路

SkillMetadata 是技能的名片,它不包含技能的完整内容,只包含 LLM 在判断是否需要某个技能时所需的关键信息,名称和描述。这就像一本书的目录,告诉你有哪些章节,但不会把所有内容都展示出来。

实现细节

public


)

        PROJECT
        USER

}

这里使用了 Java 的 record 类来定义元数据,因为元数据本质上就是一个不可变的数据载体。关键字段的作用: - name:技能的唯一标识,也是 read_skill 工具的入参,直接对应文件系统中的目录名 - description:一段简洁的功能描述,会被注入到系统提示词中供 LLM 判断 - skillFile:指向 SKILL.md 文件的路径,按需加载完整内容时直接读取这个文件 另外还有两个字段是预留设计,当前并未实际使用: - SkillSource:标识技能来源(PROJECT 项目内置 / USER 用户自定义),目前 FileSystemSkillRegistry 在构建时统一硬编码为 PROJECT,没有任何地方读取这个值。预留的目的是未来支持多来源技能的优先级管理,比如用户自定义技能可以覆盖同名项目技能 - allowedTools:从 SKILL.md 的 YAML frontmatter 中解析出来的工具白名单,SkillMetadata 中也提供了 isToolAllowed() 方法。但目前没有用,所有技能都可以使用全部工具。预留的目的是未来实现技能级别的工具权限控制,比如某个技能只允许使用 read_file 和 bash,不允许使用 write_file

技能注册表:SkillRegistry

核心思路

有了元数据定义,接下来的问题就是:技能从哪里来?如何被发现和加载? 我们通过 SkillRegistry(技能注册表) 来解决这个问题。注册表负责扫描技能目录、解析技能文件、缓存元数据和内容。整体采用接口+抽象基类+具体实现的三层结构:

SkillRegistry
    ↓
AbstractSkillRegistry
    ↓
FileSystemSkillRegistry

实现细节

SkillRegistry 接口定义了注册表的核心操作:

public


}
  • listAll / get:查询操作,获取全部或指定技能的元数据
  • readSkillContent:按需加载技能的完整内容,这是第二阶段加载的核心方法
  • reload / clearCache:支持热更新,重新加载技能目录(暂未使用) AbstractSkillRegistry 抽象基类提供了缓存和懒加载机制:
public


            contentCache


                    loaded


}

这里有两个重要的设计要点: - 双重检查锁定的懒加载:ensureLoaded() 使用 volatile + synchronized 实现线程安全的懒加载,技能目录在首次访问时才扫描,而不是在应用启动时 - 两级缓存:metadataCache 缓存技能元数据,contentCache 缓存技能完整内容。元数据缓存是必须的(每次请求都要查询),内容缓存可以避免重复读取磁盘 FileSystemSkillRegistry 是注册表的具体实现,负责从本地文件系统发现和加载技能:

public


        metadataCache


            stream


                metadataMap


}

扫描逻辑非常直观: 1. 遍历配置的技能目录 2. 对于目录下的每个子目录,检查是否存在 SKILL.md 文件 3. 如果存在,读取文件内容,解析元数据,放入缓存 以官方的 PPT 技能为例,它的目录结构如下:

~
└── autumnsgrove
    ├── SKILL
    ├── examples
    ├── references
    └── scripts

FileSystemSkillRegistry 扫描后,会为这个目录生成一个 SkillMetadata,name 为 "autumnsgrove-pptx",description 从 SKILL.md 中提取。 元数据解析的关键在于从 SKILL.md 文件中提取 description:

private


}

它支持两种描述提取方式: 1. YAML frontmatter:如果 SKILL.md 文件开头有 --- 包裹的 YAML 块,从中读取 description 字段 2. 正文回退:如果没有 frontmatter,取正文的第一段非标题文本作为描述 以官方 PPT 技能的 SKILL.md 为例,它的开头是这样的:

---
name: pptx
description: "Professional PowerPoint presentation creation, editing, and automation with support for layouts, templates, charts, images, and formatting."
---

## PowerPoint (PPTX) Skill

## Overview
This skill provides comprehensive PowerPoint presentation creation...

FileSystemSkillRegistry 会从 YAML frontmatter 中提取 description 字段,这段描述最终会被注入到系统提示词中。当用户说"帮我做一个 PPT"时,LLM 就能根据这段描述判断应该加载这个技能。

提示词格式化:SkillPromptFormatter

核心思路

有了技能元数据列表,下一步就是把它们格式化为一段系统提示词,让 LLM 知道当前有哪些技能可用。这就是 SkillPromptFormatter 的职责。

实现细节

public


                ## 可用技能列表

                【重要说明】技能不是工具!技能是使用指南和指令集合。
                当你需要使用某个技能时,必须先调用 read_skill 工具加载技能内容。
                技能内容加载后,按照技能中的指令来完成任务。


                  用户:
                  助手:
                  工具:返回 PDF 提取指令
                  助手:


}

这段提示词的设计有几个关键点: - 技能不是工具:防止 LLM 把技能名称直接当作工具调用,因为这是一个非常常见的大模型直觉 - 正确的使用流程:明确告诉 LLM 应该先调用 read_skill,再按指令执行,分两步走 - 示例对话:用一个具体的例子演示完整的调用链路 最终生成的提示词会被追加到 SkillsReactAgent 的系统提示词末尾,与 ReactAgent 的基础系统提示词合并为一个完整的 SystemMessage。

技能加载工具:ReadSkillTool

核心思路

ReadSkillTool 是整个 Skills 系统中唯一一个暴露给 LLM 的工具。它的职责非常简单:接收技能名称,返回该技能的完整内容。它是连接 LLM 和 SkillRegistry 的桥梁。

实现细节

ReadSkillTool 使用 Spring AI 的 FunctionToolCallback 来创建工具回调:

public


}

这里有几个值得关注的实现细节: 核心逻辑就是一行 skillRegistry.readSkillContent(skillName),具体的文件读取和缓存由 Registry 层处理,工具层保持薄而轻。

技能管理器:SkillManager

核心思路

SkillManager 是整个 Skills 模块的统一入口,它把 Config、Registry、PromptFormatter 这些组件组装在一起,对外提供简洁的 API。

实现细节

public


            builder


}

SkillManager 的组装过程:

SkillConfig
      ↓
buildSkillRegistry
      ↓
promptFormatter       →
      ↓
SkillManager

formatPrompt() 是 SkillManager 最核心的方法——先获取所有技能的元数据列表,再通过格式化器生成系统提示词片段。promptFormatter 支持自定义注入,默认使用内置的 SkillPromptFormatter::format。

与 ReactAgent 的整合

核心思路

前面讲了 Skills 模块自身的实现,接下来看它如何与 SkillsReactAgent 整合。整合的核心思路是:技能列表注入系统提示词,read_skill 作为工具注册,Agent 在 React 循环中自动判断和调用。ReactAgent 的基础机制在前面的课程中已经讲过,这里不再赘述。

实现细节

整合逻辑在 AgentController 的 initManualSkillsReactAgent() 方法中:

private


    log


            webSearchToolCallbacks


}

整个整合流程分为五步: 第一步:创建 SkillManager。传入技能目录路径,SkillManager 内部会构建 FileSystemSkillRegistry。 第二步:生成技能提示词。调用 skillManager.formatPrompt() 获取格式化后的提示词片段,包含所有可用技能的名称和描述。 第三步:创建 ReadSkillTool。将 Registry 传给 ReadSkillTool,使其能够在 LLM 调用时读取技能完整内容。 第四步:合并所有工具。将 read_skill 与搜索工具、文件工具、文件系统工具、Bash 工具等合并,形成完整的工具集。 第五步:构建 Agent。把工具集和技能提示词传给 SkillsReactAgent,其中 systemPrompt 参数就是技能提示词。 在 SkillsReactAgent 内部,技能提示词是这样注入的:

private


        fullSystemPrompt

    messages

}

基础系统提示词和技能提示词被拼接为一个完整的 SystemMessage,避免发送多个 SystemMessage 导致部分大模型 API 报错。 比如:MiniMax模型的的API不允许传递多个SystemMessage,否则会报错400,这个是与大模型的chattemplate有关,需要注意,但是像qwen-plus这种是可以支持多个systemmessage的。

完整执行流程

将所有组件串联起来,当用户说"帮我做一个 PPT,人工智能的发展趋势主题,10 页"时,完整的执行流程如下:

1.

   ├─
   │   └─
   ├─ skillManager
   │   └─ 触发首次扫描 → 提取多个技能的元数据(如 autumnsgrove
   │   └─
   └─

2.

   ↓

   ├─ 拼接
   ├─ 注册工具集(search
   └─ 进入

3.
   LLM 分析用户意图 → 匹配到
   → 输出

4.

   → registry
   → 读取 SKILL
   (包含 python

5.
   LLM 阅读 PPT 技能指引 → 按指引调用 write_file、bash 等工具
   →
   →

6.
   LLM 总结执行结果 → 输出自然语言回复

总结

Skills 系统的核心设计可以归纳为一句话:轻量元数据注入提示词 + 渐进式按需加载完整内容。 从工程实现的角度看,整个系统分为四层: 1. 配置层(SkillConfig):定义技能目录路径等基础配置 2. 注册表层(SkillRegistry / FileSystemSkillRegistry):扫描文件系统、解析 SKILL.md 的 YAML frontmatter、缓存元数据和内容 3. 工具层(ReadSkillTool):通过 FunctionToolCallback 将注册表能力暴露为 LLM 可调用的 read_skill 工具 4. 整合层(SkillManager + SkillPromptFormatter + SkillsReactAgent):将技能列表注入系统提示词,将 read_skill 注册为工具,在 React 循环中实现自动判断和调用 这种设计的优势在于: - Token 高效:只有被使用的技能才会加载完整内容,避免系统提示词无限膨胀 - 扩展性强:新增技能只需要在目录下添加一个文件夹目录,包含SKILL.md文件,无需修改任何代码 - 与 ReactAgent 解耦:Skills 模块是独立的,可以被任何 Agent 集成使用 - 兼容官方 Skills:直接支持 Anthropic 官方开源的 Skills 格式(如 https://github.com/anthropics/skills 中的技能),拿来即用

效果演示

为了展示 Skills 的实际运行效果,这里直接选用 MiniMax-M2.7 推理模型。相比 qwen-plus,MiniMax 能够输出思考过程(thinking),整体输出更加丰富。而且在编写 Python 代码方面,MiniMax 的表现也更加突出。不过线上的MiniMax响应比较慢,非常耗时,尤其是生成python代码的时候,大量的代码,需要编写很长的时间才能完成。 以 PPT 制作为例,当用户提出"帮我做一个 PPT,人工智能的发展趋势主题,10 页,科技风"时,MiniMax 的执行过程如下: 1. 先思考分析用户需求,匹配到 autumnsgrove-pptx 技能 2. 调用 read_skill("autumnsgrove-pptx") 加载完整的 PPT 技能指引 3. 按照技能指引,编写 Python 代码调用 python-pptx 生成 PPT(这步操作会非常耗时) 4. 通过 write_file 写入 Python 脚本,再通过 bash 执行 整个过程不需要任何人工干预,LLM 根据技能指引自主完成了从分析需求到生成文件的完整流程。 踩坑:maxTokens 截断问题 在测试过程中发现一个问题:MiniMax 在调用 write_file 工具时,参数经常为空(arguments={})。排查后发现,根本原因是 application.yml 中配置的 maxTokens: 5000 限制了模型的单次输出长度。 在 Agent Skills 场景中,当模型按照技能指引编写 Python 代码时,代码量往往很大,再加上工具调用的 JSON 结构,很容易突破 5000 token 的限制。一旦被截断,工具调用的 arguments 就不完整,最终表现为参数为空。 解决方案很简单——直接把 application.yml 中的 maxTokens 配置去掉,让模型输出不受限制:

spring
  ai
    openai
      chat
        options

这个问题在之前的 WebSearchReactAgent 中不会出现,因为搜索工具的参数都很短。但到了 Skills 场景,模型需要生成大段代码,token 消耗会远超预期。所以如果你的 Agent 涉及代码生成类的场景,一定要注意 maxTokens 的设置。

版本提示

模型、框架与接口会持续变化。涉及版本号、参数与生产配置时,请在实践前对照对应官方文档。

LLMentor系统化学习大模型应用工程

内容来自个人课程知识库备份,并经过结构化整理。技术版本持续演进,生产使用前请结合官方文档验证。