
**问题的核心是:AI 没有工具。**就像给你一双手脚,让你去盖房子,你也做不到。但如果给你一套工具箱,情况就完全不同了。
今天我们就来给 AI 装上一套工具箱,让它能够从博客园实时获取最新技术文章。
简单来说,工具调用就是让 AI 能够"借用"外部能力。
这些能力包括但不限于:
但有一个关键点要特别注意:
工具调用 不是 AI 自己去执行这些工具,而是 AI 说"我需要调用 XX 工具",真正执行的是我们的应用程序。
流程是这样的:
用户提问 → AI 分析意图 → AI 决定调用工具
→ 我们的程序执行工具 → 把结果返回给 AI → AI 继续回答让 AI 能够查询博客园用户的最新文章,并提取这些信息:
实现方案:用 Jsoup 抓取博客园页面,把数据整理后返回给 AI。
完整流程其实很简单:
核心就是:AI 不直接调用工具,而是告诉我们的程序"我需要调用这个工具",程序执行完后把结果给 AI,AI 再基于结果回答用户。
想看详细的调用链路?文章最后有完整的时序图,包你一看就懂。
先在 pom.xml 中加入 Jsoup(网页爬虫库):
<dependency>
<groupId>org.jsoup</groupId>
<artifactId>jsoup</artifactId>
<version>1.20.1</version>
</dependency>在 tools 包下创建一个工具类,用 @Tool 注解告诉 LangChain4j:"这是一个工具"。
⚠️ 重点:工具描述一定要写清楚,AI 能否正确调用工具全看这个描述!
/**
* 博客园文章搜索工具
* 用于从博客园抓取用户的最新文章信息
*
* @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("\\|",);
String target = parts[].trim();
int limit =;
if (parts.length ==) {
try {
limit = Math.max(, Math.min(, Integer.parseInt(parts[].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,,);
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() >) {
summary = summary.substring(,) + "...";
}
}
// 提取统计信息
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 =; 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() -) {
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 =;
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;
}
}
}核心逻辑:
public AiCodeHelperService aiCodeHelperService() {
ChatMemory chatMemory = MessageWindowChatMemory.withMaxMessages();
return AiServices.builder(AiCodeHelperService.class)
.chatModel(qwenChatModel)
.chatMemory(chatMemory)
.contentRetriever(contentRetriever)
.tools(new CnblogsArticleTool()) // ← 绑定工具
.build();
}
写个单元测试:
@Test
void chatWithTools() {
String result = aiCodeHelperService.chat(
"帮我查下博客园用户 BNTang 的最新文章"
);
System.out.println(result);
}关键来了,在工具方法里打断点,Debug 运行:

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

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

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

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

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

简单场景用声明式,需要动态创建工具用编程式。
除了搜索,工具调用还能实现这些功能:
更棒的是:这些工具不一定都要自己写,可以通过 MCP(Model Context Protocol)协议直接用别人开发好的工具。
如果想深入理解工具调用的每一步,看这个时序图就对了:
Jsoup(网页抓取)CnblogsArticleToolChatModel(LLM)LangChain4j框架AiCodeHelperService🧪 Test(用户)Jsoup(网页抓取)CnblogsArticleToolChatModel(LLM)LangChain4j框架AiCodeHelperService🧪 Test(用户)chatWithTools() 测试流程chat("帮我查询博客园用户 BNTang 的最新技术文章...")1转发请求2加载 system-prompt.txt3添加 ChatMemory(最近10条消息)4发送用户消息5分析意图6识别需要调用 cnblogsSearch 工具7返回工具调用请求8searchCnblogsArticles("BNTang")9解析输入参数10构造URL (https://www.cnblogs.com/BNTang/)11fetchDocumentWithRetries(url, 3, 8000)12发送HTTP请求13返回HTML文档14解析HTML (.day 元素)15提取文章信息(标题、链接、日期、摘要等)16生成JSON结果17返回文章列表JSON18发送工具结果给LLM19基于工具结果生成最终回复20返回最终答案21返回结果22返回 String 结果23System.out.println(result)24时序图解读:
cnblogsSearch 工具关键点:工具执行在应用侧(B3、T1),不在 AI 服务器(L2)。
工具调用是让 AI 突破能力边界的关键技术。
记住三个要点:
通过 LangChain4j 的 @Tool 注解,只需要几行代码,就能让 AI 拥有"超能力"。
系列文章持续更新中,关注我不错过每一篇干货。
这篇文章对你有用的话,点个赞、在看支持一下吧!
相关文章推荐:
如果这篇文章帮到了你,不妨点个分享给同样需要的朋友吧! 你的每一次支持,都是我持续创作的动力!💪