Skill 系统设计:当 Agent 的工具多到放不进 context 时

在做 Agent 工具系统的时候,我遇到了一个很现实的问题。

项目做到第 10 篇文章的时候,ToolRegistry 里已经塞了十几个工具:搜索、计算器、文件读写、API 调用、shell 执行……每个工具都有自己的 schema,加起来轻松上千 token。问题在于,Agent 不需要同时知道所有工具的存在。一个闲聊场景,为什么要加载代码执行工具的 schema?

ToolRegistry 本质上是一个扁平的注册表,它的设计哲学是「一次注册,处处可用」。这在工具数量少的时候没问题,但随着工具数量增长,LLM 的 context 变成了瓶颈。

我需要的不是另一个注册表,而是一个能帮 LLM 做「信息分层」的机制。

Progressive Disclosure:把选择拆成两步

Progressive Disclosure 在 UI 设计中很常见——先把最重要的信息展示给用户,等用户需要更多时再逐步展开。Gmail 的「更多」菜单、VS Code 的命令面板搜索,都是这个思路。

把这个概念映射到 Agent 架构里:

  • 第一层:LLM 只看到「当前有哪些技能组可用」,每个技能组是一句话摘要,几十个 token
  • 第二层:当 LLM 决定要用某个技能组时,才加载这个技能组里的详细工具 schema

Skill:Tool 的上层容器

设计上最核心的决策是:Skill 是 Tool 的容器,而不是 Tool 的替代品

这意味着 Tool 系统完全不变,现有的 ToolRegistry 继续工作。Skill 只是在上面加了一层分组和生命周期管理:

export interface Skill {
  readonly metadata: SkillMetadata;
  getTools(): Tool[];
  init?(): Promise<void>;
  dispose?(): Promise<void>;
}

getTools() 返回 Tool 实例,而不是 metadata。这样设计是因为当 LLM 决定激活某个 Skill 时,它需要的是一组「可以直接调用的工具」,而不是还需要去 ToolRegistry 查找的引用。

initdispose 都是异步的。init 可能要做网络请求或建立连接池,dispose 可能要关闭数据库连接——这些都是异步操作。如果 dispose 是同步的,调用方就没办法 await,异步清理要么变成 fire-and-forget 丢失错误,要么用同步方式阻塞等(JS/TS 里做不到)。

SkillMetadata 的角色和 ToolMetadata 是互补的:

export interface SkillMetadata {
  name: string;
  description: string;
  version: string;
  tags: string[];
  toolNames: string[];
}

ToolMetadata 告诉 LLM「这个工具怎么调用」,SkillMetadata 告诉 LLM「这组工具能解决什么问题」。前者是操作手册,后者是功能目录。

DefaultSkillRegistry 和 ProgressiveSkillLoader 的关系

注册表就是 Map<string, Skill>,不加花哨。但是两个类之间的组合关系需要说清楚:

  • DefaultSkillRegistry 是所有 Skill 的注册中心。Skill 先注册到这里,就像工具注册到 ToolRegistry 一样
  • ProgressiveSkillLoader 接收一个 SkillRegistry 实例作为依赖,通过它查询和获取 Skill

Loader 不负责注册 Skill,只负责「在当前对话中激活哪些已注册的 Skill」以及「淘汰哪些不再需要的」。职责分离很清楚:注册归 Registry,激活归 Loader。

两个细节值得提:

重复注册的保护。同名 Skill 注册时直接抛错,而不是静默覆盖。这防止了插件系统中两个模块注册了相同 Skill 名导致的后注册者静默覆盖前者。Agent 是一个需要确定性的系统,隐藏的覆盖可能让你追半天 bug。

unregister 自动 await dispose。当 Skill 从注册表移除时,如果它实现了 dispose 方法,会先等它清理完再移除。unregister 现在是异步的,调用方需要 await

匹配策略:为什么叫 relevance 而不是 recent

最初我给策略起了个名字叫 recent,意在传达「基于最近对话内容选择」。但 Review 的时候被指出了问题:读者第一眼看 recent 会理解为「最近使用过的」,但实际上逻辑是基于关键词匹配度排序。

改成了 relevance,意思直接多了——计算查询文本和 Skill 之间的相关度。计算维度:

  1. 名称匹配:权重 2。Skill 名直接命中查询词,说明用户就是在找它
  2. 标签匹配:权重 1。标签是 Skill 开发者自己打的关键词,比 description 里的自然语言更可靠
  3. 描述匹配:权重 0.5。作为兜底,在名称和标签都不匹配时还能捞一把

分数排序后取前 N 个(默认 3 个),返回 Skill 实例。N 由 maxActiveSkills 控制。

activate:激活、缓存和数组引用泄漏的教训

activate 的流程是:先查缓存,已激活则直接返回;未激活则检查上限、淘汰最老、调用 init、缓存工具列表。

缓存设计上有一个容易忽略的点:数组引用泄漏

最初的实现直接返回了缓存的 tools 数组引用:

return existing.tools;

这意味着外部模块拿到数组后,可以直接 pushpop 修改缓存内容。Review 时被指了出来:「谁动了谁负责」在工程层面不够安全——TypeScript 的 readonly 只在编译期有效,运行时完全没防护。

修复很简单:返回时浅拷贝。

return [...existing.tools];

浅拷贝的开销可以忽略(工具列表通常只有几个元素),但能防止外部意外修改内部状态。同样的修复也应用在 activate 首次激活后的返回路径上。

init 失败:为什么不缓存失败状态

init 失败时的处理是最需要小心的地方。看这段逻辑:

if (skill.init) {
  try {
    await skill.init();
  } catch (err) {
    console.warn(...);
    return [];  // ⚠️
  }
}

const tools = skill.getTools();
this.activeSkills.set(name, { skill, tools, activatedAt: Date.now() });
return [...tools];

init 抛出异常时,函数提前返回空数组,不写入 activeSkills 缓存。这意味着下次调用 activate 时,不会命中缓存,而是从头走一遍完整的激活流程(包括重试 init)。

这个行为的设计意图是:临时问题(比如网络抖动)不应该导致 Skill 永久不可用。如果 init 因网络原因失败,下一次对话中 Agent 仍然可以尝试激活这个 Skill。

代价是:如果网络真的断了,每次调用 activate 都会触发一次失败的 init。这在大多数场景下是可以接受的——Agent 通常只会在需要时才去激活一个 Skill,而不是频繁重试。

淘劣策略:为什么选 LRU 而不是 LFU

我一开始想的是 LFU(最少使用频率),理由是「用得少的 Skill 先淘汰」。但仔细一想发现不对:

Agent 的 Skill 使用模式不是「重复使用同一个 Skill」,而是「这次对话用一组 Skill,下次对话用另一组」。频率在这种场景下意义不大——一个 Skill 可能在上一轮对话中被频繁调用,但这一轮完全不需要。所以时序才是关键:最久没有刷新激活时间的 Skill 先被淘汰,其实就是 LRU 的变体。

淘汰只在 activate 触发新注册时发生,不是单独跑一个后台线程。这样是为了避免额外的并发复杂性。

超时清理:async/await 的连锁反应

cleanupExpired 需要帮 await deactivate,因为 deactivate 现在要等待 dispose 完成。这意味着整个调用链变成了异步:

cleanupExpired (async)
  └→ deactivate (async)
       └→ dispose (async)

一个设计上的取舍是:cleanupExpired 对过期 Skill 的清理是串行的(用 for 循环逐个 await),而不是用 Promise.all 并行。原因很简单:dispose 通常涉及释放资源(关闭连接、清理缓存),这些操作不应该并行竞争同一份资源。串行更安全。

超时清理的调用方设计

cleanupExpired 的调用时机交给外部决定——可能是每次 LLM 调用前触发一次,也可能是由定时任务定期触发。一个更自动化的方案(Loader 自己在构造函数里开一个 setInterval)看起来方便,但副作用是引入了一个隐式的后台线程,在测试和生命周期管理中都容易出问题。所以我选择了显式调用。

测试的价值

测试阶段发现了两个没考虑到的问题:

数组引用泄漏在代码 Review 阶段就显现了——测试中对 activate 返回的列表做修改,发现缓存被意外污染了。这是最早暴露出来的 bug。

>= 的边界问题更隐蔽。cleanupExpired> 判断超时,测试设了 activationTimeoutMs: 0 期望立即过期,结果同一个毫秒内 now - activatedAt 等于 0,条件 0 > 0 为 false。修成 >= 就好了——超时时间为 0 就应该立即过期。

回到最初的问题

当 Agent 只有 3 个工具时,ToolRegistry 就够了。但当工具膨胀到 30 个、300 个时,LLM 的选择困难症会越来越严重。

Skill 系统不是让 LLM 的选择变简单,而是让 LLM 先做粗略选择,再做精细选择。先选技能组(几十个 token),再选工具(几百个 token)。把一次大选择拆成两次小选择,信息密度和选择准确度都会提升。

TypeScript 的核心实现就这些——一个接口,一个注册表,一个加载器,再加几行匹配和淘汰逻辑。核心代码不到 400 行。

但运行时的效果很明显。至少在我的测试里,激活 3 个 Skill 时的 token 消耗是对照组的 1/3。

评论

此博客中的热门博文

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