Appearance
结构化回复
模型直接返回一段话,人看着很舒服,程序接着处理就麻烦了。
比如待办清单返回成这样:
text
买菜、写周报、回复邮件。或者这样:
text
- 买菜
- 写周报
- 回复邮件人一眼就知道是三件事,但 Python 不好判断。到底按顿号切,还是按换行切?模型多写一句“以下是整理结果”,脚本是不是又要特殊处理?这种地方靠字符串硬拆,前面几次看着能跑,材料一变就容易碎。
结构化回复解决的就是这个问题:让模型按固定字段返回,Python 再按字段读。
一、文本和结构
文本回复适合直接给人看。
text
这段文字里有三件事:买菜、写周报、回复邮件。结构化回复更适合给程序处理。
json
{
"items": [
{"title": "买菜"},
{"title": "写周报"},
{"title": "回复邮件"}
]
}差别很明显。文本里“买菜”藏在一句话里,脚本要自己猜;JSON 里 items 是列表,title 是字段,Python 直接拿。
这块我一开始容易混:让模型“输出 JSON”和 Python “拿到可用对象”不是一回事。模型哪怕看起来输出了 JSON,脚本也要解析、校验,再决定能不能继续用。
二、手动 JSON
最直接的写法,是在提示词里要求模型返回 JSON。
python
response = client.responses.create(
model=model,
instructions=(
"把输入文字整理成待办清单。"
"只返回 JSON,不要 Markdown,不要解释。"
"JSON 格式: {\"items\": [{\"title\": \"待办标题\"}]}"
),
input="今晚买菜,写周报,回复邮件。",
max_output_tokens=300,
)返回可能长这样:
json
{
"items": [
{"title": "买菜"},
{"title": "写周报"},
{"title": "回复邮件"}
]
}然后 Python 解析:
python
import json
data = json.loads(response.output_text)
for item in data["items"]:
print(item["title"])这条路能跑,但有两个小坑。
第一,模型可能在 JSON 外面多写一句话。比如:
text
以下是整理后的 JSON:
{"items": [{"title": "买菜"}]}json.loads() 看到第一行文字就会报错。
第二,字段可能不稳。今天叫 items,明天可能叫 todos;今天 title 是字符串,明天可能变成对象。提示词能减少这种情况,但不能完全替代校验。
三、解析失败
手动解析时,至少要把解析错误单独拎出来。
python
import json
import sys
try:
data = json.loads(response.output_text)
except json.JSONDecodeError as exc:
print(f"JSON 解析失败: {exc}", file=sys.stderr)
print(response.output_text, file=sys.stderr)
raise SystemExit(2)这里把原始返回也打到 stderr。不然只看到一句 JSONDecodeError,还得猜模型到底返回了什么。
解析通过也不代表字段一定对。比如返回:
json
{
"todos": [
{"name": "买菜"}
]
}这也是合法 JSON,但脚本后面读 data["items"] 会报 KeyError。JSON 只管语法对不对,不管字段是不是脚本要的那一套。
四、Pydantic 模型
Pydantic 可以把“期待什么字段”写成 Python 类。它不是专门给 AI 用的,在 FastAPI、配置校验、数据入库前检查里也很常见。
先定义待办结构:
python
from pydantic import BaseModel
class TodoItem(BaseModel):
title: str
class TodoList(BaseModel):
items: list[TodoItem]意思很直白:TodoList 里必须有 items,items 是一个列表,列表里的每一项都要有字符串字段 title。
校验效果可以单独看,不需要先调模型:
python
data = {
"items": [
{"title": "买菜"},
{"title": "写周报"},
]
}
todo_list = TodoList.model_validate(data)
print(todo_list.items[0].title)如果数据少字段:
python
data = {"todos": [{"name": "买菜"}]}
TodoList.model_validate(data)Pydantic 会报类似这样的错误:
text
items
Field required这个错误比 KeyError: 'items' 更清楚,因为它直接告诉字段缺了。
五、SDK 解析
当前 OpenAI Python SDK 可以把 Pydantic 模型交给 responses.parse()。这样模型返回后,SDK 会按这个结构解析。
新增文件:scripts/structured_todo.py
python
#!/usr/bin/env python3
"""让模型按 Pydantic 结构返回待办清单。"""
import os
import sys
from openai import OpenAI
from pydantic import BaseModel
class TodoItem(BaseModel):
title: str
class TodoList(BaseModel):
items: list[TodoItem]
def main():
api_key = os.getenv("OPENAI_API_KEY")
model = os.getenv("OPENAI_MODEL")
if not api_key:
print("缺少环境变量: OPENAI_API_KEY", file=sys.stderr)
return 2
if not model:
print("缺少环境变量: OPENAI_MODEL", file=sys.stderr)
return 2
client = OpenAI(api_key=api_key, timeout=60.0)
response = client.responses.parse(
model=model,
text_format=TodoList,
instructions="把输入文字整理成待办清单。只保留具体动作。",
input="今晚买菜,写周报,回复邮件。",
max_output_tokens=300,
)
todo_list = response.output_parsed
for item in todo_list.items:
print(item.title)
return 0
if __name__ == "__main__":
raise SystemExit(main())运行:
bash
uv run python scripts/structured_todo.py输出大概是:
text
买菜
写周报
回复邮件这里已经不是自己从 response.output_text 里拆字符串,而是直接拿 response.output_parsed。todo_list.items 是 Python 列表,item.title 是字段,后面写文件、入库、渲染页面都会清楚很多。
六、字段说明
字段名太随意,后面会很难读。items、title 这种名字简单,但复杂一点的任务里,字段要能看出含义。
比如整理一段用户反馈:
text
页面打开很慢,点保存以后等了十几秒才提示成功,而且没有进度条。可以定义成这样:
python
from typing import Literal
from pydantic import BaseModel
class Feedback(BaseModel):
summary: str
sentiment: Literal["positive", "neutral", "negative"]
tags: list[str]summary 放一句摘要,sentiment 限制成三个固定值,tags 放标签列表。
Literal 这个类型很适合做分类字段。没有限制时,模型可能返回 bad、negative、不满意、负面,脚本后面还得统一口径。限制成固定值以后,后面的统计和筛选会省事很多。
完整脚本可以写成:
python
response = client.responses.parse(
model=model,
text_format=Feedback,
instructions=(
"分析输入里的用户反馈。"
"summary 用一句中文概括。"
"sentiment 按情绪选择 positive、neutral 或 negative。"
"tags 提取 1 到 3 个简短标签。"
),
input="页面打开很慢,点保存以后等了十几秒才提示成功,而且没有进度条。",
max_output_tokens=300,
)
feedback = response.output_parsed
print(feedback.summary)
print(feedback.sentiment)
print(feedback.tags)输出可能是:
text
用户反馈页面加载和保存响应较慢,且缺少进度提示。
negative
['性能', '保存', '交互提示']这就是结构化回复的好处:结果不是一大段文字,而是能被代码继续处理的几个字段。
七、字段缺失
结构化回复也不是把所有问题都消掉。字段设计得太模糊,模型仍然会摇摆。
比如字段叫 status:
python
class Feedback(BaseModel):
status: str这个 status 到底是处理状态,还是用户情绪,还是反馈分类?模型能猜,代码读的人也要猜。字段名换成 sentiment、category、priority 会清楚很多。
还有一种情况是字段本来就允许为空。比如联系人信息里,邮箱可能没有:
python
class Contact(BaseModel):
name: str
email: str | None = Noneemail: str | None = None 表示可以没有邮箱。没写默认值时,这个字段就是必填;模型没返回,校验会失败。可空字段要想清楚,不然脚本会把“正常缺失”和“模型漏字段”混在一起。
八、保存结构化结果
Pydantic 对象可以很方便地转回字典,再保存成 JSON。
python
import json
from pathlib import Path
output_path = Path("outputs/todos.json")
output_path.parent.mkdir(exist_ok=True)
output_path.write_text(
json.dumps(todo_list.model_dump(), ensure_ascii=False, indent=2),
encoding="utf-8",
)保存后的文件长这样:
json
{
"items": [
{
"title": "买菜"
},
{
"title": "写周报"
},
{
"title": "回复邮件"
}
]
}model_dump() 把 Pydantic 对象变成普通字典,json.dumps(..., ensure_ascii=False) 负责写成中文可读的 JSON。这个结果再给别的脚本读,就不用重新猜文本格式了。
九、结构别太早复杂
结构化回复很方便,但 schema 不是越复杂越好。
比如待办清单当前只需要一个动作标题:
python
class TodoItem(BaseModel):
title: str如果一开始就加上 due_date、priority、owner、project,输入里又没有这些信息,模型就会开始猜。原文没写截止日期,它可能补一个“今天”;原文没写负责人,它可能根据上下文编一个人名。
字段要跟材料里的信息对得上。材料里只有动作,就只抽动作;材料里出现了日期,再加日期字段;材料里真的有负责人,再加负责人字段。
结构化不是让模型变成数据库,而是把已经能从材料里看出来的东西,用固定字段交给 Python。