tool-calling-json-schema-args · ZH · 2026-11-02

工具参数回来是一个 JSON 字符串而不是对象:在一把 key 上解析并校验六个后端的 tool call

多模型聚合 API 中,不同后端(Claude、GPT、DeepSeek、Qwen、GLM、Kimi)对工具调用参数的返回格式不一致:有的返回 JSON 字符串,有的直接返回对象。本文给出一段防御性解析与 schema 校验的写法,用一把 key 兼容全部后端,避免因格式差异导致的解析错误。

问题现象

用同一把 API key 调用多个模型时,工具调用(tool call / function call)的 arguments 字段有时是 JSON 字符串,有时已经是解析好的对象。如果不做兼容,代码会在其中一种后端上抛错。

典型错误包括:

  • 对字符串直接取属性,得到 undefined
  • 对对象再调用 JSON.parse,抛出 SyntaxError
  • 参数中出现未转义字符,导致 JSON 解析失败
  • 后端返回空字符串或 null 作为无参数调用的占位

为什么会有差异

工具调用的参数在协议层通常定义为字符串,便于流式传输和增量拼接。但不同厂商的 SDK、网关或聚合层可能在返回前做了预处理:有的原样透传字符串,有的直接反序列化为对象。聚合站不统一改写,是为了保留各家原始语义,因此兼容责任落在调用方。

防御性解析

核心思路:先判断类型,再决定是否解析;解析失败时保留原始文本用于排查。

function normalizeToolArguments(raw) {
  // 无参数调用
  if (raw == null) return {};

  // 已经是对象
  if (typeof raw === 'object' && !Array.isArray(raw)) {
    return raw;
  }

  // 字符串:可能是 JSON,也可能是空串
  if (typeof raw === 'string') {
    const text = raw.trim();
    if (text === '') return {};
    try {
      const parsed = JSON.parse(text);
      // 解析结果必须是对象
      if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
        return parsed;
      }
      throw new Error('arguments 不是 JSON 对象');
    } catch (err) {
      // 保留原文,便于定位是哪个后端返回了什么
      err.rawArguments = raw;
      throw err;
    }
  }

  throw new Error(`不支持的 arguments 类型: ${typeof raw}`);
}

注意点:

  • 空字符串要当作“无参数”,而不是解析失败
  • 数组不是合法参数,应视为错误
  • 解析失败时不要静默返回 {},否则会把参数错误伪装成“没有传参”

Schema 校验

解析成对象只是第一步。不同后端对同一工具的字段命名、必填性、类型可能理解不同,需要独立的校验层。可以用轻量校验库,也可以手写。

function validateAgainstSchema(obj, schema) {
  const errors = [];

  for (const [key, rule] of Object.entries(schema)) {
    const value = obj[key];

    if (value === undefined) {
      if (rule.required) errors.push(`缺少必填字段: ${key}`);
      continue;
    }

    if (rule.type && typeof value !== rule.type) {
      errors.push(`字段 ${key} 类型应为 ${rule.type},实际为 ${typeof value}`);
    }
  }

  // 拒绝未声明的字段,避免后端多传导致下游误用
  for (const key of Object.keys(obj)) {
    if (!(key in schema)) errors.push(`未知字段: ${key}`);
  }

  return errors;
}

工具定义示例:

const weatherSchema = {
  city: { type: 'string', required: true },
  unit: { type: 'string', required: false },
};

调用流程:

const args = normalizeToolArguments(toolCall.function.arguments);
const errors = validateAgainstSchema(args, weatherSchema);
if (errors.length) {
  // 把 errors 回传给模型,让其修正参数
  return { role: 'tool', content: JSON.stringify({ errors }) };
}

把错误回传给模型

工具参数校验失败时,推荐把结构化错误作为工具结果返回,而不是直接抛异常终止对话。模型通常能根据错误信息重新生成合法参数。这比在应用层猜测意图更可靠。

{
  "role": "tool",
  "tool_call_id": toolCall.id,
  "content": JSON.stringify({ ok: false, errors })
}

流式场景的额外处理

流式返回时,arguments 是分片拼接的。不要在分片阶段解析,应在收到该工具调用的结束标记后,对完整字符串统一走 normalizeToolArguments。如果某个后端在流式结束后仍返回对象,同一个函数也能处理。

一把 key 的收益

用聚合站的一把 key 调多个后端时,把格式差异收敛到一个 normalizeToolArguments 加一层 schema 校验,可以让上层业务代码不感知后端差异。切换模型只需改模型名,工具调用链路不变。用户按官方价 ×1.3 扣费,贡献 key 按官方价 ×1.1(优质 ×1.2)返 USDC,成本结构透明。

小结

  • 先判类型再解析,空串视为无参数
  • 解析失败保留原文,不静默兜底
  • 解析成功后独立做 schema 校验
  • 校验错误回传给模型,让其自我修正
  • 流式场景拼接完整后再解析

这样一套写法即可在一把 key 上兼容六个后端的工具调用返回格式。