为什么需要结构化输出
LLM 默认吐出"自然语言字符串"。做产品时这经常不够:
- 前端要渲染卡片——需要
{title, price, image_url}这种字段。 - 下游要存数据库——需要字段类型明确、不能写错。
- 要入 RAG 索引——需要切片 + 元数据。
于是问题变成:如何让模型稳定输出一个符合特定 schema 的 JSON?
业界有三种层次的答案,对应 response_format 的三种形态。
形态一:JSON mode(轻量版)
最简单的一层:告诉模型"输出 JSON",但不限制具体 schema。
resp = completion( model="gpt-4o-mini", messages=[ {"role":"system", "content":"你输出一个 JSON 对象,包含 city 和 temp 字段。"}, {"role":"user","content":"北京天气"}, ], response_format={"type": "json_object"}, ) print(resp.choices[0].message.content) # {"city": "北京", "temp": "零下2度"}
特点:
- 模型保证输出是合法的 JSON(能被
json.loads成功解析) - 字段名称、类型、层级由 prompt 决定,模型不做校验
- 兼容性最广:OpenAI、Anthropic、Gemini、DeepSeek、Moonshot、多数 vLLM 部署都支持
- 老问题:字段可能少、类型可能跑偏(
"temp": "零下2度"本该是数字)
· 用
json_object 时,prompt 里必须出现 "JSON" 这个词,不然 OpenAI 会报错 messages must contain the word 'JSON'。· 流式输出时前几个 chunk 拿到的是半截 JSON,不要每个 chunk 都 json.loads,等完整后再解析。
形态二:JSON schema(strict 版)
OpenAI 2024 年推出的重量级武器——把 schema 直接塞进 response_format,模型 100% 遵守:
SCHEMA = {
"type": "object",
"properties": {
"city": {"type": "string"},
"temp_c": {"type": "number"},
"condition": {"type": "string",
"enum": ["sunny", "cloudy", "rainy", "snowy"]},
},
"required": ["city", "temp_c", "condition"],
"additionalProperties": False,
}
resp = completion(
model="gpt-4o",
messages=[{"role":"user","content":"北京今天天气"}],
response_format={
"type": "json_schema",
"json_schema": {
"name": "weather_report",
"strict": True,
"schema": SCHEMA,
},
},
)
import json
data = json.loads(resp.choices[0].message.content)
assert isinstance(data["temp_c"], (int, float)) # 100% 保证
strict 在各厂商的现状
| Provider | JSON schema 支持 | 严格度 | 备注 |
|---|---|---|---|
| OpenAI(4o/o1/o3+) | 原生 | 100% | 业界金标准 |
| Gemini 1.5+ / 2.x | 原生 responseSchema | ~95% | LiteLLM 翻译,偶尔逃逸 |
| Anthropic Claude | 不支持 response_format,但用 tool use 强制 | ~95% | LiteLLM 自动转 tool_choice |
| vLLM / SGLang | Guided JSON (xgrammar/outlines) | 100% | 推理端拦截,最严格 |
| Ollama | json mode 近似 | ~70% | 小模型不稳 |
Claude 不支持 response_format?LiteLLM 会自动把你的 schema 包装成一个 tool,然后 tool_choice 强制调——最终效果一样,用户无感。这就是"抽象层魔法"最迷人的地方。
形态三:Pydantic 直接塞进去
手写 JSON Schema 很啰嗦。LiteLLM 跟 OpenAI 官方 SDK 一样,支持直接传 Pydantic 模型:
from pydantic import BaseModel, Field from litellm import completion class WeatherReport(BaseModel): city: str temp_c: float = Field(..., description="摄氏温度") condition: Literal["sunny","cloudy","rainy","snowy"] resp = completion( model="gpt-4o", messages=[{"role":"user","content":"北京天气"}], response_format=WeatherReport, # 直接传类 ) print(resp.choices[0].message.content)
LiteLLM 会:
- 调用 Pydantic 的
model_json_schema()生成 schema - 加上
strict: true、additionalProperties: false - 按当前 provider 翻译成原生格式
返回的 content 仍然是 JSON 字符串——你自己 WeatherReport.model_validate_json(...) 反序列化回对象。
Pydantic 的进阶:嵌套、可选、枚举
from enum import Enum from typing import Optional from pydantic import BaseModel, Field class Urgency(str, Enum): LOW = "low" MEDIUM = "medium" HIGH = "high" class Address(BaseModel): street: str city: str zip: Optional[str] = None class Ticket(BaseModel): title: str urgency: Urgency addr: Optional[Address] = None keywords: list[str] = Field(default_factory=list) resp = completion(model="gpt-4o", messages=[...], response_format=Ticket)
strict mode 下的限制:
- 不能用
default(OpenAI strict mode 规定)——要么必填,要么 Optional - 不能有 additionalProperties(LiteLLM 自动填 false)
Union、anyOf部分支持,但 GPT-4o 不是所有组合都接受
关于 Structured Output Fallback
如果你想在 Claude/Gemini 上用相同的 Pydantic 模型,LiteLLM 有个隐性黑魔法——当发现当前 provider 不原生支持 schema,自动用 tool_use 模拟。你完全不用改代码:
# 同一份 Pydantic model, 三家都能跑 for model in ["gpt-4o", "anthropic/claude-sonnet-4-5", "gemini/gemini-2.5-pro"]: resp = completion( model=model, messages=[{"role":"user","content":"北京天气"}], response_format=WeatherReport, max_tokens=512, ) data = WeatherReport.model_validate_json(resp.choices[0].message.content) print(model, data)
这是 LiteLLM 最"值"的一个能力之一——结构化输出从"一家一种写法"变成"一份 schema 到哪都跑"。
多模态一:图片输入
OpenAI 的 vision 格式是 content 数组,每个元素一个 part:
messages = [{
"role": "user",
"content": [
{"type": "text", "text": "这张图里有几只猫?"},
{"type": "image_url",
"image_url": {"url": "https://example.com/cats.jpg"}},
],
}]
resp = completion(model="gpt-4o", messages=messages)
print(resp.choices[0].message.content)
两种图片传法
# 1. URL (OpenAI、Gemini、部分 Claude 支持) {"type": "image_url", "image_url": {"url": "https://example.com/cat.jpg"}} # 2. data URL (base64, 所有家都支持) import base64 with open("cat.jpg", "rb") as f: b64 = base64.b64encode(f.read()).decode() {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{b64}"}}
LiteLLM 做的事:
所以你写的 image_url URL 版本,在 Anthropic 上也能跑——只不过 LiteLLM 替你下载了一次图。大图会显著增加首包延迟,生产代码最好自己控制 base64 编码并缓存。
图片精度 detail 参数
OpenAI 支持 detail: "low" / "high" / "auto":
{"type": "image_url",
"image_url": {"url": "...", "detail": "low"}}
low 是固定 85 tokens,适合缩略图识别;high 会把图切成 512×512 格子消耗更多 token。LiteLLM 原样转发给 OpenAI,在其他家可能忽略。
多模态二:音频输入
OpenAI GPT-4o 支持音频输入(GPT-4o Realtime / Audio API)。格式:
import base64 with open("question.mp3", "rb") as f: b64 = base64.b64encode(f.read()).decode() messages = [{ "role": "user", "content": [ {"type": "text", "text": "这段音频里的人说了什么?"}, {"type": "input_audio", "input_audio": {"data": b64, "format": "mp3"}}, ], }] resp = completion( model="gpt-4o-audio-preview", messages=messages, modalities=["text"], )
其他家的音频格式更杂:Gemini 有 audio mime type,Anthropic 2025 年开始原生支持。LiteLLM 的覆盖还在快速迭代,生产代码建议锁版本后测试。
PDF 和文档
Anthropic 和 Gemini 支持直接 PDF 输入(不用先 OCR):
with open("contract.pdf", "rb") as f: b64 = base64.b64encode(f.read()).decode() messages = [{ "role": "user", "content": [ {"type": "text", "text": "总结这份合同的关键条款"}, {"type": "file", "file": {"file_data": f"data:application/pdf;base64,{b64}"}}, ], }] resp = completion(model="anthropic/claude-sonnet-4-5", messages=messages, max_tokens=2048)
Gemini 2.0+ 同样接受 file 类型,fileData URI(GCS 或直接 base64)。
能力矩阵:谁能干什么
| 能力 | OpenAI | Claude | Gemini | Bedrock | 开源模型 |
|---|---|---|---|---|---|
| JSON mode | ✅ | ✅ (via tool) | ✅ | ✅ | 部分 |
| JSON schema strict | ✅ 原生 | ✅ (via tool) | ✅ responseSchema | ✅ (Claude via tool) | vLLM/SGLang |
| Pydantic 直传 | ✅ | ✅ | ✅ | ✅ | 部分 |
| 图片输入 URL | ✅ | ⚠️ (LiteLLM 下载) | ✅ | ⚠️ | 多数不支持 |
| 图片输入 base64 | ✅ | ✅ | ✅ | ✅ | LLaVA/Qwen-VL |
| 音频输入 | ✅ 4o-audio | ⚠️ Beta | ✅ | 部分 | Whisper-integrated |
| PDF 输入 | ❌ (需要先 OCR) | ✅ | ✅ | ✅ | ❌ |
| 图片输出 | ✅ gpt-image-1 | ❌ | ✅ Gemini 2 | 部分 | Stable Diffusion |
| 音频输出 | ✅ | ❌ | ❌ | ❌ | 部分 |
矩阵每隔几个月会变——官方发布新能力后 LiteLLM 通常 1~2 周跟进。生产前一定查最新文档。
实战:一段"看图提取商品信息"
把结构化输出和图片输入合在一起,做一个实际工作:
from pydantic import BaseModel, Field from typing import Optional from litellm import completion import base64 class Product(BaseModel): name: str = Field(..., description="商品名称") price_cn: Optional[float] = Field(None, description="价格 RMB,不确定留空") color: Optional[str] = Field(None, description="主色调") category: Literal["clothing","electronics","food","home","other"] def extract(image_path: str, model: str) -> Product: with open(image_path, "rb") as f: b64 = base64.b64encode(f.read()).decode() resp = completion( model=model, messages=[{ "role": "user", "content": [ {"type": "text", "text": "从图中提取商品信息, 缺失字段用 null"}, {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{b64}"}}, ], }], response_format=Product, max_tokens=512, ) return Product.model_validate_json(resp.choices[0].message.content) for m in ["gpt-4o", "anthropic/claude-sonnet-4-5", "gemini/gemini-2.5-pro"]: p = extract("item.jpg", m) print(m, p)
同一份 Pydantic 模型、同一段图片处理代码,三家 provider 得到的都是 Product 实例。你的业务层(存库/上报/渲染)完全 provider-agnostic。这就是"教科书级"的 LiteLLM 用法。
常见坑汇总
- JSON mode 下 prompt 不带 "JSON" 字眼:OpenAI 会报错,必须在 system 或 user 里出现一次这个词。
- strict 模式 Pydantic 有默认值:strict 不允许 default,要么必填要么 Optional。
- 图片太大 Claude 超限:Claude 单张图上限约 5MB (base64),超过会报错。大图要先缩放。
- image URL 域名不可达:LiteLLM 替 Claude 下载图片时是从你本机出去的——内网地址、需要鉴权的 CDN 会失败。预先下载转 base64。
- 枚举值大小写写错:模型返回 "High",Pydantic Literal 是 "high"——反序列化失败。prompt 里强调"值必须是小写"。
- 模型不支持 vision 却传了图:报
BadRequestError。用litellm.get_model_info先判断supports_vision。 - 多图一次传太多:GPT-4o 单请求最多 50+ 张,Claude 大约 20 张——实际测试时从 5 张起步稳妥。
本章小结
- 结构化输出三阶:JSON mode / JSON schema / Pydantic 直传
- Pydantic 直传是"优雅的最顶层",LiteLLM 替你做 schema 翻译 + Claude 的 tool_use 兜底
- 图片传法两种:URL(OpenAI/Gemini 原生)和 base64(全家都支持)
- LiteLLM 会替 Claude 自动下载 URL 转 base64,大图有延迟代价
- 音频/PDF 各家差异大,参考能力矩阵并锁定版本
- 实战:Pydantic model + 图片 + 跨 provider = "一次写完,哪家都跑"