把 Skill 装进 Agent:从扁平工具到渐进式加载

上一篇文章我写了 Skill 系统的核心设计:接口、注册表、渐进式加载器。但写完之后我盯着代码看了半天,发现一个问题——

Agent 根本不知道 Skill 的存在。

DefaultAgent 的 run loop 里,获取工具就一行代码:this.config.tools.listMetadata()。它拿着 ToolRegistry,把里面所有工具的 schema 一股脑塞给 LLM。你 Skill 设计得再好,Agent 不认识它,等于白搭。

这篇文章就干一件事:把 Skill 系统缝进 Agent 里。

先想清楚要缝在哪

Agent 的执行循环里有几个点,你能把 Skill 插进去:

  1. 配置层:AgentConfig 里新增一个可选字段,指向 Skill 加载器
  2. 工具获取:原本 listMetadata() 拿全部工具,现在改成先激活 Skill,再合并工具列表
  3. 工具解析:原本 this.config.tools.get(name) 直接从注册表拿,现在先查 Skill 激活的工具,再回退到全局

这三个点覆盖了整个链路——配置怎么进、LLM 看到什么工具、实际调用哪个工具。

但有一个微妙的问题:AgentConfig 不应该依赖 Skill 模块的具体实现。

AgentConfig 在 core/agent.ts 里,Skill 的具体实现在 skill/skill.ts 里。如果 AgentConfig 直接引用 ProgressiveSkillLoader,那就拉出了一条从 core 到 skill 的依赖线。

解决方案是定义接口,不引用具体实现类。接口只引用 SkillSummary 这个轻量类型(来自 skill/skill.ts,type-only import 运行期被擦除)。

SkillLoader:Agent 看到的 Skill 入口

Agent 不需要知道 Progressive Disclosure 怎么实现的,也不需要知道淘汰策略。它只需要三个能力:

  1. 拿到所有 Skill 的摘要(给 LLM 看的第一层信息)
  2. 根据用户消息,自动选择并激活相关 Skill
  3. 拿当前激活的所有工具

所以抽象出一个 SkillLoader 接口:

export interface SkillLoader {
  getSkillSummaries(): SkillSummary[];
  selectAndActivate(query: string): Promise<void>;
  getActiveTools(): Tool[];
  getActiveToolMetadatas(): ToolMetadata[];
  getActiveSkillNames(): string[];
}

Agent 只需要这五个方法——拿摘要、选技能激活、取工具实例、取工具 schema、查当前激活名单。

ProgressiveSkillLoader 本来就实现了这些能力(selectAndActivategetActiveToolMetadatas 是新增的便捷方法),自然成为 SkillLoader 的第一个实现。

而 AgentConfig 只认接口,不认具体类:

export interface AgentConfig {
  // ... 已有字段 ...
  skillLoader?: SkillLoader;
}

不配置时,Agent 行为不变——所有全局工具依旧全部暴露,零迁移成本。

selectAndActivate:一条虚线怎么画

ProgressiveSkillLoaderskill/skill.ts)本来有 selectSkills()activate() 两个独立方法。前者基于关键词匹配选 Skill,后者负责初始化、缓存工具。

但对 Agent 来说,这两步应该是一体的。所以我在 ProgressiveSkillLoader 上补了一个便捷方法 selectAndActivate

async selectAndActivate(query: string): Promise<void> {
  const selected = this.selectSkills(query);

  // 已经激活的只刷新时间戳,不重复 init
  for (const skill of selected) {
    if (this.activeSkills.has(skill.metadata.name)) {
      this.activeSkills.get(skill.metadata.name)!.activatedAt = Date.now();
    } else {
      await this.activate(skill);
    }
  }

  // 停用不相关的 Skill
  const selectedNames = new Set(selected.map((s) => s.metadata.name));
  for (const name of this.getActiveSkillNames()) {
    if (!selectedNames.has(name)) {
      await this.deactivate(name);
    }
  }
}

这里有一个取舍:每次调用都停用不相关的 Skill,会不会太激进?

我倾向激进。原因是:如果用户第一轮问了搜索,第二轮问数学,旧的搜索 Skill 还在激活状态,它的工具 schema 会持续占用 context。Skill 系统的核心价值就是减少不必要的 token 消耗,及时清理过期 Skill 是必须的。

改 DefaultAgent 的执行循环

代码改动量不大,但要在正确的地方动手脚。

改动一:每次 LLM 调用前,根据用户最新消息激活 Skill。

const latestQuery = this.getLatestUserQuery(messages);
if (this.config.skillLoader && latestQuery) {
  await this.config.skillLoader.selectAndActivate(latestQuery);
}

这里的 getLatestUserQuery 从消息列表中倒序遍历,找到最后一条用户消息。注意是每次迭代都查,因为多轮对话中每轮的用户意图都可能变化。

改动二:构建合并后的工具元数据。

private buildToolMetadatas(
  skillLoader: SkillLoader | undefined,
  skillToolMap: Map<string, Tool>
): ToolMetadata[] {
  if (!skillLoader) return this.config.tools.listMetadata();

  // Skill 工具元数据 + 不被 Skill 覆盖的全局工具元数据
  const skillNames = new Set(skillToolMap.keys());
  const globalMetadatas = this.config.tools
    .listMetadata()
    .filter((m) => !skillNames.has(m.name));

  const skillMetadatas = Array.from(skillToolMap.values()).map(
    (t) => t.metadata
  );

  return [...skillMetadatas, ...globalMetadatas];
}

Skill 工具优先,全局工具补充。同名工具以 Skill 为准。

改动三:工具解析时同样 Skill 优先。

private resolveTool(name: string, skillToolMap: Map<string, Tool>): Tool | undefined {
  return skillToolMap.get(name) ?? this.config.tools.get(name);
}

这里有一个细节:串行执行方法 executeToolCallsSequential 内部会通过 skillLoader.getActiveTools() 重新构建一次 skillToolMap,而不是直接引用执行循环中的同名变量。这是出于方法签名独立性的考量——resolveTool 接收的是一个纯参数,不依赖外部状态。

还有一处隐藏改动:并行执行时,我把 skill 工具和全局工具合并成一个统一的 ToolRegistry 视图,供 executeToolsParallel 使用:

private buildMergedToolRegistry(
  skillToolMap: Map<string, Tool>
): ToolRegistry {
  const globalTools = this.config.tools;
  return {
    get: (name: string) => skillToolMap.get(name) ?? globalTools.get(name),
    listMetadata: () => {
      const skillNames = new Set(skillToolMap.keys());
      const globalMetadatas = globalTools.listMetadata()
        .filter((m) => !skillNames.has(m.name));
      const skillMetadatas = Array.from(skillToolMap.values()).map(t => t.metadata);
      return [...skillMetadatas, ...globalMetadatas];
    },
    register: (tool) => globalTools.register(tool),
    registerMany: (tools) => globalTools.registerMany(tools),
    unregister: (name) => globalTools.unregister(name),
  };
}

这样串行和并行两种模式都能正确处理 skill 工具。

Tool 不需要改一行代码

这是我最在意的事。当你已经在 ToolRegistry 里注册了十几个工具,不可能为了让它们变成 Skill 就去改每个 Tool 的代码。

解决方案是一个适配函数:

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 } : {}),
  };
}

用法就是一个函数调用:

const webSkill = toSkill({
  name: 'web-search',
  description: '搜索和获取网页内容',
  tags: ['search', 'web'],
  tools: [searchTool, fetchTool],
});

原有 Tool 实例直接传进去,不改动、不包装、不代理。纯粹是数据结构的重组。

如果你有大量 Tool 已经按命名约定放在一起(比如 web_searchweb_fetch 都是 web 前缀),还有自动分组的辅助函数:

const skills = groupToolsByPrefix(allTools);
// 自动按前缀分组:web_search + web_fetch → 'web-tools',math_calc + math_stats → 'math-tools'

groupToolsByPrefix 以工具名第一个下划线前的部分作为分组键,返回的 Skill 名称为 ${prefix}-tools 格式。

另外还有一个 ToolRegistryAdapter,负责把两个来源合并成一个统一的工具视图。不过 DefaultAgent 内部用的合并逻辑是 buildToolMetadatas + resolveTool 这套组合拳,ToolRegistryAdapter 是留给那些需要直接操作合并视图的外部调用方,不是集成链路的核心一环。

// 手动控制合并,不依赖 Agent 内部的隐式逻辑
const adapter = new ToolRegistryAdapter();
adapter.registerGlobalMany(globalTools);
const { metadata, toolMap } = adapter.merge(skillTools);

测试怎么兜底

改动涉及到 Agent 的执行流程,测试必须覆盖几种关键场景:

场景一:不配 SkillLoader,用回老行为。 确保向后兼容——如果你不配置 skillLoader,Agent 的行为应该和之前完全一样。跑全部已有测试来验证。

场景二:Skill 工具 vs 全局工具。 当一个工具既在 Skill 中注册又在全局注册时,Skill 版本优先。我在测试里注册了一个叫 echo 的全局工具,再在 Skill 里注册一个同名的 Skill 版 echo,然后验证调用结果是 Skill 版本。

场景三:自动 Skill 选择。 注册两个 Skill(搜索和数学),发一条"calculate 6 times 7",验证激活了数学 Skill 而不是搜索。

场景四:与 ProgressiveSkillLoader 的真实集成。 不是用 mock,而是真实的 ProgressiveSkillLoader 实例,验证 selectAndActivate 被成功调用,且工具的 schema 真的被传给了 LLM。

import { DefaultSkillRegistry, ProgressiveSkillLoader } from '../../src/skill/skill.js';

it('integrates with ProgressiveSkillLoader', async () => {
  const registry = new DefaultSkillRegistry();
  registry.register(searchSkill);

  const loader = new ProgressiveSkillLoader(registry, { maxActiveSkills: 2 });

  // ... 创建 Agent 并执行 ...

  const result = await agent.run('search for test');
  expect(result.output).toBe('Final: search done');
});

274 个测试,15 个测试文件,全部通过。

一点感想

Skill 系统的核心价值不在 Skill 本身——一个 Skill 本质上就是一个工具组。真正的价值在于Agent 在运行过程中能动态决定加载哪些工具

这听起来很自然,但你想过没有——没有 Skill 系统的时候,LLM 的 context 里塞满了工具。100 个工具的 schema,每个平均 150 token,就是 15k token。这不是买不起,但这 15k 本来是留给对话上下文的。让工具 schema 白白占用这个空间,浪费。

Progressive Disclosure 的思路就是:先把 100 个工具抽象成 10 个 Skill 摘要(每个 30 token),加起来 300 token。LLM 看到的是"这里有搜索能力、数学能力、文件处理能力……",然后决定需要搜索,再加载搜索 Skill 下的 3 个工具(450 token)。总共 750 token,省下了 14k+。

当然,这个数字是个粗略估算。实际省多少取决于 Skill 的分组方式和激活策略。但原理是确定的——分层比平铺更省 token,因为 LLM 不需要知道它不需要的东西。

评论

此博客中的热门博文

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