LLM 结构化输出实战:JSON Schema 与容错解析

让大模型返回 JSON,看着是最简单的需求,实际却是最容易翻车的一环。你要的是结构化输出,模型给你的可能是一段被 “`json 包裹的字符串、一个少了两层嵌套的对象、一段被 max_tokens 砍断的半截 JSON,或者字段名明明是 user_id 它偏要写成 userId。JSON Schema 能把这类事故的概率从“经常”压到“偶尔”,但真要上生产,还得在 Schema 外面再叠一层容错解析和校验。本文给出可直接照抄的代码,把这条链路从调用、约束、兜底到落库一次打通。

一、先搞清楚:JSON 为什么总是“差一点”

大模型的输出本质是逐 token 采样,语法并不是硬约束,而是靠训练分布“习惯性”遵守。理解失败来源,才知道该在哪一层加钩子。

失败形态 典型表现 根因
包裹污染“`json … “` 代码块、前言“好的,这是结果:”模型沿用了对话习惯
字段漂移user_id → userId、缺 required 字段、多出解释字段缺少 schema 级强约束
截断结尾缺 } 或 ],JSON 解析报 Unexpected EOF输出超过 max_tokens
类型混淆数字写成字符串、布尔写成 “true”未声明 type 或未开 strict
全角污染中文引号 “ ”,中文逗号,尾随逗号中文语料导致的标点混用
枚举越界priority 返回 “urgent” 而你要的是 high/medium/low未用 enum 限值

这六种里,只有“截断”属于长度问题,其余五种都能靠 Schema 的 strict 模式挡掉大半。但请注意:strict 只保证结构符合定义,不保证语义正确。字段齐了,值仍可能胡说,这是后面校验层要解决的。

二、三档约束手段,强弱差别很大

很多人以为“在提示词里写一句只输出 JSON”就等于结构化,其实那只是最弱的一档。按保证强度从低到高排:

手段 保证强度 代价 适用场景
提示词约束弱,纯靠概率零成本,偶发失败一次性脚本、原型验证
response_format=json_object中,只保证是合法 JSON需 prompt 中出现 json 字样简单 KV 抽取
response_format=json_schema(strict)强,结构+类型+枚举全约束首次调用有 schema 编译开销生产入库、下游强依赖字段
Function Calling强,等价于 schema 通道多一次工具选择逻辑需要模型决定“调不调”的场景

如果你的服务要接多家模型,建议在网关层统一收口这些差异——字段名、strict 支持度、json_schema 与 tools 的兼容性各家不一,用一层网关包住能省掉大量适配代码,具体做法见LiteLLM 实战:用统一网关接入多家大模型与成本管控。

三、把输出契约写成 JSON Schema

Schema 不是越复杂越好,能表达业务约束即可。以下是提取“工单信息”的实战样例,注意四个关键点:required 列全、additionalProperties:false 封死扩展、枚举字段用 enum、数值给上下界。

{
  "type": "object",
  "additionalProperties": false,
  "required": ["ticket_id", "title", "priority", "tags", "confidence"],
  "properties": {
    "ticket_id": { "type": "string", "pattern": "^T-[0-9]{6}$" },
    "title":     { "type": "string", "minLength": 4, "maxLength": 60 },
    "priority":  { "type": "string", "enum": ["high", "medium", "low"] },
    "tags":      { "type": "array", "items": { "type": "string" }, "maxItems": 5 },
    "confidence":{ "type": "number", "minimum": 0, "maximum": 1 },
    "due_date":  { "type": ["string", "null"], "format": "date" }
  }
}

坑点提醒:format: date 在 strict 模式下属于“软约束”,模型仍可能给出 2026-13-45,所以日期字段最好保留 null 分支,把非法值交给校验层降级,而不是指望 Schema 堵住。另外 pattern、minLength 这类关键字在部分厂商的 strict 实现里会被忽略,上线前务必用真实接口跑一轮试探。

四、用 json_schema 发起调用

下面是 OpenAI 兼容接口的最小可运行示例。注意 strict:true 必须放在每个 schema 节点里,而不是只放顶层——漏掉内层是最常见的“strict 看似开了但没生效”的原因。

import json, requests

resp = requests.post(
    "https://api.openai.com/v1/chat/completions",
    headers={"Authorization": f"Bearer {API_KEY}"},
    json={
        "model": "gpt-4o-mini",
        "temperature": 0,
        "messages": [
            {"role": "system", "content": "你是工单解析器,只输出符合 schema 的 JSON。"},
            {"role": "user", "content": "客户说:系统登录一直转圈,很急,今天必须好"},
        ],
        "response_format": {
            "type": "json_schema",
            "json_schema": {"name": "ticket", "strict": True, "schema": TICKET_SCHEMA},
        },
    },
    timeout=60,
)
data = json.loads(resp.json()["choices"][0]["message"]["content"])
print(data["priority"], data["confidence"])

两个工程细节:第一,同一个 schema 第一次请求会被编译,延迟可能比后续高出一截,所以服务启动时应该发一次“预热请求”,别让首个真实用户承担这笔开销;第二,schema 一旦变更就等于换了契约,务必带版本号,并在日志里记录版本,否则出问题时你无法判断是模型变了还是契约变了。

五、Function Calling:兼容性更好的第二条路

并非所有厂商都完整支持 json_schema,但绝大多数已经支持 tools。把“函数参数”当成输出 schema 用,配合 tool_choice 强制选择,效果几乎等价,而且在国内模型上踩坑更少。

tools = [{
    "type": "function",
    "function": {
        "name": "save_ticket",
        "description": "保存解析后的工单信息",
        "parameters": {
            "type": "object",
            "properties": {
                "title":    {"type": "string"},
                "priority": {"type": "string", "enum": ["high", "medium", "low"]},
                "tags":     {"type": "array", "items": {"type": "string"}},
            },
            "required": ["title", "priority", "tags"],
        },
    },
}]

resp = client.chat.completions.create(
    model="glm-4.7-flash",
    messages=[{"role": "user", "content": user_text}],
    tools=tools,
    tool_choice={"type": "function", "function": {"name": "save_ticket"}},
)
args = json.loads(resp.choices[0].message.tool_calls[0].function.arguments)

这条路和智能体的工具调用是同一套机制,区别只在“谁来触发”——这里你强制模型调用,智能体场景下则交给模型自己判断。关于多工具编排的完整链路,可参考AI 智能体 2026:从工具调用到多智能体协作。

六、四道容错防线:把“偶尔失败”变成“几乎不失败”

即使开了 strict,线上依然会遇到截断、超时降级、模型切换导致的格式回退。我的做法是四道防线串联,每道都便宜、每道都能独立生效。

第一道:截断修复

先判断是否只是尾部不完整,是就按括号栈补齐——比直接重试便宜得多。

def repair_truncated(s: str) -> str:
    stack, in_str, esc = [], False, False
    for ch in s:
        if esc: esc = False; continue
        if ch == "\\": esc = True; continue
        if ch == '"': in_str = not in_str; continue
        if in_str: continue
        if ch in "{[":
            stack.append("}" if ch == "{" else "]")
        elif ch in "}]" and stack:
            stack.pop()
    if in_str:
        s += '"'
    return s + "".join(reversed(stack))

第二道:提取与清洗

正则抓最外层大括号,顺手清掉全角引号、中文逗号和尾随逗号。这三类污染在中文语料上出现频率高得离谱。

import re

def extract_json(text: str) -> str:
    text = text.strip()
    text = re.sub(r"^```(?:json)?|```$", "", text, flags=re.M).strip()
    start, end = text.find("{"), text.rfind("}")
    if start == -1 or end == -1:
        raise ValueError("no json object found")
    s = text[start:end + 1]
    s = s.replace("“", '"').replace("”", '"').replace("’", "'")
    s = s.replace(",", ",").replace(":", ":")
    s = re.sub(r",(\s*[}\]])", r"\1", s)   # 去尾随逗号
    return repair_truncated(s)

第三道:Schema 校验

用 jsonschema 或 Pydantic 做真正校验,重点是把错误信息结构化——它要能直接回灌给模型当修复提示。

from jsonschema import Draft202012Validator

def validate(obj, schema):
    v = Draft202012Validator(schema)
    errs = sorted(v.iter_errors(obj), key=lambda e: list(e.path))
    if not errs:
        return obj, []
    msgs = [f"{'/'.join(map(str, e.path)) or '$'}: {e.message}" for e in errs[:5]]
    return None, msgs

第四道:带错误回灌的重试

重试不是原样再问一遍,而是把校验错误原样贴回去,并把温度压到 0。两次仍失败就落库到“待人工”表,绝不让脏数据流进主表。

def structured_call(user_text, schema, max_retry=2):
    messages = [
        {"role": "system", "content": "只输出 JSON,不要解释。"},
        {"role": "user", "content": user_text},
    ]
    for i in range(max_retry + 1):
        raw = chat(messages, temperature=0)
        try:
            obj = json.loads(extract_json(raw))
        except Exception as e:
            messages += [{"role": "assistant", "content": raw},
                         {"role": "user", "content": f"JSON 解析失败:{e},请只返回合法 JSON"}]
            continue
        obj, errs = validate(obj, schema)
        if obj is not None:
            return obj, "ok"
        messages += [{"role": "assistant", "content": raw},
                     {"role": "user", "content": "字段校验失败:" + "; ".join(errs) + ",请修正后重发"}]
    return None, "manual_review"

七、流式输出下别急着校验

开了 stream=True 之后,你拿到的是碎片,任何时刻的累积结果都可能是半截 JSON,此时跑 Schema 校验几乎必然报错。实践里分两种处理:前端展示用流式,落库用非流式再确认一次;或者引入增量 JSON 解析器,只在遇到完整的对象边界时才推给下游。前一种更简单,也更省钱,因为二次确认可以只带已解析字段,不必重发全文。

同样的思路在检索类应用里更重要:当结构化输出要拼接向量召回结果时,字段契约一旦漂移,检索结果就无法映射回原文,可先看RAG 从零搭建实战:用向量检索增强大模型回答,以及Embeddings 向量嵌入实战:从原理到语义检索落地中的元数据设计部分。

八、上线前对照这份清单

检查项 建议做法 为什么
Schema 版本纳入 Git,版本号写进日志出现脏数据能定位是契约变更还是模型漂移
strict 覆盖每个嵌套节点都带 strict:true顶层 strict 不会向下继承
自由文本字段尽量换 enum,实在要自由则限 maxLength自由文本是幻觉与越界的主要来源
数值字段必须给 minimum/maximum越界值会直接污染统计与排序
失败率监控记录 extract/validate 失败次数,超 1% 告警失败率突增往往先于业务异常出现
降级路径失败落 manual_review 表,不进主表宁可少一条,不可脏一片
大对象拆成多次调用,单次 schema 不超过 20 个字段字段越多首字准确率越低

九、小结

结构化输出这件事,本质是把“请求模型自觉”改成“工程上强制”。三层就够用:能用 json_schema 就用,不能就退到 Function Calling;外面固定包一层 extract + validate + 带错重试;最后所有落库动作都过校验,失败即拦截。照这套下来,我手上的抽取任务失败率从 8% 左右降到了千分之几,剩下的基本是输入本身有歧义,属于人工该介入的部分。

上一篇 Argo CD 实战:GitOps 持续部署落地指南
下一篇 磁盘 I/O 100% 排查实战:从 iotop 到定位根因
最新评论
Docker 网络模式详解:bridge/host 生产选型 - FSData
Docker 网络模式详解:bridge/host 生产选型 - FSData
8月20日
[…] 注意最后那条:把端口绑定到 127.0.0.1,再由宿主机上的 Nginx 反代进来,比直接 -p 80:80 暴露安全得多,也方便统一做
Vue3 + TypeScript 工程化:类型安全的前端架构 - FSData
Vue3 + TypeScript 工程化:类型安全的前端架构 - FSData
8月20日
[…] Pinia 的工程化用法,可以参考我们之前的实战文章 《Vue3 组合式 API + Pinia:状态管理从入门到实战》,那里覆盖了 Sto
2026 年最值得装的 10 个 VS Code 插件(AI/前端/后端/效率全覆盖) - FSData
2026 年最值得装的 10 个 VS Code 插件(AI/前端/后端/效率全覆盖) - FSData
8月19日
[…] 状态,点一下就能起停容器,配合我们讲过的 Docker Compose 编排 […]
Redis 缓存设计:穿透、击穿、雪崩与最佳实践 - FSData
Redis 缓存设计:穿透、击穿、雪崩与最佳实践 - FSData
8月19日
[…] 缓存三大坑的本质都是”缓存失效导致请求穿透到数据库”。记住口诀:穿透用布隆、击穿用锁、雪崩用错峰。配合合理的过期策略
GitHub Actions 实战:从零搭建 CI/CD 流水线 - FSData
GitHub Actions 实战:从零搭建 CI/CD 流水线 - FSData
8月19日
[…] Docker 镜像部署。结合我们之前写的 Docker Compose 一键编排实战,可以在 CI […]