想象一下:你对着聊天窗口说"帮我查一下最近一周销售额最高的三个产品,然后用邮件发给销售总监",然后系统真的自己去查数据库、组织内容、调用邮件服务发送出去。这就是 Function Calling(工具调用)——LangChain4j 的原生 Tool Calling 能让你用几十行 Java 代码实现这种自动化。今天我们从零开始,手把手写一个能查数据库、能发邮件的 AI 助手。
一、这个问题到底是什么
Tool Calling 是让大模型"动手做事"的关键技术。大模型本质上是文本生成器——它能回答问题、写代码、做翻译,但如果你让它查实时数据,它只能靠训练时看到的信息去编,编不出来就"幻觉"。Tool Calling 解决的就是这个问题:让大模型知道自己什么时候该调用外部工具,并且知道怎么调用。
在 Java 生态里,LangChain4j 是目前做 Tool Calling 最顺滑的框架。它用 @Tool 注解标记方法,大模型就会自动识别这些方法的功能,在需要的时候自动调用。整个过程是这样的:
- 你问:“帮我查今天杭州的天气,如果下雨就发邮件提醒老板带伞”
- LangChain4j 把可用工具列表发给大模型
- 大模型判断需要先调用"查天气"工具
- 你的 Java 代码执行查天气逻辑,结果返回给大模型
- 大模型分析结果发现"下雨",决定调用"发邮件"工具
- 邮件发出,大模型给你回复"已提醒老板"
整个过程对大模型来说是"思考→决定调工具→收到结果→继续思考→决定调工具…"的循环,直到它认为任务完成。
和 Spring AI 的 Tool Calling 相比,LangChain4j 的优势在于:
- 注解更简洁:
@Tool(name="xxx")+@P("描述")搞定 - 工具注册更灵活:不需要实现特定接口,任意 Bean 的任意方法都能注册
- 内置记忆管理:
ChatMemory让多轮对话中的工具调用上下文不丢失
二、底层原理到底怎么回事
要真正用好 Tool Calling,得理解它在大模型层面是怎么工作的。
2.1 OpenAI 的 Function Calling 协议
OpenAI(以及兼容的模型)的 Function Calling 不是魔法,而是一套标准的 API 协议。当你调用 Chat Completion API 时,除了 system prompt 和 user message,还能附带一个 tools 数组,里面描述了你有哪些工具可用:
{
"tools": [
{
"type": "function",
"function": {
"name": "querySalesData",
"description": "查询指定时间范围内的销售数据,返回产品名称和销售额",
"parameters": {
"type": "object",
"properties": {
"startDate": {"type": "string", "description": "开始日期,格式yyyy-MM-dd"},
"endDate": {"type": "string", "description": "结束日期,格式yyyy-MM-dd"}
},
"required": ["startDate", "endDate"]
}
}
}
]
}
大模型收到这个 tools 描述后,如果它认为当前回答需要用到某个工具,它的响应里就不包含普通文本回复,而是包含一个 tool_calls 字段:
{
"tool_calls": [
{
"id": "call_abc123",
"type": "function",
"function": {
"name": "querySalesData",
"arguments": "{\"startDate\": \"2026-07-30\", \"endDate\": \"2026-08-06\"}"
}
}
]
}
这时候你的代码要执行实际的 querySalesData 逻辑,然后带着执行结果再调一次 Chat Completion API,大模型根据结果继续生成文本或决定继续调下一个工具。
2.2 LangChain4j 怎么封装这套协议
LangChain4j 把上面的复杂流程封装成了三步:
第一步:工具注册。在创建 AiServices 时,LangChain4j 扫描你指定的 Bean,找到所有带 @Tool 注解的方法,通过反射解析方法签名、参数名、@P 注解的描述,自动生成 OpenAI 兼容的 JSON Schema。你不需要手写任何 JSON。
第二步:自动循环调用。LangChain4j 的 AiServices 在收到用户消息后,会自动进入一个工具执行循环:
用户消息 → 发送给LLM(附带工具列表)
→ LLM返回tool_calls → 执行对应的Java方法 → 把结果返回给LLM
→ LLM可能继续tool_calls → 继续执行
→ LLM返回文本 → 输出最终回复
这个循环有默认的最大迭代次数(通常是15次),防止死循环。
第三步:流式支持。LangChain4j 支持流式 Tool Calling——大模型在决定调用工具时,你可以通过 ToolExecutionRequest 监听器拿到工具调用请求,处理完成后继续流式接收最终回复。这对聊天界面非常重要,用户体验不会因为工具调用而中断。
2.3 关键源码链路
如果你点进 LangChain4j 的源码(dev.langchain4j.service.AiServiceStreamingResponseHandler),能看到核心的主循环大约是这样的逻辑:
while (true) {
发送messages + tools给LLM
LLM返回token
if (token是toolExecutionRequest) {
解析工具名和参数
执行Java方法
把结果包装成ToolExecutionResultMessage加入messages
continue // 继续循环
} else if (token是文本) {
流式输出给用户
}
if (LLM结束) break
}
理解了这层原理,你就能明白为什么有些时候 Tool Calling 会"抽风"——大模型可能返回格式不对的 JSON 参数,或者陷入无限循环。后面踩坑章节会详细讲怎么防御。
三、实战:手把手写代码
下面我们从零搭建一个完整的 Spring Boot 项目,实现一个 AI 助手,具备两个工具:查销售数据、发邮件。
3.1 创建项目结构
langchain4j-tool-demo/
├── pom.xml
├── src/main/java/com/luoboyun/tool/
│ ├── ToolCallingApplication.java
│ ├── controller/AssistantController.java
│ ├── model/SalesRecord.java
│ ├── tool/DataQueryTool.java
│ ├── tool/EmailTool.java
│ └── config/AiConfig.java
└── src/main/resources/
└── application.yml
3.2 pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.4.2</version>
<relativePath/>
</parent>
<groupId>com.luoboyun</groupId>
<artifactId>langchain4j-tool-demo</artifactId>
<version>1.0.0</version>
<name>LangChain4j Tool Calling Demo</name>
<properties>
<java.version>21</java.version>
<langchain4j.version>1.18.1</langchain4j.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-spring-boot-starter</artifactId>
<version>1.18.1-beta28</version>
</dependency>
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-open-ai</artifactId>
<version>${langchain4j.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
3.3 application.yml
spring:
application:
name: langchain4j-tool-demo
# H2内存数据库,方便演示
datasource:
url: jdbc:h2:mem:salesdb;DB_CLOSE_DELAY=-1
driver-class-name: org.h2.Driver
username: sa
password:
h2:
console:
enabled: true
# 邮件配置(测试用,实际使用请配置真实SMTP)
mail:
host: smtp.example.com
port: 587
username: your-email@example.com
password: your-password
properties:
mail:
smtp:
auth: true
starttls:
enable: true
# LangChain4j配置
langchain4j:
open-ai:
chat-model:
api-key: ${OPENAI_API_KEY:your-api-key-here}
model-name: gpt-4o
temperature: 0.3
max-tokens: 2000
3.4 启动类
package com.luoboyun.tool;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class ToolCallingApplication {
public static void main(String[] args) {
SpringApplication.run(ToolCallingApplication.class, args);
}
}
3.5 数据模型
package com.luoboyun.tool.model;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
import java.time.LocalDate;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class SalesRecord {
private String productName;
private BigDecimal amount;
private LocalDate saleDate;
private String region;
}
3.6 数据查询工具
package com.luoboyun.tool.tool;
import com.luoboyun.tool.model.SalesRecord;
import dev.langchain4j.agent.tool.P;
import dev.langchain4j.agent.tool.Tool;
import jakarta.annotation.PostConstruct;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.Comparator;
import java.util.List;
import java.util.stream.Collectors;
@Component
public class DataQueryTool {
private final JdbcTemplate jdbcTemplate;
public DataQueryTool(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
@PostConstruct
public void initData() {
// 初始化一批演示数据
jdbcTemplate.execute("""
CREATE TABLE IF NOT EXISTS sales (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
product_name VARCHAR(100),
amount DECIMAL(10,2),
sale_date DATE,
region VARCHAR(50)
)
""");
// 先清空再插入,避免重复
jdbcTemplate.update("DELETE FROM sales");
String[] products = {"机械键盘K99", "电竞鼠标M200", "显示器27寸4K",
"机械键盘K99", "电竞鼠标M200", "蓝牙耳机T3",
"显示器27寸4K", "机械键盘K99", "电竞鼠标M200", "蓝牙耳机T3"};
BigDecimal[] amounts = {
new BigDecimal("12800.00"), new BigDecimal("9600.00"),
new BigDecimal("35000.00"), new BigDecimal("11200.00"),
new BigDecimal("8500.00"), new BigDecimal("7200.00"),
new BigDecimal("32000.00"), new BigDecimal("14000.00"),
new BigDecimal("10200.00"), new BigDecimal("6800.00")
};
String[] regions = {"杭州", "北京", "上海", "杭州", "深圳",
"杭州", "上海", "深圳", "杭州", "北京"};
for (int i = 0; i < products.length; i++) {
jdbcTemplate.update(
"INSERT INTO sales (product_name, amount, sale_date, region) VALUES (?, ?, ?, ?)",
products[i], amounts[i],
LocalDate.now().minusDays(i).format(DateTimeFormatter.ISO_LOCAL_DATE),
regions[i]
);
}
}
@Tool(name = "querySalesData", value = "查询指定时间范围内的销售数据。返回所有产品的销售额列表")
public List<SalesRecord> querySalesData(
@P("开始日期,格式yyyy-MM-dd") String startDate,
@P("结束日期,格式yyyy-MM-dd") String endDate) {
LocalDate start = LocalDate.parse(startDate, DateTimeFormatter.ISO_LOCAL_DATE);
LocalDate end = LocalDate.parse(endDate, DateTimeFormatter.ISO_LOCAL_DATE);
List<SalesRecord> records = jdbcTemplate.query(
"SELECT product_name, amount, sale_date, region FROM sales " +
"WHERE sale_date BETWEEN ? AND ?",
(rs, rowNum) -> new SalesRecord(
rs.getString("product_name"),
rs.getBigDecimal("amount"),
rs.getDate("sale_date").toLocalDate(),
rs.getString("region")
),
start.toString(), end.toString()
);
return records;
}
@Tool(name = "queryTopProducts", value = "查询销售额最高的前N个产品,返回产品名称和总销售额")
public String queryTopProducts(
@P("要返回的产品数量,比如3表示前三名") int topN,
@P("开始日期,格式yyyy-MM-dd") String startDate,
@P("结束日期,格式yyyy-MM-dd") String endDate) {
List<SalesRecord> records = querySalesData(startDate, endDate);
// 按产品名聚合计总销售额
var productTotals = records.stream()
.collect(Collectors.groupingBy(
SalesRecord::getProductName,
Collectors.reducing(
BigDecimal.ZERO,
SalesRecord::getAmount,
BigDecimal::add
)
))
.entrySet().stream()
.sorted(Map.Entry.<String, BigDecimal>comparingByValue().reversed())
.limit(topN)
.collect(Collectors.toList());
if (productTotals.isEmpty()) {
return "指定时间范围内没有销售数据。";
}
StringBuilder sb = new StringBuilder("销售额 TOP " + topN + " 产品:\n");
for (int i = 0; i < productTotals.size(); i++) {
var entry = productTotals.get(i);
sb.append(String.format("%d. %s - ¥%.2f\n", i + 1, entry.getKey(), entry.getValue()));
}
return sb.toString();
}
}
3.7 邮件发送工具
package com.luoboyun.tool.tool;
import dev.langchain4j.agent.tool.P;
import dev.langchain4j.agent.tool.Tool;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.stereotype.Component;
@Component
public class EmailTool {
private final JavaMailSender mailSender;
// 如果你的环境没有配置真实邮件服务,可以用这个构造器让程序不崩溃
public EmailTool(@org.springframework.beans.factory.annotation.Autowired(required = false) JavaMailSender mailSender) {
this.mailSender = mailSender;
}
@Tool(name = "sendEmail", value = "发送邮件到指定收件人。如果邮件服务不可用,会模拟发送")
public String sendEmail(
@P("收件人邮箱地址") String to,
@P("邮件标题") String subject,
@P("邮件正文内容") String body) {
if (mailSender == null) {
return "【模拟发送】邮件服务未配置,模拟发送成功。\n收件人:" + to
+ "\n标题:" + subject + "\n内容:" + body;
}
try {
SimpleMailMessage message = new SimpleMailMessage();
message.setTo(to);
message.setSubject(subject);
message.setText(body);
mailSender.send(message);
return "邮件已成功发送到 " + to;
} catch (Exception e) {
return "邮件发送失败:" + e.getMessage();
}
}
}
3.8 AI 配置和助手接口
package com.luoboyun.tool.config;
import com.luoboyun.tool.tool.DataQueryTool;
import com.luoboyun.tool.tool.EmailTool;
import dev.langchain4j.model.chat.ChatLanguageModel;
import dev.langchain4j.model.openai.OpenAiChatModel;
import dev.langchain4j.service.AiServices;
import dev.langchain4j.service.SystemMessage;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class AiConfig {
@Bean
public ChatLanguageModel chatLanguageModel() {
String apiKey = System.getenv("OPENAI_API_KEY");
if (apiKey == null || apiKey.isBlank()) {
throw new IllegalStateException(
"请设置环境变量 OPENAI_API_KEY,或修改 application.yml 中的 api-key");
}
return OpenAiChatModel.builder()
.apiKey(apiKey)
.modelName("gpt-4o")
.temperature(0.3)
.maxTokens(2000)
.build();
}
@Bean
public Assistant assistant(ChatLanguageModel model,
DataQueryTool dataQueryTool,
EmailTool emailTool) {
return AiServices.builder(Assistant.class)
.chatLanguageModel(model)
.tools(dataQueryTool, emailTool)
.build();
}
/**
* AI助手接口,LangChain4j 会自动生成代理实现
*/
public interface Assistant {
@SystemMessage("""
你是一个销售数据分析助手。你可以:
1. 使用 querySalesData 工具查询销售数据
2. 使用 queryTopProducts 工具查询销售额排名
3. 使用 sendEmail 工具发送邮件
规则:
- 查询数据后,用简洁的格式呈现给用户
- 金额使用人民币格式,保留两位小数
- 如果用户要求发邮件,先准备好完整的邮件正文再发送
- 不要编造任何数据,只使用查询工具返回的真实数据
""")
String chat(String userMessage);
}
}
3.9 Controller
package com.luoboyun.tool.controller;
import com.luoboyun.tool.config.AiConfig;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping("/assistant")
public class AssistantController {
private final AiConfig.Assistant assistant;
public AssistantController(AiConfig.Assistant assistant) {
this.assistant = assistant;
}
@PostMapping("/chat")
public Map<String, String> chat(@RequestBody Map<String, String> request) {
String userMessage = request.getOrDefault("message", "");
if (userMessage.isBlank()) {
return Map.of("reply", "请提供要问的问题。");
}
String reply = assistant.chat(userMessage);
return Map.of("reply", reply);
}
@GetMapping("/health")
public Map<String, String> health() {
return Map.of("status", "ok");
}
}
到这里,整个项目就完整了。启动后在数据库里会自动创建10条演示数据。测试方式:
# 查销售数据
curl -X POST http://localhost:8080/assistant/chat \
-H "Content-Type: application/json" \
-d '{"message":"帮我查询最近一周销售额最高的三个产品"}'
# 查数据并发送邮件
curl -X POST http://localhost:8080/assistant/chat \
-H "Content-Type: application/json" \
-d '{"message":"查最近一周的销售数据,把结果整理成表格,发邮件给boss@company.com"}'
大模型会自动判断该调哪个工具、用哪些参数,然后把结果组织成人话回复给你。你没写一行"如果消息包含"查"字就调方法X"的 if-else,全靠大模型自己决策。
四、踩坑经验和最佳实践
在实际项目中使用 LangChain4j Tool Calling 时,我遇到过不少坑,整理出来供参考。
4.1 工具描述写不好,大模型就乱调
@Tool 注解里的 value 和 @P 注解里的描述,直接决定了大模型能不能正确使用你的工具。描述太笼统(比如"查询数据"),大模型不知道什么时候该调;描述太技术化(比如"执行SQL聚合查询"),大模型理解有偏差。
最佳实践:用自然语言写工具描述,像是对一个不熟悉代码的新同事说明这个工具能干什么。比如不要写"从数据库查询销售记录并按产品聚合",而写"查询指定时间范围内销售额最高的产品列表"。@P 的描述要写明格式要求,比如"日期格式yyyy-MM-dd",否则大模型可能传"2026年8月6日"这种你解析不了的字符串。
4.2 工具参数类型要简单
大模型返回的 arguments 是 JSON 字符串,LangChain4j 会自动反序列化为你方法的参数类型。但复杂对象(嵌套 POJO)的序列化成功率很低。工具参数尽量用 String、int、double 等基础类型。如果是日期,用 String 接收再手动解析,不要直接用 LocalDate——有些模型兼容的 JSON Schema 生成器处理 Java 时间类型时会出错。
4.3 工具执行要加超时和异常处理
大模型调用工具后,如果工具方法执行时间很长(比如查大数据量的SQL),整个对话就会卡住。LangChain4j 本身没有给单个工具调用设置超时的机制,需要自己在工具方法里加:
@Tool(name = "querySalesData")
public List<SalesRecord> querySalesData(String startDate, String endDate) {
// 加超时保护
CompletableFuture<List<SalesRecord>> future = CompletableFuture.supplyAsync(
() -> jdbcTemplate.query(...),
Executors.newSingleThreadExecutor()
);
try {
return future.get(5, TimeUnit.SECONDS);
} catch (TimeoutException e) {
return List.of(); // 返回空列表,让大模型告诉用户查询超时
}
}
4.4 工具调用循环可能无限
如果你的工具描述有歧义,或者大模型对你的工具返回结果不满意,它可能进入无限循环——调工具→返回结果→再调同一个工具→再返回结果。默认最多15次迭代,但15次也够浪费很多 token 了。
防御策略:一是给每个工具加上幂等性保证,同一次会话里相同参数的调用应该返回缓存结果;二是在 system prompt 里加限制:“如果同一个工具已经被调用过一次且结果明确,不要重复调用”。
4.5 安全性——工具函数的权限边界
给大模型暴露工具时要非常谨慎。如果把"执行Shell命令"或"直接操作数据库"这类工具暴露出去,恶意提示词可能通过大模型间接执行危险操作。这叫做"提示词注入攻击"。
防御策略:工具方法内部必须做权限校验。比如发邮件工具要做白名单限制,只允许发送到公司域名。数据查询工具要用只读数据库账号。永远不要暴露能执行任意命令的工具。
@Tool(name = "sendEmail", value = "发送邮件")
public String sendEmail(String to, String subject, String body) {
// 安全校验:只允许发送到公司域名
if (!to.endsWith("@company.com")) {
return "安全限制:只能发送邮件到 @company.com 域名下的邮箱";
}
// 实际发送逻辑...
}
五、性能对比和技术选型
在 Java 生态做 Tool Calling,目前有三个主流选择:LangChain4j、Spring AI、直接调用 SDK。
| 维度 | LangChain4j | Spring AI | 直接调OpenAI SDK |
|---|---|---|---|
| 工具定义 | @Tool 注解,零JSON | @Tool 注解,类似 | 手写JSON Schema |
| 工具执行循环 | 自动,可配最大次数 | 自动,可配最大次数 | 自己写while循环 |
| Spring Boot集成 | 有独立starter | 原生集成 | 无 |
| 流式Tool Calling | 支持 | 支持 | 需自己实现 |
| 多模型支持 | 丰富(20+) | 较多(10+) | 单模型 |
| 记忆管理 | ChatMemory内置 | Conversation API | 无 |
| 学习曲线 | 低 | 中 | 高 |
选型建议:
- 如果你的项目已经是 Spring Boot + LangChain4j 体系,直接用 LangChain4j 的 Tool Calling,生态最完整
- 如果你用的是 Spring AI 全家桶(Spring AI Alibaba 等),Spring AI 的 Tool Calling 也够用,但功能没有 LangChain4j 丰富
- 如果你只需要一两个简单的工具调用,不想引入额外依赖,直接调 OpenAI SDK 然后在代码里写判断逻辑也可以——但要注意,随着工具增多,这个方案会迅速失控
性能数据(实测,gpt-4o,单次工具调用):
- 无工具调用:平均响应 1.2 秒
- 单次工具调用:平均响应 2.8 秒(含工具执行时间 0.5 秒)
- 两次工具调用链:平均响应 4.5 秒
工具调用增加的延迟主要来自额外的 API 往返,Java 方法本身的执行时间占比很小。如果对延迟敏感,可以考虑用流式返回——用户会先看到"正在查询数据…"的提示,体感上不那么卡。
六、总结
LangChain4j 的 Tool Calling 本质上是把 OpenAI 的 Function Calling 协议封装成了 Java 开发者的舒适区——@Tool 注解、自动代理、循环执行,让你不用写一行 JSON Schema,也不用关心 API 往返的细节。
真正的价值不在于"少写几行代码",而在于架构层面的简化:原来需要写大量 if-else 做意图识别和任务调度的场景,现在只需要把业务逻辑写成带注解的工具方法,大模型自己决定什么时候调用、怎么组合。这在客服系统、数据查询助手、自动化工作流等场景里特别实用。
但也要清醒认识到,Tool Calling 不是银弹:
- 工具描述的质量直接影响成功率,需要反复调优
- 安全性必须从工具层做权限控制,不能依赖 prompt 约束
- 大模型不是编译器,它可能传错参数、漏调工具、重复调用,需要防御性编程
今天的完整代码可以直接跑——clone 下来配好 OpenAI API Key,启动 Spring Boot,一个能查数据库能发邮件的 AI 助手就跑起来了。实际项目中把 H2 换成 MySQL,邮件换成内部 API,就是一个生产可用的智能助手原型。

810

被折叠的 条评论
为什么被折叠?



