Function Call的参数校验:如何生成高质量的JSON Schema描述
Function Call的参数校验:如何生成高质量的JSON Schema描述
适读人群:正在构建LLM工具调用系统的Java工程师 | 阅读时长:约17分钟
开篇故事
做了几个月的工具调用系统后,我发现一个规律:工具调用出错,大约80%的原因不是代码Bug,而是JSON Schema写得不够好。
最典型的例子:我有一个查询订单的工具,参数里有date字段,我写的描述是"日期"。结果LLM有时传"2024-01-15",有时传"今天",有时传"2024/01/15",还有时传"January 15"。每种格式我的代码都要特殊处理。
后来我把描述改成:"日期,格式必须为ISO 8601标准,即 YYYY-MM-DD,例如:2024-01-15。不接受相对日期(如'今天'、'昨天')。"
问题基本消失了。
JSON Schema的description,就是你和LLM之间的契约文档。写好它,比写再多校验逻辑都管用。
一、JSON Schema对LLM行为的影响
LLM在生成函数调用参数时,主要参考三个来源:
- 用户的输入(提供参数值的原始素材)
description字段(告诉LLM这个参数是什么、怎么填)type、enum、format等约束(硬性约束,LLM会努力遵守)
description的质量 > type约束 > 没有description
二、JSON Schema的关键字段及最佳实践
2.1 type字段:基本类型约束
{
"type": "string", // 字符串
"type": "integer", // 整数
"type": "number", // 浮点数
"type": "boolean", // 布尔值
"type": "array", // 数组
"type": "object" // 对象
}对于日期时间,用"format": "date"等约束:
{
"name": "start_date",
"type": "string",
"format": "date", // 提示LLM这是ISO日期格式
"description": "开始日期,格式:YYYY-MM-DD,例如:2024-01-15"
}2.2 enum字段:枚举值约束(强力)
{
"name": "order_status",
"type": "string",
"enum": ["PENDING", "PAID", "SHIPPED", "DELIVERED", "CANCELLED"],
"description": "订单状态筛选。PENDING=待支付,PAID=已支付,SHIPPED=已发货,DELIVERED=已收货,CANCELLED=已取消"
}enum是最有效的约束——LLM几乎不会生成枚举之外的值。对于固定取值的字段,一定要用enum。
2.3 description的黄金写法
三、完整代码示例
3.1 用Java生成高质量JSON Schema
// 用于工具参数的Jackson注解
import com.fasterxml.jackson.annotation.*;
@JsonClassDescription("商品搜索请求参数。" +
"当用户想要查找、搜索、浏览商品时使用。" +
"至少需要提供keyword或categoryId中的一个。")
public record ProductSearchRequest(
@JsonProperty(value = "keyword")
@JsonPropertyDescription("搜索关键词,支持商品名称、品牌名、型号等。" +
"例如:'苹果手机'、'Nike运动鞋'、'小米电视'。" +
"如果用户没有提供具体商品名,可以用null。")
String keyword,
@JsonProperty(value = "category_id")
@JsonPropertyDescription("商品分类ID,整数类型。" +
"主要分类:1=手机数码,2=服装鞋包,3=家居家电,4=食品饮料,5=美妆护肤。" +
"如果不知道分类ID,用null并依赖keyword搜索。")
Integer categoryId,
@JsonProperty(value = "min_price")
@JsonPropertyDescription("最低价格(元),数字类型,不含货币符号。" +
"例如:用户说'100块以上',则min_price=100。" +
"如果没有价格限制,用null。")
Double minPrice,
@JsonProperty(value = "max_price")
@JsonPropertyDescription("最高价格(元),数字类型,不含货币符号。" +
"例如:用户说'500以内',则max_price=500。" +
"必须大于min_price(如果两者都有)。如果没有上限,用null。")
Double maxPrice,
@JsonProperty(value = "sort_by")
@JsonPropertyDescription("排序方式。" +
"PRICE_ASC=价格从低到高,PRICE_DESC=价格从高到低," +
"SALES_DESC=销量最高,RATING_DESC=评分最高,NEWEST=最新上架。" +
"用户没有指定排序时,默认SALES_DESC。")
@JsonSetter(nulls = Nulls.SKIP)
SortBy sortBy,
@JsonProperty(value = "page")
@JsonPropertyDescription("页码,从1开始,整数类型。默认1。" +
"当用户说'下一页'、'再看看'时,页码+1。")
@JsonSetter(nulls = Nulls.SKIP)
Integer page,
@JsonProperty(value = "page_size")
@JsonPropertyDescription("每页显示数量,整数类型,范围1-50,默认10。")
@JsonSetter(nulls = Nulls.SKIP)
Integer pageSize
) {
// 默认值处理
public ProductSearchRequest {
if (sortBy == null) sortBy = SortBy.SALES_DESC;
if (page == null) page = 1;
if (pageSize == null) pageSize = 10;
}
public enum SortBy {
PRICE_ASC, PRICE_DESC, SALES_DESC, RATING_DESC, NEWEST
}
}3.2 手工构建JSON Schema的完整示例
// 有时需要手工构建Schema(不使用Jackson注解),更灵活
public class SchemaBuilder {
public static JsonNode buildOrderQuerySchema() {
ObjectMapper mapper = new ObjectMapper();
ObjectNode schema = mapper.createObjectNode();
schema.put("type", "object");
ObjectNode properties = mapper.createObjectNode();
// orderId参数
ObjectNode orderIdProp = mapper.createObjectNode();
orderIdProp.put("type", "string");
orderIdProp.put("description",
"订单ID,由系统生成的唯一标识符。" +
"格式:纯数字,长度通常为8-18位。" +
"例如:'202401150001234'。" +
"必须从用户的消息中提取,不能猜测或生成。");
orderIdProp.put("pattern", "^\\d{8,18}$"); // 正则约束(部分LLM会参考)
properties.set("order_id", orderIdProp);
// status参数(过滤条件,可选)
ObjectNode statusProp = mapper.createObjectNode();
statusProp.put("type", "string");
ArrayNode statusEnum = statusProp.putArray("enum");
statusEnum.add("PENDING").add("PAID").add("SHIPPED")
.add("DELIVERED").add("CANCELLED");
statusProp.put("description",
"可选的订单状态过滤。" +
"不提供此参数时返回所有状态的订单。" +
"PENDING=待支付,PAID=已支付待发货,SHIPPED=已发货," +
"DELIVERED=已收货,CANCELLED=已取消。");
properties.set("status", statusProp);
// 日期范围
ObjectNode startDateProp = mapper.createObjectNode();
startDateProp.put("type", "string");
startDateProp.put("format", "date");
startDateProp.put("description",
"查询开始日期,ISO格式:YYYY-MM-DD。" +
"例如:2024-01-01。" +
"当用户说'最近一个月',自动计算为30天前的日期。" +
"当用户说'今年',设为当年1月1日。");
properties.set("start_date", startDateProp);
schema.set("properties", properties);
// 必填字段
ArrayNode required = schema.putArray("required");
required.add("order_id");
// 不允许额外字段(strict mode)
schema.put("additionalProperties", false);
return schema;
}
}3.3 Schema运行时校验
// 工具执行前校验LLM传来的参数
@Component
public class ToolArgumentValidator {
@Autowired
private ObjectMapper objectMapper;
public void validate(String toolName, String argumentsJson,
Class<?> inputType) throws ToolArgumentValidationException {
try {
// 1. 基本JSON格式校验
JsonNode args = objectMapper.readTree(argumentsJson);
// 2. 反序列化校验(类型匹配)
objectMapper.treeToValue(args, inputType);
// 3. Bean Validation校验
Object inputObj = objectMapper.readValue(argumentsJson, inputType);
ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
Validator validator = factory.getValidator();
Set<ConstraintViolation<Object>> violations = validator.validate(inputObj);
if (!violations.isEmpty()) {
String errorMsg = violations.stream()
.map(v -> v.getPropertyPath() + ": " + v.getMessage())
.collect(Collectors.joining(", "));
throw new ToolArgumentValidationException(
"Invalid arguments for tool '" + toolName + "': " + errorMsg);
}
} catch (JsonProcessingException e) {
throw new ToolArgumentValidationException(
"Malformed JSON arguments for tool '" + toolName + "': " + e.getMessage());
}
}
}
// 在FunctionCallback实现中使用
public class ValidatedFunctionCallback implements FunctionCallback {
@Autowired
private ToolArgumentValidator validator;
private final FunctionCallback delegate;
private final Class<?> inputType;
@Override
public String call(String functionInput) {
try {
validator.validate(getName(), functionInput, inputType);
} catch (ToolArgumentValidationException e) {
log.warn("Tool argument validation failed: {}", e.getMessage());
return objectMapper.writeValueAsString(Map.of(
"error", "INVALID_ARGUMENTS",
"message", e.getMessage()
));
}
return delegate.call(functionInput);
}
}四、踩坑实录
坑1:LLM把中文日期表达转成错误格式
现象:用户说"查一下上个月的订单",LLM传了"上个月"给start_date,导致解析失败。
根因:description没有说清楚不接受相对日期。
解决:在description里明确说明,并在system prompt里加日期转换指令:
工具调用时,所有日期必须转换为YYYY-MM-DD格式。
"今天"= 当前日期,"昨天"= 当前日期-1,"上个月"= 上月第一天到最后一天。坑2:数字被传成字符串类型
现象:价格字段类型定义为number,但LLM传了"100"(字符串)而不是100(数字)。
根因:某些场景下LLM会把数字包在引号里。Jackson在反序列化时可以宽容处理,但严格校验时会失败。
解决:在ObjectMapper配置中开启宽容数字解析:
ObjectMapper mapper = new ObjectMapper();
mapper.configure(MapperFeature.ALLOW_COERCION_OF_SCALARS, true);
mapper.coercionConfigFor(Integer.class)
.setCoercion(CoercionInputShape.String, CoercionAction.TryConvert);坑3:required字段LLM还是传了null
required约束对LLM来说是参考,不是强制。LLM仍然可能在参数值无法从上下文提取时传null。
解决:在工具执行逻辑里做null检查,或者在system prompt里说明"如果用户没有提供必要信息,先向用户询问,不要猜测参数值"。
坑4:Schema嵌套太深导致参数填错位置
当Schema嵌套3层以上时,LLM经常把参数填到错误的层级。
规则:工具输入类型尽量扁平,嵌套不超过2层。
五、总结与延伸
高质量JSON Schema的7条原则:
- description要具体:格式、示例、边界条件都要写
- 能用enum就用enum:固定取值的字段用enum,比description约束更有效
- format字段要用:日期用
"format":"date",时间用"format":"date-time" - 扁平化设计:嵌套不超过2层
- required字段最小化:只把真正必须的字段标记为required
- additionalProperties: false:严格模式,防止LLM添加幻觉字段
- 工具函数名要语义化:
get_order_by_id比query好得多
下一篇聊OpenAI Tools和Anthropic Tools两种协议的差异,以及如何做跨平台适配。
