LLM输出校验实战:Pydantic两道关防线

在提示词里写"请返回 JSON",模型大概率不会给你一段干净的 JSON:开头一句"好的,这是结果",中间一个代码围栏,结尾再补一句祝福。字段也未必靠谱——工作年限写成"五年",薪资写成"15k左右",偶尔还漏个 key。

这些毛病里最贵的不是当场报错的那种,是不报错的那种:字符串"15k"被当成数字写进库,三个月后在报表里发现异常,已经查不出是哪条数据混进去的。校验的目的就是把这两种失败都拦在入口。

两道关,别挤在同一个 except 里

第一道关很朴素:把 JSON 从模型的废话里抠出来。

def extract_json_from_llm_response(response: str) -> dict:
    text = re.sub(r"```(?:json)?", "", response)
    match = re.search(r"\{.*\}", text, re.DOTALL)
    if not match:
        raise ValueError("响应里没找到 JSON")
    return json.loads(match.group())

这个正则写法能应付九成场景,但有个已知盲区:当 JSON 字符串的值里包含花括号文本(比如 "示例": "格式如 {a} 所示"),贪婪匹配抓到的边界可能出错,解析直接失败。对稳定性要求高的链路,换成 json.JSONDecoder().raw_decode() 配合下标扫描,或者干脆靠第二道关的重试兜底——关键是别让这一层的失败静默进入下游。

第二道关交给 Pydantic。两层分开的真正价值在报错分类:JSONDecodeError 说明模型没按格式说话,ValidationError 说明格式对了但内容不对。这两类的后续处理完全不同,塞进同一个 except Exception 等于丢掉了最重要的信息。

契约怎么写:类型、范围、跨字段

字段和约束写在一起,才是契约:

class Resume(BaseModel):
    name: str
    email: EmailStr
    expected_salary: int = Field(..., gt=0, le=100_000)
    skills: List[str] = Field(..., min_length=1)
    experiences: List[WorkExperience] = Field(..., min_length=1)
    years_experience: float = Field(..., ge=0, le=40)

数值字段一律加 gt / ge / le,这是性价比最高的约束。自定义校验器则讲究一条标准:转换规则唯一确定的就顺手规整——"15k"转 15000、"1.5万"转 15000、手机号去"+86"前缀,救得回的不报错;转换规则有歧义的必须报错——"五年"你不知道是 5 还是 5.5,替模型猜就是往库里埋雷。

单字段校验挡不住模型"编数据":三段经历合计 7 年,总年限字段写了 9。单看每个字段都合法,合起来是假的——这是 LLM 输出里最需要盯的一类。跨字段核对专治这种病:总年限要等于各段经历之和,订单明细金额之和要等于总额,日期区间不能倒着。写的时候有两个坑:校验器只能读到它前面已声明的字段,顺序别放错;容差别设成 0,模型算的合计经常差零点几。

失败之后:把错误原样回喂

校验失败最有效的处理不是放弃,是把错误信息塞回提示词让它重答:

def extract_with_retry(llm_call, max_retries: int = 3):
    last_error = None
    for attempt in range(max_retries):
        try:
            return Resume(**extract_json_from_llm_response(llm_call(last_error)))
        except ValidationError as e:
            last_error = str(e)
        except json.JSONDecodeError:
            last_error = "返回不是合法 JSON,请只输出 JSON。"
    return None

三个细节决定成败:JSON 错和 schema 错用不同的提示语;重试必须设上限,用尽后返回 None 走人工队列,别让一次解析失败拖垮整个批处理;重试时把 temperature 设为 0,否则同样的提示每次错得花样翻新,重试纯属浪费。

两个版本与时机的提醒

版本差异:Pydantic v2 的 API 和 v1 差异不小——装饰器从 @validator 换成 @field_validator,parse_obj 换成 model_validate,网上大量教程还是 v1 写法,直接抄会报不存在的参数。动手前先确认装的是 v2,对照官方迁移指南。

约束时机:OpenAI 的 Structured Outputs(strict: true)能在生成阶段就强制符合 schema,从源头减少抠 JSON 和格式失败,值得优先开。但它保证的是结构,不是业务口径——"总年限等于各段之和"这种规则它管不着。框架的结构化输出(LangChain 的 with_structured_output 等)同理。所以最外层那道 Pydantic 校验无论用什么模型都留着,它同时是换供应商时不用改下游的统一出口。

模型会不会出错你控制不了,但出错之后是当场被拦下还是安静流进数据库,完全在你手里。把契约写成代码,它就是每次调用都会执行的检查;只写在提示词里,它就是一句君子协定。

校验前后的运行对比:四类报错被逐步拦下,错误回喂重试后成功解析

0

评论0

请先
显示验证码
没有账号?注册  忘记密码?