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 没生效。
五、挑战题:改一改,看看会怎样
- ⭐ 删掉参数说明书 :把
getOrderStatus上的@P("订单编号...")整行删掉重跑演示一,看模型还能不能乖乖抽出1001,会不会把整句话塞进 orderId。答案就在源码CustomerTools.java------删完跑一次你就明白@P为什么叫防呆。 - ⭐⭐ 问一个不存在的单号:把 q1 改成"订单 9999 现在什么状态?",观察模型怎么拿着工具那句"未找到订单编号 9999"去回复用户。重点看它会不会自己编一个状态糊弄你------这就是兜底的价值。
- ⭐⭐ 加个条件判断的问题:把 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 直接跑。