结构化输出


先来看看这个问题的本质。结构化输出指的是让模型返回你的代码能直接读取的数据——一个你可以拿来做判断分支的字段,而不是一段需要手动解析的文字。这一章讲的就是如何让这件事变得可靠。
到目前为止,模型给你的都是文本,人类读起来没问题,但软件对一段文字没法做太多事情。要让代码能根据模型的回答采取行动,你需要的是数据:一个能读取的字段、一个能做判断的值、一条能存下来的记录。
这个转变看起来不大,但会彻底改变后面的处理方式。模型返回 { "sentiment": "negative" } 而不是一句"顾客看起来不太满意"这样的话之后,你的代码就能据此路由工单、更新仪表盘,或者发送提醒。真正有意思的问题是:模型本质上只是在预测文本,你要怎么让它产出干净的数据?答案会揭示一个你之前不知道自己有的杠杆。
要求它返回 JSON
最直接的想法是在提示词里要求:"以 JSON 格式回复"——这是软件之间传递数据常用的纯文本格式,用花括号包起来的字段和值。试一下会发现它大多数时候都能生效,而这恰恰是陷阱所在。模型本质上仍然只是在预测大概率的文本,而这个"大概率的文本"有时候会带一个代码围栏,有时候前面会来一句友好的"好的,给你!",有时候后面会跟一句多余的注释。这些都会破坏读取 JSON 的代码。友好地请求只是让预测偏向 JSON,并不能强制它。
所以各家提供商给了你一个真正的杠杆。通过 response_format 打开 JSON 模式,做的事情不只是"请求":它会约束模型,让返回的语法必须是合法的 JSON。
import json
response = client.chat.completions.create(
model=MODEL,
messages=[
{"role": "system", "content": 'Extract the city and the temperature. Reply as JSON: { "city": string, "tempC": number }.'},
{"role": "user", "content": "It's about 18 degrees in Oslo right now."},
],
response_format={"type": "json_object"}, # 约束输出必须是合法的 JSON 语法
)
data = json.loads(response.choices[0].message.content)
print(data["city"], data["tempC"]) # "Oslo" 18response_format={"type": "json_object"} 让输出成为合法的 JSON 语法,所以 json.loads 不会因为混进来的散文而报错。你还是要在提示词里描述清楚你想要的字段,因为 JSON 模式保证的是"合法的 JSON",不是"你心里想的那些具体字段"。它排除的是"根本不是 JSON"这种情况,而不是"JSON 但形状不对"这种情况。还有一件事它无法保证:如果回复在中途被截断,你可能收到半个 JSON 对象,所以它保证的是可靠的语法,不是在所有情况下都成立的硬保证。
response_format 设为 JSON 模式,会约束语法,让它必然是合法的 JSON。但它不保证字段和你要求的一致,一个中途被截断的回复也可能变成半个对象,所以要在提示词里描述好形状,并检查最终拿到的结果。 用 schema 锁定形状
JSON 模式只能保证语法合法,但模型仍然可能给字段改名、丢字段,或者用不同的方式嵌套。要消除这种不确定性,你需要给它一个**schema**:一份对输出必须具备的字段和类型的精确描述,一张模型必须照着填的表格。配合 strict: true,输出就能保证符合这份表格。
import json
response = client.chat.completions.create(
model=MODEL,
messages=[
{"role": "system", "content": "Extract the contact details from the message."},
{"role": "user", "content": "Hi, I'm Mara Lin, reach me at [email protected]."},
],
response_format={
"type": "json_schema",
"json_schema": {
"name": "contact",
"strict": True, # 严格按照 schema 强制执行
"schema": {
"type": "object",
"properties": {
"name": {"type": "string"},
"email": {"type": "string"},
},
"required": ["name", "email"],
"additionalProperties": False,
},
},
},
)
contact = json.loads(response.choices[0].message.content)
# {"name": "Mara Lin", "email": "[email protected]"}模型本质上只是在预测 token,那 schema 是怎么做到硬性保证的呢?因为提供商限制了模型能够预测的 token 范围。在每一步,模型仍然会给所有可能的下一个 token 打分排序,但系统会把那些会破坏 schema 的选项去掉,模型只能从剩下的选项里挑。
如果 schema 规定下一个字段必须是 email,那么在模型做选择之前,任何会开启其他字段的 token 都会被提前移出选项。不符合形状的 token 被直接拿掉了,所以模型没法偏离这个形状。
还有一个比较微妙的效应值得了解。你选的字段名会成为模型预测这个值时读到的一部分上下文,所以清晰的命名能引导出更好的答案。让模型填 tempC,它会倾向于给出摄氏度的数字;让它填 value,它能依据的信息就少得多。把字段命名清楚,既是一种指令,也是 schema 的一部分。
strict: true,它能拿到精确的字段和类型,因为系统会在模型做选择之前,把任何会破坏形状的 token 都拿掉。这是强制执行,不是礼貌请求。字段也要命名清楚,因为字段名是模型预测值时会读到的上下文。我也是花了点时间才相信 tempC 这样的名字真的有实际作用。 依然要做校验
schema 约束的是形状,但不管怎样,都要把输出当成来自外部世界的数据来对待。形状可以是合法的,但内容却可能是错的:提取出来的邮箱其实是个打错的字,一个模型瞎猜出来的数字,一个因为输入里根本没有这项信息而被留空的字段。而且这次调用本身也可能以常见的方式失败,比如被 max_tokens 截断,返回一段不完整的 JSON。要带着防御心态去解析:形状合法不代表值是对的。
import json
def parse_contact(raw):
try:
data = json.loads(raw)
if not data.get("name") or not data.get("email"):
return None # 字段存在但是空的
return data
except json.JSONDecodeError:
return None # 不是合法的 JSON(比如被截断的回复)
contact = parse_contact(response.choices[0].message.content)
if not contact:
pass # 处理失败:重试、重新提问,或者展示一个友好的错误提示这其实是LLM 工作原理里讲过的**幻觉**问题换了个说法出现:形状对,不代表值对。schema 能保证你拿到一个 name 字段,不能保证这个名字本身是正确的。在解析外面套一层 try/except,再检查一下值,这点成本比起收到一份格式完好却依然错误的回复来说,非常值得。
try/except 包住解析过程,并在信任这些值之前先检查它们。提前想清楚校验失败之后要怎么处理——重试还是给出友好的错误提示——不要等到出问题了才手忙脚乱。 两种模式:分类与提取
大部分结构化输出的工作都属于这两种形态之一。
**分类**是把一个输入归到一组固定标签中的某一个。schema 里的 enum 靠同样的 token 剪枝手段来把输出限制到那些标签上:在标签这个位置,只有允许的取值才能被预测出来,所以模型没法凭空造出一个不在你列表上的类别。
# 用于分类的 schema 片段
{"type": "string", "enum": ["billing", "technical", "general"]}提取是从自由文本里抽出特定字段,就像前面的联系人示例:一个名字、一个日期、一个金额、一份产品名称列表。分类和提取加在一起,覆盖了绝大多数实际的 AI 功能:路由支持工单、给内容打标签、把邮件转成记录、读取小票信息。这两种模式都能把一个模糊的文本生成器变成你代码可以放心依赖的组件。
enum 锁死,让模型没法凭空造出一个不在列表上的类别。提取是把自由文本里的具名字段抽出来放进一条记录。这两种模式都能把一个模糊的文本生成器变成可靠的组件,两者加在一起覆盖了绝大多数实用的 AI 功能。 实际操作
把一封杂乱的客服邮件变成一条结构化的工单,在一个 schema 里同时做分类和提取:
import json
response = client.chat.completions.create(
model=MODEL,
messages=[
{"role": "system", "content": "Turn the support email into a ticket."},
{"role": "user", "content": email_text},
],
response_format={
"type": "json_schema",
"json_schema": {
"name": "ticket",
"strict": True,
"schema": {
"type": "object",
"properties": {
"category": {"type": "string", "enum": ["billing", "technical", "general"]},
"urgency": {"type": "string", "enum": ["low", "medium", "high"]},
"summary": {"type": "string"},
},
"required": ["category", "urgency", "summary"],
"additionalProperties": False,
},
},
},
)
ticket = json.loads(response.choices[0].message.content)
# {"category": "billing", "urgency": "high", "summary": "Double-charged for last month's subscription."}一次调用就从两个维度对邮件做了分类,并提取出一段摘要,返回一条你的代码可以路由和存储的记录,形状是有保证的,值依然值得检查一遍。到目前为止,整个流程都是文本进、文本出。接下来会让模型换上一种完全不同的"感官":在Embeddings(嵌入)这一章里,文本会变成可以按含义比较的数字,这是搜索以及处理你自己文档的基础。

