结构化提取保底:当模型输出不规范时的兜底策略
结构化提取保底:当模型输出不规范时的兜底策略
在构建基于大语言模型(LLM)的应用时,我们经常期望模型返回结构化的数据——比如 JSON、XML 或特定格式的列表。但现实是,模型输出并不总是完美的。它可能会多出一句解释,少一个闭合括号,或者键名写错。如果你直接将这样的“脏输出”送入下游系统,轻则解析失败,重则造成逻辑分支错乱。
结构化提取保底 就是一套让系统在模型输出未达预期时,依然能稳定工作的兜底策略。它的核心不是“修复所有错误”,而是用最小的代价保证系统不崩溃,并争取拿回可用的数据。本篇教程将带你从零开始,系统掌握这个必备技能。
为什么要用保底策略?
很多初学者认为“只要提示词写得好,模型就会乖乖输出 JSON”。但实际生产环境中,以下情况屡见不鲜:
- 模型在输出末尾多了句
“以上是数据”。 - JSON 中的
null写成了None或nil。 - 字段顺序错乱,甚至返回了完全不同的结构。
- 由于速率限制或网络问题,输出被截断。
保底策略不是对提示工程的否定,而是对不确定性的尊重。 它让你在设计系统时就有了容错设计,而不是等到线上事故才手忙脚乱地打补丁。
保底策略的四层防护体系
我们可以把结构化提取的兜底方案设计成一个分层防御,每一层都试图挽救上一层未能处理的情况。越底层成本越高,但能兜住更离谱的输出。
第一层:预期内修正(Regex 与轻量清洗)
最简单的保底是在解析前对字符串做个“美容”。这层假设模型只是多了一些无关的修饰,结构本身还是完整的。
常见操作:
- 去除代码块标记:如果模型返回了
```json ... ```,先剥离 markdown 包裹。 - 去除前后白噪音:用正则提取第一个
{到最后一个}或[到]之间的内容,丢弃前后的废话。 - 修正常见格式错误:把尾随逗号移除、把单引号替换为双引号、把 Python 风格的
True/False/None换成true/false/null。
示例:提取 JSON 对象的核心正则
import re
import json
def basic_clean(text: str) -> str:
# 1. 去除可能的代码块标记
text = re.sub(r'```(?:json)?\s*', '', text)
text = re.sub(r'\s*```', '', text)
# 2. 尝试捕获第一个 {...} 或 [...]
match = re.search(r'(\{.*\}|\[.*\])', text, re.DOTALL)
if match:
return match.group(0)
return text
def parse_with_basic_fallback(response: str):
try:
return json.loads(response)
except json.JSONDecodeError:
cleaned = basic_clean(response)
# 再来一次解析尝试
return json.loads(cleaned)
局限性:如果模型把键的值搞乱了,或者 JSON 结构本身就是错误的(比如缺少闭合括号),这层会直接失败。
第二层:字段级模糊匹配与默认值填充
当 JSON 解析完全失败,但我们已经知道应该有哪些字段时,就可以放弃完整解析,转而从文本中“捞取”关键值。
方法:利用键名作为线索,用正则或简单的字符串匹配找到键对应的值,然后填充到预设的模板中。
实现思路
假设你期望的输出结构是:
{"name": null, "age": null, "city": null}
如果模型返回了一段错乱的文本:
名字是张三,年龄大概28,他住在北京。
你可以这样提取:
import re
def extract_fields_fuzzy(text: str, expected_keys: list) -> dict:
result = {}
# 根据键名构建一些常见的中英文对应模式
key_patterns = {
"name": r'(?:name|姓名|名字)[::]\s*([^\s,,。]+)',
"age": r'(?:age|年龄)[::]\s*(\d+)',
"city": r'(?:city|城市|住在)[::]\s*([^\s,,。]+)'
}
for key in expected_keys:
if key in key_patterns:
match = re.search(key_patterns[key], text, re.IGNORECASE)
result[key] = match.group(1).strip() if match else None
else:
result[key] = None
return result
对于更复杂的场景,可以使用相似度匹配:将文本中每个候选词与期望的键名做字符串相似度(莱文斯坦距离)比较,找到最接近的那一个。
适用场景:
- 输出只是自然语言描述,你希望能抢救出部分核心字段。
- 后端系统允许部分字段为
null。
第三层:重试与约束改写
前两层都无法拿到可用数据时,说明模型本次输出质量很差。此时最有效的策略是再次调用模型,但这次要加上更强的约束。
常见约束手段:
- 强制输出格式指令:在提示词最末尾加上类似 “You MUST respond with a valid JSON object and nothing else. Do not include any explanation.” 的强指令。
- 提供 Schema 示例:直接给模型一个期望的 JSON 示例,并说“即使信息缺失,也要用 null 填充,保持结构一致”。
- 缩小上下文:如果输入文本很长,可能是干扰信息太多。重试时只传递原文的关键段落。
- 切换模型或温度:有时模型输出随机性过高,降低
temperature到 0 或 0.1,或者换用一个更遵循指令的模型。
重试时务必将前一次失败的原因也反馈给模型,比如:“你刚才的输出缺少闭合花括号,请修正后仅输出 JSON。”
技巧:把重试包装成一个带有最多尝试次数(例如 3 次)的循环,每次失败后增加约束强度,直到解析成功。
第四层:确定性的回退值(Critical Fallback)
这是最后,也是最重要的一层。当所有智能修复都失败时,你必须有一个不依赖模型的行为。 这通常意味着返回一个完全由代码生成的默认结构。
回顾你业务的需求:
- 如果是推荐系统,返回一个空的推荐列表
[]可能比崩溃要好。 - 如果是信息抽取,返回所有字段为
null的对象,并标记状态为"extraction_failed": true。 - 如果是分类任务,返回一个预定义的“未知/其他”类别。
设计要点:
- 默认值必须让后续流程能正常走下去,而不是抛出异常。
- 日志记录要详细:记录原始输出、各层尝试的步骤,方便后续优化。
- 可以设计一个监控报警:当回退触发率超过一定阈值(如 5%)时,提示工程或模型选择需要被审视。
构建完整的保底管道
将上述四层串联起来,形成一个清晰的执行流:
- 获得模型原始输出
raw_output。 - 第一层:轻量清洗后尝试
json.loads,成功则返回清洗后的结构化数据。 - 第二层:若失败,用模糊字段匹配提取值,若能提取到至少一个非默认值,则返回这个不完整的字典,并附加
"extraction_method": "fuzzy"。 - 第三层:若提取的字段为空或者关键字段缺失,执行带约束的重试(最多 N 次)。每次重试后返回第一层逻辑进行解析。一旦成功,返回数据。
- 第四层:重试都用尽后,返回硬编码的默认结构,并带上错误标记。
Python 伪代码示例
def structured_extraction_safe(user_input: str, max_retries=2) -> dict:
# 初始调用
raw = call_llm(user_input)
result = apply_layer1(raw)
if result:
return result
# 第二层:模糊挽救
result = apply_layer2(raw, expected_keys=["name", "age"])
if any(v is not None for v in result.values()):
result["_meta"] = {"fallback_level": "fuzzy"}
return result
# 第三层:重试
for attempt in range(max_retries):
constrained_prompt = build_retry_prompt(user_input, last_output=raw, reason="Invalid structure")
raw_retry = call_llm(constrained_prompt)
result = apply_layer1(raw_retry)
if result:
return result
# 第四层:硬回退
log_failure(user_input, raw) # 详细记录
return {"name": None, "age": None, "_meta": {"fallback_level": "hard_default"}}
实战中的避坑指南
-
不要试图用魔法修复所有残损 JSON
网络上有些库号称能修复任何畸形的 JSON,但它们往往带来了新的不确定性。与其投入复杂修复逻辑,不如直接进入下一层。 -
保持回退行为的透明性
在返回的数据中加上一个_meta或__fallback_info字段,记录兜底触发的层级和方法。这让下游可以做监控,也方便调试。 -
测试你的保底管道
故意喂给管道各种“垃圾输出”:截断的 JSON、纯文本、含有 emoji 的键名、嵌套错误等,观察每一层是否按预期工作。 -
成本控制
重试会消耗额外的 token 和延迟。为不同场景设定最大重试次数,并对关键业务采用同步+异步的组合方式。
总结
结构化提取保底不是让模型变完美,而是用工程手段弥补模型的不确定性。通过四层防御体系,你可以在绝大多数异常情况下,依然获得可用的结构化数据,或至少让系统优雅降级,而不是崩溃。
记住这条黄金法则:永远假设模型的输出是错的,直到证明它是对的。 将这种思维融入你的设计与实现中,你的 LLM 应用才会真正具备生产级的稳定性。
现在,就可以开始检查你现有的提取逻辑,看看它在面对脏输出时有多脆弱——然后,把上面的保底策略一块一块地部署上去。