侧边栏壁纸
博主头像
一笑痕

人生若只如初见,
是可喜亦或者是可悲?

  • 累计撰写 117 篇文章
  • 累计收到 7 条评论

工具入参写成字符串被 Schema 拒掉,页码「1」是最高频的一种

2026-9-24 / 0 评论 / 11 阅读

工具调用失败日志里,报错是参数校验不通过,收到的入参长这样:{"订单号":"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 继续负责业务约束。代价是多一层校验逻辑,且要维护每种字段类型的格式规则。

如果工具调用量大、对延迟敏感,走宽松转换这一侧更划算。如果工具会改数据、误判的后果不可逆,严格拒绝加模型重试更稳。两种都要做的是把空串这类会导致静默错值的输入单独拦掉,它与类型错位不是一回事。

    🤞 分享