模型输出老是校验不过:schema 怎么定、失败后重试怎么设才不白跑

2026-07-29

数据截至 2026-07,各产品的额度与报错口径以官方最新说明为准。

校验失败率降不下来,多数团队的归因是模型能力不够,于是换模型、加指令、把温度调低;但真正的大头是你的 schema 在让模型做一件它没法稳定做到的事,而失败之后的重试又没带上任何有效信息,等于把同一张彩票再刮一遍。 这两件事叠在一起的结果很典型:成功率卡在某个数上不动,调什么都只在小数点后晃。

先说清这篇和站内相邻两篇的分工,免得你翻重复内容:结构化输出不稳的故障排查处理的是「压根拿不到合法 JSON」——解析层、包裹文本、约束层这条链路;给 Agent 设计工具讲的是工具名、描述、参数 schema 怎么写才能让模型选对工具。本篇只管中间那一段:JSON 已经能解析出来了,但过不了你的校验器,这时候 schema 该改成什么形状、重试该怎么设、什么时候该停手。


一、先把「校验失败」拆成五类,别当成一件事

校验器抛出来的都是一个异常,但背后是完全不同的病。不分类就开始改,你会在改 schema 和改指令之间来回横跳。

第一类是结构不合法:括号没闭合、尾部被截断、外面裹了一层说明文字。这类根本没进到字段校验就挂了。

第二类是字段缺失:结构合法,必填项没给。要区分是模型漏了,还是这条输入里本来就没有这个信息。

第三类是类型或枚举不匹配:数字给成了字符串、枚举给了一个你没定义的近义词、日期格式不是你要的那种。

第四类是格式对但值错:所有约束都过了,值是编的。这类校验器永远发现不了,只能靠抽样人工看或者交叉验证。

第五类是跨字段规则不满足:单看每个字段都合法,合起来违反业务约束,比如状态是「已取消」却带着发货单号。

判别方法比记住分类更重要。下面这张表是我排查时实际按顺序走的:

现象大概率成因怎么验证处置动作
解析就报错,文本末尾像被切断输出被长度上限截断,或流式没收全就解析打印原始文本的尾部,看是否停在半个词上;比较收到的字节数是否稳定卡在同一位置先收全再校验;缩小单次要求的字段数量;把长文本字段拆出去单独产出
解析报错,前后有自然语言包裹约束层没生效,只靠指令在压看原始文本首尾,是否有解释性句子走强约束通道(原生结构化输出或函数式调用),细节见前面那篇故障排查
某个必填字段稳定缺失该字段在输入里往往不存在,schema 却设成必填拿十条真实输入自己手工填一遍,看你能不能填出来改成可选,或者给一个显式的「未提及」枚举值
枚举值总落在定义外,且是近义词枚举语义边界模糊,或缺兜底项统计落空值的分布,看它们是不是集中在两三个词上补 other 兜底项;把边界写进字段说明;把高频落空词并入既有枚举
数字字段偶发变成字符串输入里带单位或千分位,模型照抄看失败样本的原文里数字是怎么写的字段拆成数值 + 单位两个字段;或在校验前加一层规范化
嵌套数组里的对象经常少字段嵌套层数太深,注意力被摊薄把同一任务改成扁平结构跑一遍对比拍平结构;或把数组元素抽取拆成独立一轮
每次失败的错误路径都不一样schema 整体过重,不是某个字段的问题连续跑同一条输入,看错误路径是否随机拆任务,一次只抽一组相关字段
单看字段都合法,组合起来矛盾业务规则没进 schema,只在人脑里用规则校验器跑历史数据,看历史里是否也有矛盾样本把规则写成校验器的第二段;矛盾时优先信哪个字段要提前定死

表里最容易被跳过的是第三行的「自己手工填一遍」。这一步能筛掉相当一部分伪问题——很多字段人看着原文也填不出来,那模型填出来的必然是编的,你要的不是重试,是把这个字段从必填里拿掉。


二、schema 的形状,决定了模型能不能填对

schema 有两个身份:给校验器看的类型定义,和给模型看的填空题。第二个身份经常被忘掉。同样的语义,写法不同,失败率能差出一大截。

扁平优先,嵌套是有代价的。 每多一层嵌套,模型就要多维持一层结构记忆。能用一个数组装对象就别用对象套对象再套数组。真的需要层级,考虑分两轮:第一轮出骨架,第二轮针对每个骨架元素补细节。

能枚举就别自由文本。 自由文本字段的失败方式是无穷的,枚举的失败方式只有一种——落到定义外。而且枚举天然给了你一个可统计的对象:哪个值从来没出现过,哪个值明显被滥用。枚举一定要留一个兜底项,没有兜底项,模型会去挑一个最像的,你就永远看不到「这条其实分不了类」。

必填项越少越好。 必填是一种压力:模型宁可编也不愿意违反必填。可选字段配上「缺失就是缺失」的语义,比必填加上一句「没有就填空字符串」可靠得多。空字符串和 null 是两个不同的意思,别混用——前者表示有这个值且为空,后者表示没有。

字段名本身就是指令。 amount_cny_yuanamount 强,is_refund_requestedflag 强。JSON Schema 的 description 该写的是判据而不是同义反复:不要写「订单状态」,要写「以最后一条物流记录为准;无物流记录时填 unknown」。

给不确定留出口。 加一个置信度字段(枚举,别用浮点分数,模型给的分数没有校准可言),或者加一个 needs_review 布尔位。有了这个出口,模型不用在「编一个」和「违反 schema」之间二选一,你的幻觉率会直接受益。

一个够用的形状大概是这样:

{
  "type": "object",
  "additionalProperties": false,
  "required": ["intent", "confidence", "items"],
  "properties": {
    "intent": { "type": "string", "enum": ["refund", "shipping", "other", "unknown"] },
    "confidence": { "type": "string", "enum": ["high", "low"] },
    "items": {
      "type": "array",
      "items": {
        "type": "object",
        "additionalProperties": false,
        "required": ["sku", "qty"],
        "properties": {
          "sku": { "type": "string" },
          "qty": { "type": "integer", "minimum": 1 }
        }
      }
    },
    "note": { "type": ["string", "null"] }
  }
}

additionalProperties: false 建议一开始就加上。它会让模型自作主张多加的字段立刻暴露成校验错误,而不是被你悄悄丢掉——被丢掉的多余字段常常意味着模型理解的任务边界和你的不一样。

Python 侧同一份约束,我一般用类型定义当唯一真源,JSON Schema 从它导出,避免两边手写飘掉:

from typing import Literal
from pydantic import BaseModel, ConfigDict, Field

class Item(BaseModel):
    model_config = ConfigDict(extra="forbid")
    sku: str
    qty: int = Field(ge=1)

class Extract(BaseModel):
    model_config = ConfigDict(extra="forbid")
    intent: Literal["refund", "shipping", "other", "unknown"]
    confidence: Literal["high", "low"]
    items: list[Item]
    note: str | None = None

extra="forbid" 这两行别省。类型库默认是「多出来的字段直接忽略」,跟上面 JSON Schema 里的 additionalProperties: false 是相反的行为;不显式写上,你手上就有两份语义不一致的约束,导出的 schema 拒收多余字段、本地校验却放行,排查时会得到互相打架的结论。

最后一点:schema 要有版本。字段一改,历史失败样本就没法复现了。在落库的记录里存一个 schema_version,改 schema 时旧版本保留一段时间,你才有回滚的余地。


三、三种重试各管一类失败,别混用

重试是这里最容易做成摆设的一环。多数实现是一个 for 循环,把原样的输入再发一次。这只对一类失败有用。

同参重试,只对传输层的瞬时失败有效:HTTP 429、5xx,连接超时,连接被对端重置(Node 侧对应 ETIMEDOUTECONNRESET 这类 errno,Python 侧则是 httpx 的 ConnectErrorReadTimeout 或 requests 的 ConnectionErrorTimeout 这类异常,别照抄字符串去判断,按你所用客户端的异常类型分支)。这类要指数退避加抖动,退避是给对端喘息,不是给模型思考。内网出口经常还会叠一层证书链校验失败(自签证书没进信任库),那是环境问题,重试多少次都不会好,属于另一条排查线。401/403 更是一次都不该重试——密钥或权限的事,重试只会把日志刷满。

import random, time

def backoff_sleep(attempt: int, base: float = 0.5) -> None:
    time.sleep(base * (2 ** attempt) + random.uniform(0, base))

修复重试,管的是内容层失败(上面的第二、三、五类)。核心是把校验错误结构化地回喂给模型,而不是原样重发。回喂要短,只给三样:出问题的字段路径、期望是什么、你收到的值。整包贴 schema 或者整包贴异常堆栈都是负作用——上下文被撑大,关键信息反而被稀释。

from pydantic import ValidationError

def compact_errors(exc: ValidationError, limit: int = 5) -> list[dict]:
    out = []
    for e in exc.errors(include_url=False)[:limit]:
        out.append({
            "path": ".".join(str(x) for x in e["loc"]),
            "problem": e["msg"],            # 期望是什么
            "got": str(e.get("input"))[:80],  # 实收值,截断防止长文本回灌
        })
    return out

三个键正好对应上面说的三样。include_url=False 是为了去掉错误项里那条指向文档的链接——它对模型没有任何指导作用,只会占位置。got 一定要截断:出问题的字段里塞着一整段原文的情况很常见,不截断的话你以为在做精简回喂,实际又把上下文顶回去了。

修复重试的循环大致是:把上一轮的原始输出作为助手消息放回去,再追加一条只包含错误摘要的用户消息。让模型看到自己刚才写了什么,比只告诉它「错了」有效得多。

def extract(messages, max_fix_rounds: int = 2):
    for attempt in range(max_fix_rounds + 1):
        raw = call_model(messages)
        try:
            return Extract.model_validate_json(raw)
        except ValidationError as exc:
            if attempt == max_fix_rounds:
                raise
            messages = messages + [
                {"role": "assistant", "content": raw},
                {"role": "user", "content": render_fix_hint(compact_errors(exc))},
            ]

这段里 call_model 在修复轮要比首轮把采样放开一点(首轮可以贴着确定性走,修复轮给一个略高的温度),原因在最后一节里说;如果你的封装里采样参数是写死的,这个循环写得再对也会反复撞同一个坑。

max_fix_rounds 我的默认值是 2,这是经验判断不是什么定论:一轮修不好的,两轮修好的比例明显下降,三轮往上基本是在烧钱。你自己的任务应该用失败样本回放测出来,而不是抄我的。

降级重试,管的是第七行那种「错误路径每次都不一样」的整体性失败。做法不是再问一遍,而是换个问法:把大 schema 拆成两三个小 schema 分别问;或者先让模型自由写一段分析,再用第二次调用把这段分析抽成结构。第二种在复杂判断类任务上往往比死磕一次成型稳,代价是多一次调用。

三种重试的账要分开记。同参重试的次数反映的是服务端和网络的健康度,修复重试的次数反映的是 schema 的质量,降级重试的次数反映的是任务拆分是否合理。混在一个计数器里,你就丢掉了三个不同的信号。Agent 场景下这套计数还要跟整体的失败重试策略对齐,那部分见Agent 执行失败与重试


四、什么时候别再折腾了

工程上最贵的不是修不好,是修的时间太长还没意识到该换路。给你几条我自己用的止损线。

同一条输入连续两轮修复重试失败,且两次的错误路径不同——停。这不是模型没听懂,是 schema 整体超出了它一次能稳住的复杂度。继续加轮次只是把成本线性堆上去,成功率曲线是平的。动作是拆任务,不是加重试。

换一个模型跑同样的 schema,失败模式一模一样——停。失败在你这边。模型之间的差异通常体现在失败率高低,不体现在失败的形状上;形状一致,说明是约束本身有问题。

某个字段的人工手填成功率也不高——停。这条前面说过,值得再说一遍:人看着原文都填不出来的字段,模型给出来的一定是编的。要么补上游数据,要么把这个字段降级成可选加人工复核。

schema 改版后失败率不降反升——回滚。这就是前面让你存 schema_version 的用处。回滚到旧版本,把新版本挂在旁路上跑影子流量对比,别在主链路上试。

预算耗尽时,返回结构化的失败对象,而不是继续重试或者抛裸异常。 一个带 status: failedreasonraw_output_ref 的对象,能让下游做出正确的分支决策;一个超时异常只会让上游也跟着乱。这条对流水线型的系统尤其重要——失败要能被消费,而不只是被记录。

还有一种「换条路」是彻底不用模型。如果目标结构里超过一半的字段能靠正则、模板或者已有的规则引擎拿到,那正确的架构是规则先跑,模型只补规则拿不到的那几个字段。规则的确定性是免费的,别为了统一而放弃。


五、避坑清单

用模型自己校验自己的输出。 会踩是因为省事——反正都在调模型了,再问一句「上面这个 JSON 合法吗」看起来很自然。但校验必须是确定性的:同样的输入永远得到同样的判定,才能拿来做统计和回归。怎么避:结构校验一律交给类型库或 JSON Schema 校验器,模型只在「值对不对」这种语义层面做辅助判断,而且判断结果要单独记录,不能直接决定放行。

把异常原文整包塞回去重试。 会踩是因为图省事,str(exc) 一行搞定。问题是校验器的异常常常带着完整 schema 片段和输入回显,一整包塞回去既撑上下文又把噪声抬高,模型甚至会把错误示例当成参考格式。怎么避:只回喂路径、期望、实收值三元组,条数设上限。

修复重试时采样参数原封不动。 会踩是因为重试逻辑是通用的,没人想过采样的事。同样的输入配同样的采样设置,很可能复现同样的错误。怎么避:修复重试时把温度略微调开,让它有机会走到另一条路径上;同参重试(传输层)则不用改。

流式输出边收边解析。 会踩是因为想早点渲染。半截 JSON 在校验器眼里就是非法结构,你会看到大量假失败。怎么避:结构化输出这条链路上先收全再校验,需要流式体验就单独走一条只做展示的通道,展示和落库用两套解析逻辑。

必填项一路开到底。 会踩是因为下游代码写起来省心——不用判空。代价是模型为了满足必填而编造,幻觉率被你亲手推高。怎么避:必填只留真正每条都有的字段,其余用可选加显式的 unknown 值,下游的判空成本远低于清洗脏数据的成本。

重试没有幂等保护。 会踩是因为重试是在调用层加的,写库在更靠后的地方,两边没人想到会打架。一次超时后的重试,可能让同一笔业务被写两遍。怎么避:从入口就带业务幂等键贯穿到落库,这块的通用做法见重试与幂等

只统计成功率,不留失败样本。 会踩是因为失败原文往往很长,落盘看着浪费。结果是每次改 schema 都只能靠感觉,没有回归集。怎么避:失败样本连同 schema_version、错误路径、重试轮次一起落盘,脱敏后攒成回归集;改 schema 前先在回归集上跑一遍。

把校验失败率当成唯一指标。 会踩是因为它最好统计。但把兜底项一加、必填一撤,失败率立刻好看,实际信息量可能反而下降了。怎么避:同时盯「unknown 占比」和抽样人工准确率,三个数一起看才有意义。


收束

这条排查线可以压缩成一句话:先分清失败是结构问题、缺失问题、类型问题、语义问题还是规则问题,再决定是改 schema 还是改重试,最后给整件事设一个明确的止损点。绝大多数「模型不稳」的判断,都是在第一步就跳过去了。

上线前的自检清单,六条:

  • schema 里的每个必填字段,你自己拿真实输入能手工填出来吗?
  • 每个枚举都有兜底项吗?落到定义外的值有在统计吗?
  • 传输层失败和内容层失败走的是两套重试逻辑吗?
  • 修复重试回喂的信息量,是三元组还是整包异常?
  • 重试次数有硬上限吗?预算耗尽时返回的是结构化失败对象吗?
  • 失败样本有落盘、有脱敏、能拿来回放吗?

六条里有两条答不上来,你的失败率就还有下降空间,而且大概率不在模型那一侧。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。