让模型"说人话"只是起点,让程序"听得懂"才是工程问题。本系列前两篇跑通了最小调用链路、理清了消息结构与模型选型,拿到的回复都是自由文本;可下游程序要的是字段——情感分析要拿到 sentiment 与 rating,信息抽取要拿到一条条结构化记录,拿自然语言去喂数据库,中间必然横着一道解析的坎。本篇是《OpenAI Python SDK 从入门到实战》系列的第 4 篇,讲透 Responses API 的结构化输出(Structured Outputs):为什么需要、JSON Schema 与 Pydantic 两种用法、strict 模式的限制清单、与流式的配合,最后组装成一个商品评论情感提取器。全文按"动机 → 两种方式 → 限制 → 流式 → 实战 → 总结"组织。
1、为什么要结构化输出
模型默认输出自然语言,程序消费的却是数据,两者之间的传统桥梁有两条,各有各的脆。第一条是在提示词里求模型"请返回 JSON",然后自己解析,坑在正则:用 re.search 从回复里抠字段,模型措辞从"好评"换成"正面",表达式当场失效。第二条是拿 json.loads 硬转,转不动就上第三方"JSON 修复库"缝缝补补,坑在修复:截断、尾逗号、单引号全靠启发式猜测去补,修出来的数据未必是模型本意,补丁摞补丁。示例如下:
import json
text = client.responses.create(
model="gpt-6.1-terra",
instructions="分析评论情感,请返回 JSON。",
input="物流很快,鼠标手感一流!",
).output_text
# 实际可能拿到:好的,分析如下:{"sentiment": "好评"}
data = json.loads(text) # JSONDecodeError:混入了说明文字
解读:模型对格式是"尽力配合"而非"保证服从",温度、措辞习惯、分点偏好都会让输出漂移。更早的 JSON 模式(把 text.format 设为 json_object)前进了一步,保证输出是合法 JSON,但不保证字段齐全、类型正确。
结构化输出把格式约束下沉到了解码环节:你提交一份 JSON Schema,服务端在生成时逐 token 保证输出符合这份 schema,字段缺失、类型漂移、枚举编造从"常见事故"变成"不会发生"。它的典型场景是"模型只负责判断、程序负责消费"的中间层任务:分类打标(识别垃圾评论)、信息抽取(从简历里抠出姓名与学历)、结果入库(分析结果直接写数据库字段)、Agent 间通信(上游产出固定格式给下游解析)。反过来,写文案、起名字这类以文本形态交付的任务,不需要它。官方在结构化输出指南与发布公告中明确建议:能用结构化输出,就不要停留在 JSON 模式。
2、JSON Schema 直传方式
第一种用法是把 JSON Schema 直接塞进 responses.create 的 text 参数。text 负责文本输出的配置,format 键指定格式,type 取 json_schema,同时给 schema 起个名字、把 strict 设为 True。示例如下:
schema = {
"type": "object",
"properties": {
"city": {"type": "string"},
"date": {"type": "string"},
"high_c": {"type": "integer"},
"low_c": {"type": "integer"},
},
"required": ["city", "date", "high_c", "low_c"],
"additionalProperties": False,
}
response = client.responses.create(
model="gpt-6.1-terra",
instructions="从输入中提取天气信息。",
input="北京明天晴,最高 26 度,最低 14 度。",
text={
"format": {
"type": "json_schema",
"name": "weather",
"schema": schema,
"strict": True,
},
},
)
print(response.output_text)
输出是严格贴合 schema 的 JSON 字符串:
{"city": "北京", "date": "明天", "high_c": 26, "low_c": 14}
解读:strict: True 是这套机制的开关,含义是服务端启用约束解码——每一步只允许采样符合 schema 的 token,而不是生成后再校验。name 只是这份 schema 的标识符,供日志与排查定位用,不影响生成。代价是 schema 必须遵守一组写法限制(第 4 节展开),换来的是输出严格贴合结构。注意此时 output_text 拿到的仍是字符串,程序里还要自己 json.loads 一次;想直接拿 Python 对象,看下一节。顺带对照:第 1 篇提过另一条 Chat Completions 线,它的等价参数是 response_format,SDK 里同样有 client.chat.completions.parse 做对象解析,两条线的结构化能力同源,选线逻辑与本系列主线一致,不再展开。
3、Pydantic 定义与解析
手写 schema 繁琐,还容易和代码里的类型定义脱节,SDK 为此内置了更顺手的路径:用 Pydantic 定义模型类,走 responses.parse,直接拿对象。pydantic 本就是 openai 库的既有依赖,无需额外安装,用法细节见 Pydantic 官方文档。示例如下:
from pydantic import BaseModel
class BookInfo(BaseModel):
title: str
author: str
year: int
response = client.responses.parse(
model="gpt-6.1-terra",
instructions="从文本中提取书籍信息。",
input="《三体》是刘慈欣创作的科幻小说,首版于 2008 年。",
text_format=BookInfo,
)
book = response.output_parsed # 直接是 BookInfo 实例
print(book.title, book.author, book.year)
这条链路在 SDK 内部发生的事情如下:
解读:SDK 把 BookInfo 转成等价的 JSON Schema(自动带上 strict 与全量 required),服务端约束解码生成 JSON,返回后 SDK 再把 JSON 反序列化成 BookInfo 实例挂到 output_parsed 上。对比第 2 节,schema 定义从字典换成了带类型提示的类:编辑器能补全 book.title,改字段名时重构工具跟得上,类型与 schema 永远同源。嵌套与列表也开箱支持,字段声明成 list[Chapter](Chapter 同样继承 BaseModel)即可提取结构化列表,SDK 会递归生成嵌套 schema。官方指南明确推荐这种写法,本系列后续实战一律采用。想给字段补充语义,用 Field(description=...),描述会写进 schema 帮模型理解字段含义。
4、strict 模式限制清单
约束解码的可靠性来自对 schema 的强假设,因此 strict 模式只支持 JSON Schema 的一个子集。主要限制如下:
| 限制 | 含义 |
|---|---|
| 对象必须关闭额外键 | 所有对象都要 additionalProperties: false |
| 字段全部必填 | required 必须列全,可选语义用可空类型模拟 |
| 根节点必须是对象 | 根级不能是 anyOf 或标量 |
| 组合关键字不可用 | allOf、not、if 等尚未开放 |
| 规模有上限 | 属性总数 ≤5000、嵌套 ≤10 层、枚举值总数 ≤1000 |
| 键序即输出序 | 输出字段按 schema 中的键顺序生成 |
其中模拟"可选字段"最常用的写法,是把类型写成数组并包含 null:
{
"rating": {
"type": ["integer", "null"],
"description": "评论未提及评分时为 null"
}
}
Pydantic 侧与之对应的就是 int | None。这些限制并非刁难:约束解码要求服务端在生成每一步都能确定"接下来可能出现什么",关闭额外键、把字段列全,正是把不确定性锁死的前提。除此之外还有两件事值得知道。其一,schema 不合规时不会静默降级:strict: true 配上不支持的写法,API 会直接报错,把问题挡在上线之前。其二,安全拒答不会硬凑 JSON,而是返回 refusal 内容项,此时 output_parsed 为空——处理用户生成的输入时,记得给这条分支留出口,常见做法是记日志跳过或回退到普通调用,交给人工复核。支持的部分其实很够用:字符串的 pattern 与 format、数字的 minimum 与 maximum、数组的 minItems 与 maxItems,以及 enum 与 anyOf 都在白名单内。
注意:同一份 schema 的首次请求会有额外延迟,服务端要先处理 schema;之后的相同 schema 请求不再有这部分开销。
5、结构化输出与流式
结构化与流式可以同时开启。结论先给:本系列第 3 篇讲过流式输出的基本机制,responses.stream 同样接受 text_format,逐段到达的是尚未闭合的 JSON 片段,最终对象在流结束后拿到;官方也建议把流式下的结构化解析交给 SDK 辅助方法,而不是自己拼片段。示意如下:
from pydantic import BaseModel
class Entities(BaseModel):
colors: list[str]
animals: list[str]
with client.responses.stream(
model="gpt-6.1-terra",
input="橘猫追着棕色的狗跑过草地",
text_format=Entities,
) as stream:
for event in stream:
if event.type == "response.output_text.delta":
print(event.delta, end="") # 增量 JSON 片段
result = stream.get_final_response()
print(result.output_parsed) # 完整的 Entities 实例
解读:response.output_text.delta 事件携带增量文本,适合做进度展示;get_final_response 返回完整响应,其 output_parsed 已是 Entities 实例——注意最终对象只在流结束后可用,中途的业务逻辑别急着消费它。想让字段"到一个用一个",第 4 节的键序规则是前提——字段严格按 schema 顺序生成,排在前面的字段闭合后即可先行消费。对提取类任务而言,JSON 通常只有几十个 token,流式收益有限;它真正的用武之地是长 JSON 配合逐字段渲染的界面。
6、评论情感提取器实战
把前面的零件组装起来:一个商品评论批量提取器,Pydantic 双字段——情感倾向与星级评分,循环处理评论列表。评分字段用 Field(ge=1, le=5) 划定范围,映射为 schema 的 minimum 与 maximum。完整代码如下:
from pydantic import BaseModel, Field
from openai import OpenAI
class ReviewAnalysis(BaseModel):
sentiment: str = Field(description="情感倾向:positive 或 negative")
rating: int = Field(ge=1, le=5, description="综合评分,1 到 5 星")
client = OpenAI()
REVIEWS = [
"物流很快,包装完好,鼠标手感一流,值得推荐!",
"用了一周就双击失灵,客服踢皮球,非常失望。",
"东西还行,价格便宜,但做工一般,谈不上惊喜。",
]
for i, review in enumerate(REVIEWS, 1):
response = client.responses.parse(
model="gpt-6.1-terra",
instructions="你是电商评论分析器,从评论中提取情感与评分。",
input=review,
text_format=ReviewAnalysis,
)
r = response.output_parsed
if r is None:
print(f"{i}. [拒答或解析失败]")
continue
print(f"{i}. {r.sentiment:<9} {r.rating} 星")
运行输出类似:
1. positive 5 星
2. negative 1 星
3. positive 3 星
解读这段实战的四个要点。第一,instructions 负责任务定位,字段语义由 description 兜底,两层信息各司其职。第二,r is None 的判空在真实业务里不可省:拒答、极端截断都会让 output_parsed 拿不到对象,批量任务里一条失败不该带崩整批。第三,稳定性还有一个细节:第 2 篇讲过数据抽取类任务适合把 temperature 压到 0 到 0.3,这里同理——结构化输出管住了格式,低温进一步压住评分这类主观字段的摇摆。第四,成本视角:这类短文本分类提取正是第 2 篇里 Luna 档的主场,单条只消耗几十 token,量上去之后把 model 换成 gpt-6-luna,账单能降一个数量级;对延迟敏感的并发批处理,还可以参考官方 SDK 仓库的异步客户端把循环改写成 AsyncOpenAI。
7、总结
本篇把"模型说话、程序听懂"这条链路补齐了:正则与修复两条老路各有暗坑,结构化输出用约束解码把格式保证下沉到服务端;JSON Schema 直传适合快速验证,Pydantic 加 responses.parse 是日常主推姿势,类型即 schema、解析即对象;strict 模式要求关闭额外键、字段全必填、根节点为对象,可选语义用可空类型模拟;结构化与流式可叠加,键序稳定让逐字段消费成为可能;最后的提取器把全部要点收敛成二十几行可运行代码。值得一提,下一篇讲工具调用时,工具参数的定义用的正是同一套 JSON Schema 机制,这篇打的地基直接复用。对于要做信息抽取、分类打标、结果入库的开发者,建议从 responses.parse 起步,字段设计从少开始,跑通后再逐步收紧约束。
留一道练习:给第 6 节的 ReviewAnalysis 加一个可枚举字段,比如 category: Literal["电子产品", "服装", "食品", "其它"],标注评论所属的商品类目。加完后观察两件事:枚举清单里的兜底项"其它"对输出分布的影响;再把 sentiment 从自由字符串改写成 Literal["positive", "negative"],对比改前改后第三条中评的归类结果。

