聊天模型天生爱聊。你的流水线要的却是一条记录。从「这是一段关于这张 工单的友好说明」到 {"category": "billing", "priority": "high"},这中间的 落差,正是大多数 LLM 集成悄悄出问题的地方 —— 一个多余的 markdown 围栏、一个尾随逗号、一个凭空捏造的键,下游那行 json.loads 就在凌晨三点抛了异常。这篇文章讲的,就是如何从 Claude 和 Gemini 那里逼出 合法、可用的 JSON:用同一套 OpenAI 请求结构、藏在一个 base_url 之后,再加上那一层把它从「演示级」抬升到 「生产级」的校验。
干这活有三件工具:带 json_object 的 response_format、带 json_schema 的 response_format,以及原生的工具/函数调用。它们并不能 互相替代,而选错那一个,正是结构化输出功能让人觉得不靠谱的最常见原因。 我们会逐一走一遍:什么时候该用它、schema 怎么设计、返回的东西又 该怎么校验和修复。
JSON 模式:保证能解析,不保证正确
最简单的那根杠杆,是 response_format={"type": "json_object"}。它把模型约束为只输出语法上 合法的 JSON —— 没有开场白散文、没有 ```json 围栏、没有道歉。它不做的,是强制你想要的那种结构。你仍然得在提示里把字段描述清楚,而模型照样可能漏掉一个 键、凭空多造一个,或者在你想要布尔值的地方塞个字符串。
# response_format=json_object:模型被约束为只能输出语法上
# 合法的 JSON。它并不会强制你想要的“结构” —— 你仍然得在提示里
# 把字段描述清楚。同一段调用,通过一个 base_url 即可切换 Claude 和 Gemini。
from openai import OpenAI
import json
client = OpenAI(
api_key="sk-brievio-...",
base_url="https://api.brievio.com/v1",
)
resp = client.chat.completions.create(
model="claude-sonnet-4-6", # 或换成 "gemini-2.5-flash" —— 代码不变
response_format={"type": "json_object"},
messages=[
{
"role": "system",
"content": (
"Extract the support ticket fields. Reply ONLY with a JSON object "
"with keys: category (one of billing|bug|feature|other), "
"priority (low|medium|high), summary (string), "
"needs_human (boolean)."
),
},
{"role": "user", "content": "I was charged twice this month, please refund."},
],
)
data = json.loads(resp.choices[0].message.content) # 保证能被解析
print(data["category"], data["priority"]) # "billing" "high"
# json_object 保证的是:它能被解析。它并不保证你要的键存在、
# 枚举值合法、类型正确。那些正是校验该负责的事。当结构很简单、当你对提示有很强的把控、或者当你反正都会去校验(你确实 会)时,这就是对的工具。可以这样建立心智模型:json_object 给你买到的,是 json.loads 不会抛异常的保证。它并没有 给你买到这个对象就如你所想的保证。把这个区别当成全部胜负所在。
JSON Schema 模式:约束的是结构,而不只是语法
当你想要字段名、类型和枚举被强制 —— 而不只是被请求 —— 就该取用 json_schema。schema 会随请求一起发出,配上 strict: true(在模型家族支持的地方),输出就会被约束为 与之匹配。真正让「strict」有分量的那两个字段,是 additionalProperties: false(不冒出意外的键)和一份 完整的 required 数组(不漏掉任何键)。
# response_format=json_schema:schema 会被发送给模型,输出
# 则被约束为符合它。在支持的地方设 strict=True 以获得硬性保证。
# 让“strict”真正有意义的,是 additionalProperties=False 加上 required 键。
schema = {
"name": "support_ticket",
"strict": True,
"schema": {
"type": "object",
"additionalProperties": False,
"properties": {
"category": {"type": "string", "enum": ["billing", "bug", "feature", "other"]},
"priority": {"type": "string", "enum": ["low", "medium", "high"]},
"summary": {"type": "string"},
"needs_human": {"type": "boolean"},
},
"required": ["category", "priority", "summary", "needs_human"],
},
}
resp = client.chat.completions.create(
model="claude-sonnet-4-6",
response_format={"type": "json_schema", "json_schema": schema},
messages=[
{"role": "system", "content": "Extract the support ticket fields."},
{"role": "user", "content": "I was charged twice this month, please refund."},
],
)
ticket = json.loads(resp.choices[0].message.content)
# 在 strict 的 json_schema 下,category 可证必为这四个枚举之一 ——
# 顺利路径上无需再写防御性的 "if category not in (...)"。这里有一个诚实的提醒,而且它很要紧: strict 的 json_schema 支持程度因模型家族而异。 有些模型会兑现每一条约束,包括嵌套的 additionalProperties: false;另一些则把 schema 当成一个 强提示、而非硬性语法,尤其是在深度嵌套的对象、联合类型 (anyOf)或递归结构上。Brievio 会把你的 response_format 原样透传给那个真正的第一方模型,所以你 拿到的,是真模型的真实行为 —— 而不是被打过折扣的仿真。但这也意味着 模型自身的原生上限,就是你的上限。务实的法则是: 请求 schema,然后照样校验。绝不要让「strict」把你 从那一步校验里劝退。
JSON 模式 vs. 工具调用:何时用哪个
工具/函数调用同样会返回结构化的 JSON —— 参数会以一段绑定到某个函数名的 JSON 字符串形式回来。那到底该用哪个?这个区分关乎的是意图, 而不是格式:
- 当 JSON 本身就是答案时,用 JSON 模式。 你是在抽取字段、做分类、把内容归纳成一条记录,或者生成一个配置 对象。你每一次都只想要一种结构回来。
response_format更贴合 —— 单一输出,没有函数调用那套仪式,也没有tool_choice的管道铺设。 - 当模型是在选择某个动作时,用工具调用。 它可能去调
get_weather、或search_db, 也可能用散文作答 —— 而你想让模型自己决定用哪个,甚至连调好几个。 函数调用就是为派发而生的:有许多候选结构,由模型来挑。 硬把它塞进单一一个 JSON 对象,会很别扭。 - 灰色地带:用单次强制工具调用来做结构化输出。 把
tool_choice设成要求某个特定函数,是在那些早于json_schema的模型上获得结构化输出的一种由来已久的 办法。它至今仍然好用,也是个不错的兜底。但如果一个模型支持json_schema,那条路更直接、需要操心的也更少。
如果你的负载真正关乎的是动作与派发、而非一条固定记录,那么相关机制 以及跨模型的那些坑,都收录在 Claude 与 Gemini 上的工具使用里。至于一切「给我这个对象」的场景,就守着 response_format。
设计一份模型真能命中的 schema
schema 也是一种提示。你给它塑形的方式,对命中率的影响不亚于模型本身的 选择。有几条在两个家族上都管用的法则:
- 宁可扁平,也别深度嵌套。三层嵌套再加上可选对象, 正是 strict 模式开始打摆子的地方。如果你能把
address.city拍平成city,那就拍平, 之后再在校验完成后重新整形。 - 任何封闭集合都用枚举。
"priority": {"enum": ["low","medium","high"]}远比 一个你事后再处理的自由string可靠。枚举是 schema 里 单项杠杆率最高的特性。 - 像人一样给字段起名。
needs_human_review胜过nh_flag。模型把 起好名的字段填得更准,因为名字本身就携带着指令。 - 给含糊的字段加上
description。 在 schema 内部每个字段配一行说明,就能在不改提示的情况下化解 大多数「模型猜错了」的情形。 - 把可选性写明确。如果一个字段可以缺席,那就要么 把它排除在
required之外,要么把它建模成一个可空的 联合类型 —— 别指望模型去凭空发明一个哨兵值。决定好「缺失」这种 情况归谁负责,是你还是模型。 - 能用有界类型时,就别用自由格式的数字。 把 1~5 的整数评分写成
[1,2,3,4,5]的枚举,效果要胜过 在提示里写「一个 1 到 5 之间的数字」。
校验与修复:那一层能上线的东西
单项最大的可靠性升级,是把模型输出当成一个不可信的客户端请求来对待: 解析它、用你真正的 schema 去校验它,失败时就把错误回灌后重试一次。一个 Pydantic 模型(或者 zod,或者你那门语言里的 JSON Schema 校验)能抓住那些连 strict 模式都会漏过去的情形 —— 而那个 修复回合能修好其中大多数,因为对于你直接指出来的错误,模型很擅长改正。
# 绝不信任你没校验过的输出。把模型当成一个不可信的
# 客户端:解析 -> 用你的 schema 校验 -> 把错误回灌后重试一次。
# 正是这一层,把“通常能用”变成了“能上线”。
from pydantic import BaseModel, ValidationError
from typing import Literal
class Ticket(BaseModel):
category: Literal["billing", "bug", "feature", "other"]
priority: Literal["low", "medium", "high"]
summary: str
needs_human: bool
def extract(text: str, model: str, retries: int = 1) -> Ticket:
messages = [
{"role": "system", "content": "Extract the support ticket fields as JSON."},
{"role": "user", "content": text},
]
for attempt in range(retries + 1):
resp = client.chat.completions.create(
model=model,
response_format={"type": "json_object"},
messages=messages,
)
raw = resp.choices[0].message.content
try:
return Ticket.model_validate_json(raw) # 解析 + 校验一步到位
except ValidationError as e:
if attempt == retries:
raise
# 修复回合:把到底哪里错了,原样展示给模型看。
messages += [
{"role": "assistant", "content": raw},
{"role": "user", "content": f"That failed validation: {e}. Re-emit valid JSON only."},
]
raise RuntimeError("unreachable")注意那个修复回合做了什么:它把模型自己那段糟糕输出 连同那条确切的校验错误一起展示给它,再要求重新输出一遍。 一次重试就能解决绝大多数的失败;如果它仍然失败,你会想知道,所以 让它抛异常。别没完没了地循环、白白烧 token —— 给重试设上界、把原始 载荷记下来、并在硬失败上告警。一次持续不断的校验失败,通常意味着 schema 在索要输入支撑不了的东西,而不是模型坏了。
两条生产笔记。其一,把 max_tokens 设得宽裕些: 在对象中途被截断的 JSON 就是非法的 JSON,而一个过紧的 token 上限, 正是大记录上解析失败的一个主因。其二,对抽取和分类,把 temperature 保持在低位(0 到 0.3)—— 你想要同样的输入 产出同样的记录,而当你是在填一个结构体时,创造力并不是美德。
同一套结构,两个模型家族
上面每一段代码,都只要改一个字符串 —— 那个 model 字段 —— 就能跑在 Claude Sonnet 4.6 和 Gemini 2.5 Flash 上。这正是把结构化输出 路由经 Brievio 的意义所在:OpenAI 那套 response_format 契约是完全一致的,于是你可以在一个抽取 任务上 A/B 测试一个更便宜的模型,或者在事故期间跨家族兜底,全程 无需重写你的解析或校验。你发出的请求,就是那个真正的第一方模型收到 的请求 —— 这里讲清楚了到底哪些对得上、哪些要留神 —— 当你依赖 OpenAI 兼容性时。
一套务实的工作流:先在 Sonnet 上用 strict 的 json_schema 做原型,确认你的校验器在一份留出的测试集上通过,再把同一份 schema 拿去 Flash 上试。如果那个更便宜的模型能过你的校验率,那你就在零代码 改动的情况下削掉了成本 —— 又因为 Brievio 上报诚实的 token 数、并把 失败的 4xx/5xx 调用按零计费,你那些重试和 修复回合就不会藏着一个计量上的意外。在 模型页上对比这些模型,而聊天完整的 请求/响应契约,则在 聊天文档里。
要点
当结构简单、且提示由你掌控时,取用 json_object;当你想 强制结构时,取用带 strict: true、 additionalProperties: false 和一份完整 required 数组的 json_schema;当模型是在选择 一个动作、而非产出一条固定记录时,取用工具调用。无论你选 哪个,都把 schema 设计得扁平、且多用枚举,然后永远做「解析-校验-修复」 —— 因为 strict 的支持程度因模型家族而异,而那一层校验,正是一个只能 演示的结构化输出功能、与一个能扛住真实流量的功能之间的分水岭。同一套 代码、同一份契约、那个真正的模型 —— 横跨 Claude 与 Gemini,藏在一个 base URL 之后。