LangChain4j 工具调用:Function Calling 让模型动手查数据,多工具连调实测

LangChain4j 工具调用:Function Calling 让模型动手查数据,多工具连调实测

作者:鱼宵 | LangChain4j实战精通营 · 第 6 篇

上周帮朋友排查一个客服机器人,问它"订单 1001 发货了没",它张口就来:"已发货,物流单号 SF88881234,预计明天送达。"我去后台一查,这订单压根不存在,单号是它现编的。这种"一本正经地胡说八道",就是做 AI 应用最怕的幻觉。

怎么治?给模型装一双"能查真实数据的手"------这就是 Function Calling(工具调用)。这篇带你跑通:一个问题同时问订单和库存,模型自己决定调哪个方法、自己抽参数、连调两次,再把内存表里的真实数据组织成人话。代码全在仓库 lesson-06/,clone 下来照着命令重跑,你就能在控制台亲眼看到那行 【工具被框架调用】。

一、核心原理:模型"只会动嘴,不会动手"

一句话:大模型是个被关在房间里的实习生------知识渊博、口才极好,但房间里没有电话、没有电脑、没有数据库钥匙。你问他订单到哪了,他不是不会查,是他根本没手。

1. 为什么模型需要工具

LLM 的本质是"文字接龙":给它一段上文,它预测最可能接的下一段文字。它跑在沙箱里,没有执行权------不能跑 SQL、不能发 HTTP、不能调你的 Java 方法。你问它"今天库存多少",它只能靠训练时记住的旧知识瞎猜。

Function Calling(工具调用) 就是给这个实习生塞一个对讲机:他不离开房间,但可以喊"帮我查一下订单 1001",你在外面查好,对着对讲机念给他听,他再转述给客户。

2. 五步链路:模型输出的是"意图",不是答案

很多人第一次听 Function Calling 会以为"模型能执行我的代码了"------错。真正执行代码的是你的 Java 框架,模型从头到尾只负责"说"。链路是这样的:

步骤 谁干的 干了什么
① 你提问 用户 "订单 1001 什么状态?库存还剩多少?"
② 模型输出意图 大模型 "我要调 getOrderStatus,参数 orderId=1001"(注意:它只是"说"要调,没真调)
③ 框架反射调方法 LangChain4j 真的去跑 CustomerTools.getOrderStatus("1001")
④ 结果回填 框架 把方法返回值塞回给模型,作为一条"工具消息"
⑤ 模型组织回答 大模型 拿着真实数据,说人话:"订单已发货,单号 SF..."

一个问题里要查两个东西?框架就把 ②③④ 循环跑两遍。你只调了一行 assistant.chat(...),背后可能是多次模型往返。这就是"多工具连调"------顺序是模型自己排的,不是你写死的 if-else。

3. @Tool 和 @P:贴给模型看的说明书

你把工具挂墙上,每个工具贴一张说明牌:"这是查订单的,要填纯数字单号,比如 1001"。模型决定用哪个工具时,读的就是这块牌子。

  • @Tool("..."):标在方法上,那句话会被翻译成 JSON Schema 发给模型。它是给模型读的,不是给人读的。
  • @P("..."):标在参数上,告诉模型 orderId 长什么样。没有它,模型可能把整句"订单 1001 的状态"当成 orderId 塞进去。

牌子写得越清楚,模型越不会乱按按钮、乱填参数。模型没调工具,八成就是 @Tool 描述太含糊。

4. AiServices:只写接口,不写实现

LangChain4j 最爽的一点:你定义一个 Java 接口,比如 CustomerAssistant,里面就一个 chat(String) 方法------不写实现类。运行时它用动态代理给你生成实现,自动跑完"调模型→识别工具意图→调工具→回填→再调模型"整个循环。类比成签岗位合同:你只管写清楚"这个人负责 chat",内部按几个按钮你不用管。

二、动手:编译 + 跑 fat jar

环境:Windows 11 + PowerShell 5.1 + JDK 17。这是控制台课,没有 HTTP 接口,跑完自动退出。

第 1 步:检查环境。

powershell 复制代码
java -version                              # 期望 True
[bool][Environment]::GetEnvironmentVariable('DEEPSEEK_API_KEY')        # 期望 True,只看存在别打印值

第 2 步:编译打 fat jar。

powershell 复制代码
cd langchain4j-journey\lesson-06
$env:JAVA_HOME="C:\Program Files\Java\jdk-17"     # 用户级 JAVA_HOME 指向 JDK8,每条新窗口都要重设
mvn clean install -DskipTests           # 结尾看到 BUILD SUCCESS

第 3 步:跑起来,真实调 DeepSeek。

powershell 复制代码
java -Dfile.encoding=UTF-8 -jar target\lesson-06-1.0.0.jar

重点盯控制台里 【工具被框架调用】 那几行------那就是模型自主调工具的铁证。中文不乱码,靠上面那个 -Dfile.encoding=UTF-8。

三、关键代码:四个文件,逐行拆

工程里真正要看的就是四个 Java 文件。模型配置 LlmConfig 和第 1 课同款(OpenAiChatModel + 从环境变量读 Key),这里不重复贴,重点看下面三个。

第一段:CustomerTools.java------两个被暴露给模型的工具方法。

java 复制代码
package com.langchain4j.lesson06;

import dev.langchain4j.agent.tool.P;
import dev.langchain4j.agent.tool.Tool;
import org.springframework.stereotype.Component;

import java.util.HashMap;
import java.util.Map;

/**
 * 业务工具类:一个普通 Java 类,用 @Tool 把方法"暴露"给大模型。
 * 模型不会执行代码,它只"说"要调哪个方法;真正执行的是框架。
 * 这里用内存 Map 模拟订单表/库存表,生产换成 MyBatis 查真实库。
 */
@Component
public class CustomerTools {

    /** 模拟订单表:订单编号 -> 订单状态 */
    private static final Map<String, String> ORDER_DB = new HashMap<>();
    /** 模拟库存表:商品名 -> 剩余件数 */
    private static final Map<String, Integer> STOCK_DB = new HashMap<>();

    static {
        ORDER_DB.put("1001", "已发货,物流单号 SF1234567890,预计明天送达");
        ORDER_DB.put("1002", "待付款,订单已提交但未支付");
        ORDER_DB.put("1003", "已完成,已签收并评价");
        ORDER_DB.put("1004", "已取消,用户主动取消");
        STOCK_DB.put("机械键盘", 35);
        STOCK_DB.put("无线鼠标", 120);
        STOCK_DB.put("显示器支架", 8);
        STOCK_DB.put("USB-C 数据线", 0);
    }

    /**
     * @Tool 里那句说明 = 写给模型看的工具说明书,会被转成 JSON Schema 发给模型。
     * @P 是写给参数的说明书,告诉模型 orderId 长什么样。
     */
    @Tool("根据订单编号查询订单当前状态,例如是否发货、物流信息、是否完成或取消")
    public String getOrderStatus(@P("订单编号,纯数字字符串,例如 1001、1002") String orderId) {
        // 这行打印就是"调用链记录仪":它真被执行了,说明框架真的调了这个方法
        System.out.println("   【工具被框架调用】getOrderStatus(orderId=" + orderId + ")");
        String status = ORDER_DB.get(orderId);
        if (status == null) {
            // 查不到也返回人话,模型会拿着它去跟用户说"查无此单",而不是抛异常
            System.out.println("   【工具返回】订单 " + orderId + " 不存在");
            return "未找到订单编号 " + orderId + ",请确认单号是否正确。";
        }
        System.out.println("   【工具返回】" + status);
        return "订单 " + orderId + " 的状态:" + status;
    }

    @Tool("根据商品名称查询当前库存还剩多少件")
    public String getStock(@P("商品名称,例如:机械键盘、无线鼠标") String productName) {
        System.out.println("   【工具被框架调用】getStock(productName=" + productName + ")");
        Integer stock = STOCK_DB.get(productName);
        if (stock == null) {
            System.out.println("   【工具返回】商品 " + productName + " 不存在");
            return "没有找到商品「" + productName + "」的库存记录。";
        }
        String result = stock == 0
                ? "商品「" + productName + "」当前缺货(库存 0 件),建议提醒用户补货。"
                : "商品「" + productName + "」当前库存剩余 " + stock + " 件。";
        System.out.println("   【工具返回】" + result);
        return result;
    }
}

盯三个细节:方法返回 String(返回值要回填给模型,别返回 void);查不到也 return 一句人话而不是抛异常;那行 System.out.println 是埋的记录仪,它打了就证明框架真进来了。

第二段:CustomerAssistant.java------一个接口就是一个助手。

java 复制代码
package com.langchain4j.lesson06;

/**
 * 客服助手接口:只定义,不写实现。
 * AiServices 运行时用动态代理生成实现类,把"调模型+调工具+回填"全封装掉。
 */
public interface CustomerAssistant {
    /** 给一句话,拿回一句自然语言回答;模型自己决定直接答还是先调工具 */
    String chat(String userMessage);
}

第三段:ToolCallDemoApplication.java------装配 + 两个演示。

java 复制代码
package com.langchain4j.lesson06;

import dev.langchain4j.model.chat.ChatModel;
import dev.langchain4j.service.AiServices;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

/**
 * 第 6 课启动类:控制台课,implements CommandLineRunner,run 完即退出。
 * 运行:java -Dfile.encoding=UTF-8 -jar target\lesson-06-1.0.0.jar
 */
@SpringBootApplication
public class ToolCallDemoApplication implements CommandLineRunner {

    private final ChatModel chatModel;       // 大模型客户端(LlmConfig 配好的 Bean)
    private final CustomerTools customerTools; // 业务工具(Spring 扫描成 Bean)

    public ToolCallDemoApplication(ChatModel chatModel, CustomerTools customerTools) {
        this.chatModel = chatModel;
        this.customerTools = customerTools;
    }

    public static void main(String[] args) {
        SpringApplication.run(ToolCallDemoApplication.class, args);
    }

    @Override
    public void run(String... args) {
        // 关键三行:模型(大脑)+ 工具类(双手)= 会自己调工具的助手
        CustomerAssistant assistant = AiServices.builder(CustomerAssistant.class)
                .chatModel(chatModel)     // 用哪个模型做大脑
                .tools(customerTools)     // 交出去工具类,模型就能看见里面所有 @Tool 方法
                .build();

        System.out.println("========== 第 6 课:工具调用 Function Calling ==========");

        // 演示一:一个问题同时问订单+库存,逼模型连续调两个工具
        String q1 = "订单 1001 现在是什么状态?另外机械键盘的库存还剩多少?";
        System.out.println("\n你问:" + q1);
        System.out.println("(下面会依次打印:框架调用了哪个工具、传了什么参数、返回了什么)");
        String a1 = assistant.chat(q1);
        System.out.println("AI 答:" + a1);
        System.out.println("---------- 演示一结束 ----------");

        // 演示二:闲聊问题,看模型"克制"地不调工具、直接回答
        String q2 = "你好,简单介绍一下你能帮我做什么?一句话就好。";
        System.out.println("\n你问:" + q2);
        String a2 = assistant.chat(q2);
        System.out.println("AI 答:" + a2);
        System.out.println("---------- 演示二结束 ----------");

        System.out.println("\n========== 演示结束 ==========");
    }
}

最值得盯的就是 .chatModel(...) 配大脑、.tools(...) 配双手这两行------你一行实现类都没写,助手就会自己调工具了。

四、实测输出:一个问题,连调两个工具

以下是 2026-10-05 本机真实运行(DeepSeek deepseek-chat,已省略 Spring Boot banner)。先看演示一------一个问题里同时问订单和库存:

text 复制代码
========== 第 6 课:工具调用 Function Calling ==========

你问:订单 1001 现在是什么状态?另外机械键盘的库存还剩多少?
(下面会依次打印:框架调用了哪个工具、传了什么参数、返回了什么)
   【工具被框架调用】getOrderStatus(orderId=1001)
   【工具返回】已发货,物流单号 SF1234567890,预计明天送达
   【工具被框架调用】getStock(productName=机械键盘)
   【工具返回】商品「机械键盘」当前库存剩余 35 件。
AI 答:查询结果如下:

**订单 1001 状态**
- 状态:已发货
- 物流单号:SF1234567890
- 预计送达:明天

**机械键盘库存**
- 当前剩余:35 件

如需查询其他订单或商品,随时告诉我。
---------- 演示一结束 ----------

逐行读证据:getOrderStatus(orderId=1001)------模型自己决定调它、自己从整句话里抽出参数 1001;紧接着 getStock(productName=机械键盘)------一个问题里连续规划两次调用,先订单后库存,顺序是模型自己排的。而回答里的"已发货 / SF1234567890 / 35 件",全是内存 Map 里的真实数据,模型只是排版好看了。

再看演示二,同一个助手,换个闲聊问题------这次一行工具调用都没有:

text 复制代码
你问:你好,简单介绍一下你能帮我做什么?一句话就好。
AI 答:我可以帮你查询商品库存和订单状态,比如某商品还剩多少件或某订单是否已发货。
---------- 演示二结束 ----------

看出差别了吗?这一问没有打印任何 【工具被框架调用】------模型判断"闲聊不用查数据",直接用自己的话答了。调不调工具,是模型自己决策的,不是你写死的。

排查:没看到 【工具被框架调用】 就查三处------@Tool/@P 描述够不够具体、问题里有没有明确提"订单/库存"、模型名是不是 deepseek-chat(deepseek-reasoner 行为不同)。报 401 是 Key 没生效。

五、挑战题:改一改,看看会怎样

  1. ⭐ 删掉参数说明书 :把 getOrderStatus 上的 @P("订单编号...") 整行删掉重跑演示一,看模型还能不能乖乖抽出 1001,会不会把整句话塞进 orderId。答案就在源码 CustomerTools.java------删完跑一次你就明白 @P 为什么叫防呆。
  2. ⭐⭐ 问一个不存在的单号:把 q1 改成"订单 9999 现在什么状态?",观察模型怎么拿着工具那句"未找到订单编号 9999"去回复用户。重点看它会不会自己编一个状态糊弄你------这就是兜底的价值。
  3. ⭐⭐ 加个条件判断的问题:把 q1 换成"USB-C 数据线库存还有多少?如果已经是 0 件,顺便帮我查下订单 1003 的状态。",看模型能不能先查库存、再根据结果决定要不要查订单。跑出来你就懂什么叫"模型自己安排工作流"。

六、生产环境进阶:三个加分项

1. 别把异常栈回传给模型。 启动日志里那串 ToolErrorHandlingNotice WARN 是 1.21.0 的正常提示。但它提醒你:默认工具一抛异常,异常信息会被回填给模型。生产环境要加 .toolExecutionErrorHandler(...),否则你的 SQL、服务器路径可能被原样塞进上下文,既泄露又费 token。

2. 工具方法返回 String。 返回值要回填给模型组织语言,返回 String 最省事;返回复杂对象模型拿到的是一坨 JSON,可读性差。

3. Key 只从环境变量读。 LlmConfig 里 apiKey 走 System.getenv("DEEPSEEK_API_KEY"),代码、yml、日志里都不出现明文 Key。课后 grep 一遍,只留环境变量名。

七、面试回答模板

面试官:Function Calling 的完整链路是什么?模型真的会执行你的 Java 代码吗?

一句话:模型不执行代码,它只输出结构化的"调用意图",真正执行的是框架。展开:用户提问 → 模型输出"调某工具、参数是啥" → LangChain4j 反射调你的 Java 方法 → 结果回填模型 → 模型组织最终回答。类比成关在房间里的实习生用对讲机喊你查数据,他全程没离开房间。一次 chat 内部可能多轮往返、连调多个工具。(指向本课第一节 + lesson-06 主类)
追问:@Tool 和 @P 注解是写给谁看的?不写会怎样?

写给模型看的。@Tool("...") 的说明会被转成 JSON Schema 随请求发给模型,决定它"选哪个工具";@P("...") 描述参数,决定它"填对参数"。描述含糊,模型就可能不调工具、或把整句话当 orderId 传进去------挑战题第一题就是删 @P 复现这个坑。(指向本课第三节 CustomerTools)
追问:模型凭什么知道这个问题该调工具、那个问题直接答?

它训练时学会了这个判断:涉及实时/私有数据就倾向输出调用意图,闲聊和知识问答就直接答。本课演示二就是铁证------问"你能帮我做什么",一行工具调用都没打。(指向本课第四节实测输出)

八、总结表

坑 现象 解法
JAVA_HOME 指向 JDK8 mvn 跑在 Java 8 报错 每条构建命令前 $env:JAVA_HOME="C:\Program Files\Java\jdk-17"
中文乱码 工具返回中文变 ??? 运行加 -Dfile.encoding=UTF-8,文件 UTF-8 无 BOM
模型不调工具直接编 AI 答得空泛 把 @Tool/@P 描述写具体、带示例
参数抽错 整句被当 orderId @P 写清"纯数字,例如 1001"
工具异常栈外泄 WARN 一堆、上下文泄露 生产加 .toolExecutionErrorHandler(...)
误加 langchain4j-tools 版本对不齐 1.21.0 的 @Tool/AiServices 都在核心包,不用额外加

九、关于这个系列

本文是「Java 后端实战精通营 」系列第 6 篇,原则:实战驱动、由浅到深、面试向,每篇文章的结论都可以亲手验证。

👉 LangChain4j 实战精通营(10 课):https://gitee.com/j67mk2/langchain4j-journey

  • 本文对应源码位置 :lesson-06/(控制台工程,内含 CustomerTools 两个 @Tool 工具 + CustomerAssistant 接口 + ToolCallDemoApplication 装配主类)

系列文章一览(按发布顺序):

篇 主题
1 LangChain4j 第一个对话程序:手写 LLM 调用链路,看清模型怎么"开口"
2 LangChain4j 提示词工程:模板 + few-shot + 温度,提示词从写死到复用
3 LangChain4j 结构化输出:让模型吐出 Java 对象,JSON 不再手写解析
4 LangChain4j 对话记忆:让模型记住聊过什么,三种方式实测对比
5 LangChain4j 流式输出:打字机效果 + SSE,回答不再干等三秒
6 LangChain4j 工具调用:Function Calling 让模型动手查数据,多工具连调实测
7 LangChain4j 向量检索:文本怎么变成可搜索的坐标
8 LangChain4j RAG 流水线:手写问答全流程,给 AI 开卷考试
9 LangChain4j Agent:多步推理自主调用,放手让"实习生"自己安排工作
10 LangChain4j 企业智能客服:RAG + 工具 + 记忆 + 流式 + 兜底,验收 5 项全过

下一篇预告:《LangChain4j 向量检索:文本怎么变成可搜索的坐标》------今天模型学会了"动手查数据库",但企业里更常见的知识是文档(产品手册、FAQ、规章制度),没法给每个知识点写一个 @Tool。下一课给模型装另一双眼睛:把文本变成可搜索的坐标,按"意思"而不是"关键词"去大海捞针。

跑完有任何报错,把控制台输出发评论区,一起排查。


标签建议 :LangChain4j、FunctionCalling、大模型工具调用

摘要建议(≤256 字):大模型只会文字接龙、没有执行权,问它订单状态容易幻觉编造单号。本文用 LangChain4j 的 @Tool + @P + AiServices,把查订单、查库存两个普通 Java 方法暴露给 DeepSeek,一个问题连调两个工具实测可见,模型自己决定调不调、抽什么参数。附删 @P 对照、不存在单号兜底等 3 道挑战题与面试回答模板,源码在 gitee lesson-06 可 clone 直接跑。

相关推荐
鱼宵9 小时前
LangChain4j RAG 流水线:手写问答全流程,给 AI 开卷考试
大数据·人工智能·机器学习·langchain4j
鱼宵1 天前
LangChain4j 结构化输出:让模型吐出 Java 对象,JSON 不再手写解析
java·开发语言·json·langchain4j
鱼宵4 天前
Spring AI 工具调用:@Tool 让大模型自己查订单查库存
人工智能·spring·工具调用·functioncalling·springai
云和数据.ChenGuang6 天前
JAVAEE AI工程师路线图
java·人工智能·java-ee·fastapi·springai·langchain4j
ZealSinger7 天前
LangChain4j用AiServices拼Tool
java·rag·ai智能体·langchain4j
大连好光景7 天前
MCP协议与Function Calling的区别?
functioncalling·mcp
Bug收容所10 天前
AI-Agent-是怎么工作的
agent·functioncalling·mcp
my_styles21 天前
ai开发-langchain4j-进阶-05-提示词工程
ai·langchain4j
水巷石子1 个月前
学习langChain4j的第二天,体验springBoot中的starter
java·spring boot·学习·spring·langchain4j