大型语言模型(LLM)虽然擅长生成自然语言文本,但在需要严格结构化输出(例如 JSON 格式)时,经常会出现不稳定或不合规的问题。例如,我们让模型以JSON格式回答问题,模型可能会遗漏引号、添加多余逗号,或者在JSON前后夹杂解释性文本,导致输出无法通过解析器。为什么会这样? 因为对于LLM来说,输出JSON其实是一种“格式约束”,模型有时无法100%遵守。结果就是生成的JSON“看起来差不多”但并非严格有效——这在应用中会导致解析错误,流水线中断。

这样的不稳定性在实际工程面试场景中都至关重要。例如,在生产环境中,我们可能用LLM提取结构化数据填入数据库或前端,如果JSON格式无效,整个自动化流程就可能崩溃,影响用户功能。又比如在技术面试中,有时要求候选人设计系统,让LLM返回特定格式的数据。如果候选人只通过修改Prompt来祈祷模型输出正确JSON,一旦模型稍有差池就会失败,这暴露了方案的不可靠。正如一篇行业博客所指出的:“LLM擅长生成文本,但应用需要结构化且可靠的数据…健壮的输出校验对于生产可靠性至关重要”。

问题总结:单靠提示词让模型输出JSON往往不可靠,稍微复杂的结构就可能让模型“自由发挥”而偏离预期格式。那么,我们如何 工程化地 提高LLM结构化输出的成功率呢?下面将介绍我们采用的方案:结合 Pydantic 模式校验Repair Pipeline(输出修复流水线),将模型结构化输出的成功率从原来的不理想水平提升到接近99%的可靠度。

为什么结构化输出很重要?

在深入技术方案之前,我们先明确结构化输出不稳定会带来哪些麻烦

  • 应用错误与崩溃:如果下游代码期望解析JSON,但LLM输出了无效JSON,json.loads会报错,导致程序异常。对于关键业务(如财务报表生成、配置生成等),一次解析失败就可能中断整个任务。
  • 难以调试和维护:结构化输出不稳定时,工程师往往需要反复调prompt或添加后处理规则,耗费大量时间调试随机出现的格式问题。
  • 用户体验不佳:如果是用户交互场景,模型输出结构异常可能直接反映为用户看到错误消息或无法获得预期结果,降低对系统的信任。

总之,在实际应用里,“结构化输出可靠性”往往不是锦上添花的要求,而是决定系统能否正常工作的刚需。因此,我们需要一种系统性的办法来保证LLM输出满足格式要求。

用Pydantic定义严格的输出Schema

要提高结构化输出的稳定性,第一步是明确我们期望的输出结构,并对其进行验证。这时候Pydantic就派上用场了。Pydantic是Python的一个数据验证库,我们可以用它来定义输出的数据模型(Schema),并自动检查LLM输出是否符合这个Schema。

例如,假设我们希望LLM回答一个地理问题,输出JSON包含questionanswer两个字段:我们可以使用Pydantic定义如下的数据模型:

from pydantic import BaseModel, ValidationError
import json

class QAPair(BaseModel):
    question: str
    answer: str

# 一个示例LLM输出(字符串)
llm_output = '{"question": "世界上最高的山是哪座?", "answer": "珠穆朗玛峰"}'

try:
    data = json.loads(llm_output)        # 尝试解析为Python字典
    result = QAPair.model_validate(data)  # Pydantic v2用 model_validate; v1 用 QAPair(**data)
    print("验证通过:", result)
except json.JSONDecodeError as e:
    print("JSON解析失败:", e)
except ValidationError as e:
    print("Schema校验失败:", e.errors())

上面这个例子中,我们定义了QAPair模型要求questionanswer都是字符串。如果LLM的输出缺少字段、类型不符或者有多余字段(假如模型胡乱加了一个extra字段),Pydantic都会抛出ValidationError列出具体的问题。通过这种模式约束,我们将松散的LLM文本输出转化成了严格的数据合同——要么完美符合合同,要么就视为失败并获取错误原因。

Pydantic的优势在于:声明即校验。我们只需用Python类型定义数据格式,Pydantic就自动帮我们验证类型、必填字段、值的范围等。如果需要,我们还可以在模型里加入自定义校验规则(例如字符串不能空、数值范围等)。这一切确保了任何不符合我们预期结构的输出都会被及时发现,而不会默默传入后续流程造成隐患。

设计输出修复流水线(Repair Pipeline)

定义了Schema并不能保证模型一定输出合法结构,但它让我们检测到不合法的情况。有了检测,就可以进一步自动修复。我们的目标是构建一个修复闭环:当LLM输出未通过校验时,系统自动尝试修正它,直到成功或确定无法挽救。下面详细介绍这个 Repair Pipeline 的设计步骤:

1. 解析并校验LLM输出

首先获取LLM的原始输出文本,并尝试解析出其中的JSON部分。很多时候模型输出的JSON可能被包裹在Markdown代码块、或有额外说明文字。例如模型可能输出:

好的,以下是结果:
```json
{ "question": "...", "answer": "..." }

在这种情况下,我们需要提取纯粹的`{ "question": "...", "answer": "..." }`部分。一个简单的方法是利用正则表达式找到第一个“大括号 {”到匹配的“}”之间的内容: 

```python
import re

text = llm_output  # 模型返回的完整字符串
match = re.search(r'\{[\s\S]*\}', text)  # 寻找第一个花括号对象
json_str = match.group(0) if match else text  # 提取JSON子串

提取出json_str后,我们就尝试用json.loads解析它,并用Pydantic模型校验:

try:
    data = json.loads(json_str)
    result = QAPair.model_validate(data)
    print("输出格式正确,解析结果:", result)
except Exception as e:
    print("第一次解析/校验失败,错误信息:", str(e))
    # 后续进入修复流程

这里用一个Exception捕获所有错误是简化做法,实际代码中我们可以区分是JSONDecodeError(纯语法问题)还是ValidationError(字段内容问题)。解析+校验步骤帮我们筛查出“模型输出是否完美符合预期”。如果这一步通过,那么恭喜,结果可靠;如果不通过,我们获取到错误信息,为下一步修复做准备。

2. 分析错误类型

当输出未通过校验时,先搞清楚错在哪。常见的几类错误包括:

  • JSON语法错误:例如缺失引号、额外逗号、括号不匹配、引号用错(用单引号代替双引号)等。这会导致json.loads抛出错误,连JSON都解析不了。
  • Schema验证错误:JSON虽然语法上有效,但不符合我们定义的Schema。例如字段缺失/多余、类型不对、值不满足约束等。此时Pydantic的ValidationError会给出详细的错误列表。

区分这两种情况有助于选择修复策略。如果是语法问题,我们优先考虑字符串层面的修复;如果是字段内容问题,可能需要让模型重新生成缺失字段或纠正值。

举例来说,假如ValidationError告诉我们输出缺少了answer字段,那多半需要重新让模型补充答案字段。而如果错误是JSONDecodeError: Expecting ',' delimiter,很可能是上一项缺个逗号或格式不对,可以尝试程序上修一修。

通过e.errors()(对于Pydantic的错误)或异常消息字符串,我们可以捕获这些信息并简单分类:

errors = []
if isinstance(e, ValidationError):
    errors = [err["msg"] for err in e.errors()]
else:
    errors = [str(e)]
print("识别到的错误项:", errors)

这样我们就准备好进入修复环节。

3. 自动修复策略

(1)规则替换修复:针对最常见的JSON格式错误,我们可以在代码中编写一些正则替换规则来修正字符串。例如:

  • 去除结尾多余逗号:模型有时会生成列表或对象最后一个元素后多一个逗号,违背JSON语法。我们可以用正则将逗号后紧跟}]的情况去掉。比如:

    json_str = re.sub(r',\s*([\}\]])', r'\1', json_str)
    
  • 替换错误的引号:将单引号替换为双引号:

    json_str = json_str.replace("'", '"')
    

    (注意这可能把内容中正常的单引号也替换掉,不过多数情况下JSON键必须用双引号,此替换有助于修复模型爱用单引号的问题)。

  • 给字段名加引号:有时模型输出的JSON缺少引号(把字段名当成裸标识符)。可以用正则在 {, 后找到类似 key: value 的模式,给key补上引号:

    json_str = re.sub(r'(?<=\{|,)\s*([A-Za-z_]\w*)\s*:', r'"\1":', json_str)
    
  • 补全括号:如果输出JSON缺了结尾的 }],我们可以根据字符串中 {} 的数量不匹配来在末尾补上对应的括号。

通过一系列这样的字符串操作,往往可以修复模型输出的语法小问题。完成修复后,再次尝试解析和校验:

fixed_str = fix_common_issues(json_str)  # 假设封装了上述替换操作
try:
    data = json.loads(fixed_str)
    result = QAPair.model_validate(data)
    print("经过规则修复后,输出有效:", result)
except Exception as e:
    print("规则修复后仍有错误:", e)
    # 将进入下一步

(2)LLM辅助修复:如果简单规则无法修复(比如缺字段或者数据内容问题),我们可以调用模型自身来协助修复。思路是:把原始输出校验错误以及Schema约束都反馈给模型,请它根据这些信息重新给出一个符合要求的JSON。例如,我们可以构造如下的提示:

系统:你输出的结果未通过校验。请根据以下错误和要求修正你的输出,只返回修正后的JSON。

原始输出:
{ ... 原始的错误JSON ... }

校验错误:
- answer: 字段缺失
- age: 类型应为整数

要求的JSON模式:
{
    "question": "<string>",
    "answer": "<string>",
    "age": "<integer>"
}
用户:请给出修正后的JSON。

通过这样的prompt,让模型扮演校对者,依据我们提供的Schema重新生成JSON。这相当于用另一次LLM调用作为“智能修复器”,尤其当初始模型不够聪明时,可以考虑用更强大的模型来修这个格式(当然这会增加一次API调用的开销)。

采用LLM修复要注意两点:一是Prompt要明确要求只输出JSON,避免模型啰嗦解释;二是要对模型修复后的结果再次跑解析+校验,确认它真的修好了而不是又弄出新错误。这形成一个循环,直到成功或超出重试次数。

下面是伪代码示意如何集成LLM修复:

if fix_failed:
    repair_prompt = f"""请纠正这段JSON使其符合指定Schema:
原始输出: {json_str}
错误: {errors}
Schema: {schema_description}
请直接给出修正后的JSON。"""
    new_output = llm.generate(repair_prompt)
    # 假设 llm.generate 返回字符串
    json_str = extract_json(new_output)  # 再提取一次JSON片段
    try:
        data = json.loads(json_str)
        result = QAPair.model_validate(data)
        success = True
    except Exception:
        success = False

这样,模型修复作为最后的手段,如果它也无能为力,我们的流水线会考虑放弃进一步尝试。

(3)重复尝试与次数限制:整个修复流程不应无限循环。实践中我们会设置一个最大重试次数(比如最多修两轮:一轮规则修复,一轮LLM修复)。如果超过次数仍未成功,就跳出循环进入兜底方案。值得一提的是,根据一些测试,给模型一次纠错机会通常就能把JSON成功率从90%左右提升到接近100%——大量模型的JSON输出“小毛病”经过一次简单修复或重试就迎刃而解。因此,两轮以内的修复大多已经足够。

4. 信心评分与降级兜底

尽管我们的修复管线大幅提高了成功率,但仍要考虑万一最终还是没修好怎么办。毕竟在极少数情况下,模型输出可能牛头不对马嘴,或者多轮修复反而越修越乱。这时需要有降级策略来优雅地处理:

  • 置信度评分:如果我们的应用对每次输出有评分机制,例如模型生成时提供了一个置信度值,或我们有另一个验证模型来评估输出质量,我们可以结合结构校验结果给本次结果一个置信度。如果经过多次修复仍失败,那置信度显然极低,我们可以决定不采用该结果。在面试或评估场景中,这体现为对模型输出是否可信有清醒认识,而不是盲目使用错误结果。
  • 结果截断:有些情况下,模型输出虽然不完全符合格式,但其中大部分内容是正确的,只是多了一些额外说明文本。一个简单的兜底办法是截断输出:例如找到最后一个 } 的位置,将其后的多余内容剪掉,使JSON重新有效。这其实属于前述规则修复的一种,但作为最后手段非常有用。很多时候模型只是多输出了句“希望以上答案对你有帮助”,把它截断丢弃,JSON部分就完美无缺了。
  • 默认值和缺失填充:如果只是一两个非关键字段缺失,我们可以考虑插入默认值后继续处理。例如缺个age字段,我们插入"age": null或其他默认,再进行解析。当然这种做法需谨慎,视业务重要性决定是否允许部分结果通过。
  • 通知与回退:在完全无法自动修复时,系统可以记录日志并返回一个明确的错误响应给调用方,而不是让下游默默拿到不符合Schema的乱数据。对于用户界面,可以礼貌地提示“抱歉,我没能理解您的请求格式”或尝试再次询问用户。对于流水线,则可以回退到非LLM的方案或者提供一个空的安全结果,确保系统不崩溃。

总之,降级兜底的核心是保证即使LLM输出再不可靠,系统也有办法优雅地应对,而不会无故抛异常退出。正如一些实践者总结的,在需要稳定结构化输出的场景,应当建立“约束→校验→修复重试→降级兜底”的闭环来保障稳定性。

实战示例:DeepSeek模型 + Pydantic管线

让我们把以上步骤串起来,通过一个实际案例演示如何提高LLM结构化输出成功率。假设我们使用 DeepSeek 提供的LLM模型来回答用户问题,并要求输出固定的JSON格式。DeepSeek平台本身支持JSON结构化输出模式,可以通过参数要求模型直接给出JSON;但我们依然在客户端构建自己的校验修复流程,以确保万无一失。

场景:用户提问一个常识性问题,我们希望LLM返回一个 JSON,包含 questionanswer 以及 source(信息来源)三个字段。例如:

{
    "question": "世界上最长的河流是哪一条?",
    "answer": "尼罗河",
    "source": "维基百科"
}

我们先定义Pydantic模型和准备DeepSeek调用:

from pydantic import BaseModel
import json, os
from openai import OpenAI

class QAWithSource(BaseModel):
    question: str
    answer: str
    source: str

system_msg = "请把用户提问及答案用JSON形式返回,字段包括question, answer, source。"
user_msg = "世界上最长的河流是哪一条?"
messages = [
    {"role": "system", "content": system_msg},
    {"role": "user", "content": user_msg},
]

client = OpenAI(
    api_key=os.environ["DEEPSEEK_API_KEY"],
    base_url="https://api.deepseek.com/v1",
)

response = client.chat.completions.create(
    model="deepseek-chat",
    messages=messages,
    response_format={"type": "json_object"},
)

raw_output = response.choices[0].message.content
print("LLM原始输出:", raw_output)

data = json.loads(raw_output)
validated = QAWithSource.model_validate(data)
print("校验通过:", validated)

假设以上调用返回了一个字符串raw_output。即便我们用了response_format要求JSON,有可能模型输出带有代码块标记或小的格式瑕疵。接下来进入解析和校验:

# 提取 JSON 子串
match = re.search(r'\{[\s\S]*\}', raw_output)
json_str = match.group(0) if match else raw_output

try:
    data = json.loads(json_str)
    result = QAWithSource.model_validate(data)
    print("模型输出校验通过:", result)
except Exception as e:
    print("输出格式不符合预期,进入修复流程:", str(e))
    errors = []
    if isinstance(e, ValidationError):
        errors = [err["msg"] for err in e.errors()]
    else:
        errors = [str(e)]
    # 尝试自动修复
    fixed = fix_common_issues(json_str)  # 我们实现的规则修复函数
    try:
        data = json.loads(fixed)
        result = QAWithSource.model_validate(data)
        print("修复后校验通过:", result)
    except Exception as e2:
        # 规则修复也失败,最后尝试让LLM自行修复
        repair_prompt = (
            "你的输出未通过格式校验。\n"
            f"原始输出:{json_str}\n"
            f"错误提示:{errors}\n"
            "请根据要求输出正确的JSON(只包含JSON对象本身)。"
        )
        repair_resp = deepseek_api.ChatCompletion.create(
            model="deepseek-chat",
            messages=[{"role": "user", "content": repair_prompt}]
        )
        repaired_output = repair_resp.choices[0].message.content
        repaired_json = re.search(r'\{[\s\S]*\}', repaired_output)
        repaired_json = repaired_json.group(0) if repaired_json else repaired_output
        try:
            data = json.loads(repaired_json)
            result = QAWithSource.model_validate(data)
            print("二次LLM修复后通过:", result)
        except Exception as final_e:
            print("修复管线失败,启用兜底方案。错误原因:", final_e)
            result = None  # 或者采取其他降级处理

在这个示例中,我们依次应用了直接解析正则规则修复LLM协助修复三道步骤来保证输出格式。如果最终result仍然是None,意味着即便二次尝试模型也没给出合法JSON,我们就可以在此进行兜底处理,例如记录问题并返回一个预定义错误JSON或消息给用户。

成果:成功率大幅提升

经过上述Pipeline改造后,我们对LLM结构化输出的成功率进行了对比评估。以我们选用的DeepSeek模型为例,在不使用任何校验修复的情况下,模型直接输出有效JSON的概率约为90%左右(在简单Schema下更高,但Schema稍复杂时模型可能偶尔丢字段或格式错误)。而引入Pydantic校验和Repair Pipeline之后,我们发现绝大部分异常都能被自动纠正。在一组测试中,100次问答里有10次初始输出不符合Schema,但修复管线成功挽救了其中的9次,只有1次彻底失败无法自动纠正——整体结构化输出成功率从90%提升到了99%

这一效果也印证了业界的一些经验。例如,有测试显示某些精简模型原始JSON合格率只有 ~80-90%,但通过重试和JSON修复可以把大部分失败情况补救上去。又比如DeepSeek最新模型被测在复杂JSON模式下可达到100%严格JSON通过率,即使稍逊的模型通过我们的修复闭环也能逼近这一水平。实际上,我们的Pipeline不仅提升了成功率,也带来了更高的信心——因为每一次输出我们都有校验把关,出问题就立即纠正,不让错误悄悄流入后续环节。

当然,成功率提升的代价是增加了一些额外开销:比如正则替换的计算量可以忽略不计,但如果进入LLM二次修复,那就是一次额外的模型调用,会增加延迟和费用。因此在实际应用中需要权衡修复带来的好处和成本。根据我们的观察,由于DeepSeek等模型本身在JSON格式上的可靠性很高,大部分请求无需进入昂贵的修复步骤(要么一次通过,要么规则修复就解决了)。只有极少数顽固错误才会调用第二次LLM,因此总体来看代价是值得的

结论:工程化保障LLM输出稳定

通过以上方法,我们将LLM结构化输出的问题转化为了一个工程问题并加以解决。从约束设计、Schema校验到自动修复与兜底,我们构建了一个闭环来大幅提升输出可靠性。这套方法对开发者、LLM应用工程师乃至面试官都有一些启示:

  • 不要仅依赖提示词约束输出:精心设计Prompt固然重要,但单靠Prompt无法保证100%格式正确。“差不多正确”在生产环境中是不够的,需辅以程序端的验证和纠错机制。
  • Schema + 验证即合同:使用Pydantic等工具明确约定输出结构,并在每次调用后立即验证,能快速发现问题、防止脏数据流入后续逻辑。这种合同式思维非常符合严谨的工程要求。
  • 自动修复提高鲁棒性:绝大多数格式问题都可以通过简单规则或一次模型重答来解决。建立Repair Pipeline后,LLM仿佛有了“后备保险”,让系统对偶发错误具有自愈能力。
  • 权衡成本与收益:在追求接近完美输出的同时,也要考虑性能开销和实现复杂度。在格式要求非常严格且错误代价高的场景(如财务报告、代码生成)采用修复闭环很值得;但如果只是非关键场合的小任务,可能容忍偶尔失败或者手动处理即可。这需要根据应用需求做权衡。
  • 体现工程思维:最后,从面试角度来说,能意识到并解决LLM输出的不确定性,体现了工程师的成熟度。这比起单纯调参让模型输出正确,更能给人留下深刻印象。正如某些实践文章所暗示的,与其反复调整Prompt,不如把结构化输出当成工程问题来解决,构建完整的约束-校验-修复闭环来保证稳定性。

当我们的LLM输出经过层层把关和自动修正,最终达到接近100%的格式可靠度时,我们就真正能够信赖这些模型成为更大系统中的稳健组成部分。通过DeepSeek等高性能模型配合Pydantic校验与Repair Pipeline,我们成功将LLM结构化输出从“不太让人放心”变成了“几乎万无一失”。对于任何依赖LLM提供结构化数据的项目来说,这无疑是一个巨大的质量提升。今后,我们也会持续优化这一流水线,例如引入更智能的错误分析、更多样的修复手段,以及关注模型本身的新特性(比如函数调用模式)来进一步提高输出稳定性。希望这些经验对你在LLM开发中的实践有所帮助,让我们的AI应用既聪明又可靠!

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐