工具调用失败日志里,报错是参数校验不通过,收到的入参长这样:{"订单号":"A001","页码":"1"}。Schema 里页码声明的是 integer,模型给的是字符串。
先把一份精简的工单查询 Schema 摊开,看每种畸形入参分别会被哪条规则拦下。
// 验证脚本:工具调用参数的 JSON Schema 校验在几种常见畸形入参上的表现
const 模式 = {
type: 'object',
properties: {
订单号: { type: 'string', minLength: 1 },
页码: { type: 'integer', minimum: 1 },
状态: { type: 'string', enum: ['待付款', '已发货', '已完成'] },
},
required: ['订单号'],
additionalProperties: false,
};
const 校验 = (模式定义, 值, 路径 = '$') => {
const 错误 = [];
const 类型表 = {
object: (候选) => typeof 候选 === 'object' && 候选 !== null && !Array.isArray(候选),
string: (候选) => typeof 候选 === 'string',
integer: (候选) => Number.isInteger(候选),
};
const 类型判定 = 类型表[模式定义.type];
if (类型判定 && !类型判定(值)) {
错误.push(`${路径} 类型应为 ${模式定义.type},实际为 ${Array.isArray(值) ? 'array' : typeof 值}`);
return 错误;
}
if (模式定义.minLength !== undefined && typeof 值 === 'string' && 值.length < 模式定义.minLength) {
错误.push(`${路径} 长度 ${值.length} 小于 minLength ${模式定义.minLength}`);
}
if (模式定义.minimum !== undefined && typeof 值 === 'number' && 值 < 模式定义.minimum) {
错误.push(`${路径} 值 ${值} 小于 minimum ${模式定义.minimum}`);
}
if (模式定义.enum && !模式定义.enum.includes(值)) {
错误.push(`${路径} 值 ${JSON.stringify(值)} 不在枚举 ${JSON.stringify(模式定义.enum)} 中`);
}
if (模式定义.type === 'object' && typeof 值 === 'object' && 值 !== null) {
(模式定义.required || []).forEach((键) => {
if (!(键 in 值)) 错误.push(`${路径} 缺少必填字段 ${键}`);
});
if (模式定义.additionalProperties === false) {
Object.keys(值)
.filter((键) => !(键 in (模式定义.properties || {})))
.forEach((键) => 错误.push(`${路径} 出现未声明字段 ${键}`));
}
Object.entries(值).forEach(([键, 子值]) => {
const 子模式 = (模式定义.properties || {})[键];
if (子模式) 错误.push(...校验(子模式, 子值, `${路径}.${键}`));
});
}
return 错误;
};
const 用例表 = [
{ 名称: '合法入参', 值: { 订单号: 'A001', 页码: 1, 状态: '已发货' } },
{ 名称: '缺必填', 值: { 页码: 1 } },
{ 名称: '类型不符', 值: { 订单号: 12345 } },
{ 名称: '空字符串', 值: { 订单号: '' } },
{ 名称: '页码为零', 值: { 订单号: 'A001', 页码: 0 } },
{ 名称: '页码为小数', 值: { 订单号: 'A001', 页码: 1.5 } },
{ 名称: '枚举越界', 值: { 订单号: 'A001', 状态: '已取消' } },
{ 名称: '多余字段', 值: { 订单号: 'A001', 备注: '加急' } },
{ 名称: '正确模型常给的形态', 值: { 订单号: 'A001', 页码: '1', 状态: '已发货' } },
];
用例表.forEach((用例) => {
const 错误 = 校验(模式, 用例.值);
const 结论 = 错误.length === 0 ? '通过' : `拒绝:${错误.join(';')}`;
console.log(`${用例.名称.padEnd(14)} ${结论}`);
});
实际输出:
合法入参 通过
缺必填 拒绝:$ 缺少必填字段 订单号
类型不符 拒绝:$.订单号 类型应为 string,实际为 number
空字符串 拒绝:$.订单号 长度 0 小于 minLength 1
页码为零 拒绝:$.页码 值 0 小于 minimum 1
页码为小数 拒绝:$.页码 类型应为 integer,实际为 number
枚举越界 拒绝:$.状态 值 "已取消" 不在枚举 ["待付款","已发货","已完成"] 中
多余字段 拒绝:$ 出现未声明字段 备注
正确模型常给的形态 拒绝:$.页码 类型应为 integer,实际为 string
九条用例里八条被拒,各自的原因都能对上 Schema 里的一条规则。最后一条值得单独看:它在业务上完全正确,页码就是 1,只是被写成了字符串。这类入参在模型输出里出现频率很高,因为数字在文本里天然就是字符序列,模型生成 "1" 和生成 1 的代价没有区别。
严格拒绝的后果是模型拿到报错重试,多数情况下第二次会改成数字。代价是每一次往返都要多花一轮,且模型不一定每次都改对。如果工具调用链路对延迟敏感,或者并发量大,这一轮往返的成本会被放大。
宽松强制转换能省掉这次往返。把几种常见错位放在一起对比:
// 验证脚本:模型常给的类型错位分布,以及两种修复路径的覆盖面
const 原始入参样本 = [
{ 名称: '数字写成字符串', 值: { 页码: '1' } },
{ 名称: '布尔写成字符串', 值: { 是否包含已删除: 'false' } },
{ 名称: '数组写成单元素', 值: { 标签: '急件' } },
{ 名称: '数字带空格', 值: { 页码: ' 2 ' } },
{ 名称: '小数写成字符串', 值: { 金额: '100.50' } },
{ 名称: '布尔用零一', 值: { 是否包含已删除: 0 } },
{ 名称: '本来就是数字', 值: { 页码: 1 } },
];
const 严格 = (字段模式, 值) => {
const 通过 = typeof 值 === 字段模式;
return 通过 ? `接受 ${JSON.stringify(值)}` : `拒绝(期望 ${字段模式},收到 ${typeof 值})`;
};
const 宽松 = (字段模式, 值) => {
if (字段模式 === 'number') {
const 转 = Number(值);
return Number.isFinite(转) ? `接受 ${转}` : '拒绝(转不成数字)';
}
if (字段模式 === 'boolean') {
const 真值表 = ['true', '1', true, 1];
const 假值表 = ['false', '0', false, 0];
if (真值表.includes(值)) return '接受 true';
if (假值表.includes(值)) return '接受 false';
return '拒绝(无法判定真假)';
}
if (字段模式 === 'array') {
return Array.isArray(值) ? `接受 ${JSON.stringify(值)}` : `接受 ${JSON.stringify([值])}(包装成单元素数组)`;
}
return `接受 ${JSON.stringify(值)}`;
};
const 用例表 = [
{ 模式: 'number', 样本: 原始入参样本[0] },
{ 模式: 'number', 样本: 原始入参样本[3] },
{ 模式: 'number', 样本: 原始入参样本[4] },
{ 模式: 'number', 样本: 原始入参样本[6] },
{ 模式: 'boolean', 样本: 原始入参样本[1] },
{ 模式: 'boolean', 样本: 原始入参样本[5] },
{ 模式: 'array', 样本: 原始入参样本[2] },
];
console.log('场景'.padEnd(18), '严格路径'.padEnd(34), '宽松路径');
用例表.forEach((用例) => {
const 值 = Object.values(用例.样本.值)[0];
console.log(
用例.样本.名称.padEnd(20),
严格(用例.模式, 值).padEnd(36),
宽松(用例.模式, 值)
);
});
实际输出:
场景 严格路径 宽松路径
数字写成字符串 拒绝(期望 number,收到 string) 接受 1
数字带空格 拒绝(期望 number,收到 string) 接受 2
小数写成字符串 拒绝(期望 number,收到 string) 接受 100.5
本来就是数字 接受 1 接受 1
布尔写成字符串 拒绝(期望 boolean,收到 string) 接受 false
布尔用零一 拒绝(期望 boolean,收到 number) 接受 false
数组写成单元素 拒绝(期望 array,收到 string) 接受 ["急件"](包装成单元素数组)
严格路径把六种错位全部挡下,宽松路径全部接住,包括带空格的 " 2 "。带空格这一条如果模型稳定这么给,严格路径会反复失败,实际值就是 2,拒绝它没有收益。
宽松路径的代价藏在转换本身。数字转换这一侧有两条必须单独拦:
// 验证脚本:宽松转换路径在边界写法上的实际结果
const 转数字 = (值) => {
const 转 = Number(值);
return Number.isFinite(转) ? `接受 ${转}` : '拒绝(转不成数字)';
};
console.log('=== 数字的边界写法 ===');
['1e3', '0x10', ' 12 ', '', 'abc', 'Infinity'].forEach((值) => {
console.log(`输入 ${JSON.stringify(值).padEnd(10)} -> ${转数字(值)}`);
});
实际输出:
=== 数字的边界写法 ===
输入 "1e3" -> 接受 1000
输入 "0x10" -> 接受 16
输入 " 12 " -> 接受 12
输入 "" -> 接受 0
输入 "abc" -> 拒绝(转不成数字)
输入 "Infinity" -> 拒绝(转不成数字)
空串转成 0 是最麻烦的一条。页码位置收到空串,转换后变成 页码: 0,如果 Schema 上有 minimum: 1 还能拦回来;没有下界约束的话,0 会被当成合法的第一页传下去,查出来的结果和预期不一致,而且不会有任何报错。空串必须在转换前先拦掉,不能交给 Number 处理。
"0x10" 转成 16 也是同一类问题。Number 认十六进制前缀,模型输出的 "0x10" 大概率不是想把 16 传进来,而是某种格式串被截断的结果。这条同样要在转换前判断格式,只接受纯十进制数字串。
字符串布尔这一侧要划清真值表。"false" 和 "0" 要映射成 false,"否"、"no"、空串不能当成 false,这几写法没有共识,当成假会静默改变语义。给不出明确判定时退回拒绝,让模型重试比猜错更安全。
落到实现上,判断顺序建议是:先做格式白名单校验,再做类型转换,最后跑 Schema。这样空串和非十进制写法在第一步就被挡住,转换只处理形态正确、类型不对的入参,Schema 继续负责业务约束。代价是多一层校验逻辑,且要维护每种字段类型的格式规则。
如果工具调用量大、对延迟敏感,走宽松转换这一侧更划算。如果工具会改数据、误判的后果不可逆,严格拒绝加模型重试更稳。两种都要做的是把空串这类会导致静默错值的输入单独拦掉,它与类型错位不是一回事。