LangChain4j工具调用实战:让Java方法成为大模型的双手

发布时间:2026/10/18 23:22:12
LangChain4j工具调用实战:让Java方法成为大模型的双手
写这篇东西的起因是我在维护一个 Java 后端服务时接到一个很典型的需求用户问“我这个订单现在到哪了”系统要能根据订单号去查数据库再把真实状态回复给用户。直接拿大模型答肯定不行它根本看不到数据库。有人建议把查询结果拼进提示词里再发给模型我试过短期看起来能用但换个需求又要重写一套逻辑不是长久之计。真正稳定、通用的做法是让大模型自己决定调用哪个 Java 方法拿到真实结果后再组织语言回复。这个思路就是 LangChain4j 工具调用Tool Calling / Function Calling也是这篇文章要聊透的东西。我会把工具调用的原理、完整流程、参数映射、多工具编排和实战中的坑都拆开讲。适合正在用 Java 做 AI 应用、想把大模型接入 Spring Boot 项目的同学尤其是第一次接触工具调用、想快速落地的开发者。1. 先说清楚工具调用解决的是什么问题1.1 模型会“说”但不会“做”大模型本质上是一个文本生成模型它的强项是根据上下文预测接下来最合适的 token而不是感知外部世界。你问它“今天是几号”它能从训练数据里学到日期格式但训练数据是有截止时间的它没有能力直接读取服务器时钟。你问它“我订单到哪了”它更不知道因为订单数据在你们自己的数据库里模型连连接方式都不知道。生活化类比大模型像一个坐在办公室里、知识储备很丰富的客服但手边没有档案柜也没有电话。你问它档案里的内容它只能凭记忆和经验猜。工具调用就是给这个客服配一个“查档案”的流程它有权限发起查询但真正跑去档案室翻文件、把结果抄回来的人是你们自己的 Java 代码。所以工具调用要解决的核心问题就一句话让模型在“需要事实数据或外部系统协作”的时候能够请求执行一个你预先定义好的方法而不是凭空编造。1.2 为什么不把数据直接塞进提示词很多刚接触大模型应用的人会想到一个“朴素”的方案写一段代码定时查数据把结果格式化后塞进 prompt再让模型基于这段数据回答。这个方案在演示阶段完全没问题但进入真实业务后你会发现它很僵硬。第一数据塞进去是静态的模型不会主动决定“现在需要另外一个数据”。用户继续追问一个 prompt 之外的信息模型只能再次被重新调用、重新拼接。第二种更关键的问题是“业务维度组合”当用户问“这个订单的物流状态和优惠明细都给我看一下”你需要在提示词里同时塞物流数据、订单数据、优惠数据那数据从哪来你得写一堆 if-else 甚至提前查好所有可能用到的接口。这跟“让模型自己决定调用哪个工具”相比维护成本天差地别。工具调用的价值就在于把决策权交给模型它读懂了用户的问题判断需要哪些信息再按需请求执行对应方法。你的 Java 方法只管被调用不用管用户到底问什么。1.3 为什么 Java 项目要用 LangChain4jJava 生态里调大模型最原始的做法是自己封装 HTTP 请求、处理 SSE 流、解析 tool_calls、再把执行结果回传逻辑不难但非常繁琐。尤其是工具调用模型返回的是一段 JSON你要把 JSON 转成方法调用再把结果塞回上下文中继续跑一轮自己撸一遍能写几百行。LangChain4j 把这些流程都封装好了。它对 Java 开发者非常友好可以和 Spring Boot 无缝集成工具类的定义、参数映射、会话记忆、多工具的编排都有现成的抽象。我个人的观点是Java 团队想要快速落地大模型能力LangChain4j 是最顺手的路线。2. 5 分钟跑通第一个工具调用2.1 依赖怎么加用 Maven 管理先加核心依赖。这里只放最基础的核心包模型服务商的接入模块是独立坐标。实际接的时候你需要根据自己用的模型服务商选择对应模块通常服务商都会提供 Maven 坐标和示例或者走统一兼容协议也可以。dependency groupIddev.langchain4j/groupId artifactIdlangchain4j/artifactId version1.0.0-beta2/version /dependency2.2 模型配置与基础验证LangChain4j 里一切对话能力都抽象成ChatLanguageModel。构建方式一般都是 Builder 模式具体 Builder 类名取决于你用的接入模块但参数结构大同小异接口地址、API Key、模型名称。有一点很关键如果你的模型同时要被工具调用使用temperature建议直接设为 0。ChatLanguageModel model ChatModelProvider.builder() .baseUrl(https://your-llm-gateway.example.com/v1) .apiKey(your-api-key) .modelName(your-tool-capable-model) .temperature(0.0) .build();配置好之后可以先做一个最简单的问答验证能正常回复再继续后面的工具调用。这样可以确保后面出的问题不是因为模型接入有问题。2.3 把普通 Java 方法变成工具在 LangChain4j 中给一个普通 Java 方法加上Tool注解这个方法就能被模型看到并调用。我们做一个最简单的工具获取服务器当前时间。import dev.langchain4j.agent.tool.Tool; public class TimeTools { Tool(获取服务器当前时间) public String currentTime() { return LocalDateTime.now().toString(); } }注意这个方法没有任何特殊继承关系不需要实现接口也不需要改造成 Web 服务。LangChain4j 会通过反射识别Tool注解把这个方法的名称、描述、参数列表都提取出来构造出模型的工具描述。这里有个很重要的认知模型看不到你的方法体它只知道方法名、方法描述、参数说明。所以Tool注解里的描述文字就是模型判断“什么时候该用这个工具”的唯一依据。写得太简单模型也会困惑。2.4 注册工具并完成首次对话真正把模型和工具绑定起来需要一个服务接口。你可以定义一个普通的 Java 接口接口方法只有一个chat(String userMessage)代表用户发来一条消息返回模型回复。interface TimeAssistant { String chat(String userMessage); }然后用AiServices把它和模型、工具组装起来。TimeTools tools new TimeTools(); TimeAssistant assistant AiServices.builder(TimeAssistant.class) .chatLanguageModel(model) .tools(tools) .build(); String answer assistant.chat(现在几点钟了); System.out.println(answer);执行之后模型大概率会走如下路径先返回一个“我需要调用 currentTime 工具”的请求LangChain4j 拿到请求后执行你的 Java 方法LocalDateTime.now()被调用拿到实际时间再传回给模型模型最后组织出一句自然语言回复。第一次跑通这个流程你就已经摸到工具调用的核心了。很多复杂应用不管后面加了几个工具、编排了几个 Agent底层都是这一套动作。3. 工具调用的底层过程与参数映射3.1 模型返回的“调用请求”长什么样前面说模型不会直接执行方法它是返回一个调用请求。在兼容协议中模型返回的消息体大概长这样{ choices: [ { message: { role: assistant, content: null, tool_calls: [ { type: function, function: { name: currentTime, arguments: {} } } ] } } ] }注意arguments是字符串不是什么复杂结构。模型不会真的把你的 Java 方法搞到自己进程里去执行它只是生成了一段 JSON 字符串说“我现在想调用叫 currentTime 的方法参数是空对象”。真正去执行你方法的是 LangChain4j。理解这一点后面排查问题就轻松很多尤其是参数问题。3.2 JSON 参数如何变成 Java 方法参数模型生成arguments之后框架需要将它反序列化成方法参数。如果工具方法只有String orderId、Integer count这种基础类型LangChain4j 直接做简单类型转换即可。但如果你的工具方法接收一个自定义对象那框架会把 JSON 字符串反序列化成那个 Java 对象。举个例子我想查某个日期区间的销售总额就定义一个DateRange参数对象public record DateRange(String startDate, String endDate) {} Tool(按日期区间统计销售总额) public BigDecimal getSalesAmount(DateRange range) { // 查询并返回金额 }当模型生成这段 JSON 参数时{ startDate: 2025-01-01, endDate: 2025-01-31 }框架会把它解析成DateRange(2025-01-01, 2025-01-31)再调用你的getSalesAmount方法。这种自定义对象参数在实际业务里非常有用因为一个工具往往需要传多个关联字段用对象承载比一堆散参数更清晰。这里也容易踩坑JSON 字段名和 Java 字段名如果对不上反序列化很可能失败。模型看到你的参数说明是startDate它通常就会生成startDate但如果你把字段起名叫from、to这种模糊的名字模型生成字段时偏差就大。参数命名越直白匹配越稳。3.3 工具结果如何回到模型手里工具方法返回的结果会被 LangChain4j 当作“工具执行结果”放回对话上下文然后继续让模型生成后续回复。也就是说整个对话过程从用户角度看起来是一次性返回但内部其实是多轮用户消息 -- 模型请求调用工具 -- 工具结果 -- 模型根据结果生成最终答案。这个机制决定了几个实践建议。第一工具结果会占用模型上下文空间所以工具方法最好只返回核心信息不要把整个数据库实体或者一大段日志直接返回。第二模型会组织语言把结果表达出来所以你不需要在工具里提前格式化好“您的订单正在运输中”这种话。工具返回“运输中”三个字模型自然会说成一句完整的话。第三如果工具返回结果有错模型也能感知并兜底。比如查询超时你返回“查询超时”模型就会跟用户解释“暂时查不到状态请稍后再试”整体体验比直接抛异常好得多。3.4 别忽略对话记忆默认情况下AiServices构建出来的服务是无状态的每次chat()调用都是新开一段对话模型并不知道上一轮问了什么。如果工具调用是为了支撑多轮业务必须启用会话记忆。Import dev.langchain4j.memory.chat.MessageWindowChatMemory; TimeAssistant assistant AiServices.builder(TimeAssistant.class) .chatLanguageModel(model) .tools(tools) .chatMemory(MessageWindowChatMemory.withMaxMessages(20)) .build();MessageWindowChatMemory.withMaxMessages(20)表示保留最近 20 条消息作为上下文。这里有个容易被忽略的点工具调用请求和工具结果也都是会占用记忆窗口的所以 20 条听起来多实际可能只够支撑三四轮业务问答具体窗口大小要根据你的真实链路来调整。4. 多工具并行与业务编排实战4.1 多工具场景模型自己选择实际项目里不太可能只有一个工具。当同一个工具类里存在多个Tool方法模型会读用户问题再根据每个工具的描述决定用哪个。举个例子订单服务里同时有查物流和查优惠两个工具public class OrderTools { Tool(查询订单的当前物流状态包括发货地、当前节点、预计送达时间) public String queryLogistics(String orderId) { return logisticsService.queryByOrderId(orderId); } Tool(查询订单的优惠金额和最终应付金额) public String queryOrderAmount(String orderId) { OrderAmount amount orderService.getAmount(orderId); return 优惠金额: amount.discount() ,应付金额: amount.payable(); } }用户如果问“我这个单 A10086 物流和金额都帮我看看”模型很可能一次返回两个tool_calls分别调用两个工具。LangChain4j 会逐个执行再把两个结果一起交回给它最终模型综合两个结果给出一个整体回复。这种设计下你的 Java 类只是方法的集合模型反而成了“路由层”。但路由层的判断依据就是工具描述所以每个描述都要清楚地写“什么时候用它、入参是什么意思、返回什么信息”。4.2 并行调用的执行细节并行调用虽然看起来是一次返回多个请求但实际执行层面要注意线程安全。因为多个tool_calls可能会在不同线程上并发执行你的工具方法。如果工具方法内部用到了共享的可变状态比如一个全局的currentUserId字段那一定会出问题。关于线程安全后面章节还会专门说。另一个细节是并行不等于无限制。模型到底并行还是串行发起工具调用很大程度上取决于协议和模型自身能力。如果你的模型服务商不支持并行 tool callsLangChain4j 会退化成逐个执行。这不影响结果只是耗时会长一点。4.3 需要链式调用时我建议封装而不是裸奔有时候一个业务需要连续调用多个工具比如先查库存再创建订单。你可以把这两个方法都暴露成工具让模型连续调用两次。但我实际做下来更推荐把完整的业务单位封装成一个工具Tool(根据商品ID和购买数量下单返回订单号库存不足时返回明确提示) public String purchase(String productId, int quantity) { boolean inStock stockService.check(productId, quantity); if (!inStock) { return 库存不足当前剩余库存为 stockService.remaining(productId); } return orderService.create(productId, quantity); }为什么因为模型在一次对话里连续调用多个工具的次数越多链路越长参数可能被传偏、结果可能被错误拼接的概率就越大。与其让模型扮演“编排者”去组合多个工具不如自己在代码里把一个业务闭环内的逻辑封装好模型只需要触发一次。这算是我踩过坑之后最想提醒你的一条工具粒度不是越小越好而是“能被模型理解、且能独立完成一个业务动作”最合适。查库存和下单虽然是两步但对用户来说就是“购买”这个动作封装成一个工具后事务边界、异常处理也都更好控制。4.4 限制迭代次数避免循环调用工具调用最让人头疼的问题之一是模型可能会陷入一种循环里连续调用同一个工具好几次就是不生成最终回复。比如它查了一次库存觉得不放心又查一次还查一次。LangChain4j 提供了maxIterations参数来限制一轮对话里整体工具调用的最大轮数。TimeAssistant assistant AiServices.builder(TimeAssistant.class) .chatLanguageModel(model) .tools(orderTools) .chatMemory(MessageWindowChatMemory.withMaxMessages(20)) .maxIterations(5) .build();当迭代次数超过限制框架会停止继续调用工具直接把当前已有内容作为结果返回。实际项目里查资料类工具我给 5~8 次涉及下单支付的场景我会给到 3 次以内。别小看这个参数线上出现过模型反复调用工具导致调用方超时的案例限制迭代次数是最直接的兜底。5. 现场排查常见的几个坑和解决思路5.1 参数值传错枚举是最有效的约束模型从自然语言里提取参数不可能 100% 准确。最典型的情况是你的方法希望接收PAID代表已支付但模型看到用户说“已经付过钱了”就把参数生成了已支付。然后反序列化到 String 类型时不会报错你的业务代码拿着已支付去查数据库必然查不到。解决办法有几个。第一在工具描述里明确写出可选的取值集合第二更稳妥的是直接在方法参数里使用枚举类型public enum PaymentStatus { PAID, UNPAID, CLOSED } Tool(根据支付状态查询订单列表) public ListOrder listOrders(PaymentStatus status) { // 枚举直接透传到查询 }模型看到这个枚举类型会更可能生成PAID这种合法值。当然也存在模型生成非法值导致反序列化失败的情况所以工具方法内部最好再做一次参数兜底。反正不要相信模型传来的任何参数把它当成外部入参来校验。5.2 工具结果太大上下文被撑爆工具返回结果会作为消息放入上下文这意味着它要占 token。假设一个查询数据库的工具返回了 100 行订单明细每行十几字段模型还没开始回答上下文就已经消费掉一大片 token费用高不说模型也容易抓不住重点。所以在写工具方法时结果应该做一次“面向回答的精简”只返回模型组织答案所需的字段而不是把整个实体对象丢回去。比如返回“共 3 笔待发货订单最近一笔单号 A10086”远胜于返回三整行冗长记录。5.3 异常信息怎么处理才安全工具方法在执行过程中抛异常LangChain4j 通常会把异常信息传递给模型模型能据此向用户解释错误。但这不代表可以把原始堆栈直接返回。堆栈信息里可能包含数据库连接串、文件路径、内网地址等敏感信息让模型读一次就等于让它复述一次风险。我习惯在工具方法内部做 try-catch把异常翻译成模型能理解的通用信息Tool(根据订单号查询订单状态) public String queryOrder(String orderId) { try { return orderService.query(orderId); } catch (Exception e) { return 订单查询服务暂时不可用请稍后重试; } }注意这里返回的是一段“结果”不是抛出的异常。模型会把“订单查询服务暂时不可用”理解为一个已发生的事实然后面向用户组织出友好话术。整个过程没有任何堆栈泄漏到上下文里。5.4 并发环境下工具实例要无状态如果项目是 Spring Boot你很可能把工具类直接声明成单例 Bean 注入到服务里。这时候要非常小心多个用户会话可能共享同一个工具实例工具方法里绝对不能保存“当前用户”“当前订单”这类请求级别的状态。正确做法是用户标识、会话标识尽量从外部传入或者使用请求上下文中线程安全的方案。工具方法本身应该是无状态的、线程安全的它只负责按入参执行逻辑并返回结果。一旦在工具方法里搞一个共享字段存放用户上下文并发一上来就是事故。6. 让工具调用更可靠从命名到监控6.1 工具描述要面向用户的问法模型选择工具依赖的是语义匹配。方法名只是标识真正影响选择的是Tool注解里的描述。描述写得越贴近用户的自然语言问法选中率越高。对比两个描述弱描述查询订单状态强描述当用户询问订单物流、发货进度、快递位置、预计送达时间时使用此工具入参orderId是订单号同样是查询订单第二条明显更容易被模型命中。我在实践中发现描述里加“适用场景”比加“做什么”更重要。因为模型是通过用户的话来匹配工具的描述里包含用户可能的问法匹配自然更准。6.2 返回结构化结果而不是替换模型刚开始做工具调用时我犯过一个错误把工具方法里的返回写得非常完整比如“您的订单 A10086 当前正在运输中预计明天送达”。结果模型拿到这段文字后基本原样复述不会基于它做什么整合。这样做工具调用就显得没意义。后来我改成返回结构化短字段状态:运输中,预计送达:明天,当前节点:XX转运中心。模型拿到这些信息会以自己的话组织出完整回复同时也能在用户追问细节时展示更多信息。所以工具返回的不是最终话术而是支撑话术的“素材”。6.3 给每次调用留下日志排查工具调用问题最痛苦的就是不知道模型到底调了什么、传了什么参数、返回了什么结果。LangChain4j 提供了工具执行监听机制可以非常方便地记录每一次调用的关键信息。不同版本 Listener 接口签名略有差异但核心回调思路是一致的。public class LoggingToolListener implements ToolExecutionListener { Override public void onToolExecuted(ToolExecutionRequest request, Object tool, Object result, Duration executionDuration) { System.out.println(工具名称: request.name()); System.out.println(调用参数: request.arguments()); System.out.println(返回结果: result); System.out.println(耗时: executionDuration.toMillis() ms); } }把这个监听器注册到AiServices.builder()上每次工具执行都有迹可循。亲测下来参数传错、模型乱调工具这类问题基本看一眼日志就能定位。没有日志纯靠模型最终回复去反推会非常痛苦。6.4 工具方法内部的权限判断不能省永远记住一句话模型不负责权限。它只负责根据对话内容判断“要不要调用某个工具”而“当前用户有没有权限调、参数是否越权”必须由工具方法自己做最终校验。比如有个工具是删除订单模型可能误以为用户有权限就发起了调用。如果你的工具方法直接执行删除就是安全事故。所以凡是涉及删除、下单、转账、改配置的工具方法内部必须校验当前用户身份、权限、参数合法性然后再执行。工具方法的入参可能来自模型但安全边界永远在业务代码里头。7. 可以继续扩展的方向7.1 向智能体方向演进当工具数量越来越多多工具组合越来越复杂你的服务本质上已经是一个小的智能体了。LangChain4j 提供了会话记忆、工具管理、模型选择等封装能力直接支持向 Agent 方向演进。你可以把订单查询、售后入口、优惠计算等一批工具都注册到同一个Assistant接口上模型就能跨工具调度。这种架构比一个个 if-else 写在 Controller 里清晰太多新增能力只需要新增工具方法不需要动上层路由逻辑。7.2 工具开关与会话级配置不是所有工具都要在所有人面前开放。通过读取配置决定注册哪些工具可以大幅降低模型乱调用的风险。比如给每个工具加一个开关位灰度期间只注册查询类工具注册写入类工具则等到测试成熟后再放开。LangChain4j 支持通过AiServices.builder()动态注入工具实例所以你可以基于配置组装不同的工具集合。这个思路在对外提供 AI 服务的生产项目里非常实用。7.3 让模型参与业务流程编排工具调用再往前走一步就是让模型不只是“回答问题”而是参与业务流程的编排。比如在工单系统里模型判断用户的诉求后自动选择一个工具创单、再选一个工具通知相应人员最终把处理结果反馈给用户。这个过程用户感知到的是一次智能交互背后是一个个 Java 工具方法在按逻辑执行。这类能力用来做内部效率工具、客服助手、运营后台场景很顺手。建议你先从一个查询类工具跑通链路再加会话记忆再做多工具编排一步步扩展。别一上来就套特别复杂的框架把基础原理吃透了后面就不容易偏。说实话工具调用不是那种“理解了概念就能写好”的技术坑都在细节里比如参数枚举、上下文大小、异常翻译、并发安全。我踩过不少次模型乱选工具、参数传错导致反序列化失败的坑最后发现几乎都是描述和参数设计的问题。只要把每个工具描述都当作用户需求说明书来写把工具粒度控制在能独立完成一个业务闭环的范围内LangChain4j 这套用起来确实稳。它核心不复杂一句话总结就是让模型做决策让 Java 代码做执行。如果你们团队正好在做 Java 后端的大模型应用建议从今天这个最小工具开始跑通之后再往复杂业务扩展会顺很多。