当 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 { /* 忽略清理失败 */ }
}
}
},
};
}
核心就三点:
getTools()把所有子 Skill 的工具合并,默认去重init()按依赖顺序初始化子 Skilldispose()逆序清理,一个失败了不影响其他的
依赖声明
组合 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() 是「彻底释放」,不是「引用计数减一」。
解决方案目前有两种:
- 共享的 Skill 不实现 dispose:让调用方管理生命周期
- 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。回头看,它们遵循了和前几篇一致的设计原则:
- 不破坏现有接口:ComposedSkill 仍然是 Skill,可以注册到 SkillRegistry
- 最小惊讶原则:ComposedSkill 的行为和普通 Skill 一致,只是内部多了一层委托
- 显式优于隐式:依赖声明是显式的,拓扑排序是显式的,没有魔法
- 可组合性:ComposedSkill 里可以再包含 ComposedSkill,无限嵌套
- 共享声明优于隐式继承:ComposedSkill 的依赖和子 Skill 都是显式传入的,但当前不支持在父 Skill 中声明「这个工具由子 Skill 提供」的契约。这是后续可以演进的方向。
- 失败隔离:dispose 中的 try/catch 体现了「一个清理失败不影响其他」的设计意图。这是一个值得单独拿出来说的原则——资源释放不应该因为某一个组件的问题而阻塞整个系统。
Skill 系统从「工具容器」进化到了「工作流单元」。Agent 不再只是被动选择工具,而是可以主动组合能力。
下一步
现在 Skill 系统有了五种核心能力:
- 注册与发现(DefaultSkillRegistry)
- 渐进式加载(ProgressiveSkillLoader)
- 语义选择(Embedding + cosine similarity)
- 组合打包(ComposedSkill)
- 流程编排(SkillChain)
代码已经落地,测试已经全绿。这个系列的核心设计阶段基本完成。
剩下的就是写文章、发布、然后继续下一个系列。
系列文章持续更新中。
评论
发表评论