☰
LangChain4j 动态工具实战:用 ToolProvider 把 @Tool 从满屏注解里解放出来
2026/9/29 9:30:24 网站建设 项目流程

1. 从 Demo 到生产:满屏 @Tool 是怎么把项目拖垮的

如果你写过 LangChain4j 的工具调用,大概率经历过这个阶段:为了让大模型“什么都能干”,把订单查询、库存扣减、退款申请、优惠券发放、物流跟踪、发票下载……十几个 Service 方法统统打上@Tool注解,然后一股脑塞进AiServices。本地跑起来那一刻确实爽,模型像个全能助理,问什么答什么。

但上线两周后问题就来了。我试过在一个客服 Agent 里挂了 28 个工具,结果单次请求的 Token 从 800 涨到 4200,响应时间从 1.2 秒变成 4.5 秒,更离谱的是模型开始“乱点鸳鸯谱”——用户问“我的订单到哪了”,它去调了退款接口。排查半天才发现,工具描述里“订单”两个字出现了 6 次,模型根本分不清哪个是查询哪个是写操作。

这就是静态@Tool的工程化天花板:工具一旦挂载,每次请求模型都能看见它,你无法根据用户角色、租户、对话阶段做任何收敛。工具列表本质上是权限边界的一部分,把它写死在注解里,等于把权限控制交给了提示词——而提示词是拦不住越权的。

LangChain4j 给出的解法是ToolProvider接口。它不是换种语法写@Tool,而是把“这次对话该给模型看哪些工具”变成一个运行时决策。你可以根据memoryId查用户角色、根据消息内容判断意图、根据租户 ID 加载专属 API,甚至把上百个低频工具丢进向量库做语义检索。下面我把这套方案拆成可复制的骨架,从依赖到验证一步步走。

2. 前置准备:TaoToken 接入与 LangChain4j 依赖对齐

在动手写ToolProvider之前,得先把模型通道打通。LangChain4j 本身只是编排框架,真正跑推理需要接一个兼容 OpenAI 协议的模型服务。我这边用 TaoToken 做统一入口,它的 API 地址是https://taotoken.net/api,兼容 OpenAI 的/v1/chat/completions格式,LangChain4j 的OpenAiChatModel可以直接指过去。

先确认你的pom.xml里 LangChain4j 版本。ToolProvider、ToolProviderResult、ReturnBehavior这些类在 0.35 之后的版本才稳定,建议用 0.36.x 或更高:

<dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j</artifactId> <version>0.36.2</version> </dependency> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-open-ai</artifactId> <version>0.36.2</version> </dependency>

模型配置这块,把baseUrl指向 TaoToken 的 API 地址,Key 从控制台生成。注意baseUrl结尾不要带/v1,LangChain4j 会自己拼路径:

ChatLanguageModel chatModel = OpenAiChatModel.builder() .baseUrl("https://taotoken.net/api") .apiKey(System.getenv("TAOTOKEN_API_KEY")) .modelName("gpt-4o-mini") .temperature(0.2) .build();

Key 的获取路径在 TaoToken 控制台的 API Keys 页面,生成后建议用环境变量注入,别硬编码进代码。如果你还没配好,可以先到模型对话页面验证一下通道是否通,确认能正常返回再往下走。

3. 可复制配置:ToolProvider 骨架与动态注册

ToolProvider是一个函数式接口,签名大致是ToolProviderResult provideTools(ToolProviderRequest request)。request里能拿到memoryId(会话标识)和userMessage(用户消息),这两个就是你做动态决策的全部依据。

下面这个骨架我抽成了独立 Bean,方便在多个 AiService 之间复用。核心思路是:先查上下文,再按权限和意图往 Builder 里塞工具,最后 build 返回。

import dev.langchain4j.agent.tool.ToolSpecification; import dev.langchain4j.agent.tool.ReturnBehavior; import dev.langchain4j.service.tool.ToolProvider; import dev.langchain4j.service.tool.ToolProviderResult; @Configuration public class DynamicToolConfig { @Bean public ToolProvider customerToolProvider(OrderService orderService, RefundService refundService, UserContextHolder contextHolder) { return request -> { // 1. 从 memoryId 还原当前用户上下文 UserContext user = contextHolder.get(request.memoryId()); ToolProviderResult.Builder builder = ToolProviderResult.builder(); // 2. 基础工具:所有登录用户都能查订单 ToolSpecification queryOrder = ToolSpecification.builder() .name("query_order") .description("根据订单号查询订单状态和物流信息") .build(); builder.add(queryOrder, (toolReq, memId) -> orderService.query(toolReq.arguments())); // 3. 权限工具:只有 VIP 才暴露极速退款 if (user != null && user.isVip()) { ToolSpecification vipRefund = ToolSpecification.builder() .name("vip_fast_refund") .description("为 VIP 用户提交极速退款申请,仅限已支付订单") .build(); builder.add(vipRefund, (toolReq, memId) -> refundService.applyVipRefund(toolReq.arguments())); } // 4. 阻断工具:凭证下载执行后直接返回,不让模型二次润色 if (request.userMessage().singleText().contains("下载凭证")) { ToolSpecification receipt = ToolSpecification.builder() .name("download_receipt") .description("生成订单凭证下载链接") .build(); builder.add(receipt, (toolReq, memId) -> orderService.generateDownloadLink(), ReturnBehavior.IMMEDIATE); } return builder.build(); }; } }

几个关键点值得展开。ToolProviderResult.Builder.add()有三个重载:只传规格、传规格加执行器、传规格加执行器再加ReturnBehavior。ReturnBehavior.IMMEDIATE是省钱利器——工具执行完直接把结果返回给前端,跳过大模型的二次处理。像下载链接、结构化 JSON、敏感数据这类场景,让模型“润色”纯属浪费 Token 还容易泄露。

UserContextHolder是我自己写的一个基于ConcurrentHashMap的会话上下文容器,在用户登录时把UserContext按memoryId存进去。你也可以换成 Redis 或 ThreadLocal,看你的会话管理方案。

4. 组装 AiService:静态工具、动态 Provider 与工具检索的混合

光有ToolProvider还不够,真实项目里往往是“常驻工具 + 动态工具 + 海量低频工具”三者共存。LangChain4j 允许你在AiServices上同时挂.tools()、.toolProvider()和.toolSearchStrategy(),它们会合并成最终的可见工具集。

@Configuration public class AssistantConfig { @Bean public CustomerAssistant customerAssistant(ChatLanguageModel chatModel, ToolProvider customerToolProvider, StaticCoreTools staticCoreTools, EmbeddingModel embeddingModel, EmbeddingStore<TextSegment> toolStore) { // 向量检索策略:把上百个低频工具丢进向量库,模型按需语义搜索 ToolSearchStrategy searchStrategy = VectorToolSearchStrategy.builder() .embeddingModel(embeddingModel) .embeddingStore(toolStore) .build(); return AiServices.builder(CustomerAssistant.class) .chatModel(chatModel) // 常驻静态工具:查时间、汇率换算这类无副作用纯函数 .tools(staticCoreTools) // 动态工具:按用户权限和意图实时组装 .toolProvider(customerToolProvider) // 工具检索:解决工具基数过大的问题 .toolSearchStrategy(searchStrategy) .build(); } }

StaticCoreTools里放的是那种“永远该可见”的工具,比如获取当前时间、基础单位换算。这类工具用@Tool注解写最省事,没必要动态化。而VectorToolSearchStrategy解决的是另一个维度的问题:当你的企业有几百个微服务接口时,全量暴露不现实,框架会只给模型一个“寻找工具”的元工具,模型根据用户意图触发向量检索,从工具库里捞出最相关的几个再调用。

AI Service 接口本身保持干净,不需要任何工具相关注解:

@AiService public interface CustomerAssistant { @SystemMessage("你是专业客服助理。凭证下载类请求直接调用工具返回,不要改写结果。") String chat(@MemoryId String userId, @UserMessage String message); }

5. 验证请求:从日志确认工具是否按预期收敛

配置写完不能直接信,得验证工具列表真的随上下文变化了。最直接的办法是打开 LangChain4j 的请求日志,在application.yml里把日志级别调到 DEBUG:

logging: level: dev.langchain4j: DEBUG

然后写一个测试用例,分别用普通用户和 VIP 用户的memoryId发起请求,观察日志里tools字段的差异:

@SpringBootTest class ToolProviderTest { @Autowired private CustomerAssistant assistant; @Test void normalUserShouldNotSeeVipRefund() { String reply = assistant.chat("user_normal_001", "帮我查下订单 20241120001"); System.out.println(reply); } @Test void vipUserShouldSeeVipRefund() { String reply = assistant.chat("user_vip_888", "我要退款,订单 20241120002"); System.out.println(reply); } }

实测下来,普通用户的请求日志里工具列表只有query_order,VIP 用户会多出vip_fast_refund。如果日志里两个用户看到的工具一样,说明UserContextHolder没取到值,检查memoryId是否在登录时正确写入。

再验证ReturnBehavior.IMMEDIATE的效果。发一条包含“下载凭证”的消息,观察响应时间——正常工具调用会经历“模型决策→执行→结果回传模型→模型生成回复”两轮推理,而 IMMEDIATE 工具只有一轮。日志里如果看到工具执行后直接返回、没有第二次chat请求,就说明阻断生效了。

6. 本篇常见错排查

报错一:NoClassDefFoundError: dev/langchain4j/service/tool/ToolProvider

这是版本没对齐。ToolProvider在 0.35 之前叫别的名字,升级到 0.36.2 以上即可。如果你用的是 Spring Boot Starter 方式引入,注意langchain4j-spring-boot-starter的版本要和核心包一致,别一个 0.35 一个 0.36。

报错二:工具被调用但参数是空的

ToolSpecification只写了name和description,没定义参数 Schema。模型不知道要传什么参数,就会传空对象。正确做法是用.parameters(JsonSchema...)声明参数结构,或者干脆用ToolSpecifications.toolSpecificationFrom(Method)从方法反射生成。手写 Schema 容易漏字段,建议优先用反射方式。

报错三:VIP 用户也看不到vip_fast_refund

先确认UserContextHolder.get(memoryId)返回的不是 null。常见原因是登录时写入用的memoryId和chat()传入的不一致——比如登录用userId,聊天用sessionId。统一用一个标识,或者在UserContextHolder里做一层映射。

报错四:VectorToolSearchStrategy检索不到工具

检查EmbeddingStore里是否真的存了工具描述。工具检索依赖预先向量化,你得在应用启动时把工具的名称和描述 embed 进去。如果 store 是空的,模型搜什么都是空结果。另外 embedding 模型和检索时用的模型必须是同一个,否则向量空间对不上。

报错五:IMMEDIATE 工具执行后模型还是回复了

确认ReturnBehavior.IMMEDIATE是加在builder.add()的第三个参数上,而不是加在ToolSpecification上。这个行为是执行器级别的,不是规格级别的。加错位置不会报错,但也不生效。

7. 下一步:把工具治理当成架构问题

走到这里,你的 Agent 应该已经能做到“问订单只给订单工具、VIP 才见退款、凭证下载不绕模型”了。但工具治理不止于此。当工具数量继续膨胀,你需要考虑的是:工具描述怎么版本化?不同租户的工具库怎么隔离?工具调用失败后的降级策略是什么?

我的建议是把ToolProvider当成一个“工具网关”来设计,它不只是返回工具列表,还可以做调用埋点、限流、审计。每次provideTools被调用时记一条日志,你就能知道哪个用户在哪次对话里看到了哪些工具、调用了哪个、耗时多少。这些数据反过来能帮你优化工具描述和权限策略。

如果你还在用满屏@Tool硬扛,不妨从下一个新功能开始,把它写成ToolProvider里的一个分支。迁移不用一步到位,新旧共存完全没问题。等你看到日志里工具列表随用户角色动态变化的那一刻,就会明白为什么说“工具列表就是权限边界”。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询