Chapter 06

结构化输出与多模态

把 LLM 从"写文章的"变成"填表的"和"看图的"。本章讲 response_format 的三种形态,Pydantic 集成,以及图片、音频的跨厂商怎么传。

为什么需要结构化输出

LLM 默认吐出"自然语言字符串"。做产品时这经常不够:

于是问题变成:如何让模型稳定输出一个符合特定 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_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 在各厂商的现状

ProviderJSON 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 / SGLangGuided JSON (xgrammar/outlines)100%推理端拦截,最严格
Ollamajson mode 近似~70%小模型不稳
LiteLLM 的"兜底实现"
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 会:

  1. 调用 Pydantic 的 model_json_schema() 生成 schema
  2. 加上 strict: trueadditionalProperties: false
  3. 按当前 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 下的限制:

关于 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 做的事:

给 OpenAI:URL 原样
让 OpenAI 自己去抓图。
给 Anthropic:下载 + 转 base64
Claude 原生 API 不支持 image URL(只支持 base64 或 file_id)。LiteLLM 自动下载 URL,转成 base64 塞给 Claude。
给 Gemini:URL 或 Part.fromUri
Gemini 支持 inline_data (base64) 或 fileData (GCS URI)。LiteLLM 会根据 URL 形态做判断。
给 Bedrock Claude:转 base64
和 Anthropic 一样。

所以你写的 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)。

能力矩阵:谁能干什么

能力OpenAIClaudeGeminiBedrock开源模型
JSON mode✅ (via tool)部分
JSON schema strict✅ 原生✅ (via tool)✅ responseSchema✅ (Claude via tool)vLLM/SGLang
Pydantic 直传部分
图片输入 URL⚠️ (LiteLLM 下载)⚠️多数不支持
图片输入 base64LLaVA/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 用法。

常见坑汇总

  1. JSON mode 下 prompt 不带 "JSON" 字眼:OpenAI 会报错,必须在 system 或 user 里出现一次这个词。
  2. strict 模式 Pydantic 有默认值:strict 不允许 default,要么必填要么 Optional。
  3. 图片太大 Claude 超限:Claude 单张图上限约 5MB (base64),超过会报错。大图要先缩放。
  4. image URL 域名不可达:LiteLLM 替 Claude 下载图片时是从你本机出去的——内网地址、需要鉴权的 CDN 会失败。预先下载转 base64。
  5. 枚举值大小写写错:模型返回 "High",Pydantic Literal 是 "high"——反序列化失败。prompt 里强调"值必须是小写"。
  6. 模型不支持 vision 却传了图:报 BadRequestError。用 litellm.get_model_info 先判断 supports_vision
  7. 多图一次传太多:GPT-4o 单请求最多 50+ 张,Claude 大约 20 张——实际测试时从 5 张起步稳妥。

本章小结