LangChain4j 的自定义 Tool——超出内置工具时怎么做
LangChain4j 的自定义 Tool——超出内置工具时怎么做
我记得第一次用 LangChain4j 自定义工具的时候,被一个参数类型的问题卡了将近两个小时。
那是一个数据库查询工具,用户可以传进来一个字段过滤条件,类型是 Map<String, Object>。我按照 LangChain4j 的标准写法写完之后,发现 Agent 死活传不对参数——LLM 生成的 JSON 里这个 Map 的格式一直不对,有时候直接忽略这个参数,有时候传了一个错误的结构。
后来我把参数类型重构了,改成了一个明确的 POJO,问题立刻就解决了。
这篇文章想聊的是:当 LangChain4j 的内置工具满足不了你的需求时,怎么写好一个自定义 Tool,以及有哪些坑是大多数人会踩到的。
一、内置工具 vs 自定义工具
LangChain4j 提供了一些开箱即用的工具,比如:
WebSearchTool:网页搜索FileSystemTool:文件读写CodeInterpreterTool:代码执行
这些工具覆盖了通用场景,配置一下就能用,非常方便。
但真实业务场景里,你会遇到大量的特定领域需求:
- 查你们公司自己的数据库
- 调用你们内部的微服务接口
- 执行特定的业务逻辑(如计算运费、检查库存、发送站内信)
这些东西内置工具没法覆盖,必须自定义。
自定义工具本质上就是:用 @Tool 注解标记一个 Java 方法,LangChain4j 会把这个方法的描述、参数信息自动提取成 LLM 能理解的 Tool Schema。
二、基础写法回顾
先看一个最简单的自定义工具:
@Component
public class OrderQueryTool {
@Autowired
private OrderService orderService;
@Tool("根据订单ID查询订单详情,返回订单的商品信息、金额、状态等")
public OrderDetail getOrderDetail(
@P("订单ID,格式:ORD-YYYYMMDD-XXXXXX,例如 ORD-20240115-001234")
String orderId) {
return orderService.getDetail(orderId);
}
}这是最基础的形式。@Tool 是工具的描述,@P 是参数的描述。
三、复杂参数类型的处理
现在进入真正有难度的部分。
问题:List 参数
假设你需要一个工具,能同时查询多个订单:
// 不好的写法
@Tool("批量查询订单")
public List<OrderDetail> getOrders(
@P("订单ID列表") List<String> orderIds) {
return orderService.batchGet(orderIds);
}这个写法语法上没问题,但 LLM 生成的参数有时候是:
{"orderIds": "ORD-001,ORD-002"} // 字符串,不是数组有时候是:
{"orderIds": ["ORD-001", "ORD-002"]} // 正确不稳定。
更好的做法是在 @P 里明确说明格式,或者改成字符串参数,自己解析:
@Tool("批量查询订单,一次最多查询10个")
public List<OrderDetail> getOrders(
@P("订单ID列表,多个ID用英文逗号分隔,例如:ORD-001,ORD-002,ORD-003")
String orderIdsCsv) {
if (orderIdsCsv == null || orderIdsCsv.isBlank()) {
return List.of();
}
List<String> orderIds = Arrays.stream(orderIdsCsv.split(","))
.map(String::trim)
.filter(s -> !s.isEmpty())
.limit(10) // 强制限制最多10个
.collect(Collectors.toList());
return orderService.batchGet(orderIds);
}问题:Map 参数(动态字段过滤)
这是我开头说的那个问题。需要做动态查询过滤时,最自然的想法是用 Map<String, Object>,但这对 LLM 来说很难构造正确。
解决方案:用结构化 POJO 替代 Map
/**
* 查询条件 POJO
* 用具体字段代替模糊的 Map,LLM 能更精确地理解和填充
*/
@Data
@Builder
public class OrderQueryCondition {
@JsonProperty("user_id")
@JsonPropertyDescription("用户ID,不填则不按用户过滤")
private Long userId;
@JsonProperty("status")
@JsonPropertyDescription("订单状态:PENDING/PAID/SHIPPED/COMPLETED/CANCELLED,不填则查所有状态")
private String status;
@JsonProperty("start_date")
@JsonPropertyDescription("开始日期,格式 yyyy-MM-dd,不填则不限制开始时间")
private String startDate;
@JsonProperty("end_date")
@JsonPropertyDescription("结束日期,格式 yyyy-MM-dd,不填则不限制结束时间")
private String endDate;
@JsonProperty("min_amount")
@JsonPropertyDescription("最小金额(元),不填则不限制最小金额")
private BigDecimal minAmount;
@JsonProperty("max_amount")
@JsonPropertyDescription("最大金额(元),不填则不限制最大金额")
private BigDecimal maxAmount;
@JsonProperty("page_size")
@JsonPropertyDescription("每页条数,默认10,最大50")
private Integer pageSize;
}
@Component
public class AdvancedOrderQueryTool {
@Autowired
private OrderRepository orderRepo;
@Tool("""
高级订单查询工具,支持多条件组合过滤。
所有条件都是可选的,未填写的条件不会影响查询结果。
最多返回50条记录。
""")
public OrderQueryResult queryOrders(OrderQueryCondition condition) {
// 验证和修正参数
if (condition.getPageSize() == null) condition.setPageSize(10);
condition.setPageSize(Math.min(condition.getPageSize(), 50));
// 构建查询
Specification<Order> spec = buildSpecification(condition);
Pageable pageable = PageRequest.of(0, condition.getPageSize());
Page<Order> page = orderRepo.findAll(spec, pageable);
return OrderQueryResult.builder()
.orders(page.getContent().stream()
.map(this::toSummary)
.collect(Collectors.toList()))
.total(page.getTotalElements())
.hasMore(page.hasNext())
.build();
}
private Specification<Order> buildSpecification(OrderQueryCondition condition) {
return (root, query, cb) -> {
List<Predicate> predicates = new ArrayList<>();
if (condition.getUserId() != null) {
predicates.add(cb.equal(root.get("userId"), condition.getUserId()));
}
if (condition.getStatus() != null && !condition.getStatus().isBlank()) {
predicates.add(cb.equal(root.get("status"), condition.getStatus()));
}
if (condition.getStartDate() != null) {
predicates.add(cb.greaterThanOrEqualTo(
root.get("createdAt"), LocalDate.parse(condition.getStartDate()).atStartOfDay()));
}
if (condition.getEndDate() != null) {
predicates.add(cb.lessThanOrEqualTo(
root.get("createdAt"), LocalDate.parse(condition.getEndDate()).atTime(23, 59, 59)));
}
if (condition.getMinAmount() != null) {
predicates.add(cb.greaterThanOrEqualTo(root.get("totalAmount"), condition.getMinAmount()));
}
if (condition.getMaxAmount() != null) {
predicates.add(cb.lessThanOrEqualTo(root.get("totalAmount"), condition.getMaxAmount()));
}
return cb.and(predicates.toArray(new Predicate[0]));
};
}
}用 POJO 的好处:
@JsonPropertyDescription直接成为 JSON Schema 里的字段说明,LLM 能看到- 每个字段明确了数据类型和取值范围
- 所有字段都是可选的(包装类型),LLM 不填的字段会是 null
问题:枚举参数
当参数有固定取值范围时,用枚举:
public enum OrderStatus {
PENDING, PAID, SHIPPED, COMPLETED, CANCELLED
}
@Tool("更新订单状态")
public boolean updateOrderStatus(
@P("订单ID") String orderId,
@P("新状态:PENDING(待付款)/PAID(已付款)/SHIPPED(已发货)/COMPLETED(已完成)/CANCELLED(已取消)")
String status) {
// 手动验证枚举值,给出清晰的错误提示
try {
OrderStatus newStatus = OrderStatus.valueOf(status);
return orderService.updateStatus(orderId, newStatus);
} catch (IllegalArgumentException e) {
throw new InvalidParameterException("无效的订单状态: " + status +
",有效值为:PENDING/PAID/SHIPPED/COMPLETED/CANCELLED");
}
}四、工具描述写不好比不写更糟糕
这个说法听起来有点极端,但我是认真的。
一个错误的工具描述会主动误导 LLM,比没有描述更糟糕。
我见过几种常见的"反模式":
反模式一:描述和实际行为不符
// 描述说"查询",但实际上会修改数据
@Tool("查询用户的优惠券余额") // 这是错误的描述!
public CouponBalance deductAndCheckBalance(String userId, int amount) {
// 实际上这个方法会扣减余额后返回剩余值
couponService.deduct(userId, amount);
return couponService.getBalance(userId);
}LLM 以为这只是"查询",就会在不合适的地方调用,导致用户被莫名其妙扣券。
反模式二:描述太抽象
// "处理数据"——LLM 根本不知道这是干什么的
@Tool("处理数据")
public String processData(String input) { ... }反模式三:描述了技术实现而不是业务含义
// 告诉 LLM 底层用的是 SELECT 语句,这没有任何意义
@Tool("执行一个 SELECT 语句查询订单数据库")
public List<Order> executeSelect(String sql) { ... }正确做法:描述业务含义,而不是技术实现
@Tool("""
查询用户最近的订单,用于了解用户的购买历史和当前待处理订单。
适合以下场景:
- 用户询问"我最近买了什么"
- 需要了解用户购买偏好用于推荐
- 客服查询用户订单进行售后处理
默认返回最近30天、最多20条订单,按时间倒序排列。
每条订单包含:订单号、商品名称、金额、状态、下单时间。
""")
public List<OrderSummary> getRecentOrders(
@P("用户ID") Long userId,
@P("查询天数,1-90,默认30") Integer days) {
// ...
}五、工具的错误处理和返回值设计
工具的返回值也很重要,LLM 需要理解返回值才能做出正确的下一步决策。
/**
* 通用工具返回结果包装
* 让 LLM 能清楚地知道操作是否成功,以及失败原因
*/
@Data
@Builder
public class ToolResult<T> {
private boolean success;
private T data;
private String errorMessage;
private String errorCode;
public static <T> ToolResult<T> success(T data) {
return ToolResult.<T>builder()
.success(true)
.data(data)
.build();
}
public static <T> ToolResult<T> failure(String errorCode, String errorMessage) {
return ToolResult.<T>builder()
.success(false)
.errorCode(errorCode)
.errorMessage(errorMessage)
.build();
}
}
@Tool("""
为用户发放指定类型的优惠券。
调用前请确认:
1. 用户有资格领取此类型优惠券(用checkCouponEligibility确认)
2. 库存足够
成功时返回:success=true,data包含优惠券码和有效期
失败时返回:success=false,errorCode说明失败原因:
- ALREADY_CLAIMED: 用户已领取过此类型券
- OUT_OF_STOCK: 优惠券已发完
- NOT_ELIGIBLE: 用户不符合领取条件
- SYSTEM_ERROR: 系统异常,可稍后重试
""")
public ToolResult<CouponInfo> sendCoupon(
@P("用户ID") Long userId,
@P("优惠券模板ID") String couponTemplateId) {
try {
// 检查库存
if (!couponService.hasStock(couponTemplateId)) {
return ToolResult.failure("OUT_OF_STOCK", "此优惠券已发完");
}
// 检查是否已领取
if (couponService.hasAlreadyClaimed(userId, couponTemplateId)) {
return ToolResult.failure("ALREADY_CLAIMED", "用户已领取过此类型优惠券");
}
// 发放
CouponInfo coupon = couponService.issue(userId, couponTemplateId);
return ToolResult.success(coupon);
} catch (Exception e) {
log.error("发放优惠券失败", e);
return ToolResult.failure("SYSTEM_ERROR", "系统繁忙,请稍后重试");
}
}六、完整示例:数据库查询工具(动态参数)
用一个完整的例子把上面所有技巧串起来:
/**
* 动态数据库查询工具
* 支持多表、多字段、多条件的灵活查询
*/
@Component
@Slf4j
public class DynamicDatabaseQueryTool {
@Autowired
private JdbcTemplate jdbcTemplate;
// 允许查询的表白名单(安全限制)
private static final Set<String> ALLOWED_TABLES = Set.of(
"orders", "products", "users", "categories", "reviews"
);
@Tool("""
执行动态数据库查询,支持多种过滤条件和排序。
此工具只允许查询(SELECT),不支持修改数据。
可查询的表:orders(订单)、products(商品)、users(用户)、
categories(分类)、reviews(评论)
条件格式示例:
- 等值:{"field": "status", "operator": "eq", "value": "ACTIVE"}
- 范围:{"field": "price", "operator": "between", "value": "10,100"}
- 模糊:{"field": "name", "operator": "like", "value": "iPhone"}
- 大于:{"field": "amount", "operator": "gt", "value": "500"}
""")
public QueryResult executeQuery(QueryRequest request) {
// 安全检查:只允许查询白名单中的表
if (!ALLOWED_TABLES.contains(request.getTableName().toLowerCase())) {
throw new SecurityException("不允许查询此表: " + request.getTableName());
}
// 构建 SQL
StringBuilder sql = new StringBuilder("SELECT * FROM " + request.getTableName());
List<Object> params = new ArrayList<>();
if (request.getConditions() != null && !request.getConditions().isEmpty()) {
sql.append(" WHERE ");
List<String> conditions = new ArrayList<>();
for (QueryCondition condition : request.getConditions()) {
String clause = buildConditionClause(condition, params);
conditions.add(clause);
}
sql.append(String.join(" AND ", conditions));
}
// 排序
if (request.getOrderBy() != null) {
String direction = "DESC".equalsIgnoreCase(request.getOrderDirection()) ? "DESC" : "ASC";
sql.append(" ORDER BY ").append(sanitizeFieldName(request.getOrderBy()))
.append(" ").append(direction);
}
// 分页(限制最多100条)
int limit = Math.min(request.getLimit() != null ? request.getLimit() : 20, 100);
sql.append(" LIMIT ").append(limit);
log.debug("执行查询SQL: {}", sql);
try {
List<Map<String, Object>> rows = jdbcTemplate.queryForList(sql.toString(), params.toArray());
return QueryResult.builder()
.success(true)
.rows(rows)
.rowCount(rows.size())
.sql(sql.toString()) // 返回执行的 SQL 方便调试
.build();
} catch (Exception e) {
log.error("查询执行失败: {}", sql, e);
return QueryResult.builder()
.success(false)
.errorMessage("查询执行失败: " + e.getMessage())
.build();
}
}
private String buildConditionClause(QueryCondition condition, List<Object> params) {
String field = sanitizeFieldName(condition.getField());
switch (condition.getOperator().toLowerCase()) {
case "eq":
params.add(condition.getValue());
return field + " = ?";
case "like":
params.add("%" + condition.getValue() + "%");
return field + " LIKE ?";
case "gt":
params.add(condition.getValue());
return field + " > ?";
case "lt":
params.add(condition.getValue());
return field + " < ?";
case "between":
String[] parts = condition.getValue().split(",");
params.add(parts[0].trim());
params.add(parts[1].trim());
return field + " BETWEEN ? AND ?";
default:
throw new InvalidParameterException("不支持的操作符: " + condition.getOperator());
}
}
private String sanitizeFieldName(String fieldName) {
// 防止 SQL 注入:只允许字母、数字、下划线
if (!fieldName.matches("[a-zA-Z_][a-zA-Z0-9_]*")) {
throw new SecurityException("非法字段名: " + fieldName);
}
return fieldName;
}
}
/**
* 查询请求参数(使用 POJO 而非 Map,让 LLM 能理解结构)
*/
@Data
public class QueryRequest {
@JsonPropertyDescription("要查询的表名")
private String tableName;
@JsonPropertyDescription("查询条件列表,可以为空(不填则返回所有数据)")
private List<QueryCondition> conditions;
@JsonPropertyDescription("排序字段名")
private String orderBy;
@JsonPropertyDescription("排序方向:ASC升序或DESC降序,默认DESC")
private String orderDirection;
@JsonPropertyDescription("返回条数,默认20,最大100")
private Integer limit;
}
@Data
public class QueryCondition {
@JsonPropertyDescription("字段名")
private String field;
@JsonPropertyDescription("操作符:eq(等于)/like(模糊)/gt(大于)/lt(小于)/between(范围)")
private String operator;
@JsonPropertyDescription("值,between时用逗号分隔两个值")
private String value;
}七、注册和使用自定义工具
最后,把工具注册到 Agent 里:
@Configuration
public class AgentConfig {
@Autowired
private OrderQueryTool orderQueryTool;
@Autowired
private AdvancedOrderQueryTool advancedOrderQueryTool;
@Autowired
private DynamicDatabaseQueryTool dbQueryTool;
@Bean
public CustomerServiceAssistant customerServiceAssistant(
ChatLanguageModel model) {
return AiServices.builder(CustomerServiceAssistant.class)
.chatLanguageModel(model)
.tools(orderQueryTool, advancedOrderQueryTool, dbQueryTool)
.build();
}
}八、Mermaid:自定义 Tool 的工作流程
九、总结
回到开头那个问题:为什么 Map<String, Object> 参数会让 LLM 传参不稳定?
原因很简单:LLM 是根据 JSON Schema 来构造参数的,Map 对应的 JSON Schema 是 {type: object, additionalProperties: true},这个描述太模糊,LLM 不知道这个对象应该有哪些字段。
而 POJO 对应的 JSON Schema 会列出每个字段的名字、类型、描述——这些信息直接指导 LLM 如何构造参数。
自定义工具的核心设计原则:
- 参数类型越具体越好,用 POJO 代替 Map
- 描述是给 LLM 看的,要写业务含义不是技术实现
- 返回值结构要清晰,包含成功/失败标志和错误原因
- 安全边界要在工具内部把守,不要依赖 LLM 做安全判断
