本文结合项目中的真实代码,介绍完整实现:
- 数据库存储动态 Tool 源码;
- 使用 GroovyClassLoader 动态编译;
- 通过 IDynamicAgentTool 约束脚本接口;
- 使用源码 MD5 作为缓存键;
- 使用 LRU 限制缓存大小;
- 使用 CompletableFuture 避免重复编译;
- 使用独立 ClassLoader 支持版本隔离;
- 淘汰缓存时释放 ClassLoader;
- 分析动态执行带来的安全边界。
本文不虚构性能提升数据,重点说明代码本身解决了哪些问题,以及当前方案没有解决哪些问题。
一、为什么需要动态 Tool
Agent 的工具通常有两类。
第一类是内置工具,例如项目中的:
com.hxh.apboa.core.tool.builtins.GetCurrentTimeTool
com.hxh.apboa.core.tool.builtins.ThesisFigureTool
这类工具由项目源码实现,启动时可以直接注册。
第二类是业务人员配置的自定义工具。例如在后台配置一个“数学计算工具”,不希望每次修改计算逻辑都重新发布服务。这时可以将工具源码保存到数据库,运行时动态加载。
项目中的工具配置表包含这些字段:
tool_id
tool_type
input_schema
class_path
language
code
need_confirm
enabled
version
其中:
- tool_type=BUILTIN 表示内置工具;
- tool_type=CUSTOM 表示自定义工具;
- input_schema 描述工具入参;
- code 保存动态源码;
- enabled 控制工具是否启用;
- need_confirm 控制调用前是否需要用户确认。
项目中 ToolkitFactory 根据工具类型选择不同的注册方式:
toolService.listByIds(toolIds)
.stream()
.filter(ToolConfig::getEnabled)
.forEach(toolConfig -> {
if (toolConfig.getToolType() == ToolType.BUILTIN) {
toolkit.registerTool(
ToolsRegister.getTool(toolConfig.getClassPath())
);
} else {
toolkit.registerTool(new DynamicAgentTool(toolConfig));
}
});
这里的 DynamicAgentTool 并不是具体业务工具,而是一个代理对象。真正的脚本实例会在工具被调用时动态创建或从缓存中获取。
二、动态 Tool 的接口约束
项目没有允许脚本任意返回对象,而是统一要求实现 IDynamicAgentTool:
public interface IDynamicAgentTool {
Object execute(Object… args);
default String toJsonString(Object obj) {
return JsonUtils.toJsonStr(obj);
}
default JsonNode parse(String string) {
return JsonUtils.parse(string);
}
default <T> T parse(String string, Class<T> clazz) {
return JsonUtils.parse(string, clazz);
}
}
动态源码只需要实现一个方法:
public class MathCalculator implements IDynamicAgentTool {
@Override
public Object execute(Object… args) {
return "tool result";
}
}
这样做有几个好处:
项目中的加载器校验逻辑如下:
Object instance = getObject(clazz);
if (!(instance instanceof IDynamicAgentTool)) {
throw new IllegalArgumentException(
"Glue script class needs to implement "
+ IDynamicAgentTool.class.getName()
);
}
如果源码没有实现接口,加载阶段直接失败,而不是等到 Agent 调用时才出现类型错误。
三、动态工具如何接入 AgentScope
动态工具的入口是 DynamicAgentTool。
它实现了 AgentScope 的 AgentTool 接口:
public class DynamicAgentTool implements AgentTool {
private final ToolConfig toolConfig;
public DynamicAgentTool(ToolConfig toolConfig) {
this.toolConfig = toolConfig;
}
@Override
public String getName() {
return toolConfig.getToolId();
}
@Override
public String getDescription() {
return toolConfig.getDescription();
}
}
它还会根据数据库中的 input_schema 生成工具参数定义:
@Override
public Map<String, Object> getParameters() {
JsonNode inputSchema = toolConfig.getInputSchema();
if (inputSchema == null || inputSchema.isNull()) {
return Map.of();
}
Map<String, Object> properties = new HashMap<>();
List<String> required = new ArrayList<>();
for (JsonNode jsonNode : inputSchema) {
String name = JsonUtils.getStringValue(jsonNode, "name", true);
String type = JsonUtils.getStringValue(jsonNode, "type", true);
String description =
JsonUtils.getStringValue(jsonNode, "description", false);
boolean requiredArg =
JsonUtils.getBooleanValue(jsonNode, "required", false);
properties.put(name, Map.of(
"type", type,
"description", description == null ? name : description
));
if (requiredArg) {
required.add(name);
}
}
return Map.of(
"type", "object",
"properties", properties,
"required", required
);
}
例如,项目中数学工具的入参配置可以是:
[
{
"name": "num1",
"description": "第一个数字",
"type": "string",
"required": true
},
{
"name": "num2",
"description": "第二个数字",
"type": "string",
"required": true
},
{
"name": "operator",
"description": "运算符",
"type": "string",
"required": true
}
]
AgentScope 看到的是标准化工具描述,而不是数据库表结构。
四、调用动态工具时加载源码
真正的动态加载发生在 callAsync 方法中:
@Override
public Mono<ToolResultBlock> callAsync(ToolCallParam param) {
return Mono.fromCallable(() -> {
try {
List<Object> args = getObjects(param);
IDynamicAgentTool dynamicAgentTool =
ToolInstanceLoadFactory
.getInstanceLoader(toolConfig.getLanguage())
.loadInstance(toolConfig.getCode());
Object result = dynamicAgentTool.execute(args.toArray());
return ToolResultBlock.of(
param.getToolUseBlock().getId(),
param.getToolUseBlock().getName(),
TextBlock.builder()
.text(JsonUtils.toJsonStr(R.data(result)))
.build()
);
} catch (Exception e) {
return ToolResultBlock.of(
param.getToolUseBlock().getId(),
param.getToolUseBlock().getName(),
TextBlock.builder()
.text(JsonUtils.toJsonStr(R.fail(e.getMessage())))
.build()
);
}
});
}
调用流程是:
AgentScope 调用 Tool
|
v
DynamicAgentTool.callAsync()
|
v
根据 language 获取加载器
|
v
GroovyToolInstanceLoader.loadInstance(code)
|
v
获取缓存或动态编译
|
v
IDynamicAgentTool.execute(args)
|
v
包装为 ToolResultBlock
参数顺序由 input_schema 决定:
private List<Object> getObjects(ToolCallParam param) {
List<Object> args = new LinkedList<>();
Map<String, Object> input = param.getInput();
JsonNode inputSchema = toolConfig.getInputSchema();
if (input != null
&& !input.isEmpty()
&& inputSchema != null
&& !inputSchema.isEmpty()) {
inputSchema.forEach(jsonNode -> {
if (jsonNode.has("name")
&& !jsonNode.get("name").asText().trim().isEmpty()) {
String name = jsonNode.get("name").textValue();
args.add(input.getOrDefault(name, null));
}
});
}
return args;
}
这意味着脚本接收到的是按配置顺序排列的参数:
args[0] -> num1
args[1] -> num2
args[2] -> operator
这也是为什么 input_schema 的顺序必须稳定,否则脚本参数可能错位。
五、加载器工厂如何选择实现
项目通过 ToolInstanceLoadFactory 管理不同语言的加载器:
public class ToolInstanceLoadFactory {
private static final Map<CodeLanguage, ToolInstanceLoader> loaders =
new HashMap<>();
public static void registerLoader(ToolInstanceLoader loader) {
loaders.put(loader.getLanguage(), loader);
}
public static ToolInstanceLoader getInstanceLoader(
CodeLanguage language) {
ToolInstanceLoader loader = loaders.get(language);
if (loader == null) {
throw new RuntimeException(
"No loader found for language " + language
);
}
return loader;
}
}
加载器通过 Spring 初始化阶段自动注册:
public interface ToolInstanceLoader
extends InstanceLoader<IDynamicAgentTool> {
@Override
default void afterSingletonsInstantiated() {
ToolInstanceLoadFactory.registerLoader(this);
}
}
项目当前实现的是 GroovyToolInstanceLoader。
需要说明一个容易混淆的地方:项目中的 CodeLanguage 枚举字段目前使用 JAVA 表示这类动态 Java/Groovy 代码配置,但具体实现类使用的是 GroovyClassLoader。文章中更准确的表述应该是:
项目当前通过 GroovyClassLoader 编译动态 Java 语法源码,数据库中的语言枚举沿用了 JAVA 配置值。
不能简单描述为“系统同时支持 Java、Groovy、Kotlin 三种脚本语言”,因为目前代码中没有对应的其他加载器实现。
六、源码编译:GroovyClassLoader
核心加载器位于:
core/src/main/java/com/hxh/apboa/core/tool/dynamices/loader/GroovyToolInstanceLoader.java
源码编译逻辑如下:
private LoadedTool doLoad(String codeSource) {
GroovyClassLoader classLoader =
new GroovyClassLoader(getClass().getClassLoader());
try {
Class<?> clazz = classLoader.parseClass(codeSource);
if (clazz == null) {
throw new IllegalArgumentException(
"Dynamic tool source is empty"
);
}
Object instance = getObject(clazz);
if (!(instance instanceof IDynamicAgentTool)) {
throw new IllegalArgumentException(
"Dynamic tool must implement IDynamicAgentTool"
);
}
dependencyInjection(instance);
return new LoadedTool(
classLoader,
(IDynamicAgentTool) instance
);
} catch (RuntimeException | Error e) {
closeQuietly(classLoader);
throw e;
}
}
这里有三个关键点。
1. 每次编译创建独立 ClassLoader
new GroovyClassLoader(getClass().getClassLoader())
没有复用同一个 ClassLoader,而是每个源码版本创建独立加载器。
这样做是为了实现版本隔离:
旧源码 -> ClassLoader A -> OldTool
新源码 -> ClassLoader B -> NewTool
当数据库中的 Tool 源码发生变化时,源码 MD5 也会变化,系统会编译出新的类,不会继续复用旧版本 Class。
2. 校验源码是否实现约定接口
if (!(instance instanceof IDynamicAgentTool)) {
throw new IllegalArgumentException(…);
}
动态语言本身比较灵活,但平台必须在边界处建立约束,否则错误会延迟到真正执行时才暴露。
3. 加载失败时立即清理
catch (RuntimeException | Error e) {
closeQuietly(classLoader);
throw e;
}
如果编译失败、实例化失败或依赖注入失败,ClassLoader 不应该继续保留。
七、为什么不能每次调用都重新编译
如果每次 Agent 调用工具都执行:
new GroovyClassLoader(…).parseClass(code);
会有几个问题:
因此项目在加载器中增加了源码缓存。
八、源码 MD5 作为缓存键
加载器首先校验源码:
if (FuncUtils.isEmpty(codeSource)) {
throw new IllegalArgumentException(
"Dynamic tool source is empty"
);
}
然后生成源码身份:
String identity = codeIdentity(codeSource);
codeIdentity 定义在公共的 InstanceLoader 接口中,本质上根据源码内容生成 MD5:
default String codeIdentity(String codeSource) {
try {
MessageDigest md5 =
MessageDigest.getInstance("MD5");
byte[] digest = md5.digest(
codeSource.getBytes(StandardCharsets.UTF_8)
);
return HexFormat.of().formatHex(digest);
} catch (NoSuchAlgorithmException e) {
return codeSource;
}
}
缓存键使用源码内容,而不是工具 ID:
toolId + 相同源码 -> 命中同一个缓存
toolId + 源码修改 -> 生成新的缓存键
这样可以自然支持热加载:
第一次源码:
class Demo implements IDynamicAgentTool { … }
MD5 = A
修改源码:
class Demo implements IDynamicAgentTool { … }
MD5 = B
由于 A != B,第二次调用会自动加载新版本。
九、LRU 缓存限制 ClassLoader 数量
项目使用一个带访问顺序的 LinkedHashMap:
private final Map<String, LoadedTool> toolObjCache =
Collections.synchronizedMap(
new LinkedHashMap<>(16, 0.75f, true) {
@Override
protected boolean removeEldestEntry(
Map.Entry<String, LoadedTool> eldest) {
if (size() <= Math.max(1, maxCacheSize)) {
return false;
}
releaseQuietly(
eldest.getValue().classLoader()
);
return true;
}
}
);
构造函数中的第三个参数:
new LinkedHashMap<>(16, 0.75f, true)
true 表示按照访问顺序排序,而不是插入顺序。
因此它实现的是 LRU:
最近使用的源码保留
长时间没有使用的源码优先淘汰
缓存容量由配置控制:
@Value("${agentOri.groovy.script-cache.max-size:256}")
private int maxCacheSize = DEFAULT_MAX_CACHE_SIZE;
默认值是 256。
这里的 256 是项目中的默认配置,不是压测得出的最优值。实际部署时应根据:
- 动态工具数量;
- 单个脚本的类数量;
- JVM 堆和 Metaspace;
- 工具更新频率;
- 并发调用量;
进行调整。
十、并发加载为什么使用 CompletableFuture
仅仅加一个缓存还不够。
假设同一份源码第一次被 100 个请求同时调用:
请求 A: 缓存未命中 -> 开始编译
请求 B: 缓存未命中 -> 开始编译
请求 C: 缓存未命中 -> 开始编译
如果没有并发控制,同一份源码可能被编译很多次。
项目使用:
private final ConcurrentMap<String,
CompletableFuture<LoadedTool>> loading =
new ConcurrentHashMap<>();
加载逻辑如下:
CompletableFuture<LoadedTool> mine =
new CompletableFuture<>();
CompletableFuture<LoadedTool> existing =
loading.putIfAbsent(identity, mine);
if (existing != null) {
return await(existing).instance();
}
完整过程是:
LoadedTool cached = toolObjCache.get(identity);
if (cached != null) {
return cached.instance();
}
CompletableFuture<LoadedTool> mine =
new CompletableFuture<>();
CompletableFuture<LoadedTool> existing =
loading.putIfAbsent(identity, mine);
if (existing != null) {
return await(existing).instance();
}
含义是:
- 第一个线程成功放入 loading,负责编译;
- 后续线程发现已有 Future,直接等待;
- 编译完成后,所有等待线程拿到同一个结果;
- 同一份源码只进行一次编译。
真正执行编译的线程:
try {
LoadedTool loaded = doLoad(codeSource);
toolObjCache.put(identity, loaded);
mine.complete(loaded);
return loaded.instance();
} catch (RuntimeException | Error e) {
mine.completeExceptionally(e);
throw e;
} finally {
loading.remove(identity, mine);
}
这里先写入缓存,再唤醒等待者:
toolObjCache.put(identity, loaded);
mine.complete(loaded);
这样等待线程被唤醒后,可以直接命中缓存。
十一、异常时如何避免并发请求永久等待
如果编译失败,不能只抛出异常,还要结束 Future:
mine.completeExceptionally(e);
否则其他线程会一直等待:
await(existing)
项目中的 await 会把 CompletionException 还原成原始异常:
private LoadedTool await(
CompletableFuture<LoadedTool> future) {
try {
return future.join();
} catch (CompletionException e) {
Throwable cause = e.getCause();
if (cause instanceof RuntimeException runtimeException) {
throw runtimeException;
}
if (cause instanceof Error error) {
throw error;
}
throw e;
}
}
这个处理很重要。
如果不还原异常,调用方看到的可能只是:
CompletionException
而不是实际的:
Groovy compilation error
恢复原始异常后,日志和工具返回结果更容易定位。
十二、ClassLoader 为什么需要回收
动态编译最大的风险之一是 ClassLoader 生命周期。
每个 ClassLoader 都可能持有:
- 动态生成的 Class;
- 方法元数据;
- 常量池;
- 反射信息;
- 相关对象引用。
如果源码版本不断变化,却始终把旧版本保留在缓存中,可能导致 Metaspace 持续增长。
项目在缓存淘汰时执行:
private static void releaseQuietly(
GroovyClassLoader classLoader) {
if (classLoader == null) {
return;
}
try {
classLoader.clearCache();
} catch (Exception e) {
log.warn(
"Failed to clear GroovyClassLoader cache: {}",
e.getMessage()
);
}
}
加载失败时则执行更彻底的清理:
private static void closeQuietly(
GroovyClassLoader classLoader) {
if (classLoader == null) {
return;
}
releaseQuietly(classLoader);
try {
classLoader.close();
} catch (Exception e) {
log.warn(
"Failed to close GroovyClassLoader: {}",
e.getMessage()
);
}
}
缓存中保存的不是单独的工具实例,而是:
private record LoadedTool(
GroovyClassLoader classLoader,
IDynamicAgentTool instance
) {
}
这样淘汰工具时可以同时找到对应 ClassLoader。
项目没有在工具刚执行完就关闭 ClassLoader,因为缓存中的实例还可能被继续使用。正确时机是:
缓存淘汰
-> 清理 ClassLoader 缓存
-> 移除旧版本引用
-> 后续由 JVM 回收
需要注意,clearCache() 和 close() 并不等于立即触发 Full GC。它们只是解除框架层面的引用,最终是否回收还由 JVM 决定。
十三、一个真实的动态工具示例
项目初始化 SQL 中包含自定义数学工具,代码结构类似:
import com.hxh.apboa.core.tool.dynamices.IDynamicAgentTool;
import java.util.*;
public class MathCalculator
implements IDynamicAgentTool {
@Override
public Object execute(Object… args) {
if (args == null || args.length < 3) {
return Map.of(
"error",
"需要三个参数:数字1、数字2、运算符"
);
}
String num1Text = args[0].toString();
String num2Text = args[1].toString();
String operator = args[2].toString();
double num1 = Double.parseDouble(num1Text);
double num2 = Double.parseDouble(num2Text);
double result;
switch (operator) {
case "+":
result = num1 + num2;
break;
case "-":
result = num1 – num2;
break;
case "*":
result = num1 * num2;
break;
case "/":
if (num2 == 0) {
throw new ArithmeticException("除数不能为0");
}
result = num1 / num2;
break;
default:
return Map.of(
"error",
"不支持的运算符: " + operator
);
}
return Map.of(
"num1", num1Text,
"num2", num2Text,
"operator", operator,
"result", result
);
}
}
调用时的真实过程:
Agent 识别需要调用 math_calculator
|
v
DynamicAgentTool 获取输入参数
|
v
GroovyToolInstanceLoader.loadInstance(code)
|
v
源码 MD5 计算
|
+– 命中缓存 -> 直接取实例
|
+– 未命中 -> GroovyClassLoader 编译
|
v
MathCalculator.execute(args)
|
v
封装成 ToolResultBlock 返回 Agent
这段流程没有依赖虚构的性能数据,完全对应项目的运行时结构。
十四、热加载是如何发生的
假设数据库中原来的源码是:
return num1 + num2;
修改为:
return num1 * num2;
调用流程如下:
旧源码 -> MD5-A -> 缓存中存在 -> 使用旧版本
新源码 -> MD5-B -> 缓存中不存在 -> 编译新版本
系统不需要额外调用:
reload()
refresh()
restart()
因为缓存键由源码内容决定,源码发生变化后自然会产生新版本。
但这里也有一个边界:
代码热加载只针对已经传入加载器的源码内容,不代表数据库配置修改后所有已经创建的 Agent 会自动重新构建。
如果 Agent 或 Tool 配置在某个运行上下文中已经被读取,是否立即生效还取决于上层 Agent 的创建时机和缓存策略。
因此面试时不能说“修改数据库后所有在线 Agent 立即热更新”,更准确的说法是:
动态 Tool 加载器能够根据源码内容自动识别新版本,首次调用新源码时重新编译;上层 Agent 是否立即使用新配置,还取决于 Agent 的重新装配时机。
十五、当前方案解决了什么问题
当前实现已经解决:
1. 不重启服务即可更新动态 Tool
源码存储在数据库中,运行时读取并编译。
2. 相同源码不会重复编译
源码 MD5 作为缓存键。
3. 并发首次加载不会重复编译
ConcurrentHashMap + CompletableFuture 实现单飞加载。
4. 不同版本的源码相互隔离
每个版本使用独立的 GroovyClassLoader。
5. 缓存不会无限增长
LRU 缓存限制了缓存容量。
6. 旧版本具备释放路径
缓存淘汰和编译失败时会清理 ClassLoader。
7. AgentScope 不需要感知脚本细节
DynamicAgentTool 对外提供标准的 AgentTool 接口。
十六、当前方案没有解决什么问题
动态执行代码本身风险很高,当前实现不能自动解决以下问题。
1. 没有真正的沙箱隔离
动态脚本理论上可以访问:
- 文件系统;
- 网络;
- JVM 系统属性;
- 环境变量;
- 反射 API;
- 进程执行能力。
因此不能把普通后台用户提交的代码直接当作可信脚本执行。
更安全的生产方案通常需要:
- 独立进程执行;
- 容器隔离;
- 限制网络访问;
- 限制文件系统;
- CPU 和内存配额;
- 超时强制终止;
- 代码审核和发布审批。
2. 没有源码版本回滚机制
缓存能够区分不同源码版本,但当前加载器没有实现:
版本列表
版本审核
版本回滚
灰度发布
这些属于配置中心或发布系统能力。
3. 没有分布式缓存
当前缓存位于单个 JVM 进程内。
多实例部署时:
节点 A 有一份缓存
节点 B 有另一份缓存
同一份源码可能在不同节点分别编译。这不影响功能正确性,但会增加各节点的编译开销。
4. 没有统计编译耗时和缓存命中率
当前代码有日志和异常处理,但没有完整的指标体系。因此不能在文章中写“缓存命中率达到多少”或“编译耗时下降多少”。
5. tool_config 中的 language 还不是完整多语言体系
项目虽然通过 ToolInstanceLoadFactory 抽象了加载器,但目前主要实现是 GroovyToolInstanceLoader。未来如果增加 JavaScript、Python 等语言,还需要增加对应的:
ToolInstanceLoader
并注册到工厂。
十七、为什么不用简单的反射加载
反射加载通常要求类已经编译并存在于应用 Classpath 中:
Class.forName("com.example.MyTool");
它适合加载项目内置类,不适合数据库中刚修改的源码。
动态编译方案的区别是:
反射加载:
已编译 Class -> Class.forName() -> 实例
动态加载:
源码字符串 -> GroovyClassLoader.parseClass() -> 实例
AgentOri 同时支持两种工具:
if (toolConfig.getToolType() == ToolType.BUILTIN) {
toolkit.registerTool(
ToolsRegister.getTool(toolConfig.getClassPath())
);
} else {
toolkit.registerTool(
new DynamicAgentTool(toolConfig)
);
}
这是一种比较实用的混合方案:
- 稳定能力使用内置工具;
- 需要频繁调整的能力使用动态工具;
- AgentScope 侧统一看到 AgentTool。
十八、总结
AgentOri 的动态 Tool 不是简单地执行一段字符串,而是完成了一个相对完整的运行时链路:
数据库 ToolConfig
|
v
DynamicAgentTool
|
v
ToolInstanceLoadFactory
|
v
GroovyToolInstanceLoader
|
+– 源码校验
+– MD5 生成版本身份
+– LRU 缓存查询
+– CompletableFuture 并发去重
+– GroovyClassLoader 编译
+– IDynamicAgentTool 类型校验
+– 依赖注入
+– ClassLoader 回收
|
v
IDynamicAgentTool.execute()
|
v
AgentScope ToolResultBlock
这套设计解决的是“工具需要动态配置和更新”的问题,同时通过缓存和并发控制避免重复编译,通过独立 ClassLoader 和 LRU 降低动态类长期占用 JVM 资源的风险。
但它仍然不是一个完整的脚本安全平台。生产环境中还需要补充沙箱隔离、代码审核、版本管理、执行超时、资源限制和监控指标。


