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 再选工具,两步决策都比一步决策准。
但设计归设计,落地归落地。上面这些坑,每一个都是真实场景里撞出来的。代码可以写在文档里,但这些问题只能在实际使用中暴露出来。
这也是为什么我们说"先实现,再优化"。你不先把系统跑起来,根本不知道哪些地方会出问题。
评论
发表评论