输出 JSON 格式不固定的解决方案和原理

一、问题背景

在调用大模型、第三方接口或聚合服务时,经常会遇到“输出 JSON 格式不固定”的问题。同一个业务意图,模型可能返回对象、数组、带 Markdown 代码围栏的 JSON,甚至在字段名、字段类型和嵌套层级上发生变化。

如果下游代码直接使用 json.loads() 或按固定字段取值,偶发的格式变化就会导致解析异常、数据丢失,严重时还会让整个任务失败。

二、格式不固定的常见表现

  • 同一类结果有时返回 JSON 对象,有时返回 JSON 数组。
  • JSON 被包在 Markdown 代码围栏中。
  • 字段值类型不稳定,例如 "count": 1 变成 "count": "1"。
  • 字段缺失、字段名变化,或者多出解释性文本。
  • 嵌套对象层级不一致,例如结果有时在 data 中,有时直接位于根节点。

三、解决思路:从“字符串解析”升级为“结构化约束”

稳定处理 JSON 输出,通常需要建立三层防线:

  1. 生成阶段约束格式:在提示词、接口参数或模型能力允许的情况下,明确要求只输出 JSON,并给出字段结构。
  2. 解析阶段清理和校验:去除代码围栏,解析 JSON 后检查根类型、必填字段和字段类型。
  3. 业务阶段统一模型:把不同输入映射成内部统一的数据结构,业务代码只依赖统一结构。

这三层分别解决“尽量少生成错误”“识别并隔离错误”“避免格式差异扩散到业务代码”三个问题。

四、Python 通用解析器

下面的代码适合处理常见的模型文本输出。它会依次完成清理代码围栏、提取 JSON、解析 JSON、统一根节点和校验字段。

import json
import re
from typing import Any


def parse_json_output(text: str) -> dict:
    if not isinstance(text, str) or not text.strip():
        raise ValueError("模型输出为空")

    content = text.strip()

    # 去除 Markdown 代码围栏
    content = re.sub(r"^```(?:json)?\s*", "", content, flags=re.I)
    content = re.sub(r"\s*```$", "", content)
    content = content.strip()

    # 兼容前后存在解释性文本的情况
    if not (content.startswith("{") or content.startswith("[")):
        start_obj = content.find("{")
        start_arr = content.find("[")
        starts = [i for i in (start_obj, start_arr) if i >= 0]
        if not starts:
            raise ValueError("输出中没有找到 JSON")
        content = content[min(starts):]

    try:
        value: Any = json.loads(content)
    except json.JSONDecodeError as exc:
        raise ValueError(f"JSON 解析失败: {exc}") from exc

    # 统一根节点:数组包装成 items,方便下游处理
    if isinstance(value, list):
        result = {"items": value}
    elif isinstance(value, dict):
        result = value
    else:
        raise ValueError("JSON 根节点必须是对象或数组")

    # 业务字段校验与默认值
    result.setdefault("items", [])
    if not isinstance(result["items"], list):
        raise ValueError("items 必须是数组")

    return result

五、为什么不能只靠提示词

提示词只能降低格式漂移的概率,不能替代程序校验。模型输出受到上下文、采样参数、模型版本和异常输入影响,即使明确写了“只输出 JSON”,仍可能出现额外说明、截断或字段类型变化。

更可靠的方式是把模型当作不完全可信的外部输入:先解析,再校验,最后转换。解析失败时记录原始响应,并根据业务需要重试或进入人工处理队列。

六、使用 Pydantic 做结构化校验

在 FastAPI 项目中,可以使用 Pydantic 把 JSON 校验和类型转换集中起来:

from pydantic import BaseModel, Field


class Item(BaseModel):
    name: str
    score: float = Field(default=0, ge=0, le=1)


class Output(BaseModel):
    items: list[Item] = Field(default_factory=list)


def validate_output(data: dict) -> Output:
    return Output.model_validate(data)

这样做的价值是把“字段缺失、类型不对、数值越界”等问题统一转换成明确的校验异常。FastAPI 还可以直接复用这些模型作为接口的请求和响应模型。

七、典型场景:期望 data 是对象,却返回数组

例如接口协议约定模型必须返回:

{
  "data": {}
}

但模型实际返回:

{
  "data": []
}

这段内容在 JSON 语法上是合法的,但 data 的类型已经从对象变成数组,属于“格式合法、协议不合法”。如果业务需要的是对象,不能只判断 JSON 是否能解析成功,还要校验字段类型。

7.1 用 Schema 和 Pydantic 拒绝错误类型

from typing import Any
from pydantic import BaseModel


class ModelOutput(BaseModel):
    data: dict[str, Any]


value = ModelOutput.model_validate({"data": []})
# ValidationError: data 必须是对象

JSON Schema 中也要明确写出 data 的类型:

{
  "type": "object",
  "properties": {
    "data": {
      "type": "object",
      "additionalProperties": true
    }
  },
  "required": ["data"],
  "additionalProperties": false
}

7.2 是否把 [] 自动转换成 {}

不能无条件转换,要看业务语义。如果空数组明确表示“没有数据”,可以在边界层转换,并记录警告:

def normalize_output(raw: dict) -> dict:
    data = raw.get("data")
    if isinstance(data, dict):
        return {"data": data}
    if data == []:
        # 只有业务明确允许时才能这样归一化
        return {"data": {}}
    raise ValueError("data 必须是对象")

如果返回的是非空数组,就不要猜测转换成对象。数组可能包含多个元素,强行合并会产生字段覆盖和语义丢失。此时应拒绝结果并重试,或者把协议改成 data: list[...]。

八、Structured Outputs:格式和字段双维度强制

JSON Mode 主要保证结果是合法 JSON,但不能充分保证字段存在、字段名正确以及字段类型符合业务协议。Structured Outputs 的核心是通过完整的 JSON Schema 同时约束两件事:

  • 格式维度:结果必须是合法 JSON,根节点和嵌套节点类型符合定义。
  • 字段维度:字段名、必填项、字段类型、枚举值和层级符合 Schema。

例如把 data 定义为 object 后,模型就不能把它输出成 array。调用接口时应传入完整 Schema,并在服务端继续使用 Pydantic 校验,因为模型服务的能力、版本和业务规则仍可能不同。

schema = {
    "type": "object",
    "properties": {
        "data": {
            "type": "object",
            "properties": {
                "name": {"type": "string"},
                "score": {"type": "number"}
            },
            "required": ["name", "score"],
            "additionalProperties": false
        }
    },
    "required": ["data"],
    "additionalProperties": false
}

response = client.chat.completions.create(
    model="your-model",
    messages=messages,
    response_format={
        "type": "json_schema",
        "json_schema": {
            "name": "model_output",
            "strict": True,
            "schema": schema
        }
    }
)

不同模型厂商的参数名称可能不同,实际接入时应以对应 SDK 文档为准。关键不是参数名本身,而是把“期望的结构”交给模型服务做生成约束,再由后端做最终校验。

九、Function Calling:用工具参数承载结构化数据

Function Calling 的目标是让模型选择并调用一个工具。工具的参数 Schema 定义了字段名、字段类型和必填项,模型返回的工具参数本身就是一段结构化 JSON。

tools = [{
    "type": "function",
    "function": {
        "name": "submit_result",
        "description": "提交结构化分析结果",
        "parameters": {
            "type": "object",
            "properties": {
                "data": {
                    "type": "object",
                    "properties": {
                        "name": {"type": "string"},
                        "score": {"type": "number"}
                    },
                    "required": ["name", "score"],
                    "additionalProperties": false
                }
            },
            "required": ["data"],
            "additionalProperties": false
        }
    }
}]

response = client.chat.completions.create(
    model="your-model",
    messages=messages,
    tools=tools,
    tool_choice={
        "type": "function",
        "function": {"name": "submit_result"}
    }
)

arguments = response.choices[0].message.tool_calls[0].function.arguments
result = ModelOutput.model_validate(json.loads(arguments))

这里的关键是通过 tool_choice 强制模型调用指定工具,然后只读取工具参数。与 Structured Outputs 的区别在于:Structured Outputs 直接约束最终响应结构,Function Calling 则把结构化结果放进工具调用参数中,适合需要触发业务动作、查询数据或调用外部服务的场景。

十、如何选择

场景推荐方式原因
只需要模型返回结构化结果 Structured Outputs 直接约束最终 JSON 的格式和字段
需要模型触发查询、写入或计算动作 Function Calling 工具参数是结构化 JSON,调用动作也有明确入口
模型不支持上述能力 提示词 + 解析 + Pydantic 依靠边界层清理和服务端校验兜底
生产接口 Schema/工具约束 + Pydantic + 业务校验 同时覆盖生成约束、类型校验和业务语义

可以把两种能力理解成不同的出口:Structured Outputs 约束“模型最终交付什么结构”,Function Calling 约束“模型通过哪个工具、携带什么参数执行动作”。两者都可以使用 JSON Schema,但使用目的不同。

十一、推荐的生产实践

  1. 优先使用原生结构化输出能力:如果模型服务支持 JSON Schema、结构化输出或函数调用,优先使用这些能力。
  2. 固定内部协议:无论外部返回对象还是数组,进入业务层前都转换成同一个 Pydantic 模型。
  3. 限制重试次数:解析失败可以重试,但应设置次数上限和退避时间。
  4. 保留诊断信息:记录请求标识、模型版本、提示词版本、原始输出摘要和解析错误。
  5. 区分可恢复错误:代码围栏属于可自动清理的问题,字段缺失可能需要重新请求,语义错误则需要业务规则或人工介入。
  6. 不要用正则替代 JSON 解析:正则适合清理外围文本,不适合解析嵌套 JSON。

八、总结

输出 JSON 格式不固定,本质上是外部生成内容缺少稳定契约。解决方案不是在业务代码中不断增加特殊分支,而是建立“结构化生成、边界解析、模型校验、内部统一”的处理链路。

对于 FastAPI 项目,推荐使用“结构化输出能力 + json.loads + Pydantic model_validate”的组合,并在失败时做好重试、日志和降级。这样既能提高接口稳定性,也能让格式变化被限制在边界层内。

posted @ 2026-10-02 16:14  fitch_liu  阅读(19)  评论(0)    收藏  举报