第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 循环。