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(网页爬虫库):

xml 复制代码
<dependency>
    <groupId>org.jsoup</groupId>
    <artifactId>jsoup</artifactId>
    <version>1.20.1</version>
</dependency>

步骤 2:编写工具类

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

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

java 复制代码
/**
 * 博客园文章搜索工具
 * 用于从博客园抓取用户的最新文章信息
 *
 * @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

java 复制代码
public AiCodeHelperService aiCodeHelperService() {
    ChatMemory chatMemory = MessageWindowChatMemory.withMaxMessages(10);

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

步骤 4:测试一下

写个单元测试:

java 复制代码
@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 拥有"超能力"。


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

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


相关文章推荐

相关推荐
红尘散仙2 小时前
我把终端小说阅读器接上了 AI Agent:TRNovel 现在能用 skill 生成书源了
人工智能·后端·rust
卷毛的技术笔记3 小时前
告别硬编码!Spring AI Alibaba 实现 AI Agent 智能工具调用(Tool Calling)
java·人工智能·后端·python·spring·ai编程
会编程的土豆3 小时前
Go 语言反射(Reflection)详解
开发语言·后端·golang
喵个咪3 小时前
GoWind Toolkit Go后端代码生成 完整全流程实战
后端·go·orm
basketball6164 小时前
Go 语言从入门到进阶:4. 数组和MAP使用方法总结
开发语言·后端·golang
qq_2518364574 小时前
SpringBoot+Vue 共享电池柜管理系统 完整实现 前后端分离项目实战 完整代码
vue.js·spring boot·后端
zhangxingchao4 小时前
AI 大模型核心六:量化、Workflow 与 Agent、多轮 RAG
前端·人工智能·后端
IT_陈寒5 小时前
Vite打包时遇到的坑,原来问题出在这里
前端·人工智能·后端
ayqy贾杰7 小时前
基层管理的三板斧,在AI时代行不通了
前端·后端·团队管理
Apifox7 小时前
Apifox 5 月更新|Postman 导入优化、Runner 支持非 root 运行、请求代码自动带鉴权
前端·后端·安全