LangChain4j 工具调用实战

你有没有遇到过这种场景:

  • 用户问 AI:"帮我查下今天上海的天气"
  • AI 回答:"抱歉,我无法获取实时信息。"

问题的核心是:AI 没有工具。就像给你一双手脚,让你去盖房子,你也做不到。但如果给你一套工具箱,情况就完全不同了。

今天我们就来给 AI 装上一套工具箱,让它能够从博客园实时获取最新技术文章。

什么是工具调用?

简单来说,工具调用就是让 AI 能够"借用"外部能力。

这些能力包括但不限于:

  • 联网搜索
  • 调用第三方 API
  • 读写文件
  • 查询数据库
  • 执行代码

但有一个关键点要特别注意

工具调用 不是 AI 自己去执行这些工具,而是 AI 说"我需要调用 XX 工具",真正执行的是我们的应用程序。

流程是这样的:

用户提问 → AI 分析意图 → AI 决定调用工具
→ 我们的程序执行工具 → 把结果返回给 AI → AI 继续回答

要实现的目标

让 AI 能够查询博客园用户的最新文章,并提取这些信息:

  • 文章标题
  • 文章链接
  • 发布日期
  • 摘要内容
  • 阅读数、评论数、推荐数

实现方案:用 Jsoup 抓取博客园页面,把数据整理后返回给 AI。

快速了解流程

完整流程其实很简单:

  1. 用户提问 → 2. AI 分析意图 → 3. AI 决定调用工具 → 4. 程序执行工具 → 5. 结果返回给 AI → 6. AI 整理后回复用户

核心就是:AI 不直接调用工具,而是告诉我们的程序"我需要调用这个工具",程序执行完后把结果给 AI,AI 再基于结果回答用户。

想看详细的调用链路?文章最后有完整的时序图,包你一看就懂。

动手实现(四步搞定)

步骤 1:引入依赖

先在 pom.xml 中加入 Jsoup(网页爬虫库):

/* by yours.tools - online tools website : yours.tools/zh/enphp.html */
<dependency>
    <groupId>org.jsoup</groupId>
    <artifactId>jsoup</artifactId>
    <version>1.20.1</version>
</dependency>

步骤 2:编写工具类

tools 包下创建一个工具类,用 @Tool 注解告诉 LangChain4j:"这是一个工具"。

⚠️ 重点:工具描述一定要写清楚,AI 能否正确调用工具全看这个描述!

/* by yours.tools - online tools website : yours.tools/zh/enphp.html */
/**
 * 博客园文章搜索工具
 * 用于从博客园抓取用户的最新文章信息
 *
 * @author BNTang
 */
@Slf4j
public class CnblogsArticleTool {

    /**
     * 从指定用户的博客园主页获取最新的技术文章列表。
     * 支持提取文章标题、链接、发布日期、摘要、阅读数、评论数和推荐数等信息。
     *
     * @param input 博客园用户名或URL,可选地附加"|N"来限制结果数量
     * @return 技术文章列表的JSON格式,包含详细信息,若失败则返回错误信息
     */
    @Tool(name = "cnblogsSearch", value = """
            从博客园获取最新文章。输入可以是:
            - 博客园用户名(例如:'someUser')
            - 完整的个人主页URL(例如:'https://www.cnblogs.com/someUser/')
            可选择性地附加'|N'来限制结果数量,例如:'someUser|5'。
            返回包含标题、链接、日期、摘要、阅读数、评论数、推荐数的JSON数组。
            """
    )
    public String searchCnblogsArticles(@P(value = "用户名或URL(可选地附加|限制数量)") String input) {
        if (input == null || input.trim().isEmpty()) {
            return "{\"error\":\"Empty input\"}";
        }

        String[] parts = input.trim().split("\\|", 2);
        String target = parts[0].trim();
        int limit = 10;
        if (parts.length == 2) {
            try {
                limit = Math.max(1, Math.min(100, Integer.parseInt(parts[1].trim())));
            } catch (NumberFormatException ignored) { /* keep default */ }
        }

        String url;
        if (target.startsWith("http://") || target.startsWith("https://")) {
            url = target;
        } else {
            url = "https://www.cnblogs.com/" + target + "/";
        }

        Document doc = fetchDocumentWithRetries(url, 3, 8000);
        if (doc == null) {
            return "{\"error\":\"Failed to fetch or parse page\"}";
        }

        // 选择博客文章的主容器
        Elements dayElements = doc.select(".day");

        List<ArticleInfo> results = new ArrayList<>();

        for (Element dayEl : dayElements) {
            if (results.size() >= limit) {
                break;
            }

            // 提取标题和链接
            Element titleEl = dayEl.selectFirst(".postTitle a, .postTitle2");
            if (titleEl == null) {
                continue;
            }

            String title = titleEl.text().trim();
            // 移除"[置顶]"标记
            title = title.replaceAll("^\\[置顶]\\s*", "");

            String href = titleEl.absUrl("href");
            if (href.isEmpty()) {
                href = titleEl.attr("href").trim();
            }

            // 去重检查
            boolean seen = false;
            for (ArticleInfo r : results) {
                if (r.url.equals(href)) {
                    seen = true;
                    break;
                }
            }
            if (seen) {
                continue;
            }

            // 提取日期
            String date = "";
            Element dateEl = dayEl.selectFirst(".dayTitle a");
            if (dateEl != null) {
                date = dateEl.text().trim();
            }

            // 提取摘要
            String summary = "";
            Element summaryEl = dayEl.selectFirst(".c_b_p_desc, .postCon");
            if (summaryEl != null) {
                summary = summaryEl.text().trim();
                // 移除"阅读全文"链接文本
                summary = summary.replaceAll("阅读全文$", "").trim();
                // 限制摘要长度
                if (summary.length() > 200) {
                    summary = summary.substring(0, 200) + "...";
                }
            }

            // 提取统计信息
            String viewCount = "0";
            String commentCount = "0";
            String diggCount = "0";

            Element postDesc = dayEl.selectFirst(".postDesc");
            if (postDesc != null) {
                Element viewEl = postDesc.selectFirst(".post-view-count");
                if (viewEl != null) {
                    viewCount = extractNumber(viewEl.text());
                }

                Element commentEl = postDesc.selectFirst(".post-comment-count");
                if (commentEl != null) {
                    commentCount = extractNumber(commentEl.text());
                }

                Element diggEl = postDesc.selectFirst(".post-digg-count");
                if (diggEl != null) {
                    diggCount = extractNumber(diggEl.text());
                }
            }

            if (!title.isEmpty() && !href.isEmpty()) {
                results.add(new ArticleInfo(title, href, date, summary, viewCount, commentCount, diggCount));
            }
        }

        if (results.isEmpty()) {
            return "{\"message\":\"未找到文章。\"}";
        }

        StringBuilder sb = new StringBuilder();
        sb.append("[");
        for (int i = 0; i < results.size(); i++) {
            ArticleInfo article = results.get(i);
            sb.append("{");
            sb.append("\"title\":").append(jsonEscape(article.title)).append(",");
            sb.append("\"url\":").append(jsonEscape(article.url)).append(",");
            sb.append("\"date\":").append(jsonEscape(article.date)).append(",");
            sb.append("\"summary\":").append(jsonEscape(article.summary)).append(",");
            sb.append("\"viewCount\":").append(article.viewCount).append(",");
            sb.append("\"commentCount\":").append(article.commentCount).append(",");
            sb.append("\"diggCount\":").append(article.diggCount);
            sb.append("}");
            if (i < results.size() - 1) {
                sb.append(",");
            }
        }
        sb.append("]");
        return sb.toString();
    }

    /**
     * 带重试机制获取网页文档
     *
     * @param url         目标URL
     * @param maxAttempts 最大尝试次数
     * @param timeoutMs   超时时间(毫秒)
     * @return Jsoup文档对象,失败返回null
     */
    private Document fetchDocumentWithRetries(String url, int maxAttempts, int timeoutMs) {
        String userAgent = "Mozilla/5.0 (compatible; Bot/1.0; +https://example.com/bot)";
        int attempt = 0;
        while (attempt < maxAttempts) {
            attempt++;
            try {
                return Jsoup.connect(url)
                        .userAgent(userAgent)
                        .timeout(timeoutMs)
                        .referrer("https://www.google.com")
                        .get();
            } catch (IOException e) {
                log.warn("第{}次尝试获取 {} 失败: {}", attempt, url, e.getMessage());
                try {
                    Thread.sleep(500L * attempt);
                } catch (InterruptedException ignored) {
                    Thread.currentThread().interrupt();
                    break;
                }
            }
        }
        log.error("所有尝试均失败,无法获取 {}", url);
        return null;
    }

    /**
     * 从文本中提取数字
     *
     * @param text 包含数字的文本,如"阅读(123)"
     * @return 提取的数字字符串
     */
    private String extractNumber(String text) {
        if (text == null) {
            return "0";
        }
        text = text.replaceAll("[^0-9]", "");
        return text.isEmpty() ? "0" : text;
    }

    /**
     * JSON字符串转义
     *
     * @param s 待转义的字符串
     * @return 转义后的JSON字符串
     */
    private String jsonEscape(String s) {
        if (s == null) {
            return "\"\"";
        }
        String escaped = s.replace("\\", "\\\\")
                .replace("\"", "\\\"")
                .replace("\n", "\\n")
                .replace("\r", "\\r");
        return "\"" + escaped + "\"";
    }

    /**
     * 文章信息类
     */
    private static class ArticleInfo {
        String title;
        String url;
        String date;
        String summary;
        String viewCount;
        String commentCount;
        String diggCount;

        ArticleInfo(String title, String url, String date, String summary,
                    String viewCount, String commentCount, String diggCount) {
            this.title = title;
            this.url = url;
            this.date = date;
            this.summary = summary;
            this.viewCount = viewCount;
            this.commentCount = commentCount;
            this.diggCount = diggCount;
        }
    }
}

核心逻辑

  1. 解析用户输入(支持用户名或 URL)
  2. 用 Jsoup 抓取博客园页面
  3. 用 CSS 选择器提取文章信息
  4. 返回 JSON 格式的结果

步骤 3:把工具绑定到 AI Service

public AiCodeHelperService aiCodeHelperService() {
    ChatMemory chatMemory = MessageWindowChatMemory.withMaxMessages(10);

    return AiServices.builder(AiCodeHelperService.class)
            .chatModel(qwenChatModel)
            .chatMemory(chatMemory)
            .contentRetriever(contentRetriever)
            .tools(new CnblogsArticleTool())  // ← 绑定工具
            .build();
}

步骤 4:测试一下

写个单元测试:

@Test
void chatWithTools() {
    String result = aiCodeHelperService.chat(
        "帮我查下博客园用户 BNTang 的最新文章"
    );
    System.out.println(result);
}

关键来了,在工具方法里打断点,Debug 运行:

你会看到断点真的停下来了!

这说明 AI 真的调用了我们的工具

工具把数据返回给 AI 后,AI 会整理成自然语言:

在 Debug 模式下,你还能看到 AI Service 加载了工具:

以及工具的完整调用链路:

完美运行!

工具定义的两种方式

前面用的是声明式定义(注解),LangChain4j 也支持编程式定义:

简单场景用声明式,需要动态创建工具用编程式。

还能做更多

除了搜索,工具调用还能实现这些功能:

  • 读写本地文件
  • 生成 PDF 报告
  • 执行 Shell 命令
  • 生成图表
  • 调用企业内部 API

更棒的是:这些工具不一定都要自己写,可以通过 MCP(Model Context Protocol)协议直接用别人开发好的工具。

完整的调用链路

如果想深入理解工具调用的每一步,看这个时序图就对了:

sequenceDiagram autonumber participant U2 as 🧪 Test(用户) participant B1 as AiCodeHelperService participant L1 as LangChain4j框架 participant L2 as ChatModel(LLM) participant B3 as CnblogsArticleTool participant T1 as Jsoup(网页抓取) Note over U2,T1: chatWithTools() 测试流程 U2->>B1: chat("帮我查询博客园用户 BNTang 的最新技术文章...") B1->>L1: 转发请求 L1->>L1: 加载 system-prompt.txt L1->>L1: 添加 ChatMemory(最近10条消息) L1->>L2: 发送用户消息 L2->>L2: 分析意图 L2->>L2: 识别需要调用 cnblogsSearch 工具 L2-->>L1: 返回工具调用请求 L1->>B3: searchCnblogsArticles("BNTang") B3->>B3: 解析输入参数 B3->>B3: 构造URL (https://www.cnblogs.com/BNTang/) B3->>T1: fetchDocumentWithRetries(url, 3, 8000) T1->>T1: 发送HTTP请求 T1-->>B3: 返回HTML文档 B3->>B3: 解析HTML (.day 元素) B3->>B3: 提取文章信息(标题、链接、日期、摘要等) B3->>B3: 生成JSON结果 B3-->>L1: 返回文章列表JSON L1->>L2: 发送工具结果给LLM L2->>L2: 基于工具结果生成最终回复 L2-->>L1: 返回最终答案 L1-->>B1: 返回结果 B1-->>U2: 返回 String 结果 U2->>U2: System.out.println(result)

时序图解读

  1. 用户发起请求(步骤 1-4):Test 调用 Service,Service 转发给 LangChain4j 框架
  2. AI 分析意图(步骤 5-7):LLM 分析用户问题,决定需要调用 cnblogsSearch 工具
  3. 工具执行(步骤 8-17):Tool 用 Jsoup 抓取博客园页面,解析数据
  4. 结果返回(步骤 18-21):工具结果返回给 LLM,LLM 生成最终答案

关键点:工具执行在应用侧(B3、T1),不在 AI 服务器(L2)。

写在最后

工具调用是让 AI 突破能力边界的关键技术。

记住三个要点

  1. 工具描述写清楚,AI 才能正确调用
  2. 工具在应用侧执行,不在 AI 服务器
  3. 声明式定义简单,编程式定义灵活

通过 LangChain4j 的 @Tool 注解,只需要几行代码,就能让 AI 拥有"超能力"。


系列文章持续更新中,关注我不错过每一篇干货。

这篇文章对你有用的话,点个赞、在看支持一下吧!


相关文章推荐

  • 智谱 GLM-4.7 编程第一
  • LangChain4j 结构化输出实战
  • 让 AI 不再失忆
  • Claude Code 免费指南
【轴承故障诊断】加权多尺度字典学习模型(WMSDL)及其在轴承故障诊断上的应用(Matlab代码实现)内容概要:本文介绍了加权多尺度字典学习模型(WMSDL)在轴承故障诊断中的应用,并提供了基于Matlab的代码实现。该模型结合多尺度分析与字典学习技术,能够有效提取轴承振动信号中的故障特征,提升故障识别精度。文档重点阐述了WMSDL模型的理论基础、算法流程及其在实际故障诊断中的实施步骤,展示了其相较于传统方法在特征表达能力和诊断准确性方面的优势。同时,文中还提及该资源属于一个涵盖多个科研方向的技术合集,包括智能优化算法、机器学习、信号处理、电力系统等多个领域的Matlab仿真案例。; 适合人群:具备一定信号处理和机器学习基础,从事机械故障诊断、工业自动化、智能制造等相关领域的研究生、科研人员及工程技术人员。; 使用场景及目标:①学习并掌握加权多尺度字典学习模型的基本原理与实现方法;②将其应用于旋转机械的轴承故障特征提取与智能诊断;③结合实际工程数据复现算法,提升故障诊断系统的准确性和鲁棒性。; 阅读建议:建议读者结合提供的Matlab代码进行实践操作,重点关注字典学习的训练过程与多尺度分解的实现细节,同时可参考文中提到的其他相关技术(如VMD、CNN、BILSTM等)进行对比实验与算法优化。
【硕士论文复现】可再生能源发电与电动汽车的协同调度策略研究(Matlab代码实现)内容概要:本文档围绕“可再生能源发电与电动汽车的协同调度策略研究”展开,旨在通过Matlab代码复现硕士论文中的核心模型与算法,探讨可再生能源(如风电、光伏)与大规模电动汽车接入电网后的协同优化调度方法。研究重点包括考虑需求侧响应的多时间尺度调度、电动汽车集群有序充电优化、源荷不确定性建模及鲁棒优化方法的应用。文中提供了完整的Matlab实现代码与仿真模型,涵盖从场景生成、数学建模到求解算法(如NSGA-III、粒子群优化、ADMM等)的全过程,帮助读者深入理解微电网与智能电网中的能量管理机制。; 适合人群:具备一定电力系统基础知识和Matlab编程能力的研究生、科研人员及从事新能源、智能电网、电动汽车等领域技术研发的工程人员。; 使用场景及目标:①用于复现和验证硕士论文中的协同调度模型;②支撑科研工作中关于可再生能源消纳、电动汽车V2G调度、需求响应机制等课题的算法开发与仿真验证;③作为教学案例辅助讲授能源互联网中的优化调度理论与实践。; 阅读建议:建议结合文档提供的网盘资源下载完整代码,按照目录顺序逐步学习各模块实现,重点关注模型构建逻辑与优化算法的Matlab实现细节,并通过修改参数进行仿真实验以加深理解。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值