Skip to content

结构化回复

模型直接返回一段话,人看着很舒服,程序接着处理就麻烦了。

比如待办清单返回成这样:

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 里必须有 itemsitems 是一个列表,列表里的每一项都要有字符串字段 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_parsedtodo_list.items 是 Python 列表,item.title 是字段,后面写文件、入库、渲染页面都会清楚很多。

六、字段说明

字段名太随意,后面会很难读。itemstitle 这种名字简单,但复杂一点的任务里,字段要能看出含义。

比如整理一段用户反馈:

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 这个类型很适合做分类字段。没有限制时,模型可能返回 badnegative不满意负面,脚本后面还得统一口径。限制成固定值以后,后面的统计和筛选会省事很多。

完整脚本可以写成:

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 到底是处理状态,还是用户情绪,还是反馈分类?模型能猜,代码读的人也要猜。字段名换成 sentimentcategorypriority 会清楚很多。

还有一种情况是字段本来就允许为空。比如联系人信息里,邮箱可能没有:

python
class Contact(BaseModel):
    name: str
    email: str | None = None

email: 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_datepriorityownerproject,输入里又没有这些信息,模型就会开始猜。原文没写截止日期,它可能补一个“今天”;原文没写负责人,它可能根据上下文编一个人名。

字段要跟材料里的信息对得上。材料里只有动作,就只抽动作;材料里出现了日期,再加日期字段;材料里真的有负责人,再加负责人字段。

结构化不是让模型变成数据库,而是把已经能从材料里看出来的东西,用固定字段交给 Python。