当 Skill 开始「搭积木」:组合与编排

前四篇文章我们把 Skill 系统的骨架搭完了:接口、注册表、渐进式加载器、语义选择,还有和 Agent 的集成。代码能跑,测试全绿。

但用了一段时间后,我发现一个新的问题:Skill 之间是孤立的。

你有一个搜索 Skill,一个摘要 Skill,一个格式化 Skill。Agent 每次只能激活一个 Skill,工具列表里要么有搜索,要么有摘要。可现实任务往往需要串起来:先搜,再读,再总结,再写。

这时候你就会想:能不能把几个 Skill 打包成一个更大的 Skill?

这篇文章就干这件事:Skill 的组合与编排。


一、从「工具组」到「工作流」

前几篇里,Skill 的定义一直是「一组相关工具的容器」。这个定义在工具数量不多的时候够用,但很快会遇到边界:

  • 一个 Skill 里的工具太多,激活后 context 又膨胀了
  • 几个 Skill 需要按固定顺序执行,但 Agent 不一定知道这个顺序
  • Skill A 依赖 Skill B 的资源,但 B 被淘汰后 A 就废了

本质上,Skill 系统缺的不是更多工具,而是工具之间的关系

我需要的不是一个更胖的 Skill,而是一个能把多个 Skill 像搭积木一样拼起来的机制。

两种玩法

积木可以有两种玩法:

  • 拼好了再玩:把几块积木预先粘成一个组件,拿起来直接用。这是组合(Composition)。
  • 边玩边拼:按照图纸一步步搭,每一步依赖上一步的结果。这是编排(Orchestration)。

前者是静态打包,后者是动态流程。两者都要。


二、ComposedSkill:把 Skill 打包成新 Skill

最直接的想法是:写一个函数,接收一组 Skill,返回一个新的 Skill。

export function composedSkill(options: ComposedSkillOptions): Skill {
  const {
    name,
    description,
    tags,
    skills,
    dependencies = [],
    toolAggregator,
  } = options;

  // 校验 required 依赖
  for (const dep of dependencies) {
    if (dep.type === 'required') {
      const found = skills.some((s) => s.metadata.name === dep.name);
      if (!found) {
        throw new Error(
          `ComposedSkill '${name}' requires skill '${dep.name}', ` +
          `but it was not provided in the skills array.`
        );
      }
    }
  }

  const defaultAggregator = (toolLists: Tool[][]): Tool[] => {
    const result: Tool[] = [];
    const seen = new Set<string>();
    for (const tools of toolLists) {
      for (const tool of tools) {
        if (!seen.has(tool.metadata.name)) {
          seen.add(tool.metadata.name);
          result.push(tool);
        }
      }
    }
    return result;
  };

  const metadata: SkillMetadata = {
    name,
    description,
    version: '1.0.0',
    tags,
    toolNames: [...new Set(skills.flatMap((s) => s.metadata.toolNames))],
  };

  const sorted = sortByDependencies(skills, dependencies);

  return {
    metadata,
    getTools: (): Tool[] => {
      const toolLists = skills.map((s) => s.getTools());
      return toolAggregator ? toolAggregator(toolLists) : defaultAggregator(toolLists);
    },
    async init(): Promise<void> {
      for (const skill of sorted) {
        if (skill.init) await skill.init();
      }
    },
    async dispose(): Promise<void> {
      const reversed = [...sorted].reverse();
      for (const skill of reversed) {
        if (skill.dispose) {
          try { await skill.dispose(); } catch { /* 忽略清理失败 */ }
        }
      }
    },
  };
}

核心就三点:

  1. getTools() 把所有子 Skill 的工具合并,默认去重
  2. init() 按依赖顺序初始化子 Skill
  3. dispose() 逆序清理,一个失败了不影响其他的

依赖声明

组合 Skill 经常需要表达「这个 Skill 依赖那个 Skill」。比如一个「研究报告」Skill 依赖「搜索」和「摘要」Skill。

我加了一个轻量的依赖声明:

export interface SkillDependency {
  name: string;
  type: 'required' | 'optional';
}

type 目前只有两个值:required 表示必须存在,optional 表示有也好没有也罢。这已经覆盖了大部分场景。

sortByDependencies 负责根据依赖排序。目前实现是简化版,直接返回原始顺序。这意味着依赖声明不影响 init 的实际执行顺序——它更像元数据,而不是硬约束。如果你需要真正的拓扑排序,需要自行实现替换这个函数。

自定义工具聚合

默认的 getTools() 是简单展平加去重。但有些场景需要更精细的控制:

  • 只保留第一个 Skill 的工具
  • 按优先级过滤
  • 合并同名工具的参数

toolAggregator 参数就是干这个的:

const research = composedSkill({
  name: 'research',
  description: '搜索并摘要',
  skills: [searchSkill, summarizeSkill],
  toolAggregator: (toolLists) => {
    // 只暴露搜索 Skill 的工具给 Agent
    return toolLists[0] ?? [];
  },
});

三、SkillChain:按顺序执行 Skill

ComposedSkill 是「打包」,把所有子 Skill 的工具合并成一个 Skill。但有时候我不想合并,我想让 Skill 按顺序执行。

比如一个数据处理管道:先读取文件,再清洗,再分析。这三个 Skill 的工具完全不同,合并没有意义。我需要的是编排

SkillChain 干的就是这件事:

export class SkillChain {
  private config: SkillChainConfig;

  constructor(config: SkillChainConfig) {
    this.config = config;
  }

  async execute(): Promise<Tool[][]> {
    const results: Tool[][] = [];
    const sorted = sortByDependencies(this.config.skills, this.config.dependencies ?? []);

    for (const skill of sorted) {
      const tools = await this.activateSkill(skill);
      results.push(tools);
      this.config.afterEach?.(skill, tools);
    }

    this.config.afterAll?.(results);
    return results;
  }

  getAllTools(): Tool[] {
    const toolLists = this.config.skills.map((s) => s.getTools());
    const seen = new Set<string>();
    const result: Tool[] = [];
    for (const tools of toolLists) {
      for (const tool of tools) {
        if (!seen.has(tool.metadata.name)) {
          seen.add(tool.metadata.name);
          result.push(tool);
        }
      }
    }
    return result;
  }

  getSummaries(): SkillMetadata[] {
    return this.config.skills.map((s) => s.metadata);
  }

  private async activateSkill(skill: Skill): Promise<Tool[]> {
    if (skill.init) {
      await skill.init();
    }
    return skill.getTools();
  }
}

和 ComposedSkill 的区别:

  • ComposedSkill 返回一个 Skill,可以注册到 SkillRegistry
  • SkillChain 是一个执行器,直接运行,不注册

SkillChain 更像一个 workflow runner,ComposedSkill 更像一个 module bundler。

钩子函数

SkillChain 提供了两个钩子:

  • afterEach(skill, tools):每个 Skill 执行完后调用
  • afterAll(results):整个链执行完后调用

这两个钩子的设计目的是让外部可以观察执行过程,而不用修改 SkillChain 内部逻辑。


四、回到 Agent:组合 Skill 怎么用

ComposedSkill 和 SkillChain 是基础设施,最终还是要回到 Agent 里才有用。

场景一:Agent 配置里注册组合 Skill

const registry = new DefaultSkillRegistry();

// 注册基础 Skill
registry.register(searchSkill);
registry.register(summarizeSkill);
registry.register(formatSkill);

// 打包成组合 Skill
const researchSkill = composedSkill({
  name: 'research-report',
  description: '搜索、摘要并生成结构化报告',
  tags: ['research', 'report', 'workflow'],
  skills: [searchSkill, summarizeSkill, formatSkill],
  dependencies: [
    { name: 'search', type: 'required' },
    { name: 'summarize', type: 'required' },
  ],
});

registry.register(researchSkill);

// Agent 配置不变,skillLoader 自动发现
const agent = new DefaultAgent({
  tools: globalToolRegistry,
  skillLoader: new ProgressiveSkillLoader(registry),
});

Agent 完全无感知。它看到的只是一个叫 research-report 的 Skill,里面有三个工具。

注意去重:如果基础 Skill 和组合 Skill 同时注册到同一个 registry,Agent 在语义选择时会看到两套有重叠的工具列表。建议在注册组合 Skill 后,将基础 Skill 从 registry 中移除(或其工具不暴露给 Agent),避免重复暴露。

场景二:SkillChain 作为预处理

有些任务需要先准备好工具链,再交给 Agent 执行:

const chain = new SkillChain({
  skills: [searchSkill, summarizeSkill],
  afterEach: (skill, tools) => {
    console.log(`Activated ${skill.metadata.name}: ${tools.length} tools`);
  },
});

const toolResults = await chain.execute();
// toolResults[0] = searchSkill 的工具
// toolResults[1] = summarizeSkill 的工具

这个模式适合那些执行顺序固定、不需要 Agent 动态决策的场景。

场景三:渐进式组合

更高级的用法是:ComposedSkill 里再包含 ComposedSkill。

// 假设已有基础 Skill
const analysisSkill = composedSkill({
  name: 'data-analysis',
  description: '数据清洗与分析',
  skills: [cleanSkill, statsSkill],
});

const deepResearch = composedSkill({
  name: 'deep-research',
  description: '深度研究报告',
  skills: [researchSkill, analysisSkill, formatSkill],
});

嵌套组合的能力是无限的。一个 Skill 可以是一组工具,可以是一组 Skill,可以是一组 Skill 的组合。


五、一个容易踩的坑:共享子 Skill 的生命周期管理

ComposedSkill 的 init()dispose() 会自动调用子 Skill 的对应方法。但这里有一个微妙的 bug:

子 Skill 可能被多个 ComposedSkill 共享。

const shared = createMockSkill('shared', [tool1]);

const combo1 = composedSkill({ name: 'c1', skills: [shared] });
const combo2 = composedSkill({ name: 'c2', skills: [shared] });

// combo1.dispose() 会调用 shared.dispose()
// combo2 再用 shared 时,资源已经被释放了

这个问题的根本原因是:Skill 的 dispose() 是「彻底释放」,不是「引用计数减一」。

解决方案目前有两种:

  1. 共享的 Skill 不实现 dispose:让调用方管理生命周期
  2. ComposedSkill 不自动 dispose 子 Skill:只负责调用 init,dispose 留给外部

我倾向于方案一。ComposedSkill 自动 dispose 的行为是「便利」,不是「必须」。如果子 Skill 被共享,便利就变成了陷阱。

目前 ComposedSkill 的实现保留了自动 dispose,但在文档里明确标注了这个限制。如果你发现子 Skill 被共享,请改用方案二。


六、性能 overhead

组合和编排不是免费的。

ComposedSkill 的 overhead
- getTools() 遍历所有子 Skill,时间复杂度 O(n)
- init() 串行调用子 Skill 的 init,总耗时是各子 Skill init 之和
- dispose() 逆序清理,同样串行

SkillChain 的 overhead
- execute() 按顺序激活每个 Skill,每个 Skill 的 init 都是独立的异步操作
- 如果 Skill 之间有依赖(后一个需要前一个的输出),串行是必须的

去重开销
- 默认 aggregator 用 Set 去重,时间复杂度 O(n),n 是总工具数
- 自定义 aggregator 可以跳过去重,减少 overhead

实际测试中,一个包含 3 个 Skill、每个 Skill 有 2-3 个工具的 ComposedSkill,getTools() 耗时约 0.1ms(Node.js v20, Intel i7-12700H)。这个数字在大多数场景下可以忽略,但如果你的 Skill 数量超过 20 个,建议做基准测试。


七、设计原则复盘

这篇文章加了两个新抽象:ComposedSkill 和 SkillChain。回头看,它们遵循了和前几篇一致的设计原则:

  1. 不破坏现有接口:ComposedSkill 仍然是 Skill,可以注册到 SkillRegistry
  2. 最小惊讶原则:ComposedSkill 的行为和普通 Skill 一致,只是内部多了一层委托
  3. 显式优于隐式:依赖声明是显式的,拓扑排序是显式的,没有魔法
  4. 可组合性:ComposedSkill 里可以再包含 ComposedSkill,无限嵌套
  5. 共享声明优于隐式继承:ComposedSkill 的依赖和子 Skill 都是显式传入的,但当前不支持在父 Skill 中声明「这个工具由子 Skill 提供」的契约。这是后续可以演进的方向。
  6. 失败隔离:dispose 中的 try/catch 体现了「一个清理失败不影响其他」的设计意图。这是一个值得单独拿出来说的原则——资源释放不应该因为某一个组件的问题而阻塞整个系统。

Skill 系统从「工具容器」进化到了「工作流单元」。Agent 不再只是被动选择工具,而是可以主动组合能力。


下一步

现在 Skill 系统有了五种核心能力:
- 注册与发现(DefaultSkillRegistry)
- 渐进式加载(ProgressiveSkillLoader)
- 语义选择(Embedding + cosine similarity)
- 组合打包(ComposedSkill)
- 流程编排(SkillChain)

代码已经落地,测试已经全绿。这个系列的核心设计阶段基本完成。

剩下的就是写文章、发布、然后继续下一个系列。


系列文章持续更新中。

评论

此博客中的热门博文

我写了半年 prompt,这是我发现的最好的技巧