结构化提取保底:当模型输出不规范时的兜底策略

FreeGuideOnline 最新 2026-06-29

结构化提取保底:当模型输出不规范时的兜底策略

在构建基于大语言模型(LLM)的应用时,我们经常期望模型返回结构化的数据——比如 JSON、XML 或特定格式的列表。但现实是,模型输出并不总是完美的。它可能会多出一句解释,少一个闭合括号,或者键名写错。如果你直接将这样的“脏输出”送入下游系统,轻则解析失败,重则造成逻辑分支错乱。

结构化提取保底 就是一套让系统在模型输出未达预期时,依然能稳定工作的兜底策略。它的核心不是“修复所有错误”,而是用最小的代价保证系统不崩溃,并争取拿回可用的数据。本篇教程将带你从零开始,系统掌握这个必备技能。


为什么要用保底策略?

很多初学者认为“只要提示词写得好,模型就会乖乖输出 JSON”。但实际生产环境中,以下情况屡见不鲜:

  • 模型在输出末尾多了句 “以上是数据”
  • JSON 中的 null 写成了 Nonenil
  • 字段顺序错乱,甚至返回了完全不同的结构。
  • 由于速率限制或网络问题,输出被截断。

保底策略不是对提示工程的否定,而是对不确定性的尊重。 它让你在设计系统时就有了容错设计,而不是等到线上事故才手忙脚乱地打补丁。


保底策略的四层防护体系

我们可以把结构化提取的兜底方案设计成一个分层防御,每一层都试图挽救上一层未能处理的情况。越底层成本越高,但能兜住更离谱的输出。

第一层:预期内修正(Regex 与轻量清洗)

最简单的保底是在解析前对字符串做个“美容”。这层假设模型只是多了一些无关的修饰,结构本身还是完整的。

常见操作:

  1. 去除代码块标记:如果模型返回了 ```json ... ```,先剥离 markdown 包裹。
  2. 去除前后白噪音:用正则提取第一个 { 到最后一个 }[] 之间的内容,丢弃前后的废话。
  3. 修正常见格式错误:把尾随逗号移除、把单引号替换为双引号、把 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

第三层:重试与约束改写

前两层都无法拿到可用数据时,说明模型本次输出质量很差。此时最有效的策略是再次调用模型,但这次要加上更强的约束。

常见约束手段

  1. 强制输出格式指令:在提示词最末尾加上类似 “You MUST respond with a valid JSON object and nothing else. Do not include any explanation.” 的强指令。
  2. 提供 Schema 示例:直接给模型一个期望的 JSON 示例,并说“即使信息缺失,也要用 null 填充,保持结构一致”。
  3. 缩小上下文:如果输入文本很长,可能是干扰信息太多。重试时只传递原文的关键段落。
  4. 切换模型或温度:有时模型输出随机性过高,降低 temperature 到 0 或 0.1,或者换用一个更遵循指令的模型。

重试时务必将前一次失败的原因也反馈给模型,比如:“你刚才的输出缺少闭合花括号,请修正后仅输出 JSON。”

技巧:把重试包装成一个带有最多尝试次数(例如 3 次)的循环,每次失败后增加约束强度,直到解析成功。


第四层:确定性的回退值(Critical Fallback)

这是最后,也是最重要的一层。当所有智能修复都失败时,你必须有一个不依赖模型的行为。 这通常意味着返回一个完全由代码生成的默认结构。

回顾你业务的需求:

  • 如果是推荐系统,返回一个空的推荐列表 [] 可能比崩溃要好。
  • 如果是信息抽取,返回所有字段为 null 的对象,并标记状态为 "extraction_failed": true
  • 如果是分类任务,返回一个预定义的“未知/其他”类别。

设计要点

  • 默认值必须让后续流程能正常走下去,而不是抛出异常。
  • 日志记录要详细:记录原始输出、各层尝试的步骤,方便后续优化。
  • 可以设计一个监控报警:当回退触发率超过一定阈值(如 5%)时,提示工程或模型选择需要被审视。

构建完整的保底管道

将上述四层串联起来,形成一个清晰的执行流:

  1. 获得模型原始输出 raw_output
  2. 第一层:轻量清洗后尝试 json.loads,成功则返回清洗后的结构化数据。
  3. 第二层:若失败,用模糊字段匹配提取值,若能提取到至少一个非默认值,则返回这个不完整的字典,并附加 "extraction_method": "fuzzy"
  4. 第三层:若提取的字段为空或者关键字段缺失,执行带约束的重试(最多 N 次)。每次重试后返回第一层逻辑进行解析。一旦成功,返回数据。
  5. 第四层:重试都用尽后,返回硬编码的默认结构,并带上错误标记。

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"}}

实战中的避坑指南

  1. 不要试图用魔法修复所有残损 JSON
    网络上有些库号称能修复任何畸形的 JSON,但它们往往带来了新的不确定性。与其投入复杂修复逻辑,不如直接进入下一层。

  2. 保持回退行为的透明性
    在返回的数据中加上一个 _meta__fallback_info 字段,记录兜底触发的层级和方法。这让下游可以做监控,也方便调试。

  3. 测试你的保底管道
    故意喂给管道各种“垃圾输出”:截断的 JSON、纯文本、含有 emoji 的键名、嵌套错误等,观察每一层是否按预期工作。

  4. 成本控制
    重试会消耗额外的 token 和延迟。为不同场景设定最大重试次数,并对关键业务采用同步+异步的组合方式。


总结

结构化提取保底不是让模型变完美,而是用工程手段弥补模型的不确定性。通过四层防御体系,你可以在绝大多数异常情况下,依然获得可用的结构化数据,或至少让系统优雅降级,而不是崩溃。

记住这条黄金法则:永远假设模型的输出是错的,直到证明它是对的。 将这种思维融入你的设计与实现中,你的 LLM 应用才会真正具备生产级的稳定性。

现在,就可以开始检查你现有的提取逻辑,看看它在面对脏输出时有多脆弱——然后,把上面的保底策略一块一块地部署上去。