Skip to content

第 4 章(上):Skill——把重复操作封装成技能

我遇到了什么问题

第 3 章解决了"上下文从哪来"的问题——记忆、检索、规范,三者构成 Agent 的知识体系。

但知识体系建好之后,新的问题出现了。

问题 1:创建需求文档,每次都要从头教

我的项目有文档先行的规范——新功能开发前,必须先写需求分析文档。文档有固定的格式:文件名用 MMDD-HHMMSS-需求名.md,放在 Documents/需求分析记录 目录下,内容要包含业务背景、流程分析、架构设计、修改清单。

但每次让 Agent 写需求文档,它都要我重新说一遍:放哪个目录?文件名什么格式?文档结构是什么?模块架构有几层?说了第一遍,第二篇需求文档来的时候,又从头问起。

问题 2:执行测试,每次都像第一次

我有一套完整的组件测试框架——支持工作流、节点、CLI、LLM 等十几种组件类型的测试。测试命令是 dotnet run --project Components.Test -- -w WorkflowName,参数不同测试类型不同。

Agent 每次执行测试,都要先"理解"一遍测试框架结构:这个目录是工作流测试还是节点测试?该用 -w 还是 -n?canvas.json 和 input.json 哪个是必需的?这些知识我已经说过无数遍了,但它每次都像第一次接触这个项目。

问题 3:Git 推送,细节总是忘

提交代码看起来简单,但我的项目有提交规范——commit message 要用 type(scope): subject 格式,scope 要从固定模块列表里选,多行提交信息要用编辑器模式而不是 -m 参数。

Agent 每次提交,要么忘了 scope,要么在 PowerShell 里用 -m 写多行结果报错,要么提交前不审查变更。都是小问题,但每次都烦一遍。

本质:重复操作没有标准化

回过头看,这三个问题的本质是一样的:重复的操作没有被标准化。

创建需求文档、执行测试、Git 推送——这些操作每天都在做,步骤几乎一样,但每次都靠口头教。这不就是带新人时的感觉吗——你教过他一次,但他下次还是从头来。

换句话说,Agent 缺的不是记忆力,缺的是一套"操作手册"。

我做了什么决策

决策 1:Skill——把反复说的操作手册写成文件

一开始我的做法是:每次开始干活前,先花几分钟把规范再说一遍。"文档放这个目录,文件名用时间戳,格式是 MMDD-HHMMSS"。说了几次之后意识到,这不就是带新人时该写操作手册的事吗?

于是我把这些反复说的操作手册写成了文件——这就是 Skill

一个 Skill 就是一份标准化的操作手册——输入是什么、输出是什么、放在哪里、遵循什么规范。比如我实际用的创建需求文档的 Skill(节选核心部分):

markdown
---
name: create-proposal
description: 创建需求分析设计文档,遵循"文档先行、架构清晰、
  自顶向下设计、自底向上实现"的核心思想。
  当用户提出新功能需求、模块修改、Bug修复等需要编写
  需求分析文档时使用。通过 /create-proposal 快捷命令触发。
---

# 需求分析设计 Skill

> **需求文档路径**: `Documents\需求分析记录`

## 核心思想
**文档先行,架构清晰,自顶向下设计,自底向上实现。**

## 文件名格式
`MMDD-HHMMSS-具体需求名字.md`

## 工作流程
1. 获取当前时间,生成时间戳文件名
2.`Documents/需求分析记录` 目录创建文档
3. 按模板填写:需求分析 → 业务流程 → 架构设计 → 修改清单
4. 确认方案后再编码

## 编码顺序(自底向上)
Entity → Repository → Engine → Implementation → Interface → Module注册

这个 Skill 不到 30 行(完整版有 200 多行,包含模块架构约束、命名规范、完整示例等),但核心信息已经够了:放哪个目录、文件名什么格式、工作流程是什么、编码顺序是什么。

有了这个 Skill,Agent 每次写需求文档都会自动遵循规范——文件放对位置、命名格式正确、内容结构完整。

同样的思路,我把执行测试的操作手册也写成了 Skill——测试框架有哪些参数、哪种测试用哪个命令、canvas.json 和 input.json 分别是什么,全写进去了。Git 推送也是——commit 格式、scope 列表、PowerShell 兼容性注意事项,一次写清,永久生效。

同样的事情不用再说第二遍。

决策 2:Skill 的系统化管理

当 Skill 数量超过 10 个之后,新的问题出现了:怎么让 Agent 知道有哪些 Skill 可以用?

最直觉的做法:把所有 Skill 的路径硬编码到代码里。

csharp
// 硬编码方式
var skillPaths = new List<string>
{
    "/path/to/skill1",
    "/path/to/skill2",
    // 每加一个 Skill 就要改代码
};

这种方式的问题是:每加一个 Skill 就要改代码、重新编译。

更好的做法:让各模块自己报告 Skill 路径

csharp
// 接口定义:各模块实现此接口,报告自己的 Skill 路径
public interface IAgentSkillsContributor
{
    IReadOnlyList<string> GetSkillPaths();
}

// 管理器:从 DI 收集所有贡献者的路径,统一构建 Provider
internal class AgentSkillsManager
{
    private readonly IEnumerable<IAgentSkillsContributor> _contributors;

    public AgentSkillsProvider BuildProvider()
    {
        var allPaths = _contributors
            .SelectMany(c => c.GetSkillPaths())
            .Distinct()
            .ToList();

        return new AgentSkillsProvider(allPaths, ...);
    }
}

这个设计的好处:

  • 新增 Skill 不需要改代码:只需要在对应模块的 GetSkillPaths() 里加一个路径
  • 模块解耦:每个模块管自己的 Skill,不需要知道其他模块有什么
  • 延迟加载:首次 LLM 调用时才扫描 Skill 文件,启动时不花时间

然后在 AiAgentModule 中注册:

csharp
// 核心层注册默认空实现(无任何 Skill)
services.TryAddSingleton<IAgentSkillsContributor, EmptySkillsContributor>();

// Integration 层可以注册自己的实现,覆盖默认
// services.AddSingleton<IAgentSkillsContributor, MyIntegrationSkillsContributor>();

这里用了 Provider 可覆盖模式——核心层注册默认空实现,业务层通过提前注册来覆盖。这是整个 Components 项目的核心设计模式之一。

渐进式加载三层机制:广告→加载→读取执行

决策 3:Skill 的渐进式加载——解决“撑爆上下文”

Skill 系统化管理之后,新的问题来了:当 Skill 数量超过 20 个,如果把所有 Skill 的全文都塞进 System Prompt,上下文窗口直接爆了。

这就是扁平化加载的问题——每次 LLM 调用,所有 Skill 全文一次性拼进去:

【扁平式:所有 Skill 全文一次性塞入】
System Prompt = 角色定义 + 记忆 + ▼全部 Skill 全文▼ + 工具指引
                                    ↑ 20 个 Skill × 平均 200 行 = 4000 行
                                    ↑ 上下文窗口直接爆了

解法是渐进式加载(Progressive Disclosure)——分三层,按需加载:

第 1 层:广告(Advertise)
  → System Prompt 中只注入 Skill 的 name + description(几行)
  → LLM 知道"有哪些 Skill 可用",但还没看全文

第 2 层:加载(Load)
  → LLM 判断当前任务需要某个 Skill → 调用 load_skill("create-csharp-module")
  → 框架返回该 Skill 的 SKILL.md 全文

第 3 层:读取/执行(Read/Execute)
  → LLM 需要 Skill 的附属资源 → 调用 read_skill_resource(...)
  → 需要执行脚本 → 调用 run_skill_script(...)

关键思路:LLM 自己决定什么时候加载哪个 Skill。 不是我们帮它全塞进去,而是它看到目录之后自己判断。

这就像你去图书馆——不是把所有书搬到你面前,而是先看目录索引,需要哪本再取。

框架怎么实现的

微软的 Microsoft.Agents.AI 框架提供了 AgentSkillsProvider,它就是实现这个三层加载的核心类:

csharp
// AgentSkillsProvider 作为 AIContextProvider 注入到 Agent 管道
// 每次 LLM 调用前,框架自动执行 ProvideAIContextAsync()
//
// 它做两件事:
// 1. Instructions → 生成 <available_skills> 列表(只有 name + description)
// 2. Tools → 注册 load_skill / read_skill_resource / run_skill_script 三个工具
//
// LLM 看到 available_skills 列表后,如果需要某个 Skill,就调用 load_skill 工具

在我们的产品中,AgentSkillsManager 负责从 DI 收集所有 Skill 路径,构建 AgentSkillsProvider

各业务模块(Integration、Workflow...)
  │ 各自实现 IAgentSkillsContributor.GetSkillPaths()
  │ 返回各自的 Skills/ 目录

AgentSkillsManager(核心层)
  │ 汇总所有路径 → 构建 AgentSkillsProvider
  │ 作为 AIContextProvider 注册到管道

Agent 每次调 LLM 前,框架自动执行:
  ├── Instructions: "可用 Skill 列表(仅 name + desc)"
  └── Tools: [load_skill, read_skill_resource, run_skill_script]

这就是为什么 Skill 文件必须有 YAML Frontmatter——namedescription 字段就是给第 1 层"广告"用的。没有 Frontmatter,LLM 连 Skill 的名字都看不到,更别说按需加载了。

上下文还是太多怎么办?

渐进式加载解决了"Skill 太多塞不下"的问题。但 Skill 加载完之后,长对话本身也会撑爆上下文——工具调用结果越积越多、历史消息越来越长。这个问题在第 3 章讲过,这里不展开,核心思路是上下文压缩(Context Compaction)——在消息到达窗口上限之前,主动压缩历史。

代码里长成了什么样

Skill 的目录结构

.qoder/skills/
├── create-proposal/          ← 创建需求分析文档(最常用)
│   └── SKILL.md
├── test-component/           ← 执行组件测试(最常用)
│   └── SKILL.md
├── git-push/                 ← Git 提交推送(最常用)
│   └── SKILL.md
├── create-csharp-module/     ← 创建 C# 模块
│   └── SKILL.md
├── create-web-module/        ← 创建前端模块
│   ├── SKILL.md
│   └── web-module-standard/  ← 子规范
│       ├── SKILL.md
│       └── templates/
├── design-wpf-ui/            ← 设计 WPF 界面
│   └── SKILL.md
├── framework/                ← 框架参考文档(作为 Skill 注入)
│   ├── dictionary-extensions.md
│   ├── enum-extensions.md
│   └── ...(12 份基础类库文档)
└── ...(还有十几个 Skill)

Skill 在 Agent 调用链中的位置

用户发消息

Agent 准备调用 LLM

框架自动执行所有 AIContextProvider:
  ├── MemoryFactsContextProvider   → 注入记忆事实
  ├── CompactionProvider           → 按需压缩历史上下文
  ├── MonitoringContextProvider    → 记录监控数据
  ├── TitleContextProvider         → 更新会话标题
  └── AgentSkillsProvider          → 注入 Skill 目录(仅 name+desc)
                                    + 注册 load_skill 等工具

所有上下文合并 → 调用 LLM

LLM 判断需要某个 Skill → 调用 load_skill("xxx") → 获取全文

LLM 输出 → 返回结果

业界怎么做的:Skill 加载机制对比

LangGraph 没有 Skill 概念——所有知识编码到图节点的系统提示词里。每个节点有自己的 prompt,节点之间不共享。好处是简单直接,坏处是知识不能复用——两个节点需要相同知识时,得写两遍。

微软 Agent Framework (MAF) 通过 AgentSkillsProvider 实现渐进式加载——先注入 Skill 目录(仅 name + desc),LLM 按需调用 load_skill 工具获取全文。这个设计最接近我的思路,也是我在产品中实际使用的方案。

Hermes 用 Skill 文件 + 插件系统来定义 Agent 的能力边界。每个 Skill 是一份 Markdown 文档,定义了 Agent 在特定场景下应该怎么做。Hermes 还有一个"Curator"机制——自动维护 Skill 的有效性,过时的 Skill 会被标记或移除。这个思路很好,但我还没实现。

AgentScope Java 的做法是"知识注入"——通过 AIContextProvider 在 Agent 执行前注入上下文。没有独立的 Skill 概念,所有知识通过 ContextProvider 统一注入。

对比总结

框架Skill 加载方式特点
LangGraph无 Skill,知识写进节点 prompt简单直接,但知识不能复用
MAFAgentSkillsProvider 渐进式加载最接近我的设计,按需加载
HermesSkill 文件 + Curator 自动维护自动化高,但还没实现类似机制
AgentScopeAIContextProvider 统一注入解耦彻底,但没有独立 Skill 概念

关键认知

这个阶段做完,我有了两个关键认知:

Skill 是"肌肉记忆"——解决"怎么做"的问题,让 Agent 不用每次重新教。

Skill 的价值不在于省时间,在于让输出质量稳定。 不管是我口头教还是 Agent 自己摸索,每次做出来的结果都可能不一样。但有了 Skill,第 1 次和第 100 次的输出结构是一样的。

但光有 Skill 还不够。Agent 还需要知道"不能怎么做"——编码约束,防止它犯已知的错误。

比如"异常不能吞掉,必须上抛"——这不是"怎么做一件事",而是"做这件事时必须遵守什么"。这类东西不适合写成 Skill,因为它是每条代码都要遵守的约束,不是按需加载的操作手册。

这就是 Rule 要解决的问题,下一章来讲。