Appearance
06|结构化输出:让结果可解析
上一篇的模板让 Prompt 能带变量了,但模型返回的还是一段文本。程序要从这段文本里提取"现象""检查项""证据",靠正则或字符串匹配,脆且容易漏。
同样的要求,模型这轮写"建议检查项",下轮写"建议",再下轮加个编号。报告渲染、告警备注、评估脚本都要读这些内容,不能靠猜字段名。本篇解决输出格式约束:让模型直接返回结构化数据,程序不用解析文本,直接拿字段。
文本输出的解析困境
看两次输出就明白问题。

第一次:
text
现象:Nginx 返回 502,上游请求超时。
建议检查项:检查 10.0.0.12:8080 端口和应用日志。
证据:14:32:01 upstream timed out。第二次:
text
1. 现象:Nginx 返回 502
2. 建议:检查 upstream 存活状态
3. 日志证据:upstream timed out意思接近,但程序提取"建议检查项"时,得兼容"建议""日志证据""编号"各种写法。写一堆正则不如让模型直接返回固定字段。
先要求 JSON
最简单的结构化是要求模型只输出 JSON:
python
instructions = """
只输出 JSON,不输出解释。
字段:
- symptom: 字符串,故障现象
- checks: 字符串数组,建议检查项
- evidence: 字符串数组,使用到的证据
"""
data = json.loads(response.output_text)
print(data["symptom"])这版仍可能失败。模型可能多包一层 Markdown 代码块,也可能返回合法 JSON 但字段名不对。解析失败时先存坏样本,别直接丢——坏样本是调试资产,以后调整 Prompt 或模型时拿来回归验证。
python
try:
data = json.loads(response.output_text)
except json.JSONDecodeError:
Path("outputs/bad-json.txt").write_text(response.output_text, encoding="utf-8")
raiseJSON 合法不等于字段正确
合法 JSON 但不是程序需要的结构,这比解析失败更隐蔽:
json
{
"summary": "Nginx 返回 502",
"actions": ["检查 upstream"]
}JSON 没问题,但字段名不对,程序取 symptom 会取不到。用 Pydantic 写清楚字段,校验失败直接报错,坏数据不会流到下游:
python
from pydantic import BaseModel, Field
class TriageResult(BaseModel):
symptom: str = Field(description="材料能证明的故障现象")
checks: list[str] = Field(description="建议检查项,最多 3 条")
evidence: list[str] = Field(description="使用到的证据")
result = TriageResult.model_validate(data)到这一步还只是"模型按提示输出 JSON,生成后再解析"。结构化输出能做得更可靠:把约束前移到生成阶段。
Structured Outputs:两种形态
OpenAI 的 Structured Outputs 把 schema 约束前移到模型解码阶段,让模型只能产出符合 schema 的 token。它有两种落地形态,用途不同,别混用。
第一种是 text.format,约束最终回答的形状。通过 responses.parse() 传 Pydantic 模型,SDK 声明输出结构并按模型校验:
python
from openai import OpenAI
from pydantic import BaseModel, Field
class TriageResult(BaseModel):
symptom: str
checks: list[str] = Field(max_length=3)
evidence: list[str]
client = OpenAI()
response = client.responses.parse(
model=settings.model,
instructions="根据材料生成结构化排查结果。",
input=materials,
text_format=TriageResult,
)
result = response.output_parsedtext.format 约束的是"模型最终回答长什么样",适合只需要结构化数据本身的场景:告警分诊、字段抽取、报告生成。
第二种是 function calling 的 strict schema,约束的是工具调用参数的形状,不是最终回答。模型返回的是工具调用请求,参数按 schema 生成(下一篇讲)。两者共用同一套 strict 规则,但挂在不同的地方——一个挂在 text.format,一个挂在工具定义的 parameters。需要工具调用就用 function calling schema,需要最终答案结构化就用 text.format。
strict 模式的规则和代价
strict 模式有三条硬规则:additionalProperties 必须为 false、所有字段必须进 required、可选字段不能靠省略表达,要用 null 类型表达"可以有也可以没有"。

python
{
"type": "function",
"name": "read_log",
"strict": True,
"parameters": {
"type": "object",
"properties": {
"service": {"type": "string", "enum": ["nginx", "redis", "mysql"]},
"lines": {"type": "integer", "minimum": 1, "maximum": 100},
"keyword": {"type": ["string", "null"], "description": "可选过滤词,不传为 null"},
},
"required": ["service", "lines", "keyword"], # 可选字段也要进 required
"additionalProperties": False,
},
}keyword 是可选的,但 strict 下不能省略,只能填 null。这和直觉相反——可选不是字段不存在,是字段值为 null。
strict 有代价。一是灵活性降低:模型必须完全遵循字段定义,某字段在特定场景确实无法填充时,strict 会强迫模型编造。二是首次延迟:每个 schema 第一次请求时,服务端要把它预处理成上下文无关文法,比非 strict 慢。后续相同 schema 复用处理结果。
strict 适用于字段必须存在且格式固定的场景(API 接口、自动化流水线)。灵活模式适用于字段可有可无的场景(人工阅读的报告)。不要全系统统一开 strict,按输出用途选。
refusal:模型拒绝抽取
Structured Outputs 有个容易被忽略的返回形态:refusal。模型判断不该抽取时(材料含敏感信息、请求违反安全策略、输入不像该任务),它不返回结构化数据,而是返回一段拒绝说明。
python
if response.output_parsed is None:
refusal = response.output[0].refusal
log_refusal(refusal)
return fallback_result()把 refusal 当 JSON 解析错误去重试是错的——重试只会再拒绝。它是和"成功返回结构化数据"并列的正常返回,调用方要显式处理这条分支。区分三种情况:正常返回结构化数据、refusal(模型拒绝)、解析失败(schema 没生效或模型偏离)。前两个是模型的主动行为,第三个是工程问题。
schema 约束格式,不约束事实
这是最重要的一点:schema 约束的是格式,不是内容真实性。

模型仍然可能在 evidence 字段里写一条材料中没有的日志行——结构合法、字段齐全,内容却是编造的。结构化输出只保证形状对,不保证内容真。
后置校验必须检查 evidence 里的每条记录是否真实存在于输入材料中:
python
def validate_evidence(result: TriageResult, materials: str) -> list[str]:
fabrications = []
for ev in result.evidence:
if ev not in materials:
fabrications.append(ev)
return fabrications把结构化输出当成"事实保证"是最危险的误解。运维报告最怕把"已确认事实"和"建议检查的动作"混在一起,字段设计时就分开:symptom 和 evidence 只能写材料已证明的内容,likely_causes 是基于证据的推断,recommended_checks 是下一步动作。字段命名本身就在强制执行这条区分。
结构化之后
模型现在能按 schema 返回结构化数据了。这个能力有个自然延伸:模型不只返回数据,还能返回"要调哪个工具、参数是什么"。结构化输出的思想用到工具上,就是工具调用——模型只输出结构化请求,客户端执行。
下一篇讲工具调用。agent 要查本地数据时,不能把整份文件塞进上下文,而是让模型说"我要查张三的负责人信息",Python 去查、把结果回填。