找回密码
 立即注册
首页 业界区 业界 LangChain4j 工具调用实战

LangChain4j 工具调用实战

睁扼妤 2 小时前
你有没有遇到过这种场景:

  • 用户问 AI:"帮我查下今天上海的天气"
  • AI 回答:"抱歉,我无法获取实时信息。"
问题的核心是:AI 没有工具。就像给你一双手脚,让你去盖房子,你也做不到。但如果给你一套工具箱,情况就完全不同了。
今天我们就来给 AI 装上一套工具箱,让它能够从博客园实时获取最新技术文章。
什么是工具调用?

简单来说,工具调用就是让 AI 能够"借用"外部能力。
这些能力包括但不限于:

  • 联网搜索
  • 调用第三方 API
  • 读写文件
  • 查询数据库
  • 执行代码
但有一个关键点要特别注意
工具调用 不是 AI 自己去执行这些工具,而是 AI 说"我需要调用 XX 工具",真正执行的是我们的应用程序。
流程是这样的:
  1. 用户提问 → AI 分析意图 → AI 决定调用工具
  2. → 我们的程序执行工具 → 把结果返回给 AI → AI 继续回答
复制代码
要实现的目标

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

  • 文章标题
  • 文章链接
  • 发布日期
  • 摘要内容
  • 阅读数、评论数、推荐数
实现方案:用 Jsoup 抓取博客园页面,把数据整理后返回给 AI。
快速了解流程

完整流程其实很简单:

  • 用户提问 → 2. AI 分析意图 → 3. AI 决定调用工具 → 4. 程序执行工具 → 5. 结果返回给 AI → 6. AI 整理后回复用户
核心就是:AI 不直接调用工具,而是告诉我们的程序"我需要调用这个工具",程序执行完后把结果给 AI,AI 再基于结果回答用户。
想看详细的调用链路?文章最后有完整的时序图,包你一看就懂。
动手实现(四步搞定)

步骤 1:引入依赖

先在 pom.xml 中加入 Jsoup(网页爬虫库):
  1. <dependency>
  2.     <groupId>org.jsoup</groupId>
  3.     jsoup</artifactId>
  4.     <version>1.20.1</version>
  5. </dependency>
复制代码
步骤 2:编写工具类

在 tools 包下创建一个工具类,用 @Tool 注解告诉 LangChain4j:"这是一个工具"。
⚠️ 重点:工具描述一定要写清楚,AI 能否正确调用工具全看这个描述!
  1. /**
  2. * 博客园文章搜索工具
  3. * 用于从博客园抓取用户的最新文章信息
  4. *
  5. * @author BNTang
  6. */
  7. @Slf4j
  8. public class CnblogsArticleTool {
  9.     /**
  10.      * 从指定用户的博客园主页获取最新的技术文章列表。
  11.      * 支持提取文章标题、链接、发布日期、摘要、阅读数、评论数和推荐数等信息。
  12.      *
  13.      * @param input 博客园用户名或URL,可选地附加"|N"来限制结果数量
  14.      * @return 技术文章列表的JSON格式,包含详细信息,若失败则返回错误信息
  15.      */
  16.     @Tool(name = "cnblogsSearch", value = """
  17.             从博客园获取最新文章。输入可以是:
  18.             - 博客园用户名(例如:'someUser')
  19.             - 完整的个人主页URL(例如:'https://www.cnblogs.com/someUser/')
  20.             可选择性地附加'|N'来限制结果数量,例如:'someUser|5'。
  21.             返回包含标题、链接、日期、摘要、阅读数、评论数、推荐数的JSON数组。
  22.             """
  23.     )
  24.     public String searchCnblogsArticles(@P(value = "用户名或URL(可选地附加|限制数量)") String input) {
  25.         if (input == null || input.trim().isEmpty()) {
  26.             return "{"error":"Empty input"}";
  27.         }
  28.         String[] parts = input.trim().split("\\|", 2);
  29.         String target = parts[0].trim();
  30.         int limit = 10;
  31.         if (parts.length == 2) {
  32.             try {
  33.                 limit = Math.max(1, Math.min(100, Integer.parseInt(parts[1].trim())));
  34.             } catch (NumberFormatException ignored) { /* keep default */ }
  35.         }
  36.         String url;
  37.         if (target.startsWith("http://") || target.startsWith("https://")) {
  38.             url = target;
  39.         } else {
  40.             url = "https://www.cnblogs.com/" + target + "/";
  41.         }
  42.         Document doc = fetchDocumentWithRetries(url, 3, 8000);
  43.         if (doc == null) {
  44.             return "{"error":"Failed to fetch or parse page"}";
  45.         }
  46.         // 选择博客文章的主容器
  47.         Elements dayElements = doc.select(".day");
  48.         List results = new ArrayList<>();
  49.         for (Element dayEl : dayElements) {
  50.             if (results.size() >= limit) {
  51.                 break;
  52.             }
  53.             // 提取标题和链接
  54.             Element titleEl = dayEl.selectFirst(".postTitle a, .postTitle2");
  55.             if (titleEl == null) {
  56.                 continue;
  57.             }
  58.             String title = titleEl.text().trim();
  59.             // 移除"[置顶]"标记
  60.             title = title.replaceAll("^\\[置顶]\\s*", "");
  61.             String href = titleEl.absUrl("href");
  62.             if (href.isEmpty()) {
  63.                 href = titleEl.attr("href").trim();
  64.             }
  65.             // 去重检查
  66.             boolean seen = false;
  67.             for (ArticleInfo r : results) {
  68.                 if (r.url.equals(href)) {
  69.                     seen = true;
  70.                     break;
  71.                 }
  72.             }
  73.             if (seen) {
  74.                 continue;
  75.             }
  76.             // 提取日期
  77.             String date = "";
  78.             Element dateEl = dayEl.selectFirst(".dayTitle a");
  79.             if (dateEl != null) {
  80.                 date = dateEl.text().trim();
  81.             }
  82.             // 提取摘要
  83.             String summary = "";
  84.             Element summaryEl = dayEl.selectFirst(".c_b_p_desc, .postCon");
  85.             if (summaryEl != null) {
  86.                 summary = summaryEl.text().trim();
  87.                 // 移除"阅读全文"链接文本
  88.                 summary = summary.replaceAll("阅读全文$", "").trim();
  89.                 // 限制摘要长度
  90.                 if (summary.length() > 200) {
  91.                     summary = summary.substring(0, 200) + "...";
  92.                 }
  93.             }
  94.             // 提取统计信息
  95.             String viewCount = "0";
  96.             String commentCount = "0";
  97.             String diggCount = "0";
  98.             Element postDesc = dayEl.selectFirst(".postDesc");
  99.             if (postDesc != null) {
  100.                 Element viewEl = postDesc.selectFirst(".post-view-count");
  101.                 if (viewEl != null) {
  102.                     viewCount = extractNumber(viewEl.text());
  103.                 }
  104.                 Element commentEl = postDesc.selectFirst(".post-comment-count");
  105.                 if (commentEl != null) {
  106.                     commentCount = extractNumber(commentEl.text());
  107.                 }
  108.                 Element diggEl = postDesc.selectFirst(".post-digg-count");
  109.                 if (diggEl != null) {
  110.                     diggCount = extractNumber(diggEl.text());
  111.                 }
  112.             }
  113.             if (!title.isEmpty() && !href.isEmpty()) {
  114.                 results.add(new ArticleInfo(title, href, date, summary, viewCount, commentCount, diggCount));
  115.             }
  116.         }
  117.         if (results.isEmpty()) {
  118.             return "{"message":"未找到文章。"}";
  119.         }
  120.         StringBuilder sb = new StringBuilder();
  121.         sb.append("[");
  122.         for (int i = 0; i < results.size(); i++) {
  123.             ArticleInfo article = results.get(i);
  124.             sb.append("{");
  125.             sb.append(""title":").append(jsonEscape(article.title)).append(",");
  126.             sb.append(""url":").append(jsonEscape(article.url)).append(",");
  127.             sb.append(""date":").append(jsonEscape(article.date)).append(",");
  128.             sb.append(""summary":").append(jsonEscape(article.summary)).append(",");
  129.             sb.append(""viewCount":").append(article.viewCount).append(",");
  130.             sb.append(""commentCount":").append(article.commentCount).append(",");
  131.             sb.append(""diggCount":").append(article.diggCount);
  132.             sb.append("}");
  133.             if (i < results.size() - 1) {
  134.                 sb.append(",");
  135.             }
  136.         }
  137.         sb.append("]");
  138.         return sb.toString();
  139.     }
  140.     /**
  141.      * 带重试机制获取网页文档
  142.      *
  143.      * @param url         目标URL
  144.      * @param maxAttempts 最大尝试次数
  145.      * @param timeoutMs   超时时间(毫秒)
  146.      * @return Jsoup文档对象,失败返回null
  147.      */
  148.     private Document fetchDocumentWithRetries(String url, int maxAttempts, int timeoutMs) {
  149.         String userAgent = "Mozilla/5.0 (compatible; Bot/1.0; +https://example.com/bot)";
  150.         int attempt = 0;
  151.         while (attempt < maxAttempts) {
  152.             attempt++;
  153.             try {
  154.                 return Jsoup.connect(url)
  155.                         .userAgent(userAgent)
  156.                         .timeout(timeoutMs)
  157.                         .referrer("https://www.google.com")
  158.                         .get();
  159.             } catch (IOException e) {
  160.                 log.warn("第{}次尝试获取 {} 失败: {}", attempt, url, e.getMessage());
  161.                 try {
  162.                     Thread.sleep(500L * attempt);
  163.                 } catch (InterruptedException ignored) {
  164.                     Thread.currentThread().interrupt();
  165.                     break;
  166.                 }
  167.             }
  168.         }
  169.         log.error("所有尝试均失败,无法获取 {}", url);
  170.         return null;
  171.     }
  172.     /**
  173.      * 从文本中提取数字
  174.      *
  175.      * @param text 包含数字的文本,如"阅读(123)"
  176.      * @return 提取的数字字符串
  177.      */
  178.     private String extractNumber(String text) {
  179.         if (text == null) {
  180.             return "0";
  181.         }
  182.         text = text.replaceAll("[^0-9]", "");
  183.         return text.isEmpty() ? "0" : text;
  184.     }
  185.     /**
  186.      * JSON字符串转义
  187.      *
  188.      * @param s 待转义的字符串
  189.      * @return 转义后的JSON字符串
  190.      */
  191.     private String jsonEscape(String s) {
  192.         if (s == null) {
  193.             return """";
  194.         }
  195.         String escaped = s.replace("\", "\\\")
  196.                 .replace(""", "\\"")
  197.                 .replace("\n", "\\n")
  198.                 .replace("\r", "\\r");
  199.         return """ + escaped + """;
  200.     }
  201.     /**
  202.      * 文章信息类
  203.      */
  204.     private static class ArticleInfo {
  205.         String title;
  206.         String url;
  207.         String date;
  208.         String summary;
  209.         String viewCount;
  210.         String commentCount;
  211.         String diggCount;
  212.         ArticleInfo(String title, String url, String date, String summary,
  213.                     String viewCount, String commentCount, String diggCount) {
  214.             this.title = title;
  215.             this.url = url;
  216.             this.date = date;
  217.             this.summary = summary;
  218.             this.viewCount = viewCount;
  219.             this.commentCount = commentCount;
  220.             this.diggCount = diggCount;
  221.         }
  222.     }
  223. }
复制代码
核心逻辑

  • 解析用户输入(支持用户名或 URL)
  • 用 Jsoup 抓取博客园页面
  • 用 CSS 选择器提取文章信息
  • 返回 JSON 格式的结果
步骤 3:把工具绑定到 AI Service
  1. public AiCodeHelperService aiCodeHelperService() {
  2.     ChatMemory chatMemory = MessageWindowChatMemory.withMaxMessages(10);
  3.     return AiServices.builder(AiCodeHelperService.class)
  4.             .chatModel(qwenChatModel)
  5.             .chatMemory(chatMemory)
  6.             .contentRetriever(contentRetriever)
  7.             .tools(new CnblogsArticleTool())  // ← 绑定工具
  8.             .build();
  9. }
复制代码
1.png

步骤 4:测试一下

写个单元测试:
  1. @Test
  2. void chatWithTools() {
  3.     String result = aiCodeHelperService.chat(
  4.         "帮我查下博客园用户 BNTang 的最新文章"
  5.     );
  6.     System.out.println(result);
  7. }
复制代码
关键来了,在工具方法里打断点,Debug 运行:
2.png

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

这说明 AI 真的调用了我们的工具
工具把数据返回给 AI 后,AI 会整理成自然语言:
4.png

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

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

完美运行!
工具定义的两种方式

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

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

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

  • 读写本地文件
  • 生成 PDF 报告
  • 执行 Shell 命令
  • 生成图表
  • 调用企业内部 API
更棒的是:这些工具不一定都要自己写,可以通过 MCP(Model Context Protocol)协议直接用别人开发好的工具。
完整的调用链路

如果想深入理解工具调用的每一步,看这个时序图就对了:
sequenceDiagram      autonumber      participant U2 as
来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!

相关推荐

您需要登录后才可以回帖 登录 | 立即注册