☰
LangChain4j Tool Calling 实战:Java 大模型自动查库与发邮件的完整实现
2026/10/14 8:16:13 网站建设 项目流程

聊到 LangChain4j,很多 Java 开发者第一反应是:这玩意儿到底能在真实项目里干什么?如果你只用它做了个聊天机器人,那其实连皮毛都没碰到。真正让大模型从“能聊”变成“能干活”的能力,恰恰是 Tool Calling(工具调用)。今天这篇实战记录,我就用 LangChain4j + 数据库查询 + 邮件发送这两个最典型的场景,完整拆解 Tool Calling 从声明、注入、执行到结果回传的完整链路。目标只有一个:让你看完之后,能照着把“一句话让大模型查库发邮件”这个能力落到自己的项目里。

这不是一篇 API 文档的复述,是我在实际业务系统里踩过坑、填过坑之后的经验总结。从依赖选型、版本避坑,到 @Tool 注解的四个核心要素,再到 SQL 注入防护和邮件模板的工程化处理,我都会讲清楚“为什么这么做”,而不是只给你一堆能跑的代码。

1. 需求拆解:这个标题到底在解决什么问题

先聊聊本质。所谓“一句话搞定复杂任务”,背后的完整链路其实是这样的:用户输入自然语言 → 大模型理解意图 → 模型决定调用哪个工具 → 生成结构化参数 → 你的代码执行真实操作(查库、发邮件) → 把结果回传给模型 → 模型用自然语言组织答案并继续决策。

拆开看,LangChain4j 在这里承担的是一个“翻译中枢 + 执行调度”的角色。它不负责真正的 SQL 查询,也不负责真正发邮件,它负责的是让大模型知道“你有哪些能力、每个能力需要什么参数”,然后把模型输出的“调用意图”翻译成 Java 方法调用。

1.1 核心问题一:大模型本身不会查库发邮件

这是新手最容易犯的认知错误。你把数据库连接串、邮件服务配置写进提示词,指望模型自己去执行?不行。LLM 的本质是一个概率化的文本生成模型,它没有执行环境,也没有真实访问数据库的通道。它唯一能做的,就是“输出一段看起来像 SQL 的文本”或者“输出一封看起来像邮件的 JSON”。

所以老一辈的接法是:模型输出 SQL → 你用正则或 JSON 解析把 SQL 提出来 → 自己封装执行 → 把结果塞回提示词让模型回答。这套路听起来可行,但你在生产环境试一次就知道多痛苦。模型生成的 SQL 有时候带注释、有时候带多余换行、有时候直接生成 Markdown 代码块,解析的边界情况多到怀疑人生。

1.2 核心问题二:解析式方案为什么被淘汰

后来大家转向 Function Calling / Tool Calling,核心原因是:把“模型决定调用什么工具”变成了一个结构化协议。模型不再自由发挥输出文本,而是输出一个严格遵循 JSON Schema 的调用请求:工具名、参数名、参数值。你的代码只需要把这个 JSON 映射到 Java 方法上,剩下的脏活累活框架全干了。

LangChain4j 在这条路上的设计很讨喜:它把 OpenAI 的 function calling、Anthropic 的 tool use、以及本地模型各种兼容协议都统一成了同一个概念——@Tool 注解。你的 Java 方法就是工具,方法名是工具名,方法参数是工具参数,Javadoc 或 @ToolParam 注解里的描述就是给模型的“使用说明书”。

我用一个表格概括不同方案的痛点对比:

实现方式稳定性开发量可维护性工具扩展成本
纯提示词 + 正则解析低,一次 SQL 格式变化就挂大,各种边界解析差,提示词一改全崩每次都要写解析逻辑
提示词 + JSON 约束输出中,依赖模型对格式的遵守中中仍需手写映射
LangChain4j @Tool高,协议层保证结构小,一个方法就是一个工具好,加工具等于加方法几乎为零

数据说明一切。我接手过一个老项目,用第一种方案对接一个开源模型,光解析异常就有七种,后来全部重构到 Tool Calling,代码量反而减少了一半。

2. 环境准备与版本选型:这些坑我提前帮你踩了

开始动手前,先确认你的技术栈。LangChain4j 在 Java 生态里的定位,有点类似 Spring 全家桶那种“约定优于配置”的感觉。官方核心库 + 各个模型厂商的适配包 + 各个向量库的适配包,组成了一套完整体系。不过版本之间的兼容性是真的会咬人的。

2.1 依赖引入与版本策略

我在项目里用的是当前比较稳妥的版本组合:

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

这里有一个很重要的经验:langchain4j-core 和 langchain4j-open-ai 的版本必须严格一致。这玩意儿不像 Spring Boot 那样能容忍小版本漂移,一旦 core 和 open-ai 版本不一致,运行时可能会出现 AbstractMethodError,而且报错的信息极其隐晦,不会直接告诉你“版本不匹配”,而是抛一个让你摸不着头脑的 LinkageError。我第一次踩的时候排查了大半天,最后看 Maven 依赖树才发现是版本错位。

如果你用的是国内可直连的模型接口,需要确认它是否兼容 OpenAI 的 tool call 协议。大部分兼容 OpenAI 格式的接口都能用 langchain4j-open-ai 接入,只是有几个模型的 tool call 能力比较弱,后面我在常见问题里单独展开讲。

2.2 模型选择与配置文件

工具调用对模型的指令遵循能力和结构化输出能力要求很高。我实测下来,能力相对可靠的模型在 complex reasoning 场景下表现更稳定,尤其当工具数量超过 3 个、且用户一句话里隐含多个连续调用需求时,模型的决策准确率会有明显差异。对于中文场景,建议优先选择在指令遵循评测中表现好的模型;如果你只是本地测试,先用主流模型的 API 跑通链路,再评估本地部署,不要一上来就上本地小模型,否则你根本分不清是代码问题还是模型能力问题。

配置层面,我在 application.yml 里习惯这样组织:

langchain4j: open-ai: chat-model: base-url: ${LLM_BASE_URL:https://api.xxx.com/v1} api-key: ${LLM_API_KEY:} model-name: ${LLM_MODEL:gpt-4o-mini} temperature: 0.0 timeout: 60s

这里有两个细节值得注意。第一,temperature 在工具调用场景里务必设置为 0,或者至少无限接近 0。工具调用要求的是确定性,不是创造性。温度太高会出现模型“自创工具名”或者“多传一个参数”的情况。第二,timeout 要设大一点。工具调用的链路通常是多轮往返,模型可能在一次请求里同时决定调用两个工具,然后框架会触发二次请求让模型基于工具结果继续作答,这个总耗时会明显高于普通聊天,我一般设置 60 秒以上。

2.3 什么是 AiServices:你的业务入口

LangChain4j 的 AiServices 是这个框架使用体验最丝滑的部分。它的作用简单说就是:把“大模型 + 记忆 + 工具”绑定成一个可执行的 Agent。你不是直接拿 ChatModel 去调工具,而是定义一个接口,AiServices 帮你生成动态代理实现这个接口,把大模型、记忆、工具全部组装好。

public interface CustomerAssistant { String chat(String userMessage); } Assistant assistant = AiServices.builder(CustomerAssistant.class) .chatModel(chatModel) .chatMemory(MessageWindowChatMemory.withMaxMessages(20)) .tools(new CustomerTools()) .build();

这个设计很有意思,它把工具调用从“底层 API 操作”抽象成了“接口方法调用”。你的业务代码只需要依赖 CustomerAssistant 这个接口,具体是哪个模型、挂了哪些工具,全都在组装时决定,对上层业务完全透明。这种解耦方式特别适合团队协作场景:负责业务的同事只需要知道调 chat 方法,负责 AI 的同事只管在 tools 里增删能力。

3. 核心机制:@Tool 注解就是给大模型的一份“说明书”

这一节是整个实战的重头戏。LangChain4j 的工具调用之所以好用,核心就在 @Tool 注解的四个信息维度上。把它们讲透,你就理解了 Tool Calling 的底层逻辑。

3.1 注解四要素:名称、描述、参数、返回值

我直接给一个标准示例:

public class CustomerTools { @Tool("查询用户数据库,可用于获取用户信息、订单记录、账户状态") public String queryDatabase( @ToolParam("完整的 SQL 查询语句,只能执行 SELECT 查询") String sql ) { // jdbc 执行逻辑 } }

这里面的规则我逐一拆给你看:

  • 方法名其实是次要的,真正决定模型“怎么找到这个工具”的是 @Tool 注解里的 description。你不要写“查询数据库”这么简单,尽量包含适用场景、边界条件,比如“仅用于查询,不能执行修改操作”。
  • 方法参数上的 @ToolParam 是对模型最直接的约束,参数描述必须精确到格式。如果你写“SQL 语句”,模型可能会生成带分号的、带注释的、甚至带 Markdown 代码块的文本;但如果你写“一条完整的 SQL 查询语句,不含分号,仅 SELECT 开头”,模型生成的参数会规范非常多。
  • 返回值的设计很多人忽略,但其实是决定链路能否成立的关键。工具执行完返回的字符串会作为一条消息再次交给大模型,模型基于它组织最终回答。所以你的返回值应当包含足够的信息,让模型“有话可说”。

3.2 方法签名就是 JSON Schema

理解 LangChain4j 的工具调用原理,你需要形成一个底层认识:每一个 @Tool 注解的 Java 方法,在模型通信时会自动编译成一个 JSON Schema 描述。

{ "name": "queryDatabase", "description": "查询用户数据库,可用于获取用户信息、订单记录、账户状态", "parameters": { "type": "object", "properties": { "sql": { "type": "string", "description": "完整的 SQL 查询语句,只能执行 SELECT 查询" } }, "required": ["sql"] } }

这个 Schema 就是模型在决策时看到的“工具说明书”。模型根据用户的话 + 这个 Schema,判断自己需要调用哪个工具、往参数里填什么。然后模型输出的不是一个普通文本,而是一个结构化 JSON:

{ "tool": "queryDatabase", "arguments": { "sql": "SELECT COUNT(*) FROM users WHERE create_time > NOW() - INTERVAL 7 DAY" } }

LangChain4j 底层解析这个 JSON,通过反射把参数映射到方法调用上。这就是为什么方法参数类型必须是基本类型、String、或者能被 Jackson 反序列化的 POJO——你定义的参数类型越复杂,模型生成 JSON 的出错率越高。能拆成 String 就别用对象,能用基本字段就别嵌套。

3.3 为什么推荐参数传 SQL 而不是传结构化条件

这里有个设计取舍值得展开说一说。查数据库的工具,本质上你可以有两种设计:

方案一:工具方法接收 sql 字符串,直接执行。方案二:工具方法接收 startDate、endDate、userStatus 等结构化参数,由你的代码拼接 SQL。

从“模型调用成功率”角度看,方案二更稳,因为模型填写结构化字段比生成完整 SQL 的出错率低很多。但从“灵活性和通用性”角度看,方案一更好,你不需要为每个查询场景定义新工具。

我的建议是混合使用:核心的、高频的查询条件用结构化参数,开放式的、不确定的探索性查询用 SELECT 白名单校验后的 SQL。下面这一节我把两种方案都写出来,你可以根据业务场景取舍。

4. 实战一:让大模型自动查数据库

现在进入真正的编码环节。先说清楚我们要完成的效果:用户对系统说“查一下最近一周注册的用户数量”,大模型自动组装 SQL、执行查询、把结果翻译成自然语言回答用户。

4.1 数据库工具类完整实现

直接上代码,我会在后面解释关键设计点:

public class DatabaseTools { private final DataSource dataSource; public DatabaseTools(DataSource dataSource) { this.dataSource = dataSource; } @Tool("查询用户业务数据库,可执行数据库查询操作,涉及用户信息、订单、账户等业务数据") public String queryDatabase( @ToolParam("标准 SQL 查询语句,必须以 SELECT 开头,不允许包含分号、注释、INSERT/UPDATE/DELETE/DDL 语句") String sql ) { // 防御:只允许只读查询 if (!isReadOnlySql(sql)) { return "非法查询:只能执行 SELECT 语句"; } // 防御:加 limit 限制返回行数,防止模型生成超大批量查询拖垮数据库 String limitedSql = appendLimit(sql, 200); try (Connection conn = dataSource.getConnection(); Statement stmt = conn.createStatement(); ResultSet rs = stmt.executeQuery(limitedSql)) { return ResultSetFormatter.convertToJson(rs); } catch (SQLException e) { return "SQL 执行失败,错误信息:" + e.getMessage(); } } private boolean isReadOnlySql(String sql) { String normalized = sql.trim().toLowerCase(); return normalized.startsWith("select") && !normalized.contains(";") && !normalized.contains("--") && !normalized.contains("/*"); } private String appendLimit(String sql, int maxRows) { return sql.replaceAll(";\\s*$", "") + " LIMIT " + maxRows; } }

4.2 为什么这么做:三道防线背后的思考

第一道防线是只读校验。大模型本身没有安全意识,你给它“可以执行 SQL 的工具”,它可能老老实实执行 SELECT,也可能在你测试时因为前面聊过几句“update users set name = ...”就真的生成一条 UPDATE。所以工具方法内部必须在入口处拦截任何非 SELECT 语句。

第二道防线是自动加 LIMIT。如果用户问“把所有用户列出来”,模型生成的是 “SELECT * FROM users”,你直接执行可能返回几十万行。这不仅浪费内存,更重要的是模型拿到一个超长 JSON 时会直接爆 token 上限。加 LIMIT 200 既能保证响应可控,也限制了被滥用时的伤害。

第三道防线是返回 JSON 结构格式化。工具返回的结果要能充分覆盖模型回答所需的信息。我用 ResultSetFormatter 来做这件事,把 ResultSet 转成 JSON 数组,字段名保留数据库原始列名。这样模型能清楚看到每一列的数据,回答用户问题时就有据可依。

4.3 组装服务并测试

工具类有了之后,组装 AiServices:

ChatModel chatModel = OpenAiChatModel.builder() .baseUrl(cfg.baseUrl()) .apiKey(cfg.apiKey()) .modelName(cfg.modelName()) .temperature(0.0) .timeout(Duration.ofSeconds(60)) .build(); interface Assistant { String chat(String message); } Assistant assistant = AiServices.builder(Assistant.class) .chatModel(chatModel) .tools(new DatabaseTools(dataSource)) .build(); // 测试 String answer = assistant.chat("查一下最近一周注册的用户数量?按天分组"); System.out.println(answer);

实际跑起来后,模型端的行为是这样的:它先识别出用户意图是“查询数据库”,然后根据 @Tool 描述生成 SQL,调用工具,拿到 JSON 结果,再生成最终回答。你最终看到的是类似“根据查询结果,最近一周注册用户数量如下:3 月 1 日 23 人,3 月 2 日 18 人……”这样的自然语言输出。

这里有个值得注意的细节:如果模型生成的 SQL 本身有语法错误,工具返回“SQL 执行失败”,模型不会就此放弃,它会根据错误信息自己修正 SQL,再调用一次工具。这种“自我纠错”机制是 Tool Calling 链路里非常强大的能力,前提是你要把错误信息原样返回给它,而不是吞掉或只返回一个“false”。

5. 实战二:让大模型自动发邮件,并串联起完整链路

数据库能查了,接下来把邮件工具加上,让模型具备“查数据 → 组织内容 → 发邮件”这个完整闭环。这个场景非常适合业务通知、定期报表、客户跟进等自动化需求。

5.1 邮件工具实现:模板比模型自由发挥更可靠

邮件内容的生产有两种路径:让模型用自然语言直接生成邮件正文,或者让模型只填充结构化数据、由你的代码套用模板生成。我强烈推荐第二种。原因很简单:模型直接生成邮件正文,你无法控制语气、格式、签名档,而且会增加模型“幻觉”的风险——它可能在某封邮件里编造一个根本不存在的订单编号。

我的设计是这样的:邮件工具接收关键参数,邮件正文由代码里的模板决定。

public class EmailTools { private final JavaMailSender mailSender; public EmailTools(JavaMailSender mailSender) { this.mailSender = mailSender; } @Tool("发送邮件给指定收件人,需要提供收件人地址、主题、以及业务所需的参数") public String sendOrderNotificationEmail( @ToolParam("收件人的邮箱地址") String to, @ToolParam("邮件主题") String subject, @ToolParam("用户名,用于邮件正文展示") String username, @ToolParam("订单号") String orderId, @ToolParam("订单金额,数字格式,精确到两位小数") BigDecimal amount ) { String content = """ 您好 %s, 您的订单 %s 已支付成功,金额 ¥%s。 如有疑问请随时联系我们。 """.formatted(username, orderId, amount); try { mailSender.send(buildMessage(to, subject, content)); return "邮件已成功发送给 " + to; } catch (Exception e) { return "邮件发送失败:" + e.getMessage(); } } }

这样做有很明显的好处:即使模型在参数里填错了格式,它也只能在“结构框架”内错,而不会把整封邮件写得离谱。同时你也保证了邮件签名、版权信息这些固定内容不可能被模型篡改。

5.2 两个工具的联动:从一次对话看完整链路

现在我们把 DatabaseTools 和 EmailTools 同时挂到同一个 Assistant 上:

Assistant assistant = AiServices.builder(Assistant.class) .chatModel(chatModel) .chatMemory(MessageWindowChatMemory.withMaxMessages(20)) .tools(new DatabaseTools(dataSource), new EmailTools(mailSender)) .build();

用户输入这样一句话:

帮我查一下订单 202503011234 的金额,然后给 ops@example.com 发一封邮件,告诉运营这个订单已经支付成功,收件人写张经理。

这看起来是一句话,但模型的处理过程是分步的。第一步,模型识别“查订单金额”需要调用 queryDatabase;第二步,拿到返回的金额后,结合用户对收件人、称呼的描述,决定调用 sendOrderNotificationEmail;第三步,邮件工具返回成功后,模型对用户说“已完成,邮件已发送”。

这里的关键支撑是 chatMemory。没有记忆的话,模型查完金额就“忘了”用户让它发邮件这件事,或者发邮件时需要订单号但上下文里已经没有了。MessageWindowChatMemory 默认是窗口记忆,保留最近 N 条消息,在这个场景里足够用。

我有一个实际测试过的示例对话流,模型内部会这样执行:

用户消息 → SQL 查询工具 → 返回 JSON 结果 → 模型根据结果 + 用户原话生成邮件参数 → 邮件工具 → 返回成功 → 模型输出最终回复。整个过程中用户只看到提交了一句话,然后收到完成提示,中间发生了什么完全由模型自主编排。

5.3 组合工具时如何避免“工具爆炸”

当你同时挂多个工具时,会面临一个实际挑战:模型可能会在工具选择上犹豫,或者选错。比如用户说“查一下账户余额”,但你的工具列表里有“查询订单”“查询用户信息”“查询库存”“发送邮件”等五六个工具,模型需要从中选一个最匹配的。

我的经验是:控制每轮对话中暴露给模型的工具数量,不要超过 8 个。真的超过时,可以考虑对工具做分层——先让模型调用一个 Router 工具做粗分类,再根据分类结果动态加载具体工具。这个模式在大厂的项目里很常见,但按我的评估,大部分业务场景根本到不了那个复杂度,你只要把每个工具的 description 写得足够清晰,模型基本都能选对。

6. 常见问题与排查技巧实录

最后这部分是含金量最高的,全部来自我实际踩坑的记录。很多问题官方文档不会写,但你在上线一周内一定会遇到。

6.1 工具返回为空或 null 导致模型自说自话

现象:模型调用了工具,但你的方法返回 null,LangChain4j 会认为工具执行失败,直接把控制权交还给模型。模型此时只能凭“猜”来回答用户,甚至可能编造一个执行结果。

排查与解决:工具方法永远不要返回 null,至少要返回一个描述性的字符串。失败时返回错误信息,成功时返回结果内容。我习惯在方法开头就写一个默认返回值兜底,确保任何异常路径都不会出现 null。

6.2 模型生成的 JSON 参数解析崩溃

现象:模型返回的 arguments 无法被 Jackson 解析成方法参数的 POJO,方法没有被调用,对话直接中断。

根因通常是两种:一种是参数描述不清晰,导致模型在字段类型上“乱来”(比如把金额字符串填给 BigDecimal 字段);另一种是模型本身能力弱,生成的 JSON 不标准。

排查与解决:先把 @ToolParam 的描述尽量写详细,带上枚举值、格式、取值范围。再用日志把模型返回的原始 arguments 打出来,直接看它到底生成了什么。这一步是排查的捷径,很多问题看一眼原始输出就明白了。

6.3 工具结果太长把 token 打爆

现象:数据库查到的结果有几千行,工具返回的 JSON 字符串有好几万 token,模型还没来得及回答用户,请求就因为超长被拦截。

排查与解决:在工具内部提前截断。除了前面说的 SQL 加 LIMIT,还可以在 ResultSet 转 JSON 时设置最大行数和字段长度。经验数值:单次工具返回值控制在 2000 token 以内比较安全。我一般让 JSON 序列化工具只保留前 100 行数据,并且每行字段超长时截断到 200 字符。

6.4 本地小模型工具调用能力不足

现象:接本地模型后,工具调用频繁失败,要么不调用,要么生成了错误的工具名。

根因:工具调用依赖模型在预训练阶段见过大量 function calling 数据,本地小参数模型这方面的能力普遍偏弱。不是代码问题,是模型能力天花板。

解决思路:优先选用对工具调用支持较好的模型。如果必须用本地模型,可以在配置里显式提示模型“你现在可以使用以下工具”,并且减少工具数量、简化参数结构,提高调用成功率。

6.5 并发场景下的共享状态污染

现象:多线程并发请求同一个 Assistant 实例,发现用户 A 的数据被用户 B 查到了。

根因:AiServices 构建的 Assistant 如果绑定了全局 chatMemory,那么所有用户共享同一份上下文,这在高并发多租户场景是致命的。

解决方式:每个用户会话创建独立的 Assistant 实例,或者在 builder 里注入基于会话 ID 的 ChatMemory。LangChain4j 支持 ChatMemoryProvider,你可以实现一个基于 SessionId 返回独立内存的实现。这是多用户聊天场景的必经之路。

7. 一些还没展开的进阶玩法

数据库查询和邮件发送只是 Tool Calling 的入门组合,但它们的架构模式可以复用到几乎所有“AI + 业务系统”的交互场景上。我这里抛几个方向,方便你后续扩展。

企业内部知识库查询:把文档搜索服务封装成工具,模型根据用户问题决定调用哪些检索策略,再基于检索结果回答。它跟数据库查询是同一套模式,只是工具内部换成向量检索或者全文检索。

审批流驱动:把审批系统的接口封装成工具,模型可以查询待办、提交审批意见、流转工单。注意,凡是涉及写操作的场景,一定要在工具调用前引入“人工确认”机制,不要在模型端直接放行。

多模型路由:让一个模型负责意图识别,另一个模型负责工具调用后的答案组织。LangChain4j 支持在链路里配置不同的 ChatModel,这种拆分在复杂业务里能显著提升回答质量。

别忘了把这些工具的方法统一挂到同一个 Assistant 的 tools 参数里,每个工具都是一个普通 Java 方法,加新能力不过就是新增一个方法而已,这是这套方案最迷人的地方。


在实际项目中,我见过太多团队停留在“调 API 聊天”的阶段,做出来的东西除了 Demo 一无是处。真正价值的体现在于把大模型接进业务流程的那一刻:一句“帮我查一下数据并通知团队”,背后完成的是数据库安全查询、数据格式化、邮件发送、消息回执的完整闭环。Tool Calling 就是推开这扇门的那把钥匙,而 LangChain4j 把这把钥匙做得足够顺手。如果这篇文章能帮你少走几步弯路,哪怕只是避开一个版本坑,都算值了。

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

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

立即咨询