旧工具怎么「无缝」接入 Skill 体系:ToolRegistryAdapter 与渐进式迁移
前五篇文章我们把 Skill 系统的核心能力搭完了:注册表、渐进式加载、语义选择、组合编排。代码能跑,测试全绿,设计上也算自洽。
但真正要把 Skill 体系落地到一个已有项目里时,我遇到了一个绕不开的问题:
我原来已经有几十个 Tool 了,难道要把它们全部改成 Skill 吗?
这篇文章回答这个问题。核心思路是:不改老代码,加一层适配器,让旧 Tool 和新 Skill 和平共处。
一、问题的本质:两套体系的并存
在 Skill 系统出现之前,项目里的工具管理是这样的:
// 老样子:ToolRegistry 管理所有工具
const registry = new DefaultToolRegistry();
registry.register(SearchTool);
registry.register(CalculatorTool);
registry.register(FileReadTool);
// ... 十几个工具直接塞进去
// Agent 启动时,所有工具的 schema 一次性注入 LLM context
const agent = new DefaultAgent({
tools: registry,
// ...
});
这套机制简单直接,但随着工具变多,context 膨胀的问题越来越严重。
Skill 系统解决的是「信息分层」:先让 LLM 选 Skill,再加载具体工具。但 Skill 系统的前提是:工具已经被 Skill 包装过了。
getTools() 返回的是 Tool 实例,这些实例原本来自 ToolRegistry。如果我把 ToolRegistry 里的工具全部「手动」包装成 Skill,工作量大且容易出错。更关键的是:很多工具已经被其他代码引用了,改接口会牵一发动全身。
我需要的是一个桥梁:ToolRegistry 继续管它的工具,SkillRegistry 管 Skill,但 Agent 看到的是统一视图。
二、三个层次的适配
2.1 零成本包装:toSkill
最轻量的做法是把现有 Tool 直接套一层 Skill 壳。
export function toSkill(options: ToSkillOptions): Skill {
const metadata: SkillMetadata = {
name: options.name,
description: options.description,
version: options.version ?? '1.0.0',
tags: options.tags ?? [],
toolNames: options.tools.map((t) => t.metadata.name),
};
return {
metadata,
getTools: () => options.tools,
...(options.init ? { init: options.init } : {}),
...(options.dispose ? { dispose: options.dispose } : {}),
};
}
toSkill 是一个纯函数,接收一组 Tool 和元数据,返回一个 Skill 实例。它不修改 Tool,不修改 ToolRegistry,只是在外面包了一层。
使用场景:
- 已有工具不想改动代码
- 按领域对工具分组
- 无侵入迁移:保持 ToolRegistry 不变,同时开启 Skill 系统
// 把搜索相关工具打包成一个 Skill
const searchSkill = toSkill({
name: 'web-search',
description: '搜索和获取网页内容',
tags: ['search', 'web', 'research'],
tools: [SearchTool, WebFetchTool],
});
这个函数看起来简单,但解决了一个关键问题:Skill 和 Tool 的生命周期是独立的。
Tool 本身没有 init/dispose 概念。toSkill 允许你在包装时注入这些方法:
const dbSkill = toSkill({
name: 'database',
description: '数据库查询',
tools: [QueryTool, MigrateTool],
init: async () => { await pool.connect(); },
dispose: async () => { await pool.end(); },
});
这样,Skill 层的生命周期管理就「附加」在了原有的 Tool 之上,不需要 Tool 本身做任何改动。
2.2 自动分组:groupToolsByPrefix
如果 ToolRegistry 里已经有一批工具,按命名约定分组是最省力的方式。
export function groupToolsByPrefix(
tools: Tool[],
descriptions?: Record<string, string>
): Skill[] {
const groups = new Map<string, Tool[]>();
for (const tool of tools) {
const prefix = tool.metadata.name.split('_')[0];
groups.get(prefix)?.push(tool) ?? groups.set(prefix, [tool]);
}
return Array.from(groups.entries()).map(([prefix, groupTools]) =>
toSkill({
name: `${prefix}-tools`,
description: descriptions?.[prefix] ?? `Provides ${groupTools.length} tools related to ${prefix}`,
tags: [prefix],
tools: groupTools,
})
);
}
命名约定:Tool 名称用下划线分段,第一段作为 Skill 分组名。
web_search → prefix: 'web'
web_fetch → prefix: 'web'
math_calc → prefix: 'math'
math_stats → prefix: 'math'
file_reader → prefix: 'file'
好处是零配置——只要工具命名规范,自动就能分组。坏处也很明显:命名约定是隐式契约,如果有人起了个叫 weatherForecast 的工具(驼峰),自动分组就失效了。
这个函数适合快速原型和演示场景。生产环境里,我更推荐显式声明 Skill,而不是依赖命名约定。
2.3 统一视图:ToolRegistryAdapter
toSkill 和 groupToolsByPrefix 解决了「怎么把 Tool 变成 Skill」的问题。但 Agent 运行时还有另一个问题:LLM 看到的工具列表应该从哪来?
旧系统里,LLM 看到的是 ToolRegistry 里的所有工具。新系统里,LLM 应该看到:
1. 全局工具(始终暴露,比如系统内置工具)
2. 当前激活 Skill 的工具
这两套列表需要合并,而且如果同一个工具在两边都出现了,应该优先使用 Skill 层的版本。
export class ToolRegistryAdapter {
private toolMap = new Map<string, Tool>();
private overridden = new Set<string>();
registerGlobal(tool: Tool): void {
this.toolMap.set(tool.metadata.name, tool);
}
registerGlobalMany(tools: Tool[]): void {
for (const tool of tools) {
this.registerGlobal(tool);
}
}
markOverridden(toolName: string): void {
this.overridden.add(toolName);
}
merge(activeSkillTools: Tool[]): { metadata: ToolMetadata[]; toolMap: Map<string, Tool> } {
const mergedMap = new Map<string, Tool>();
// 1. 先放全局工具兜底
for (const [name, tool] of this.toolMap.entries()) {
mergedMap.set(name, tool);
}
// 2. 再用 Skill 工具覆盖(同名工具优先使用 Skill 层版本)
for (const tool of activeSkillTools) {
mergedMap.set(tool.metadata.name, tool);
}
const metadata = Array.from(mergedMap.values()).map((t) => t.metadata);
return { metadata, toolMap: mergedMap };
}
getGlobalNames(): string[] {
return Array.from(this.toolMap.keys());
}
}
merge 的合并策略很简单:
1. 全局工具先入库兜底
2. Skill 同名时用 Skill 版本覆盖
这个顺序很自然:全局工具先入库兜底,Skill 同名时用 set 覆盖掉。Map.set() 天然支持覆盖,不需要额外逻辑。
三、回到 Agent:怎么把适配器串起来
ToolRegistryAdapter 不是独立工作的,它需要和 ProgressiveSkillLoader 配合。流程是这样的:
const toolRegistry = new DefaultToolRegistry();
const skillRegistry = new DefaultSkillRegistry();
const adapter = new ToolRegistryAdapter();
// 1. 注册全局工具(旧系统)
toolRegistry.register(SearchTool);
toolRegistry.register(CalculatorTool);
adapter.registerGlobal(SearchTool);
adapter.registerGlobal(CalculatorTool);
// 2. 把旧工具包装成 Skill(新系统)
const searchSkill = toSkill({
name: 'web-search',
description: '搜索和获取网页',
tags: ['search', 'web'],
tools: [SearchTool],
});
const calcSkill = toSkill({
name: 'calculator',
description: '数学计算',
tags: ['math', 'calc'],
tools: [CalculatorTool],
});
skillRegistry.register(searchSkill);
skillRegistry.register(calcSkill);
// 3. 配置 ProgressiveSkillLoader
const skillLoader = new ProgressiveSkillLoader(skillRegistry);
// 4. Agent 运行时
const agent = new DefaultAgent({
tools: toolRegistry, // 旧系统继续工作
skillLoader, // 新系统接入
});
Agent 每次调用 LLM 前的准备流程:
// Agent 内部逻辑(伪代码)
async function prepareToolsForLLM(userQuery: string) {
// 1. 让 SkillLoader 选择和激活相关 Skill
await this.config.skillLoader.selectAndActivate(userQuery);
// 2. 获取激活 Skill 的工具
const activeSkillTools = this.config.skillLoader.getActiveTools();
// 3. 合并全局工具和 Skill 工具
const { metadata, toolMap } = this.adapter.merge(activeSkillTools);
// 4. 把合并后的工具元数据传给 LLM
return metadata;
}
LLM 看到的是合并后的工具列表,但工具实例优先来自 Skill 层。这意味着如果 Skill 层对某个工具做了特殊包装(比如加了日志、重试、缓存),LLM 调用时会用 Skill 层的版本。
一个容易混淆的点
ToolRegistryAdapter 和 ProgressiveSkillLoader 的职责边界:
ProgressiveSkillLoader:负责「选哪个 Skill、激活哪个 Skill」ToolRegistryAdapter:负责「Skill 工具和全局工具怎么合并」
不要把「选择 Skill」的逻辑放到 Adapter 里,也不要把「合并工具」的逻辑放到 Loader 里。两个类各管一段,Agent 负责串起来。
四、渐进式迁移的三种策略
现实项目里,你不可能一夜之间把所有 Tool 都改成 Skill。需要过渡方案。
策略一:双注册
老代码继续用 ToolRegistry,新代码用 SkillRegistry。Agent 同时配置两者:
const agent = new DefaultAgent({
tools: toolRegistry, // 旧工具继续服务
skillLoader, // 新 Skill 系统接入
});
这种方式最简单,风险最低。缺点是旧工具仍然一次性全量注入 LLM context,context 膨胀问题没有完全解决。
适合场景:项目初期,Skill 数量少,先跑起来看效果。
策略二:按领域迁移
把工具按领域分组,逐个领域迁移到 Skill:
// 第一周:搜索领域迁移
const searchSkill = toSkill({ name: 'web-search', tools: [SearchTool, FetchTool], ... });
skillRegistry.register(searchSkill);
// toolRegistry 里不再注册 search 相关工具
// 第二周:计算领域迁移
const calcSkill = toSkill({ name: 'calculator', tools: [CalcTool, StatsTool], ... });
skillRegistry.register(calcSkill);
// toolRegistry 里不再注册 calc 相关工具
每次迁移一个领域,验证没问题再继续。Agent 的 adapter.merge() 会自动处理新旧工具的合并。
适合场景:工具数量多,需要分阶段迁移。
策略三:完全切换到 Skill
所有工具都包装成 Skill,ToolRegistry 只保留少数全局工具(比如系统工具、调试工具):
// 全部迁移后
const agent = new DefaultAgent({
tools: new DefaultToolRegistry(), // 几乎空
skillLoader,
});
LLM 看到的工具列表完全由 SkillLoader 控制。ToolRegistry 退化成「兜底注册表」,只放那些不需要 Skill 包装的全局工具。
适合场景:Skill 系统成熟,工具管理完全交给 Skill。
五、一个微妙的 bug:Skill 和 Tool 的「双重暴露」
我在双注册阶段踩过一个坑。
场景:SearchTool 同时注册在 ToolRegistry 和 SkillRegistry 里。
// ToolRegistry 里有 SearchTool
toolRegistry.register(SearchTool);
// SkillRegistry 里也有包装后的 SearchTool
skillRegistry.register(toSkill({ name: 'web-search', tools: [SearchTool] }));
Agent 调用 adapter.merge(activeSkillTools) 时,SearchTool 会从 Skill 层进入合并列表。全局工具列表里也有一个 SearchTool。
最终列表里只有一份 SearchTool,因为 merge() 先放全局工具兜底,再用 Skill 工具覆盖同名项。
但问题出在工具实例不是同一个。
// ToolRegistry 里的实例
const toolRegistryInstance = toolRegistry.get('search');
// Skill 包装后的实例
const skillInstance = searchSkill.getTools()[0];
console.log(toolRegistryInstance === skillInstance); // false
两个实例虽然 schema 相同,但对象引用不同。如果 Agent 的执行逻辑依赖「工具实例的同一性」(比如在 Map 里做缓存),就会出问题。
解决方案有两个:
方案一:Skill 包装时复用原实例
const searchSkill = toSkill({
name: 'web-search',
tools: [toolRegistry.get('search')!], // 直接用注册表里的实例
});
这样 Skill 层的工具和全局工具是同一个对象引用。adapter.merge() 里先放全局再放 Skill,Map.set() 会用 Skill 版本覆盖全局版本,最终结果一致。
方案二:adapter 层做实例统一
merge(activeSkillTools) {
const mergedMap = new Map<string, Tool>();
// 先放全局工具
for (const [name, tool] of this.toolMap.entries()) {
mergedMap.set(name, tool);
}
// 再用 Skill 工具覆盖
for (const tool of activeSkillTools) {
mergedMap.set(tool.metadata.name, tool);
}
return { metadata, toolMap: mergedMap };
}
这种写法更符合直觉:先有全局,再用 Skill 覆盖。无论实例是否相同,最终结果都是 Skill 版本。
我最终选了方案二,因为逻辑更清晰,而且不依赖调用方保证实例同一性。
六、groupToolsByPrefix 的适用边界
groupToolsByPrefix 适合快速原型,但生产环境里要谨慎。
核心问题是:分组逻辑是隐式的。
工具命名规范是团队约定,不是类型系统能 enforce 的。如果有人新加了一个叫 llm_call 的工具,按约定应该分到 llm Skill。但如果这个人不知道这个约定,或者故意不遵守,工具就会被分到错误的 Skill 里,甚至因为 split('_')[0] 取到整个名字而单独成组。
我的建议是:
- 快速原型、demo、教学场景:放心用 groupToolsByPrefix
- 生产环境:写显式的 Skill 定义,每个 Skill 的 tools 数组明确列出包含哪些工具
显式声明虽然多写几行代码,但换来的是可读性和可维护性。三个月后你再看代码,显式声明的 Skill 一目了然,自动分组的 Skill 你得去猜命名规则。
七、设计原则复盘
这篇文章加了两个新抽象:toSkill/groupToolsByPrefix 和 ToolRegistryAdapter。回头看,它们遵循了和前几篇一致的设计原则:
- 不破坏现有接口:ToolRegistry 完全不变,SkillRegistry 是新增的
- 最小惊讶原则:
merge()的行为和直觉一致,Skill 优先覆盖全局 - 显式优于隐式:
groupToolsByPrefix是便利函数,不是推荐的生产模式 - 渐进式迁移:支持双注册、按领域迁移、完全切换三种策略
- 实例同一性:merge 策略保证最终工具实例的唯一来源
旧系统和新系统的关系,不是「替代」,而是「共生」。ToolRegistry 继续管理工具生命周期,SkillRegistry 管理能力分组,Adapter 负责统一视图。三个组件各司其职,Agent 不用关心底层用的是哪套系统。
下一步
Skill 系统的核心模块已经全部落地:
- 注册与发现(DefaultSkillRegistry)
- 渐进式加载(ProgressiveSkillLoader)
- 语义选择(Embedding + cosine similarity)
- 组合打包(ComposedSkill)
- 流程编排(SkillChain)
- 旧系统适配(ToolRegistryAdapter、toSkill、groupToolsByPrefix)
代码已经落地,测试已经全绿。这个系列的基础设施阶段基本完成。
剩下的就是写文章、发布、然后继续下一个系列。
系列文章持续更新中。
评论
发表评论