Skill 版本管理与热更新:Agent 的「即插即用」

不要指望重启进程来更新 Skill。生产环境里的 Agent 没有「停下来升级」这个选项。你必须让代码在运行时热替换自己。

ProgressiveSkillLoader 的缓存机制带来一个新问题:Skill 已经激活、工具列表已经缓存、可能还有连接池没关。你改了代码、部署上去,但 Agent 仍在用旧实例响应请求。

这不是 bug,是架构缺陷。版本管理就是来解决这个问题的。

一个真实的冲突

假设你做了一个 web-search Skill。Agent 正在服务线上用户,Skill 已经激活、工具列表已经缓存、可能还有连接池没关。

这时你发现搜索工具的参数校验有 bug,需要修复。你改完代码,重新打包,部署上去。

然后呢?

如果 Agent 启动时一次性注册所有 Skill,你必须重启整个进程才能让新代码生效。这对测试环境还好,对生产环境就是灾难。

更麻烦的是,ProgressiveSkillLoader 会缓存激活的 Skill 实例。就算你在运行时动态替换了注册表里的 Skill,Loader 仍然返回缓存中的旧实例——因为旧实例的版本号没变,它不知道已经过期了。

版本号是触发条件

SkillMetadata 里一直有个 version 字段,但之前它只是元数据,没人真正检查它。

现在它变成了热更新的触发器。

版本号的语义很简单:同一个 Skill 名称下,版本号不同意味着实现已经改变。这包括工具列表变化、初始化逻辑变化、甚至参数 schema 变化。

设计上有一个选择:用语义化版本(1.2.3)还是用时间戳(202601291200)?

我选了让用户自己定。SkillMetadata 只要求 version 是字符串,不限制格式。你可以用 semver,可以用 git commit hash,可以用任何能标识「这一次和上一次不一样」的东西。

关键不在于格式,而在于每次 Skill 实现变化时,必须同步更新版本号。这跟你在 package.json 里改版本号是一样的道理。

activate 里的版本检测

热更新的核心逻辑在 ProgressiveSkillLoader.activate() 里。

原来的流程是查缓存,已激活直接返回。现在的流程加了一步版本检查:

const existing = this.activeSkills.get(skill.metadata.name);
if (existing) {
  if (existing.skill.metadata.version !== skill.metadata.version) {
    // 版本变了:清理旧实例,准备重新激活
    this.embeddingCache.delete(skill.metadata.name);
    await this.deactivate(skill.metadata.name);
  } else {
    existing.activatedAt = Date.now();
    return [...existing.tools];
  }
}

这段代码的意思是:如果缓存里有同名 Skill,但版本号不一致,就认为实现已经变了。先清理旧实例的 embedding 缓存(第四篇引入的语义向量缓存),再停用旧实例,然后走一遍完整的激活流程。

为什么先删 embedding 缓存?

因为 Skill 的 description 或 tags 变了,语义向量就变了。旧的 embedding 缓存如果留着,下次做语义选择时会拿过期向量算相似度,结果一定不准。

deactivate 会调用旧 Skill 的 dispose(),释放连接、清理临时文件。然后新 Skill 重新 init(),重新 getTools(),整个状态焕然一新。

一个容易踩的坑:registry 的注册机制

写测试的时候踩了一个坑,值得单独说。

DefaultSkillRegistry 不允许同名 Skill 重复注册,会直接抛错。这意味着你不能这么做:

registry.register(skillV1);
registry.register(skillV2); // ❌ 抛错:already registered

更新 Skill 的正确流程是:

await registry.unregister('web-search');
registry.register(skillV2); // ✅ 先注销再注册

这个设计是有意为之。重复注册静默覆盖是插件系统里最常见的 bug 来源——两个模块不小心注册了同名 Skill,后注册者静默覆盖前者,调试起来非常痛苦。直接抛错,逼调用方显式处理。

但在热更新场景里,这意味着更新 Skill 需要两步操作。如果你在做自动热更新(比如监听文件变化),记得先 unregisterregister

这里有一个竞态窗口:unregisterregister 之间,如果 selectAndActivate 恰好被触发,需要选 web-search Skill 却找不到注册记录,会返回空列表。生产环境如果对可用性要求高,需要在这个窗口期做 graceful 降级。

selectAndActivate 的隐式重建

之前 selectAndActivate 的实现有一个「小聪明」:

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 只刷新时间戳,不重新 init。本意是优化性能,但副作用是:即使 Skill 版本变了,也不会自动重建

现在统一走 activate

for (const skill of selected) {
  await this.activate(skill);
}

不管之前有没有激活过,每次都走一遍版本检查。如果版本一致,activate 内部会命中缓存快速返回;如果版本变了,activate 会处理重建。

外层的调用方不需要关心版本问题,统一交给 activate 处理。这是职责集中化——版本检测是激活流程的一部分,不应该散落在调用方。

这里还有一个并发问题:如果两个请求同时进入 selectAndActivate,都发现同一个 Skill 版本变了,会触发两次 deactivate + activatedeactivate 里有 dispose(),但 dispose() 被调用两次会发生什么?如果 Skill 实现没有加 guard(比如连接池已经关过一次,第二次调用抛错),整个流程会炸。

当前实现没有做并发保护。生产环境如果需要,可以在 Loader 层加一个「正在重建」的锁,或者要求调用方保证同一时刻只有一个更新流程在跑。

什么时候需要改版本号?

不是所有 Skill 更新都需要改版本号。ProgressiveSkillLoader 是按需加载的,一个 Skill 的代码变了但还没被激活,Loader 不会感知到。

版本检测只在重新激活时触发。所以你需要理解这个流程:

  1. Agent 对话开始,Loader 根据查询选择 Skill
  2. Skill 被激活,版本号记录在缓存里
  3. 后续对话中,同一 Skill 如果还在激活列表,直接走缓存
  4. 直到 Skill 因为超时被清理,或者被淘汰,或者版本变了

如果你的 Skill 已经激活,改版本号不会立即生效——要等到下次 activate 被调用时才会检测到。在大多数场景下这没问题,因为 Agent 的 Skill 激活本来就是按对话轮次的。

但如果你想强制刷新,可以直接调用 deactivate(name) 卸载,再 activate(skill) 重新加载。

还有一个边界情况:如果版本号没变但实现变了(人为失误),系统无法检测。!== 比较是字面量比对,不负责判断「这个版本号对应的代码是不是真的变了」。所以版本号的管理要靠约定,不是靠系统强制执行。

热更新的完整流程

把上面串起来,一个 Skill 热更新的完整流程是这样的:

  1. 开发者修改 Skill 代码,更新 version
  2. 部署新代码到运行环境
  3. Agent 的 registry 更新:unregister 旧实例,register 新实例
  4. 下一轮对话触发 selectAndActivate
  5. Loader 调用 activate,发现版本号不同
  6. 清理旧实例的 embedding 缓存
  7. 调用旧实例的 dispose 释放资源
  8. 调用新实例的 init 初始化
  9. 缓存新实例的工具列表
  10. 返回新工具给 Agent

对 Agent 本身来说,这个过程是无感知的。它只是发现某个 Skill 「恰好」需要重新激活,然后拿到了新的工具列表。

没有银弹

版本管理解决了一个问题,也引入了一个新问题。

现在每个 Skill 的实现者都需要理解版本号的语义:什么时候该升,升多少,改了 bug 算不算版本变化。如果 Skill 是你的团队内部开发的,这不成问题——约定好就行。但如果 Skill 来自第三方插件市场,版本号的管理就变得重要了。

把 Skill 的 version 和它实际发布的版本绑定。如果你用 npm 管理 Skill 包,直接抄 package.json 里的版本号。如果是内部 Skill,可以用 git tag 或者简单的递增数字。

还有一个没解决的问题:怎么知道 Skill 在注册表里已经变了?

现在的热更新是「懒触发」的——只有 activate 被调用时才会检测版本。如果你想让更新更即时,可以在 registry 上做一个监听机制,当 Skill 被替换时自动通知 Loader 清理缓存。但这会引入更多复杂性。

懒触发够用了。

回到代码

这个系列走到第七篇,Skill 系统从接口设计、懒加载、语义选择、组合编排,一直到现在加上了版本管理和热更新。

代码核心不到 400 行,但覆盖了 Agent 工程里一个很实际的问题:运行中的系统怎么更新。版本号不是语义化的装饰品,是工程上用来隔离「旧状态」和「新状态」的边界线。

下一篇是这个系列的倒数第二篇。我想写写 Skill 系统和记忆系统的配合——当 Agent 拥有长期记忆之后,Skill 的激活策略该怎么调整。

评论

此博客中的热门博文

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