你在 prompt 里写了 "请返回 JSON",模型返回的大概率不是一段干净的 JSON,而是一段话:开头一句"好的,这是抽取结果",中间一个 ```json 围栏,结尾还跟一句"希望对你有帮助"。
字段本身也未必靠谱,比如工作年限写成"五年",期望薪资写成"15k左右",偶尔还漏掉一两个 key。
这些毛病里最贵的不是报错的那几种,是不报错的那种:字符串 "15k" 被当成数字一路写进库,等你三个月后在报表里发现异常,已经查不出是哪条数据混进去的了。
下面我们拿"从简历文本里抽结构化信息"当例子,把不加校验和加校验两条路各跑一遍,看差别到底在哪里,然后给一套能直接搬走的写法:抠 JSON、定义 schema、类型与范围校验、跨字段核对、失败后重试。
全程用模拟数据,不需要 API Key,pip install pydantic[email] 就能跑。
1. 不校验的时候,问题藏在哪
同一段模型返回,用最朴素的写法处理,会连着遇到四种情况:
json.loads(llm_text) # ① 外层裹了话
data["years_experience"] + 1 # ② 值是 "五年"
len(data["skills"]) # ③ 压根没这个 key
"15k左右" * 12 # ④ 算年薪
实际输出:
JSONDecodeError: Expecting value: line 2 column 1 (char 1)
TypeError: can only concatenate str (not "int") to str
KeyError: 'skills'
'15k左右' * 12 = 15k左右15k左右15k左右…(重复 12 次)
①②③还算运气好,程序当场炸给你看,顶多是离出错的地方有点远——KeyError 往往是在下游生成报表的那一行才冒出来,跟解析这段代码隔着好几个函数。
真正麻烦的是④:字符串乘法在 Python 里是完全合法的操作,不抛异常、不写日志,结果是一串谁都没见过的东西,然后它就这么进了你的年薪字段。
共同点是:问题要么发现得太晚,要么根本发现不了。
json.loads 只保证"这是合法的 JSON 语法",它对"字段有没有、类型对不对、数值合不合理"一句话都不说。
2. 补上校验,其实是两道关
把校验加进来之后,链路从"一段文本直接进业务"变成两道关:先把 JSON 从一堆话里抠出来,再按 schema 逐字段验证。
第一道关很朴素:去掉 markdown 围栏,再用正则抓最外层那对花括号。
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())
第二道关交给 Pydantic。
两层分开的价值在于报错信息——JSONDecodeError 说明"模型没按格式说话",ValidationError 说明"格式对了但内容不对",这两种情况的处理方式完全不同(后面第 5 节会用到这个区别),所以别把它们塞进同一个 except Exception。
3. 契约长什么样:类型、范围、格式
定义一个 Resume,字段和约束写在一起:
class Resume(BaseModel):
name: str
email: EmailStr
phone: str
city: str
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)
EmailStr 直接验证邮箱格式(要装 email-validator,也就是 pydantic[email] 那个 extra);
Field(gt=0, le=100_000) 把取值范围写进类型提示;
字段默认必填,少了任何一个当场报 Field required,再加上 min_length=1 保证列表不为空。
这几行就把第 1 节里的 ③ 挡住了:缺字段在入口就失败,不会等到下游才抛 KeyError。
有意思的是自定义校验器。能确定地转换的值就别让它报错,顺手规整掉:
@field_validator("phone")
@classmethod
def normalize_phone(cls, v: str) -> str:
digits = re.sub(r"\D", "", v) # 去掉 "-"、空格
if digits.startswith("86") and len(digits) == 13: # 去掉 +86 前缀
digits = digits[2:]
if len(digits) != 11 or not digits.startswith("1"):
raise ValueError(f"不是有效的手机号:{v!r}")
return digits
@field_validator("expected_salary", mode="before")
@classmethod
def parse_salary(cls, v):
"""把 '15k' / '1.5万' 先转成数字,再交给 int 校验。"""
...
mode="before" 的意思是"在类型校验之前先跑我",所以它能吃到原始字符串。
跑一遍看效果:
手机号 '138-0013-8000' -> 13800138000
薪资 '15k左右' -> 15000
格式乱七八糟但语义明确的输入被救回来了,库里存的永远是规整的 11 位手机号和整数金额。
救不回来的才报错,比如"五年"——你没法也不能替模型猜它是 5 还是 5.5,那就让它失败:
Input should be a valid number, unable to parse string as a number [input_value='五年']
这条信息里字段名、原因、原始值都在,正好可以直接回喂给模型(后面第 5 节)。
判断标准就一条:转换规则是不是唯一确定的——是,就转成规范值;不是,就报错。
4. 嵌套和跨字段:抓"编"出来的数
真实数据很少是扁平的。
简历里有工作经历列表,那就再定义一个子模型,主模型里写 List[WorkExperience]:
Pydantic 会递归构造子对象,哪一条的哪个字段出错,报错信息会精确到"第 2 条经历的 start"。
但范围和格式校验只能挡住"格式错",挡不住模型一本正经地编数据。比如三段经历加起来才 7 年,它却在总年限里写了 9。
这时候要跨字段核对:
@field_validator("years_experience")
@classmethod
def check_years_match(cls, v: float, info) -> float:
experiences = info.data.get("experiences") or []
if experiences:
total = sum(_years_of(e.start, e.end) for e in experiences)
if abs(total - v) > 0.5: # 留 0.5 年的容差
raise ValueError(f"总工作年限 {v} 年与各段经历合计 {total:.2f} 年对不上")
return v
跑出来是这样:
Value error, 总工作年限 9.0 年与各段经历合计 7.00 年对不上
这种"自己跟自己对不上"的问题,是 LLM 输出里最需要盯的一类——单看每个字段都合法,合起来是假的。
同样的思路可以用在很多地方:订单明细金额之和要等于订单总额、日报里的客单价要等于营业额除以订单数、抽取的日期区间不能倒过来。
写的时候有个坑:校验器只能读到它前面已经校验过的字段,所以 years_experience 必须声明在 experiences 后面,否则 info.data 里是空的。
另外容差别设成 0,模型算出来的合计经常差个零点几。
5. 失败之后:把错误原样回喂给模型
校验失败了怎么办?最省事也最有效的办法是把上一次的错误信息塞进 prompt 让它重答。
def extract_with_retry(llm_call_function, max_retries: int = 3):
last_error = None
for attempt in range(max_retries):
try:
return Resume(**extract_json_from_llm_response(llm_call_function(last_error)))
except ValidationError as e:
last_error = str(e) # schema 错:带上具体字段问题
except json.JSONDecodeError:
last_error = "返回不是合法 JSON,请只输出 JSON,不要任何解释文字。"
if attempt == max_retries - 1:
return None # 用尽次数,交给上层降级
llm_call_function(last_error) 负责把错误拼进 prompt,模型因此知道该改哪里。
同一份简历文本跑三轮的真实输出:
第 1 次失败:Input should be a valid number …("五年")
第 2 次失败:总工作年限 9.0 年与各段经历合计 7.00 年对不上
成功:陈舒雅,7.0 年经验,期望 15000 元,技能 ['Python', 'SQL', 'FineBI']
三个细节要注意:
- JSON 错和 schema 错用不同的提示语,前者是"你没按格式说话",后者是"某个字段不对";
- 一定要设上限,用完次数返回
None让上层降级(写人工审核队列、打日志告警),而不是让整个流程崩掉; - 调用时把
temperature设成 0,否则同样的 prompt 每次错的地方都不一样,重试就白做了。
6. 接到真实的模型调用上
前面的例子用的是模拟返回,换成真实 API 只多两步:把 schema 塞进 prompt,把返回值交给同一个 parse_resume。
schema_hint = json.dumps(Resume.model_json_schema(), ensure_ascii=False)
response = client.chat.completions.create(
model="gpt-4o-mini", temperature=0,
messages=[{"role": "user", "content": f"按这个 JSON Schema 抽取:\n{schema_hint}\n\n{text}"}])
return parse_resume(response.choices[0].message.content)
用框架的话还更省事:LangChain 有 llm.with_structured_output(Resume)(走原生 function calling,推荐)和 PydanticOutputParser(靠 prompt 引导,任何模型都能用);
LlamaIndex 有 LLMTextCompletionProgram.from_defaults(output_cls=Resume)。
但框架帮忙约束的是"结构",不是"业务口径"。function calling 能保证字段和类型对,保证不了"总年限等于各段经历之和"这种你们业务自己的规则;
而且结构化输出在极端情况下照样会不合规。
所以最后那层 Pydantic 校验建议一直留着,它同时也是你接任何一家模型供应商时的统一出口——换模型不用改下游。
7. 完整代码
完整可运行脚本共享在:resume_extraction_demo.py url11.ctfile.com/f/45455611-…
里面所有"模型返回"都是模拟字符串,装好 pydantic[email] 直接 python resume_extraction_demo.py 就能看到这篇文章里的每一组输出。
模拟的代码执行结果如下:
想替换自己的场景也简单:
第一步,换模型。 把 Resume 的字段换成你的:单据日期、金额、类别、ID、负责人。先分清"必须有"和"可能有",后者写 Optional[str] = None,别一股脑都设成必填,否则模型每次都被你打回来。数值字段一律加 gt / ge / le,这类约束最便宜也最有效。
第二步,换解析。 extract_json_from_llm_response 和分层异常直接照抄,prompt 里带上 model_json_schema() 的输出,temperature=0。
第三步,换兜底。 失败 → 重试 2~3 次 → 还不行返回 None 走人工或降级。别让一次解析失败把整个批处理任务搞挂。
回到开头那段 "返回 JSON" 的承诺。模型会不会出错,你控制不了;但"出错之后是当场被拦下、还是安静地流进数据库",这一层完全在你手里。
差别就是有没有把契约写成代码,写下来,它就变成每次调用都会执行的检查,而不是一句只在 prompt 里生效的口头约定。