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 查找的引用。
init 和 dispose 都是异步的。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 之间的相关度。计算维度:
- 名称匹配:权重 2。Skill 名直接命中查询词,说明用户就是在找它
- 标签匹配:权重 1。标签是 Skill 开发者自己打的关键词,比 description 里的自然语言更可靠
- 描述匹配:权重 0.5。作为兜底,在名称和标签都不匹配时还能捞一把
分数排序后取前 N 个(默认 3 个),返回 Skill 实例。N 由 maxActiveSkills 控制。
activate:激活、缓存和数组引用泄漏的教训
activate 的流程是:先查缓存,已激活则直接返回;未激活则检查上限、淘汰最老、调用 init、缓存工具列表。
缓存设计上有一个容易忽略的点:数组引用泄漏。
最初的实现直接返回了缓存的 tools 数组引用:
return existing.tools;
这意味着外部模块拿到数组后,可以直接 push 或 pop 修改缓存内容。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。
评论
发表评论