第1901篇:LangChain4j高级特性——动态工具注册与运行时工具发现
第1901篇:LangChain4j高级特性——动态工具注册与运行时工具发现
说实话,我最开始用 LangChain4j 的时候,工具注册这块踩了不少坑。官方文档写的是静态注册,你在 AiServices.builder() 里把工具列好,编译时就定死了。这在 demo 里没问题,但放到生产环境,用户权限不一样,有人能用"发邮件"工具,有人不能;晚上八点后某些工具要自动禁用;多租户场景下不同租户压根不应该看到同一套工具。
静态注册的方式完全撑不住这些需求。
这篇文章就聊聊怎么做动态工具注册——让工具集在运行时可以变化,AI 能在实际调用前发现当前有哪些工具可用。
先搞清楚 LangChain4j 的工具注册机制
在深入之前,得先理解底层是怎么运作的。
LangChain4j 里的工具(Tool)本质上是一种特殊的 Java 方法,通过 @Tool 注解标记。框架在构建 AiService 的时候,会用反射扫描你传入的 tool provider 对象,找出所有 @Tool 方法,提取方法签名、参数类型、注解描述,然后生成对应的 JSON Schema,最终拼进发给 LLM 的请求里。
// 典型的静态工具注册方式
@Tool("查询用户账户余额")
public String getBalance(@P("用户ID") String userId) {
return accountService.getBalance(userId).toString();
}这套机制的问题在于:工具集在 AiServices.builder().tools(toolProvider) 那一刻就已经固定了。后续每次调用这个 AI 服务,传给模型的 functions 列表都是同一套。
那怎么做到动态?有两个切入点:
- 每次请求重新构建 AiService(重量级,性能差,不推荐)
- 利用
ToolProvider接口的动态能力(推荐)
LangChain4j 0.27 之后引入了 ToolProvider 接口,这是做动态工具的关键。
ToolProvider 接口:动态工具的入口
public interface ToolProvider {
ToolProviderResult provideTools(ToolProviderRequest request);
}ToolProviderRequest 里包含了当前请求的上下文:用户消息、历史会话等。ToolProviderResult 则是你返回的工具列表——每次调用都可以不一样。
这就是我们要利用的核心接口。
先来一个最简单的动态工具 provider,根据用户上下文返回不同工具:
@Component
public class DynamicToolProvider implements ToolProvider {
@Autowired
private PermissionService permissionService;
@Autowired
private ToolRegistry toolRegistry;
@Override
public ToolProviderResult provideTools(ToolProviderRequest request) {
// 从请求上下文中提取用户信息
String userId = extractUserId(request);
// 根据用户权限获取可用工具列表
Set<String> allowedTools = permissionService.getAllowedTools(userId);
// 构建工具列表
List<ToolSpecification> specs = new ArrayList<>();
Map<ToolSpecification, ToolExecutor> executors = new HashMap<>();
for (String toolName : allowedTools) {
ToolDefinition def = toolRegistry.getDefinition(toolName);
if (def != null && def.isEnabled()) {
specs.add(def.getSpecification());
executors.put(def.getSpecification(), def.getExecutor());
}
}
return ToolProviderResult.builder()
.tools(executors)
.build();
}
private String extractUserId(ToolProviderRequest request) {
// 实际项目里通常从 ThreadLocal 或请求头里取
return UserContext.getCurrentUserId();
}
}工具注册中心的设计
动态工具的核心是有一个工具注册中心,在运行时管理所有可用工具的元信息。
@Component
public class ToolRegistry {
private final ConcurrentHashMap<String, ToolDefinition> registry = new ConcurrentHashMap<>();
/**
* 注册工具
*/
public void register(String name, ToolDefinition definition) {
registry.put(name, definition);
log.info("工具注册成功: {}, 描述: {}", name, definition.getDescription());
}
/**
* 注销工具
*/
public void unregister(String name) {
ToolDefinition removed = registry.remove(name);
if (removed != null) {
log.info("工具注销: {}", name);
}
}
/**
* 启用/禁用工具
*/
public void setEnabled(String name, boolean enabled) {
ToolDefinition def = registry.get(name);
if (def != null) {
def.setEnabled(enabled);
}
}
public ToolDefinition getDefinition(String name) {
return registry.get(name);
}
public Collection<ToolDefinition> getAllDefinitions() {
return registry.values();
}
}ToolDefinition 是工具的完整描述,包含 specification(给 LLM 看的 JSON Schema)和 executor(实际执行逻辑):
@Data
@Builder
public class ToolDefinition {
private String name;
private String description;
private boolean enabled;
private ToolSpecification specification;
private ToolExecutor executor;
private Set<String> requiredPermissions;
private LocalTime availableFrom;
private LocalTime availableTo;
/**
* 判断工具当前是否可用(考虑时间窗口)
*/
public boolean isCurrentlyAvailable() {
if (!enabled) return false;
if (availableFrom != null && availableTo != null) {
LocalTime now = LocalTime.now();
return now.isAfter(availableFrom) && now.isBefore(availableTo);
}
return true;
}
}运行时工具发现:从注解扫描到动态注册
实际项目中,工具往往分散在各个 Service 里,手动构建 ToolSpecification 太繁琐。我们可以保留 @Tool 注解,但把扫描过程改为在运行时按需执行,而不是在构建 AiService 时一次性完成。
@Component
public class AnnotationBasedToolScanner {
@Autowired
private ApplicationContext applicationContext;
@Autowired
private ToolRegistry toolRegistry;
/**
* 扫描指定 Bean 上的 @Tool 方法,动态注册到 ToolRegistry
*/
public void scanAndRegister(Object toolBean) {
Class<?> clazz = AopUtils.getTargetClass(toolBean);
for (Method method : clazz.getDeclaredMethods()) {
Tool toolAnnotation = method.getAnnotation(Tool.class);
if (toolAnnotation == null) continue;
String toolName = method.getName();
String description = toolAnnotation.value().isEmpty()
? toolAnnotation.name()
: toolAnnotation.value();
// 构建参数 Schema
JsonObjectSchema parameters = buildParameterSchema(method);
ToolSpecification spec = ToolSpecification.builder()
.name(toolName)
.description(description)
.parameters(parameters)
.build();
// 构建执行器
ToolExecutor executor = buildExecutor(toolBean, method);
// 提取权限注解
RequiresPermission permAnnotation = method.getAnnotation(RequiresPermission.class);
Set<String> requiredPerms = permAnnotation != null
? new HashSet<>(Arrays.asList(permAnnotation.value()))
: Collections.emptySet();
ToolDefinition definition = ToolDefinition.builder()
.name(toolName)
.description(description)
.enabled(true)
.specification(spec)
.executor(executor)
.requiredPermissions(requiredPerms)
.build();
toolRegistry.register(toolName, definition);
}
}
private JsonObjectSchema buildParameterSchema(Method method) {
Map<String, JsonSchemaElement> properties = new LinkedHashMap<>();
List<String> required = new ArrayList<>();
Parameter[] parameters = method.getParameters();
for (Parameter param : parameters) {
P pAnnotation = param.getAnnotation(P.class);
String paramDesc = pAnnotation != null ? pAnnotation.value() : param.getName();
JsonSchemaElement schema = typeToJsonSchema(param.getType());
properties.put(param.getName(), schema);
if (!param.getType().equals(Optional.class)) {
required.add(param.getName());
}
}
return JsonObjectSchema.builder()
.properties(properties)
.required(required)
.build();
}
private JsonSchemaElement typeToJsonSchema(Class<?> type) {
if (type == String.class) {
return JsonStringSchema.builder().build();
} else if (type == Integer.class || type == int.class
|| type == Long.class || type == long.class) {
return JsonIntegerSchema.builder().build();
} else if (type == Boolean.class || type == boolean.class) {
return JsonBooleanSchema.builder().build();
} else if (type == Double.class || type == double.class
|| type == Float.class || type == float.class) {
return JsonNumberSchema.builder().build();
}
return JsonStringSchema.builder().build();
}
private ToolExecutor buildExecutor(Object bean, Method method) {
return (toolExecutionRequest, memoryId) -> {
try {
// 解析参数
Object[] args = parseArguments(method, toolExecutionRequest.arguments());
method.setAccessible(true);
Object result = method.invoke(bean, args);
return result != null ? result.toString() : "执行成功,无返回值";
} catch (Exception e) {
log.error("工具执行异常: {}", method.getName(), e);
return "工具执行失败: " + e.getMessage();
}
};
}
}工具热插拔:不重启服务动态增删工具
这是最有意思的部分——在服务运行期间,通过 API 来增加或移除工具。
@RestController
@RequestMapping("/admin/tools")
public class ToolManagementController {
@Autowired
private ToolRegistry toolRegistry;
@Autowired
private AnnotationBasedToolScanner scanner;
@Autowired
private ApplicationContext applicationContext;
/**
* 注册新工具(通过 Bean 名称)
*/
@PostMapping("/register/{beanName}")
public ResponseEntity<String> registerTool(@PathVariable String beanName) {
try {
Object bean = applicationContext.getBean(beanName);
scanner.scanAndRegister(bean);
return ResponseEntity.ok("工具注册成功: " + beanName);
} catch (BeansException e) {
return ResponseEntity.badRequest().body("Bean 不存在: " + beanName);
}
}
/**
* 禁用工具
*/
@PostMapping("/{toolName}/disable")
public ResponseEntity<String> disableTool(@PathVariable String toolName) {
toolRegistry.setEnabled(toolName, false);
return ResponseEntity.ok("工具已禁用: " + toolName);
}
/**
* 启用工具
*/
@PostMapping("/{toolName}/enable")
public ResponseEntity<String> enableTool(@PathVariable String toolName) {
toolRegistry.setEnabled(toolName, true);
return ResponseEntity.ok("工具已启用: " + toolName);
}
/**
* 查看当前所有工具
*/
@GetMapping
public List<ToolInfoVO> listTools() {
return toolRegistry.getAllDefinitions().stream()
.map(def -> ToolInfoVO.builder()
.name(def.getName())
.description(def.getDescription())
.enabled(def.isEnabled())
.available(def.isCurrentlyAvailable())
.requiredPermissions(def.getRequiredPermissions())
.build())
.collect(Collectors.toList());
}
}多租户场景下的工具隔离
多租户是动态工具最典型的应用场景之一。不同租户有不同的工具集,甚至同一个工具在不同租户里的实现逻辑也不同。
@Component
public class TenantAwareToolProvider implements ToolProvider {
@Autowired
private TenantToolConfigRepository configRepo;
@Autowired
private ToolRegistry globalRegistry;
// 每个租户的工具扩展注册中心
private final ConcurrentHashMap<String, ToolRegistry> tenantRegistries
= new ConcurrentHashMap<>();
@Override
public ToolProviderResult provideTools(ToolProviderRequest request) {
String tenantId = TenantContext.getCurrentTenantId();
// 获取租户配置的工具列表
TenantToolConfig config = configRepo.findByTenantId(tenantId);
Map<ToolSpecification, ToolExecutor> tools = new HashMap<>();
// 先加载全局工具
for (String toolName : config.getGlobalTools()) {
ToolDefinition def = globalRegistry.getDefinition(toolName);
if (def != null && def.isCurrentlyAvailable()) {
tools.put(def.getSpecification(), def.getExecutor());
}
}
// 再加载租户私有工具(会覆盖同名全局工具,实现租户级定制)
ToolRegistry tenantRegistry = tenantRegistries.get(tenantId);
if (tenantRegistry != null) {
for (String toolName : config.getTenantPrivateTools()) {
ToolDefinition def = tenantRegistry.getDefinition(toolName);
if (def != null && def.isCurrentlyAvailable()) {
tools.put(def.getSpecification(), def.getExecutor());
}
}
}
return ToolProviderResult.builder()
.tools(tools)
.build();
}
/**
* 为租户注册私有工具
*/
public void registerTenantTool(String tenantId, ToolDefinition definition) {
tenantRegistries.computeIfAbsent(tenantId, k -> new ToolRegistry())
.register(definition.getName(), definition);
}
}工具发现的流程可视化
踩坑记录:我在生产上遇到的几个问题
坑1:工具名冲突
当多个 Bean 里有同名的 @Tool 方法时,后注册的会覆盖先注册的,但覆盖过程是静默的,没有任何警告。我在一次上线后发现有个工具执行的是另一个 Bean 的逻辑,排查了很久。
解决方案:在注册时加命名空间,比如 BeanName.methodName,或者加注册冲突检测:
public void register(String name, ToolDefinition definition) {
if (registry.containsKey(name)) {
log.warn("工具名称冲突!{} 已存在,将被覆盖。旧工具: {}, 新工具: {}",
name,
registry.get(name).getDescription(),
definition.getDescription());
}
registry.put(name, definition);
}坑2:ToolExecutor 的线程安全
ToolExecutor 是函数式接口,用 Lambda 实现时要注意闭包里的变量是否线程安全。我之前有个工具用了一个实例变量做计数,在并发场景下计数混乱。
所有状态要么是无状态的,要么用 ThreadLocal,要么用线程安全容器。
坑3:工具描述写得太短
这不是代码问题,是 prompt 工程问题。工具描述太简短,LLM 在有多个相似工具时经常选错。比如我有两个工具:queryOrder(查询订单详情)和 searchOrders(按条件搜索订单列表),描述如果就写"查询订单"和"搜索订单",LLM 经常搞混。
解决方案:在描述里加使用场景说明:
@Tool("查询单个订单的详细信息。当用户提供了具体订单号、需要查看订单状态或物流信息时使用。不适用于查询订单列表。")
public OrderDetail queryOrder(@P("订单号,格式为 ORD-XXXXXX") String orderId) {
// ...
}
@Tool("按条件搜索订单列表。当用户想查询某段时间内的订单、按状态筛选订单、或列出所有订单时使用。返回多个订单的摘要信息。")
public List<OrderSummary> searchOrders(
@P("开始日期,格式 yyyy-MM-dd,可为空") String startDate,
@P("结束日期,格式 yyyy-MM-dd,可为空") String endDate,
@P("订单状态:PENDING/PAID/SHIPPED/COMPLETED,可为空") String status) {
// ...
}坑4:工具数量不是越多越好
有次我们把 30 多个工具全塞给 LLM,结果响应质量明显下降,LLM 在选工具时犹豫,有时候会串用。
后来把工具集控制在每次请求最多 10-15 个,通过动态 provider 按场景过滤,效果好多了。这也是动态工具注册最核心的价值——不是所有工具都适合每次都出现。
完整的 AiService 配置示例
把上面所有东西串起来,看一个完整的配置:
@Configuration
public class AiServiceConfig {
@Autowired
private TenantAwareToolProvider toolProvider;
@Bean
public ChatAssistant chatAssistant(ChatLanguageModel model) {
return AiServices.builder(ChatAssistant.class)
.chatLanguageModel(model)
.toolProvider(toolProvider) // 使用动态 tool provider
.chatMemoryProvider(memoryId -> MessageWindowChatMemory.withMaxMessages(20))
.build();
}
}
// AI 服务接口定义
public interface ChatAssistant {
@SystemMessage("你是一个智能助手,请根据用户需求调用合适的工具完成任务。")
String chat(@MemoryId String sessionId, @UserMessage String message);
}小结
动态工具注册这个特性,用好了能让 AI 应用在权限控制、多租户、时间窗口等复杂场景下灵活运转。核心思路就三点:
- 用
ToolProvider接口代替静态工具列表,每次请求按上下文动态决定工具集 - 维护一个集中的
ToolRegistry,支持运行时增删启停 - 工具描述要写得有辨识度,适当控制每次请求的工具数量
下一篇我们聊 LangChain4j 的内存模块深度定制,同样是进阶向的内容。
