🛠️ 第四篇:工具与扩展

第9章 插件系统设计:从 ClassLoader 到对话执行

用稳定契约连接 Agent、插件运行时与外部业务系统

9.1 本章导读

插件系统的核心不是“把一个 JAR 放进目录”,而是建立一条可演进的扩展链路:宿主负责 Agent 编排和执行治理,插件负责把工具调用转换为业务调用,业务系统负责真实数据、业务规则和最终权限。

本章的核心结论

ClassLoader 负责类和依赖隔离,插件运行时负责生命周期,Tool Registry 负责能力发现,ToolCallExecutor 负责统一执行治理,业务系统负责真实数据和最终权限。

9.2 插件系统的边界与依赖方向

插件不是另一个 Agent,也不是数据库访问层的简单暴露。它是一个受宿主管理的能力适配器:把模型能够理解的 Tool,转换为业务系统能够执行的请求,再把结果转换成模型可以继续推理的结构。

层次主要职责不应承担的职责
外部应用用户身份、会话上下文、对话请求、结果展示自行实现 ReAct 循环和插件内部加载
Harness 宿主模型调用、工具目录、执行器、事件、审批、超时直接理解每个业务系统的内部模型
插件Tool 定义、参数校验、业务适配、结果裁剪替代业务系统的最终权限和核心规则
业务系统真实数据、业务规则、身份授权、审计把数据库表直接暴露给模型

推荐的依赖方向是:插件只依赖稳定的 deepseek-harness-java-types 契约包,宿主实现并提供运行时能力。插件不应依赖宿主的 Controller、DAO、内部领域对象或具体 Registry 实现。

插件实现
    ↓ 依赖
deepseek-harness-java-types(稳定 SPI 契约)
    ↑ 由宿主实现和提供
deepseek-harness-java(运行时与基础设施)

9.3 SPI、Tool 与 PluginContext

插件边界需要三个层次的契约。入口契约负责生命周期和能力声明,Tool 契约负责模型可见的动作,Context 契约负责插件向宿主注册扩展并获取受控能力。

9.3.1 插件入口

入口对象是插件的根对象。宿主通过 SPI 或元数据找到它,再按生命周期调用。入口不应直接持有宿主内部 Bean,而应通过 PluginContext 获取宿主提供的能力。

public interface JavaHarnessPlugin {
    String pluginId();
    List<ToolDefinition> tools();
    void configure(PluginContext context);
    void onStart();
    void onStop();
}

9.3.2 Tool 契约

一个 Tool 至少包含名称、描述、参数 Schema 和执行函数。名称和描述会进入模型工具目录,参数 Schema 用于约束模型生成的调用参数,执行函数只处理一次明确的业务动作。

Tool
├── name                 局部工具名
├── description          用于模型选择的语义说明
├── inputSchema          参数结构与约束
├── execute(arguments)   执行适配逻辑
└── result                成功、业务拒绝或可恢复错误

9.3.3 PluginContext

PluginContext 是插件与宿主的受控连接面。它可以提供工具注册、配置读取、Prompt 扩展、Hook 注册、事件订阅和资源释放登记。宿主保存这些注册项,并在停止插件时统一撤销。

9.4 ClassLoader 的作用与边界

Java Native 插件运行在宿主 JVM 内。宿主为每个插件创建独立的 URLClassLoader,使插件拥有独立的类命名空间,同时通过父加载器共享稳定 SPI 类型。

9.4.1 为什么必须隔离

如果所有插件共享同一个加载器,一个插件的第三方依赖版本可能覆盖另一个插件的版本,静态状态也可能互相污染。独立加载器将插件实现放入不同的命名空间,降低依赖冲突和升级影响。

9.4.2 父加载器与共享类型

Java 通常采用父加载优先模型。宿主和插件必须使用同一份 JavaHarnessPlugin、Tool 基础接口及结果契约。如果相同全限定名由不同的 ClassLoader 加载,JVM 会把它们视为不同类型:

相同全限定名 + 不同 ClassLoader = 不同 Java 类型

因此,SPI 契约不能被插件重新打包成私有副本。否则可能出现 ClassCastException、SPI 找不到实现或方法参数无法转换。

9.4.3 ClassLoader 不是安全沙箱

类加载隔离解决的是命名空间和依赖问题,不是进程安全问题。插件仍然和宿主共享 CPU、内存、线程和 JVM 权限,也可能访问文件和网络。对不可信代码,需要独立进程、容器或远程执行边界。

9.4.4 卸载与内存泄漏

关闭 URLClassLoader 只表示释放 JAR 文件句柄,不等于插件类立即卸载。只有当加载器及其类没有任何可达引用时,JVM 才可能回收它。停止插件必须清理 Tool、Prompt、Hook、事件订阅、线程池、定时器、ThreadLocal、异步回调和注册表引用。

9.5 生命周期与运行时状态

插件状态表示宿主当前是否允许它贡献能力。安装、激活和运行必须分开,避免“文件存在”被误认为“插件已经可执行”。

Java Native 启动过程为:定位 JAR,创建插件 ClassLoader,读取元数据,定位入口类,实例化入口,调用启动方法,注册 Tool、Prompt、Hook 和事件,最后将状态置为 ACTIVE。任一步失败,都必须回滚已经完成的注册并关闭加载器。

停止过程遵循逆序释放:先停止接收新调用,再注销 Tool 和其他扩展,取消事件与任务,释放连接和线程资源,调用插件停止方法,最后关闭 ClassLoader。热重载不是在原加载器中覆盖类,而是完整停止旧实例后创建新的加载器。

9.6 Tool Registry 与统一执行器

插件 Tool 注册后,不应绕过宿主执行链直接调用模型或向外部应用返回结果。它必须和内置工具一样进入 Registry 和 ToolCallExecutor

工具名应使用命名空间,例如 plugin__<pluginId>__<toolName>。这样既避免局部名称冲突,也能让执行器根据工具名定位所属插件。执行器还应统一处理参数校验、权限判断、幂等或并发策略、超时、异常转换、审计和 ToolCall / ToolResult 事件。

9.7 一次对话的完整时序

外部应用只需要调用对话接口。模型并不直接访问插件,也不直接访问业务数据库;它先从工具目录中选择一个 Tool,再由宿主执行器驱动插件。

这条链路中,模型只负责选择和组织动作,宿主负责是否允许执行,插件负责如何适配业务,业务系统负责是否真正授权。最终答案只是 ReAct 循环结束后的表达,不应被当作业务权限判断的依据。

9.8 插件如何访问业务数据

插件访问业务数据有三种常见方式:调用业务 HTTP API、调用稳定 SDK、在明确边界内使用 JDBC。选择哪一种取决于业务系统的所有权、权限模型和部署位置,但原则不变:插件应使用业务系统认可的入口。

访问方式适用边界设计要求
HTTP / RPC业务系统独立部署或由其他团队维护身份传递、超时、重试、错误码和审计必须明确
稳定 SDK业务能力有版本化客户端契约控制依赖版本,避免把内部对象穿过插件边界
JDBC插件与数据源同属受控部署边界限制 SQL 能力、字段范围、连接权限和资源消耗

插件不应把原始数据库表、内部异常堆栈、访问令牌或无关字段直接返回给模型。应在插件层完成参数白名单、结果裁剪、敏感字段脱敏和错误归一化;最终的用户权限仍需由业务系统再次校验。

9.9 设计检查清单

  • ☐ 插件只依赖稳定 SPI,不依赖宿主内部实现
  • ☐ 每个插件拥有独立 ClassLoader,SPI 类型由父加载器统一提供
  • ☐ Tool 使用命名空间,描述和参数 Schema 足够明确
  • ☐ 所有调用都经过 Tool Registry 和 ToolCallExecutor
  • ☐ 生命周期失败可回滚,停止时清理跨边界引用
  • ☐ 业务权限、数据裁剪和审计位于业务系统或明确的业务边界内
  • ☐ 对不可信插件采用独立进程或更强隔离,而不是仅依赖 ClassLoader

本章总结:插件系统通过 SPI 建立契约,通过 ClassLoader 隔离实现,通过生命周期管理运行,通过 Tool Registry 进入 Agent,通过统一执行器完成治理,再由插件访问业务系统并把结果回填到 ReAct 循环。

查看配套 draw.io 架构图 · 阅读完整设计方案