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 格式正确"
- "不要添加注释"
问题是:
- 模型是概率性的:即使提示词很明确,模型仍有一定概率违反
- 上下文干扰:对话历史中的其他内容可能干扰输出格式
- 模型差异:不同模型、不同版本的行为不同
- 无法保证 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"]}
阶段二:初步解析与清理
第二阶段是对模型输出进行初步处理:
- 提取 JSON:如果输出被 Markdown 代码块包裹,提取其中的 JSON 部分
- 移除开场白/结尾语:检测并移除 JSON 前后的自然语言
- 修复常见语法错误:
- 移除尾随逗号
- 移除注释
- 修复不匹配的引号
- 补全被截断的 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 语法正确但语义不正确时(字段缺失、类型错误、值不在允许范围内),使用修复循环。
修复循环的工作方式:
- 验证:根据 schema 验证解析后的对象
- 诊断:如果验证失败,生成具体的错误描述
- 修复提示:将原始输出 + 错误描述发送给模型,要求修复
- 合并:将修复后的字段合并回原始对象
- 重复:直到验证通过或达到最大修复次数
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 结构化输出不是提示词技巧,而是一个完整的工程流水线。
核心要点:
- 问题:"返回 JSON"在生产中失败——Markdown 包裹、开场白、尾随逗号、类型错误、截断等
- 解决方案:多阶段流水线——结构化提示 → 提取清理 → 解析 → 重试 → 修复循环 → Schema 验证
- 核心创新:修复循环(Repair Loop)——当 JSON 语法正确但语义不正确时,将错误反馈给模型进行修复
- 进阶技术:函数调用/JSON Mode、语法约束解码、输出缓存、监控告警
- 最佳实践:从简单开始、定义清晰 Schema、限制输出复杂度、建立评估集、优雅降级
- 常见陷阱:过度依赖提示词、修复循环过于复杂、忽略性能影响、不记录失败案例
对于正在将 LLM 接入产品的开发者来说,这个流水线模式可以直接应用。关键是要接受模型是概率性的这一事实,然后用工程手段来保证可靠性。就像文章所说的:"一旦你内化了这个模式,你就再也不用为格式错误的 JSON 救火了。"
原文链接:https://dev.to/devshakib/structured-output-from-llms-a-retry-repair-loop-your-parser-never-sees-through-3b0b