Skill 系统的工程实践:从设计到落地的那些坑

前两篇文章我们把 Skill 系统的骨架搭起来了:接口定义、注册表、渐进式加载器,以及和 Agent 的集成。代码写出来不难,但要把这套东西跑在生产环境里,坑比设计文档里写的多得多。

这篇文章不是讲新概念,是讲落地时实际遇到的问题,以及我们是怎么解决的。

一、启动时全加载还是按需加载?

Skill 系统第一个要回答的问题是:Agent 启动时,要不要把所有 Skill 都初始化一遍?

全加载的诱惑

最直观的想法是:启动时遍历注册表,对每个 Skill 调用 init(),把工具 schema 全准备好。好处是后续运行时零延迟。LLM 要什么工具直接给。

但问题很明显。

有些 Skill 的 init() 要做网络请求,比如连接数据库、验证 API key。启动时全量执行会让 Agent 启动变慢。

有些 Skill 可能根本不会被用到,比如代码执行工具在纯文本对话场景里永远不会激活。提前初始化就是浪费。

如果某个 Skill 的 init() 失败了,整个 Agent 启动就失败了,哪怕这个 Skill 永远不会被用到。

按需加载的代价

我们选的是按需加载。ProgressiveSkillLoader 只在 activate() 被调用时才执行 init()

相应的,第一次激活 Skill 时有延迟。如果 init() 需要 200ms 建连接,用户会感觉到工具调用变慢了。

解决方案是预热

// 在 Agent 初始化后,根据历史对话预激活 Skill
const recentQueries = memory.shortTerm.getRecentUserQueries(3);
for (const query of recentQueries) {
  await skillLoader.selectAndActivate(query);
}

这不是必须的,但能显著改善首次调用的体验。

二、缓存失效:什么时候该清掉?

Skill 被激活后,工具列表被缓存在 activeSkills Map 里。但缓存不是永远有效的。

场景一:Skill 代码更新了

如果你的 Skill 是从文件系统动态加载的(比如插件系统),文件可能被修改。缓存的工具列表就过期了。

有个 Skill 升级了版本,新增了一个参数。但缓存里还是旧 schema,LLM 调用时传了旧参数,API 返回 400。查了半天才发现是缓存没刷新。

这次事故之后,我们在 ActiveSkillRecord 里加了 version 字段。激活时记录 Skill 的 version,下次激活前比对。如果版本变了,强制重新加载。

场景二:Skill 的依赖变了

有些 Skill 依赖外部资源(数据库连接、API 客户端)。这些资源可能被其他代码关闭或重建。

我们加了 dispose() 的主动调用时机:

// 在每次 LLM 调用前检查资源健康状态
if (this.config.skillLoader) {
  await this.config.skillLoader.healthCheck();
}

healthCheck() 是我们在 ProgressiveSkillLoader 上新增的一个方法,不在 SkillLoader 接口上。因为它不是 Agent 必须的,而是生产环境的运维需求。

它遍历所有激活的 Skill,检查它们的资源是否仍然有效。失效的 Skill 会被标记为"需要重新激活"。

三、淘汰策略的工程细节

LRU 的实现陷阱

我们选了 LRU(最久未使用优先淘汰),但实现时踩了两个坑:

坑一:activatedAt 不是"最后使用时间"

activatedAt 记录的是 Skill 被激活的时间。但一个 Skill 被激活后,它的工具可能被 LLM 调用多次。如果 LRU 只看激活时间,一个刚被调用过的 Skill 可能因为"激活时间久"被淘汰。

修复很简单:在每次工具调用后刷新 activatedAt

// 工具执行后,刷新对应 Skill 的激活时间
const toolName = result.toolName;
for (const [skillName, record] of this.activeSkills.entries()) {
  if (record.tools.some(t => t.metadata.name === toolName)) {
    record.activatedAt = Date.now();
  }
}

坑二:并发激活时的竞态

如果两个并发的 LLM 调用同时触发 activate(),可能同时判断"需要淘汰最老的 Skill",然后淘汰同一个 Skill。

修复:加一个简单的互斥锁。

private evicting = false;

private async evictOldest(): Promise<void> {
  if (this.evicting) return;
  this.evicting = true;
  try {
    // ... 淘汰逻辑
  } finally {
    this.evicting = false;
  }
}

这对 Agent 场景足够了——Agent 通常是单线程执行循环,并发激活很少见。但如果你要做多 Agent 协作,就需要更完善的锁机制。

四、错误处理:Skill 挂了怎么办?

init 失败

我们已经处理了 init() 失败的情况:返回空工具列表,不缓存,下次还能重试。

但还有一个细节:失败后要不要记录?

如果某个 Skill 连续 3 次 activate() 都失败,说明它可能真的有问题,比如 API 地址错了。继续每次重试只会浪费时间。

我们加了一个简单的熔断计数器:

interface ActiveSkillRecord {
  skill: Skill;
  activatedAt: number;
  tools: Tool[];
  version: string;
  initFailures: number;
}

// 连续失败 3 次后,标记为"暂时不可用"
const MAX_INIT_FAILURES = 3;

被熔断的 Skill 不会出现在 selectSkills() 的结果里,直到 Agent 重启或手动重置。

dispose 失败

deactivate() 时如果 dispose() 抛出异常,我们已经做了 catch 并继续删除记录。但失败时要不要记录?

我们的选择是:不记录dispose() 失败通常意味着资源释放有问题,但这不应该阻止 Skill 被移除。如果资源真的泄漏了,那是 Skill 实现的问题,不是 Loader 的问题。

五、性能:激活 overhead 到底有多大?

我们在真实场景测了一下数据。测试环境:单次 LLM 调用平均执行 3 次工具调用,每个工具调用平均耗时 50ms。

场景 工具数量 全部加载 (ms) 按需激活 (ms) 节省
简单对话 5 12 8 33%
复杂对话 20 45 15 67%
工具密集型 50 180 25 86%

简单对话是 Q&A 场景,LLM 只调用 1-2 个工具。复杂对话是多轮工具调用,比如搜索+计算+总结。工具密集型是代码生成场景,LLM 频繁调用文件读写、代码执行等工具。

"按需激活"的 25ms 里,selectSkills() 占 2ms,activate()init() 占 15ms,getTools() 加缓存占 8ms。

如果 Skill 不需要初始化,比如纯计算型的数学 Skill,激活耗时可以降到 10ms 以下。

六、一个容易忽略的设计细节

selectAndActivate() 每次都会停用"不相关"的 Skill。这个行为在单轮对话里没问题,但在多轮对话里有个边界情况:

用户第一轮问"搜索",第二轮问"计算",第三轮又问"搜索"

  • 第一轮:激活搜索 Skill
  • 第二轮:停用搜索,激活数学 Skill
  • 第三轮:停用数学,重新激活搜索 Skill

第三轮重新激活搜索 Skill 时,会再次调用 init()。如果搜索 Skill 的 init() 很耗时(比如建立 HTTP 连接池),用户会感觉到延迟。

解决方案:延长超时时间

我们把默认超时从 30 分钟改成了 2 小时。Agent 的一次会话通常不会超过几十分钟,2 小时的超时能覆盖大部分连续对话场景。

如果用户真的隔了一天再来,Skill 被清理了,重新初始化也是合理的。但真正的问题是:重新激活的成本太高(init() 耗时),而不是超时时间太短。

我们也试过另一种方案:在 deactivate() 时不真正释放资源,只从 context 中移除工具 schema,保留一个「休眠」状态。重新激活时如果资源还在,直接跳过 init()。这个方案在测试里跑通了,但实际使用中发现一个问题:休眠的 Skill 占用的连接池、内存不会被释放,长时间运行后资源泄漏风险增加。

最终我们选了折中方案:超时时间默认设为 2 小时,同时支持手动强制释放。这样既覆盖了大部分连续对话场景,又不会让资源无限期占用。

七、回到设计初衷

Skill 系统的核心问题不是"怎么管理工具",而是"怎么让 LLM 在有限的 context 里做出更好的选择"

上线后我们看了几个指标:Skill 激活命中率(用户问题匹配到正确 Skill 的比例)稳定在 85% 以上,平均每次 LLM 调用暴露的工具数量从 23 个降到了 7 个,token 消耗平均减少了 40%。

Progressive Disclosure 的本质是把选择压力从 LLM 身上卸下来。LLM 不需要在 100 个工具里挑,只需要在 10 个 Skill 摘要里挑。选完 Skill 再选工具,两步决策都比一步决策准。

但设计归设计,落地归落地。上面这些坑,每一个都是真实场景里撞出来的。代码可以写在文档里,但这些问题只能在实际使用中暴露出来。

这也是为什么我们说"先实现,再优化"。你不先把系统跑起来,根本不知道哪些地方会出问题。

评论

此博客中的热门博文

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