LLM 输出 JSON 不稳定:Schema + 重试的实战方案
- 约 7 分钟读完 - 全文 2527 字LLM 输出 JSON 不稳定:Schema + 重试的实战方案
做 AI Agent 或 LLM 应用时,一个高频需求是让模型输出结构化数据。比如 Agent 需要 LLM 决定调用哪个 Tool、视觉规划需要输出 JSON 数组、代码生成需要返回固定格式的片段。但 LLM 输出 JSON 很容易出问题:格式错乱、字段缺失、类型不对、多了句废话。这篇文章分享我们是如何把结构化输出成功率从 75% 左右提升到 98% 以上的。
一、问题的普遍性
我们先看几个真实遇到的 LLM 输出:
场景 1:Agent 决定调用 Tool
期望输出:
{
"tool": "RetrieveMaterials",
"params": {"query": "海边日落"}
}实际输出:
我决定调用 RetrieveMaterials 工具,参数是 {"query": "海边日落"}。
场景 2:视觉规划输出 JSON 数组
期望输出:
[{"element_type": "TitleCard", "content": "AI 剪辑实战"}]实际输出:
{
"result": [{"element_type": "TitleCard", "content": "AI 剪辑实战"}]
}场景 3:字段类型错误
{
"duration": "30秒"
}期望是数字:30。
这些输出人都看得懂,但程序解析不了。不解决的话,LLM 应用根本跑不稳。
二、常见的四层解决方案
我们实践下来,可靠的方案是四层防护:
flowchart TD
A["第一层:Prompt 设计(明确格式 + Few-shot)"] --> B["第二层:JSON Schema / Function Calling 约束"]
B --> C["第三层:后端 Pydantic 校验"]
C --> D["第四层:自动重试 + 降级"]
单层都不够,必须组合使用。
三、第一层:Prompt 设计
3.1 明确输出格式
Prompt 里必须清楚说明输出格式,不要含糊:
请直接输出 JSON,不要包含任何解释性文字。
JSON 格式如下:
{
"tool": "工具名称",
"params": {
"参数名": "参数值"
}
}
3.2 Few-shot 示例
在 Prompt 里给 2-3 个正确示例,效果提升明显:
示例 1:
输入:用户想要一个海边日落视频
输出:{"tool": "RetrieveMaterials", "params": {"query": "海边日落"}}
示例 2:
输入:用户想要删除视频中的语气词
输出:{"tool": "RemoveFillers", "params": {"aggressive": false}}
3.3 限制输出长度
对于简单任务,明确要求 LLM 不要展开:
输出必须严格符合上述 JSON 格式,总长度不超过 200 个字符。
四、第二层:JSON Schema 约束
4.1 什么是 JSON Schema
JSON Schema 是一种描述 JSON 数据结构的规范。你可以把它传给 LLM,让模型按这个结构输出。
DeepSeek、GPT-4、Claude 3 都支持 JSON Schema 或 Function Calling。
4.2 我们的 Schema 示例
visual_plan_schema = {
"type": "array",
"items": {
"type": "object",
"properties": {
"sentence_index": {"type": "integer"},
"element_type": {
"type": "string",
"enum": ["TitleCard", "BRoll", "QuoteCard", "CodeBlock", "ListCard", "TalkingHead"]
},
"reason": {"type": "string"},
"suggested_content": {"type": "string"}
},
"required": ["sentence_index", "element_type", "reason"]
}
}4.3 DeepSeek 调用示例
import openai
client = openai.OpenAI(
api_key="your-api-key",
base_url="https://api.deepseek.com/v1"
)
response = client.chat.completions.create(
model="deepseek-chat",
messages=[
{"role": "system", "content": "你是一个专业的视频剪辑规划助手。"},
{"role": "user", "content": prompt}
],
response_format={
"type": "json_schema",
"json_schema": {
"name": "visual_plan",
"schema": visual_plan_schema
}
},
temperature=0.2
)
result = json.loads(response.choices[0].message.content)4.4 Function Calling
Function Calling 本质上也是让 LLM 输出结构化的函数调用参数:
response = client.chat.completions.create(
model="deepseek-chat",
messages=[{"role": "user", "content": prompt}],
tools=[
{
"type": "function",
"function": {
"name": "plan_visual_elements",
"description": "为口播视频规划视觉元素",
"parameters": visual_plan_schema
}
}
],
tool_choice={"type": "function", "function": {"name": "plan_visual_elements"}}
)
tool_call = response.choices[0].message.tool_calls[0]
result = json.loads(tool_call.function.arguments)Function Calling 的好处是模型更清楚自己在做什么,输出稳定性通常更好。
五、第三层:Pydantic 后端校验
即使 LLM 支持 Schema,也不能 100% 保证输出合法。后端必须用 Pydantic 做严格校验。
5.1 模型定义
from pydantic import BaseModel, Field, field_validator
from typing import Literal
class VisualElement(BaseModel):
sentence_index: int = Field(ge=0)
element_type: Literal["TitleCard", "BRoll", "QuoteCard", "CodeBlock", "ListCard", "TalkingHead"]
reason: str = Field(min_length=5, max_length=200)
suggested_content: str = ""
@field_validator('suggested_content')
@classmethod
def validate_content(cls, v, info):
element_type = info.data.get('element_type')
if element_type == "TitleCard" and len(v) < 2:
raise ValueError("TitleCard 必须有标题内容")
return v
class VisualPlan(BaseModel):
elements: list[VisualElement]5.2 校验与错误收集
def parse_visual_plan(raw_output: str) -> VisualPlan:
try:
data = json.loads(raw_output)
except json.JSONDecodeError as e:
raise ValueError(f"JSON 解析失败: {e}")
# 兼容 LLM 外层多套一层 result 的情况
if isinstance(data, dict) and "result" in data and isinstance(data["result"], list):
data = data["result"]
return VisualPlan(elements=data)5.3 常见兼容处理
实际生产中会见到各种「不标准」的输出,我们做了几个兼容:
- 去掉 Markdown 代码块标记
json ... - 去掉多余的前缀说明文字
- 兼容外层多套一层
{"result": [...]}的情况 - 兼容中文字段名(如果 Prompt 不小心用了中文)
六、第四层:自动重试 + 降级
6.1 重试策略
当校验失败时,不要直接报错,而是把错误信息回传给 LLM,让它重新生成。
from tenacity import retry, stop_after_attempt, wait_exponential
@retry(
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=1, min=1, max=5),
retry=retry_if_exception_type(ValueError)
)
def generate_with_retry(prompt: str, schema: dict) -> dict:
raw = llm.generate(prompt, response_format=schema)
try:
return parse_visual_plan(raw)
except ValueError as e:
# 把错误信息加入 Prompt,重新生成
prompt += f"\n\n注意:你之前的输出校验失败,错误是:{e}\n请严格按照 JSON Schema 输出。"
raise6.2 温度参数调整
重试时降低 temperature,让输出更稳定:
temperature_schedule = [0.2, 0.1, 0.0]6.3 降级方案
如果 3 次重试都失败,不能卡住,要有降级:
def safe_generate_visual_plan(script: str) -> VisualPlan:
try:
return generate_with_retry(script)
except Exception:
# 降级:所有句子保持 TalkingHead
return VisualPlan(elements=[
VisualElement(
sentence_index=i,
element_type="TalkingHead",
reason="LLM 规划失败,降级为默认方案"
)
for i in range(len(script.split('。')))
])这样至少能保证系统不崩,用户可以拿到一个基础版本的视频。
七、完整代码示例
import json
from pydantic import BaseModel, Field
from typing import Literal
class VisualElement(BaseModel):
sentence_index: int = Field(ge=0)
element_type: Literal["TitleCard", "BRoll", "QuoteCard", "CodeBlock", "ListCard", "TalkingHead"]
reason: str = Field(min_length=5, max_length=200)
suggested_content: str = ""
def clean_llm_output(raw: str) -> str:
"""清理 LLM 可能产生的多余格式"""
raw = raw.strip()
if raw.startswith("```"):
raw = raw.strip("`")
if raw.startswith("json"):
raw = raw[4:].strip()
return raw
def robust_parse(raw: str) -> list[VisualElement]:
raw = clean_llm_output(raw)
try:
data = json.loads(raw)
except json.JSONDecodeError:
# 尝试找到第一个 [ 和最后一个 ]
start = raw.find("[")
end = raw.rfind("]")
if start != -1 and end != -1:
data = json.loads(raw[start:end+1])
else:
raise
# 兼容外层嵌套
if isinstance(data, dict):
for key in ["result", "data", "elements"]:
if key in data and isinstance(data[key], list):
data = data[key]
break
if not isinstance(data, list):
raise ValueError(f"期望数组,实际得到 {type(data)}")
return [VisualElement(**item) for item in data]八、效果数据
在我们实际使用中,这套四层方案的效果如下:
| 方案 | 解析成功率 |
|---|---|
| 纯 Prompt 约束 | 约 75% |
| + JSON Schema | 约 90% |
| + Pydantic 校验 + 重试 | 约 98% |
| + 降级方案 | 系统可用性 99.9%+ |
(待补充真实数据)
九、踩过的坑
9.1 Schema 太复杂导致模型懵
早期的 Schema 有 20 多个字段,模型输出经常漏字段。
解决:Schema 只保留必要字段,非必要字段放 metadata 或后续步骤再补。
9.2 中文标点导致 JSON 解析失败
LLM 输出中文引号或中文逗号,导致 JSON 解析失败。
解决:在 Prompt 里明确说明用英文标点,后端做字符替换兼容。
9.3 模型输出超长
复杂任务下,LLM 输出解释性文字 + JSON,超出 Schema 控制。
解决:
- 明确要求「只输出 JSON,不要解释」
- 设置
max_tokens上限 - 后端做截断和清洗
9.4 不同模型 Schema 支持不一致
DeepSeek、GPT、Claude 对 JSON Schema 的支持细节不同。
解决:
- 统一封装 LLM 调用层
- 对不同模型做适配
- 尽量使用 Function Calling,兼容性更好
十、总结
LLM 输出结构化数据不稳定,不是模型不行,而是提示、约束、校验、重试四个环节必须闭环。我们的经验是:
- Prompt 要明确要求格式,别指望模型自己猜
- JSON Schema / Function Calling 是第二层保险
- 后端 Pydantic 校验必不可少,不要信任模型输出
- 重试 + 降级保证系统可用性,而不是一失败就抛异常
- 对模型差异做抽象封装,方便切换和升级
这套方案不只适用于视频生成,几乎所有 LLM Agent 应用都需要。
如果你在 LLM 结构化输出方面也踩过坑,欢迎在评论区分享。