如何在运行时动态加载和卸载 Skill,而不中断 Agent 主进程?

面试官:“如果我想在运行时动态加载或卸载某个 Skill,而不重启 Agent 主进程,你会怎么设计?”

候选人:

“这其实就是热插拔能力。核心思想是:把每个 Skill 当成一个独立的插件,用隔离的类加载器加载,通过注册中心动态感知上下线,配合优雅停机确保飞行中任务不丢失。 我分四个关键机制来讲。


机制一:自定义类加载器 + 版本隔离

要让 JVM 能在运行时加载、卸载一个类,最大的障碍是默认类加载器一旦加载了类,就不会主动卸载,只有当类加载器实例本身被 GC 时,它加载的类才会被回收。

所以,每个 Skill 都应该使用独立的、专门的类加载器来加载,比如 SkillClassLoader。这个加载器只负责加载该 Skill 的 jar 包或类路径,和应用主类加载器完全隔离。

  • 加载:当需要上线一个 Skill 时,创建一个新的 SkillClassLoader 实例,用它加载 Skill 的主类,然后通过反射或工厂创建 Skill 实例。

  • 卸载:要卸载一个 Skill 时,先把该 Skill 的所有实例置为无效,断开所有引用,然后把对应的 SkillClassLoader 置为 null。只要没有其他强引用指向这个类加载器,下次 GC 时它就会被回收,连带着它加载的所有 Skill 类一起卸载。这就是“通过回收类加载器来卸载类”的原理。

为了确保隔离彻底,通常还会:

  • 不同 Skill 用不同的类加载器,避免互相污染。

  • 指定父加载器为 PlatformClassLoader(Java 9+),避免把核心库也隔离进去。


机制二:注册中心的动态发现

加载卸载只是“物理”层面的操作,Agent 怎么感知到 Skill 的变化,要靠注册中心的事件驱动。

  • 每个 Skill 上线时,向注册中心(Redis、Zookeeper、Nacos 等)注册自己的元信息,并发送一个 skill:online 事件。

  • Agent 主进程内有一个 Skill Manager 组件,它监听注册中心的变更通知。当监听到 skill:online 事件时,才触发上述的类加载和实例化;当监听到 skill:offline 事件时,触发卸载流程。

  • 为了保证 Agent 主线程完全不被阻塞,整个加载/卸载过程可以放在异步线程池中执行。这样即便加载一个较重的 Skill 消耗了几秒,也不会让主线程卡住。

于是,开发运维人员只需要通过控制台或 CI 工具,把 Skill 的 jar 包放到指定目录,然后在注册中心里“启用”它,Agent 就会自动感知并热加载。


机制三:优雅停机与飞行中任务保护

卸载一个 Skill 时最怕的是“这个 Skill 还有任务在执行”。粗暴地直接销毁实例,可能导致交易中断或数据不一致。所以一定要走优雅停机流程:

  1. 标记停止:Skill Manager 先把该 Skill 标记为 STOPPING 状态,拒绝所有新请求。新来的任务会被路由到其他同功能 Skill,或者返回“服务暂时不可用”。

  2. 等待存量任务完成:Skill 内部通常有一个计数器或任务队列(比如 CompletableFuture 列表)。卸载线程调用 skill.shutdown(),Skill 开始拒绝新任务,并每隔一小段时间检查正在进行中的任务数。等待直到任务归零,或者达到最大等待超时(如 30 秒)。

  3. 强制退出与清理:超时后仍然有任务,则中断线程,强制取消,并记录告警。然后释放 Skill 占用的数据库连接、缓存等外部资源。

  4. 注销类加载器:最后将 Skill 实例置 null,清除注册中心信息,并让类加载器可被 GC。

这个过程如果设计成两阶段,效果更好:

  • 第一阶段:停止接受新请求(预卸载)。

  • 第二阶段:等待存量任务清空后真正销毁。


机制四:版本共存与灰度切换

动态加载还有一个高阶需求:同一个 Skill 可能有新旧两个版本,如何做到平滑切换?

  • 注册中心里的 Skill 标识带上 version 字段,如 send_email:1.3.0send_email:1.4.0

  • Agent 的路由层在匹配 Skill 时,可以根据灰度策略,把部分用户的请求路由到新版本,老用户继续用旧版本。

  • 当新版本稳定后,下掉旧版本 Skill,卸载其类加载器。整个过程用户几乎无感知。