openai-sdk-tool-call-shape · ZH · 2026-10-28

OpenAI 兼容端点返回的 tool_calls 形状略有出入时怎么兜住

兼容端点在不同后端返回 tool_calls 时存在序列化细节差异,本文梳理常见差异点并给出一个归一化包装层,让客户端解析器不因字段形状不一致而抛错。

为什么会有“形状差异”

聚合站对外只暴露一套 OpenAI 兼容接口,但请求最终会落到不同后端(不同厂商、不同版本的模型服务)。工具调用(tool_calls)本身有统一的语义,各家在序列化成 JSON 时却可能在细节上略有出入。客户端如果按某一家返回的样式写死解析逻辑,换一个后端就可能解析失败。

差异通常不是“有没有工具调用”这种结构性问题,而是字段层面的微小出入,例如:

  • arguments 有时是 JSON 字符串,有时在流式增量里被拆成多段拼接
  • 空值字段被省略、显式写成 null,或写成空字符串/空数组
  • index 是否存在、是否连续
  • id 的格式与长度不同,同一轮里是否唯一
  • type 字段是否始终出现
  • 流式场景下 finish_reason 与最后一个 delta 的先后顺序

这些差异单独看都无伤大雅,但客户端的 JSON.parse、按固定字段名取值、或严格类型校验很容易因此抛错。

先分清“语义”与“表示”

处理这类问题的核心原则:把工具调用当成语义对象,而不是某一段固定 JSON 的镜像。

需要保留的语义是:

  • 这一轮调用了哪些工具
  • 每个调用对应哪个函数名
  • 每个调用的参数(最终应是可解析的 JSON 对象)
  • 每个调用的标识,用于把结果回填给正确的调用

至于参数字符串是分几段到达的、空字段写没写、id 长什么样,都属于表示层。归一化层要做的,就是把表示层收敛到一种稳定形状。

归一化层要覆盖的几件事

参数拼接

流式场景下,arguments 常以增量片段到达。归一化层应按 index 或 id 分组,把片段按到达顺序拼成完整字符串,再尝试解析。解析失败不要直接抛出,而是保留原始字符串并标记为未完成,交给上层决定重试或提示。

缺失字段补默认值

对每个 tool call,统一补齐:

  • id 缺失时按稳定规则生成占位标识(同一次调用内保持一致)
  • type 缺失时补为 function
  • arguments 缺失时补为空字符串或空对象,二者选一并在文档里写死
  • name 缺失时不要静默用空串,应作为异常暴露

空值与省略

把 null、空字符串、字段不存在统一映射到同一个内部表示。关键是只在一个地方做映射,而不是每个调用点各写一遍 ??。

index 的顺序

不要假设 index 从 0 开始连续递增。归一化层按首次出现的顺序维护一个列表,index 只用做分组键,不做数组下标。

finish_reason 的处理

注意不同后端在流式结束时可能先给 finishreason: toolcalls 再结束,也可能在最后一个 delta 里同时带内容。归一化层应在流结束时触发一次“收尾”,把仍未解析的参数做最后一次尝试,并产出完整调用列表。

一个包装层的写法

思路是:输入是任意后端的原始响应,输出是固定的内部结构。 客户端只依赖内部结构。

def normalize_tool_calls(raw_calls):
    result = []
    for i, c in enumerate(raw_calls or []):
        fn = c.get("function") or {}
        args = fn.get("arguments")
        if args is None:
            args = ""
        parsed = None
        if isinstance(args, str) and args.strip():
            try:
                parsed = json.loads(args)
            except json.JSONDecodeError:
                parsed = None
        elif isinstance(args, dict):
            parsed = args
            args = json.dumps(args, ensure_ascii=False)
        result.append({
            "id": c.get("id") or f"call_{i}",
            "type": c.get("type") or "function",
            "name": fn.get("name"),
            "arguments_raw": args,
            "arguments": parsed,
        })
    return result

要点:

  • 同时保留 arguments_raw 和解析后的 arguments,解析失败不丢信息
  • name 缺失保留为 None,由上层判断是否要报错,而不是静默吞掉
  • id 缺失用基于位置的占位符,保证同轮内可回填

流式版本按分组累积:

class ToolCallAccumulator:
    def __init__(self):
        self.order = []
        self.buf = {}

    def feed(self, deltas):
        for d in deltas or []:
            key = d.get("index")
            if key is None:
                key = d.get("id") or len(self.order)
            if key not in self.buf:
                self.buf[key] = {"id": None, "name": None, "args": ""}
                self.order.append(key)
            slot = self.buf[key]
            if d.get("id"):
                slot["id"] = d["id"]
            fn = d.get("function") or {}
            if fn.get("name"):
                slot["name"] = fn["name"]
            if fn.get("arguments"):
                slot["args"] += fn["arguments"]

    def finish(self):
        raw = []
        for k in self.order:
            s = self.buf[k]
            raw.append({
                "id": s["id"],
                "type": "function",
                "function": {"name": s["name"], "arguments": s["args"]},
            })
        return normalize_tool_calls(raw)

这样把“拼接 + 归一化”两件事收在一处,客户端只消费 finish() 的输出。

落地建议

  • 契约先行:把归一化后的结构写进你自己的类型定义,客户端只依赖它,不直接解析原始响应。
  • 宽容输入、严格输出:入口处对缺失字段尽量兜住,出口处结构必须完整,缺 name 这类关键信息要显式失败。
  • 保留原始片段:解析失败时保留 arguments_raw,便于日志排查和重试,不要直接丢弃。
  • 不要假设跨后端完全一致:新增后端或模型版本升级后,用几条带工具调用的样例回归一下归一化层。
  • 文档写清默认值:arguments 缺失时补空串还是空对象,团队内要有统一约定,避免不同模块行为不一致。

小结

工具调用响应的差异大多停留在序列化细节上,不改变语义。与其在客户端到处写防御性判断,不如在聚合入口加一个归一化包装层:按语义聚合、补齐默认值、宽容解析、严格输出。代价很小,却能让解析器在面对不同后端时保持稳定。