编程 LLM 结构化输出可靠方案:重试-修复循环让解析器永不见到坏 JSON

2026-09-06 07:16:17

LLM 结构化输出可靠方案:重试-修复循环让解析器永不见到坏 JSON

一位开发者在 Dev.to 上分享了他在真实产品中接入 LLM 的经验。文章开场就很真实:第一次把 LLM 接入产品功能时,他做了最天真的事——提示模型"返回 JSON",然后 jsonDecode 响应,继续。Demo 中一切正常。但上线真实流量后,凌晨 2 点开始收到 FormatException——模型把 JSON 包在了 ```json 代码块里,或者加了一句"这是你要的数据!"的开场白,或者在闭合大括号前多了一个逗号。一个 97% 正确率的模型,每天仍会在数千次请求中出错。可靠的结构化输出不是提示词技巧——它是一个小型流水线,最后一道是解析器永远见不到的修复循环。

背景:为什么"返回 JSON"在生产中失败

常见失败模式

提示词驱动的 JSON 输出有几种可预测的失败方式:

  • Markdown 代码块包裹:模型将 JSON 包在 ```json ... ```
  • 开场白/结尾语:模型在 JSON 前后添加自然语言说明
  • 尾随逗号:在最后一个元素后多了一个逗号,导致解析失败
  • 注释:模型在 JSON 中添加 // 注释
  • 截断:输出被 max_tokens 截断,JSON 不完整
  • 类型错误:应该是数字的地方返回了字符串,应该是数组的地方返回了对象
  • 额外字段:返回了 schema 之外的字段
  • 缺失字段:缺少必填字段

这些失败模式"无聊且持续不断",这正是值得明确命名的原因。

为什么提示词无法解决

很多人试图用更好的提示词解决这些问题:

  • "只返回 JSON,不要任何其他文字"
  • "不要使用 Markdown 代码块"
  • "确保 JSON 格式正确"
  • "不要添加注释"

问题是:

  1. 模型是概率性的:即使提示词很明确,模型仍有一定概率违反
  2. 上下文干扰:对话历史中的其他内容可能干扰输出格式
  3. 模型差异:不同模型、不同版本的行为不同
  4. 无法保证 100%:97% 正确率意味着 3% 的失败,在高流量下是大量失败

解决方案:结构化输出流水线

文章提出的方案是一个多阶段流水线,而不是单一的提示词。

阶段一:结构化提示

第一阶段仍然是提示词,但要更加结构化:

  • 明确 schema:在提示词中提供完整的 JSON schema,包括字段名、类型、是否必填
  • 示例:提供 1-2 个正确输出的示例(few-shot)
  • 约束说明:明确说明不要添加额外文字、不要使用代码块、不要添加注释
  • 角色设定:设定模型为"数据提取引擎",只输出数据
你是一个数据提取引擎。只返回符合以下 schema 的 JSON,不要任何其他文字、解释或 Markdown 代码块。

Schema:
{
  "name": string (必填),
  "email": string (必填, 邮箱格式),
  "age": number (可选),
  "tags": string[] (可选, 字符串数组)
}

示例输入: "John Smith, john@example.com, 30 years old, developer and runner"
示例输出: {"name":"John Smith","email":"john@example.com","age":30,"tags":["developer","runner"]}

阶段二:初步解析与清理

第二阶段是对模型输出进行初步处理:

  1. 提取 JSON:如果输出被 Markdown 代码块包裹,提取其中的 JSON 部分
  2. 移除开场白/结尾语:检测并移除 JSON 前后的自然语言
  3. 修复常见语法错误
    • 移除尾随逗号
    • 移除注释
    • 修复不匹配的引号
    • 补全被截断的 JSON(如果可能)
def extract_json(text: str) -> str:
    """从模型输出中提取 JSON 字符串"""
    # 尝试提取 Markdown 代码块中的 JSON
    import re
    match = re.search(r'```(?:json)?\s*\n?(.*?)\n?```', text, re.DOTALL)
    if match:
        return match.group(1).strip()
    
    # 尝试找到第一个 { 和最后一个 }
    start = text.find('{')
    end = text.rfind('}')
    if start != -1 and end != -1 and end > start:
        return text[start:end+1]
    
    return text.strip()

def fix_common_json_errors(json_str: str) -> str:
    """修复常见的 JSON 语法错误"""
    import re
    # 移除尾随逗号
    json_str = re.sub(r',\s*([}\]])', r'\1', json_str)
    # 移除单行注释
    json_str = re.sub(r'//.*$', '', json_str, flags=re.MULTILINE)
    # 移除多行注释
    json_str = re.sub(r'/\*.*?\*/', '', json_str, flags=re.DOTALL)
    return json_str

阶段三:重试(Retry)

如果清理后的 JSON 仍然无法解析,进行重试:

  • 重新提示:在重试时明确指出之前的输出有格式问题
  • 提供错误信息:将解析错误信息反馈给模型
  • 限制重试次数:通常 2-3 次重试足够
  • 指数退避:如果是 API 限流,使用指数退避
def parse_with_retry(prompt: str, max_retries: int = 3) -> dict:
    """带重试的 JSON 解析"""
    last_error = None
    for attempt in range(max_retries):
        response = call_llm(prompt)
        json_str = extract_json(response)
        json_str = fix_common_json_errors(json_str)
        try:
            return json.loads(json_str)
        except json.JSONDecodeError as e:
            last_error = e
            # 在重试提示中包含错误信息
            prompt += f"\n\n之前的输出有格式错误: {e}. 请只返回有效的 JSON."
    raise last_error

阶段四:修复循环(Repair Loop)——核心创新

这是文章的核心创新:当 JSON 语法正确但语义不正确时(字段缺失、类型错误、值不在允许范围内),使用修复循环。

修复循环的工作方式:

  1. 验证:根据 schema 验证解析后的对象
  2. 诊断:如果验证失败,生成具体的错误描述
  3. 修复提示:将原始输出 + 错误描述发送给模型,要求修复
  4. 合并:将修复后的字段合并回原始对象
  5. 重复:直到验证通过或达到最大修复次数
def validate_and_repair(data: dict, schema: dict, max_repairs: int = 3) -> dict:
    """验证并修复结构化输出"""
    for attempt in range(max_repairs):
        errors = validate_against_schema(data, schema)
        if not errors:
            return data  # 验证通过
        
        # 生成修复提示
        repair_prompt = f"""
        以下 JSON 输出有以下问题:
        {errors}
        
        原始输出:
        {json.dumps(data, indent=2)}
        
        请只返回修复后的完整 JSON,不要任何其他文字。
        """
        
        response = call_llm(repair_prompt)
        repaired = json.loads(extract_json(response))
        
        # 合并修复后的字段(保留原始正确字段)
        data = deep_merge(data, repaired)
    
    raise ValueError(f"修复失败,剩余错误: {errors}")

阶段五:Schema 验证与默认值

最后阶段是严格的 schema 验证:

  • 类型检查:确保每个字段的类型正确
  • 必填检查:确保必填字段存在
  • 枚举检查:确保值在允许的枚举范围内
  • 范围检查:确保数字在合理范围内
  • 格式检查:确保邮箱、URL、日期等格式正确
  • 默认值填充:为可选字段填充默认值
  • 截断/裁剪:对超长字符串进行截断
def validate_against_schema(data: dict, schema: dict) -> list[str]:
    """根据 schema 验证数据,返回错误列表"""
    errors = []
    for field, rules in schema.items():
        value = data.get(field)
        
        # 必填检查
        if rules.get('required') and value is None:
            errors.append(f"字段 '{field}' 是必填的,但缺失")
            continue
        
        if value is None:
            continue
        
        # 类型检查
        expected_type = rules.get('type')
        if expected_type and not check_type(value, expected_type):
            errors.append(f"字段 '{field}' 类型错误: 期望 {expected_type}, 实际 {type(value).__name__}")
        
        # 枚举检查
        if 'enum' in rules and value not in rules['enum']:
            errors.append(f"字段 '{field}' 值 '{value}' 不在允许范围内: {rules['enum']}")
        
        # 范围检查
        if 'min' in rules and value < rules['min']:
            errors.append(f"字段 '{field}' 值 {value} 小于最小值 {rules['min']}")
        if 'max' in rules and value > rules['max']:
            errors.append(f"字段 '{field}' 值 {value} 大于最大值 {rules['max']}")
    
    return errors

完整流水线架构

用户输入
    │
    ▼
┌─────────────────┐
│  结构化提示词     │  包含 schema、示例、约束
│  (few-shot)     │
└────────┬────────┘
         │
         ▼
┌─────────────────┐
│  LLM 生成        │
└────────┬────────┘
         │
         ▼
┌─────────────────┐
│  提取与清理      │  提取 JSON、移除代码块、修复语法
└────────┬────────┘
         │
         ▼
┌─────────────────┐
│  解析 JSON       │
└────────┬────────┘
     ┌───┴───┐
     │ 成功?  │
     └───┬───┘
      否│    │是
        ▼    ▼
┌──────────┐ ┌──────────────┐
│  重试     │ │ Schema 验证   │
│ (2-3次)  │ └──────┬───────┘
└────┬─────┘        │
     │          ┌───┴───┐
     │          │ 通过?  │
     │          └───┬───┘
     │           否│    │是
     │             ▼    ▼
     │      ┌──────────┐ ┌──────────┐
     │      │ 修复循环   │ │ 默认值填充 │
     │      │ (2-3次)   │ │ 类型转换   │
     │      └─────┬────┘ └────┬─────┘
     │            │             │
     └────────────┘             │
                  ▼              ▼
          ┌──────────────────────┐
          │   最终结构化输出       │
          └──────────────────────┘

进阶技术

1. 函数调用 / JSON Mode

许多现代 LLM API 提供了原生的结构化输出功能:

  • OpenAI Function Calling:定义函数 schema,模型返回函数调用参数
  • OpenAI JSON Mode:强制模型返回有效的 JSON
  • Anthropic Tool Use:类似的工具调用机制
  • Google Gemini Function Calling:类似的功能

这些功能可以大幅降低格式错误的概率,但:

  • 不能完全消除错误(仍可能有语义错误)
  • 不是所有模型都支持
  • 可能有额外的成本或限制

建议:优先使用原生结构化输出功能,同时保留修复循环作为后备

2. 基于语法的解码(Grammar-Constrained Decoding)

更高级的技术是在解码层面约束输出:

  • JSON Schema 约束:在生成时约束 token 必须符合 JSON schema
  • CFG(上下文无关文法)约束:使用文法约束生成
  • Outlines / Guidance / Llama.cpp Grammar:开源库支持语法约束解码

这种方法可以保证输出 100% 符合语法,但:

  • 需要本地部署模型或支持该功能的 API
  • 可能影响生成质量
  • 实现复杂度较高

3. 输出缓存与去重

对于相同的输入,可以缓存结构化输出:

  • 输入哈希:对输入文本进行哈希
  • 缓存查找:先检查缓存中是否有相同输入的结果
  • 缓存存储:使用 Redis、数据库或本地文件存储缓存
  • 缓存失效:设置合理的过期时间

这可以降低成本和延迟,同时提高一致性。

4. 监控与告警

建立结构化输出的监控体系:

  • 格式错误率:监控 JSON 解析失败率
  • 语义错误率:监控 schema 验证失败率
  • 重试率:监控需要重试的比例
  • 修复率:监控需要修复循环的比例
  • 延迟:监控端到端延迟
  • 告警:当错误率超过阈值时触发告警

最佳实践

1. 从简单开始

  • 先用简单的提示词 + 基本的错误处理
  • 监控失败率,根据需要逐步增加复杂度
  • 不要一开始就构建完整的修复循环

2. 定义清晰的 Schema

  • 花时间设计清晰、完整的 schema
  • 明确每个字段的类型、是否必填、允许的值范围
  • 提供字段说明和示例值
  • 避免过于复杂的嵌套结构

3. 限制输出复杂度

  • 字段数量控制在合理范围内(建议 < 20 个)
  • 避免过深的嵌套(建议 < 3 层)
  • 复杂对象拆分为多个简单对象
  • 使用枚举而不是自由文本

4. 建立评估集

  • 收集真实的输入输出样本
  • 建立评估集,包含各种边界情况
  • 每次修改流水线后运行评估
  • 跟踪成功率、延迟、成本等指标

5. 优雅降级

  • 当所有重试和修复都失败时,有优雅的降级方案
  • 返回部分结果(尽可能多的有效字段)
  • 标记为需要人工审核
  • 记录失败案例,用于后续改进

常见陷阱

1. 过度依赖提示词

  • 提示词只能降低错误率,不能消除错误
  • 总是需要后端的验证和修复
  • 不要在提示词优化上投入过多时间而忽略工程方案

2. 修复循环过于复杂

  • 修复循环可能引入新的错误
  • 限制修复次数(2-3 次)
  • 确保修复不会破坏已经正确的字段
  • 记录修复过程,便于调试

3. 忽略性能影响

  • 重试和修复会增加延迟和成本
  • 监控端到端延迟
  • 设置合理的超时
  • 考虑异步处理非实时场景

4. 不记录失败案例

  • 失败案例是改进的宝贵资源
  • 记录输入、输出、错误信息
  • 定期分析失败模式
  • 将失败案例加入评估集

总结

可靠的 LLM 结构化输出不是提示词技巧,而是一个完整的工程流水线。

核心要点:

  1. 问题:"返回 JSON"在生产中失败——Markdown 包裹、开场白、尾随逗号、类型错误、截断等
  2. 解决方案:多阶段流水线——结构化提示 → 提取清理 → 解析 → 重试 → 修复循环 → Schema 验证
  3. 核心创新:修复循环(Repair Loop)——当 JSON 语法正确但语义不正确时,将错误反馈给模型进行修复
  4. 进阶技术:函数调用/JSON Mode、语法约束解码、输出缓存、监控告警
  5. 最佳实践:从简单开始、定义清晰 Schema、限制输出复杂度、建立评估集、优雅降级
  6. 常见陷阱:过度依赖提示词、修复循环过于复杂、忽略性能影响、不记录失败案例

对于正在将 LLM 接入产品的开发者来说,这个流水线模式可以直接应用。关键是要接受模型是概率性的这一事实,然后用工程手段来保证可靠性。就像文章所说的:"一旦你内化了这个模式,你就再也不用为格式错误的 JSON 救火了。"

原文链接:https://dev.to/devshakib/structured-output-from-llms-a-retry-repair-loop-your-parser-never-sees-through-3b0b

推荐文章

程序员茄子在线接单