Corner项目实战 Spring Boot + LangChain4j Tool Calling实战:让AI自动选择推荐策略
本期聚焦内容:
- 问题场景:如何让 AI 根据用户数据量,自动选择不同推荐策略
- 系统提示词设计:展示那三条判断规则(优先收藏+情绪 → 官方库 → 联网搜索)
- Tool 定义方式:如何让模型能"调用"你的 Java 方法
- 简化代码示例:Tool 的定义、注册、调用流程
适合读者:想了解 LangChain4j 中 Tool 如何实际落地的人。
问题场景
在学习、制作Corner项目中,前端需要根据用户历史记录的多少,调用不同推荐接口,导致逻辑复杂
于是,我结合Langchain的工具Tool,根据用户上下文,自主判断调用哪个Tool
选择原因
| 维度 | Tool Calling(Langchain4j) |
传统 if-else 硬编码 |
|---|---|---|
| 可扩展性 | 新增推荐策略 = 新增一个 @Tool 方法,零修改调用方 |
新增策略需要改 Controller + Service + 前端 |
| 灵活性 | AI 根据语义自主组合调用多个 Tool,甚至决定调用顺序 | 逻辑在代码中写死,无法动态调整 |
| 解耦程度 | 前端只发一条消息,后端 AI 模型做路由决策 | 前端必须知道后端有多少种推荐策略 |
| 维护成本 | 策略之间彼此独立,互不干扰 | 一个 if-else 分支出错可能影响整个链路 |
| 多模态融合 | AI 可自然融合历史 + 位置 + 情绪多源信息 | 需要手写融合逻辑,代码臃肿 |
三层tool的定义
围绕目前的核心业务,设计了三个推荐Tool:
| Tool 名称 | 触发场景 | 数据来源 | 返回内容 |
|---|---|---|---|
queryUserHistoryTool |
用户有历史出行记录 | UserPlaceMemory 表 + Redis |
去过的地方、频率、情绪标签 |
queryOfficialPlacesTool |
用户给定城市/区域但无历史 | PlaceEmotionLibrary 表 |
官方收录的景点 + 情绪匹配 |
searchOnlineTool |
用户问实时/热门/未收录地点 | 百度地图 API / 在线搜索 | 实时 POI + 路线信息 |
- 当前在线搜索实在是太多问题:
查询输出内容乱码、内容不准确等问题降级成LLM大模型应用
三、代码实现
3.1定义Tool接口与实现
文件:RecommendAITools
java
@Component
public class RecommendAITools {
@Resource
private UserPlaceMemoryRepository memoryRepo;
@Resource
private PlaceService placeService;
@Resource
private BaiduMapService baiduMapService;
/**
* Tool 1:查询用户历史足迹
* AI 判定用户有历史记录时自动调用
*/
@Tool(name = "queryUserHistoryTool", description = """
查询用户的历史出行记录和偏好。
当用户提到去过某地、或当前用户有历史足迹数据时调用此工具。
返回:用户去过的地点列表、访问频率、当时的情绪标签
""")
public List<UserHistory> queryUserHistory(Long userId) {
// 从 UserPlaceMemory 表查询用户历史足迹
List<UserPlaceMemory> memories = memoryRepo.findByUserIdOrderByVisitCountDesc(userId);
if (memories.isEmpty()) {
return Collections.emptyList();
}
return memories.stream()
.map(m -> new UserHistory(m.getPlaceName(), m.getVisitCount(), m.getEmotionTag()))
.limit(5) // ⚠️ 截断防止 Token 超限
.toList();
}
/**
* Tool 2:查询官方收录景点
* AI 需要推荐目的地但用户无历史记录时调用
*/
@Tool(name = "queryOfficialPlacesTool", description = """
查询系统中官方收录的推荐景点。
当用户询问某个城市的推荐去处、或用户没有历史记录需要推荐时调用。
参数 city:城市名称,如"北京"
返回:该城市的官方景点列表及相关情绪标签
""")
public List<PlaceCard> queryOfficialPlaces(String city) {
// 从 PlaceEmotionLibrary 按城市查询
return placeService.getPlacesByCity(city).stream()
.map(p -> new PlaceCard(p.getName(), p.getEmotionTags(), p.getSummary()))
.limit(8) // ⚠️ 截断
.toList();
}
/**
* Tool 3:在线搜索实时地点(百度地图)
* AI 需要实时/热门/未收录地点时调用
*/
@Tool(name = "searchOnlineTool", description = """
在线搜索实时地点信息(调用百度地图 API)。
当用户询问实时路线、热门景点、或官方未收录的地点时调用。
参数 query:搜索关键词
参数 location:位置坐标或城市名
返回:实时搜索结果列表
""")
public List<PlaceCard> searchOnline(String query, String location) {
// 调用百度地图 POI 搜索
return baiduMapService.searchPoi(query, location).stream()
.map(p -> new PlaceCard(p.getName(), null, p.getAddress()))
.limit(5) // ⚠️ 关键:截断防止Token超限
.toList();
}
}
3.2@AiService接口定义+System Prompt引导
文件 :RecommendAIService.java
java
@AiService // Langchain4j注解
public interface RecommendAIService {
/**
* 统一推荐接口:
* - message: 用户自然语言输入(如"推荐一个适合散心的地方")
* - memoryId: 用户 ID,用于对话记忆 + 查询历史
*
* AI 模型会根据 SystemPrompt 的引导,自主决定调用哪个 Tool
*/
@SystemPrompt("""
你是 Corner 旅行助手的推荐引擎。你的核心职责是根据用户上下文,自主选择合适的工具获取信息,然后给出个性化推荐。
## 工具调用决策规则(按优先级):
1. **先查用户历史**:调用 queryUserHistoryTool 获取用户去过的地方。如果用户历史丰富(≥3条),优先推荐用户未去过但情绪匹配的新地点。
2. **历史不足查官方库**:如果用户历史为空或不足,调用 queryOfficialPlacesTool 获取官方推荐景点。
3. **实时需求在线查**:如果用户明确要求"实时""热门""最新",或官方库没有匹配结果,调用 searchOnlineTool。
4. **多源融合**:允许同时调用多个工具(如查历史 + 查官方),将结果融合后给出综合推荐。
## 回答原则:
- 每次推荐给出 3-5 个地点,附上推荐理由
- 如果用户有历史记录,对比"之前去过"和"这次推荐"的差异
- 语气温暖、个性化,像老朋友推荐地方一样
""")
@UserMessage("""
用户请求:{message}
请根据用户上下文自主决策调用合适的工具,生成个性化推荐。
""")
Flux<String> chat(@UserMessage String message, @MemoryId String memoryId);
}
3.3 Controller层------收敛成一个统一接口
文件 :RecommendController.java
java
@RestController
@RequestMapping("/api/recommend")
public class RecommendController {
@Resource
private RecommendService recommendService;
/**
* 统一推荐接口(取代之前多个推荐接口)
*
* 前端不再需要判断用户有没有历史记录,
* 只需将要推荐的自然语言描述发过来即可。
*/
@PostMapping(value = "/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> chat(@RequestBody @Valid RecommendRequest request) {
// 直接转发给 AI Service,由 AI 自己决定调哪个 Tool
return recommendService.chat(request.getMessage(), request.getUserId());
}
}
文件 :RecommendServiceImpl.java
java
@Service
public class RecommendServiceImpl implements RecommendService {
@Resource
private RecommendAIService recommendAIService;
@Override
public Flux<String> chat(String message, Long userId) {
// memoryId 传入 userId,AI 服务会自动关联对话记忆
return recommendAIService.chat(message, String.valueOf(userId));
}
}
微总结
| 场景 | 原因 |
|---|---|
| 决策链路不固定,依赖上下文 | AI 自主判断比硬编码更灵活 |
| 多数据源需要融合 | AI 天然擅长多源信息融合 |
| 需求频繁变化/新增策略 | 新增一个 @Tool 方法即可,零改动 |
| 希望自然语言交互 | Tool Calling + LLM = 对话式交互 |
四、踩坑与解决
模型不按预期调用Tool
有时用户说"推荐被禁好玩的地方",AI没有调用queryOfficialPlacesTool,而是出现凭空捏造景点
解决->提示词调优
"当用户提到城市名时,必须调用
queryOfficialPlacesTool获取数据,禁止凭空编造景点信息。如果官方库没有结果,再调用
searchOnlineTool补充。"
- 使用强约束词
- 同时给出反例约束
- 设定一个优先级:先查询什么,再查询什么
Tool返回数据过多导致Token超出限制
一个用户历史记录有200多条,Tool返回全部数据->Token额度用尽->AI回答被截断
解决->结果截断+摘要策略
在项目中通过窗口函数限制只返回前5条用户信息
java
// 方案 A:限制返回条数(推荐)
List<UserHistory> histories = memories.stream()
.limit(5) // 只取 Top-5
.toList();
- 一般情况下返回的元数据不要超过2000tokens,在Corner项目中我限定了8-10条
多个Tool结果如何融合成最终回答
AI会出现同时调用queryUserHistoryTool和queryOfficialPlacesTool,但是回答时只用了其中一个,另一个被忽略
解决->在SystemPrompt中明确融合策略:
java
@SystemPrompt("""
## 多源融合规则(重要):
当你调用了多个工具时,必须按以下方式融合:
1. 用 queryUserHistoryTool 的结果排除用户去过的地方
2. 用 queryOfficialPlacesTool 的结果作为推荐候选
3. 如果用户情绪标签匹配,优先推荐匹配项
4. 最终回答中注明"根据你的历史足迹,为你推荐..."
""")
Corner实际做法:让AI成为融合中枢------Tool只负责获取数据,AI负责理解、对比、筛选、生成推荐理由,不再写额外的Java融合代码
Tool被重复调用导致API费用翻倍
用户发一条消息,日志显示同一个Tool被调用了3次。
根因:LangChain4j在某些版本中,如果Tool返回结果不符合模型的预期(比如返回空列表),模型会尝试重新调用。
解决 :在Tool实现中,返回空结果时附带一句提示,如"暂无历史记录,请调用其他工具",让模型明白不是调用失败,而是确实没数据。
五、效果比对
| 维度 | 改造前(if-else) | 改造后(Tool Calling) |
|---|---|---|
| 用户输入 | 必须选择"无历史记录"按钮 | 直接说"推荐北京好玩的地方" |
| 前端请求 | 调用 /api/recommend/byCity?city=北京 |
调用 /api/recommend/chat |
| 后端逻辑 | Controller 查是否登录 → 查历史 → 返回 | AI 自动调用 queryOfficialPlacesTool |
| 用户体验 | 僵硬,需要用户手动选择模式 | 自然对话,零学习成本 |
六、总结与适用场景
✅ 什么时候该用 Tool Calling?
| 场景 | 原因 |
|---|---|
| 决策链路不固定,依赖上下文 | AI 自主判断比硬编码更灵活 |
| 多数据源需要融合 | AI 天然擅长多源信息融合 |
| 需求频繁变化/新增策略 | 新增一个 @Tool 方法即可,零改动 |
| 希望自然语言交互 | Tool Calling + LLM = 对话式交互 |
❌ 什么时候该用传统 if-else?
| 场景 | 原因 |
|---|---|
| 决策逻辑确定且简单(如 if userId == null) | if-else 更轻量、延迟更低 |
| 对延迟极度敏感(< 100ms) | LLM 推理有延迟,不适合高频调用 |
| 数据源固定(如只查一张表) | 没有必要引入 AI 推理开销 |
| 预算有限 | LLM API 调用有成本 |
Corner 项目的平衡之道
markdown
Controller 层:统一入口(Tool Calling)
↓
AI Service 层:决策路由(Tool Calling)
↓
Tool 实现层:具体数据查询(if-else 处理边缘情况)
↓
数据源:MySQL / Redis / 百度地图 API
- 上层(Controller → Service):用 Tool Calling,灵活决策
- 下层(Tool 实现内部):用 if-else,处理数据查询的边界情况
- 各司其职,发挥各自优势
最后
当然,Tool Calling 并非银弹。它会带来额外的 LLM 推理延迟(约 1-2 秒,处理不好会导致输出结果时间延长,影响用户体验),且提示词设计不当可能导致模型决策失误。因此建议:只在决策链路不固定的场景使用,核心链路仍然保留 if-else 兜底。
📚 本文是Corner项目实战系列第2篇