旧工具怎么「无缝」接入 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

toSkillgroupToolsByPrefix 解决了「怎么把 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 层的版本。

一个容易混淆的点

ToolRegistryAdapterProgressiveSkillLoader 的职责边界:

  • 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/groupToolsByPrefixToolRegistryAdapter。回头看,它们遵循了和前几篇一致的设计原则:

  1. 不破坏现有接口:ToolRegistry 完全不变,SkillRegistry 是新增的
  2. 最小惊讶原则merge() 的行为和直觉一致,Skill 优先覆盖全局
  3. 显式优于隐式groupToolsByPrefix 是便利函数,不是推荐的生产模式
  4. 渐进式迁移:支持双注册、按领域迁移、完全切换三种策略
  5. 实例同一性:merge 策略保证最终工具实例的唯一来源

旧系统和新系统的关系,不是「替代」,而是「共生」。ToolRegistry 继续管理工具生命周期,SkillRegistry 管理能力分组,Adapter 负责统一视图。三个组件各司其职,Agent 不用关心底层用的是哪套系统。


下一步

Skill 系统的核心模块已经全部落地:
- 注册与发现(DefaultSkillRegistry)
- 渐进式加载(ProgressiveSkillLoader)
- 语义选择(Embedding + cosine similarity)
- 组合打包(ComposedSkill)
- 流程编排(SkillChain)
- 旧系统适配(ToolRegistryAdapter、toSkill、groupToolsByPrefix)

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

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


系列文章持续更新中。

评论

此博客中的热门博文

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