登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  科技周边 >  人工智能

Structured Outputs让模型结果贴合 JSON Schema的实现方法

来源:17golang原创

时间:2026-09-15 19:20:20 191浏览 收藏

我在把客服文本接入工单系统时,最容易踩的坑不是模型不会回答,而是回答“看起来像 JSON”,业务却无法稳定使用:字段偶尔缺失,intent 写成了未约定的值,安全拒答还被误判成空结果。解决这类问题,重点是让模型输出直接服从 JSON Schema,并把正常对象、明确拒答和其他失败分开处理。

官方地址:https://developers.openai.com/api/docs/guides/structured-outputs

要点速览
  • Structured Outputs 约束的是结构,不替业务判断事实真伪。
  • Pydantic 模型适合把字段、类型和枚举集中成一个契约。
  • refusalparsed 和传输/截断异常必须分别记录和兜底。

先把自由文本和目标对象分开

用户输入可以是不规则的自然语言,输出却应该是稳定的业务对象。先写清楚“需要哪些字段”,再决定哪些字段必填、哪些值只能来自枚举,别一开始就要求模型输出一大段包含说明文字的 JSON。

例如工单分类只需要意图、订单号和原因。intent 可以限定为退款、换货、查询三种;订单号缺失时不要让模型编造,应该允许业务层把它送回补充信息队列。这样 Schema 负责形状,业务代码负责是否足够办理。

用 strict Schema 固定字段和枚举

Python SDK 可以用 Pydantic 模型声明结构,再通过 chat.completions.parse 请求解析结果。下面的模型名从环境变量读取,避免把某个模型版本硬编码到业务逻辑中:

import os
from typing import Literal

from openai import OpenAI
from pydantic import BaseModel


class TicketIntent(BaseModel):
    # 枚举限制业务分支,避免模型临时创造新的意图名
    intent: Literal["refund", "exchange", "status"]
    # 订单号可以为空字符串,但不能让模型虚构一个编号
    order_id: str
    # 保留用户原始诉求,便于人工复核和二次分流
    reason: str


client = OpenAI()
response = client.chat.completions.parse(
    model=os.environ.get("OPENAI_MODEL", "gpt-6-astra"),
    messages=[
        {"role": "system", "content": "Extract a support ticket intent."},
        {"role": "user", "content": "我想退掉订单 A-2048,尺寸选错了。"},
    ],
    response_format=TicketIntent,
)

message = response.choices[0].message
if message.parsed is not None:
    # 解析成功后使用类型对象,不再手工 split 或猜字段位置
    ticket = message.parsed
    print(ticket.intent, ticket.order_id)

这里的关键不是提示词里反复强调“必须是 JSON”,而是把结构交给 response_format。OpenAI 官方文档说明,Structured Outputs 会让响应遵守提供的 JSON Schema;Python 库也支持用 Pydantic 定义对象。它比旧式 JSON mode 更适合字段契约,但不会替你判断订单号是否真实存在。

Structured Outputs 将自然语言输入约束为 TicketIntent JSON Schema 和 Pydantic 类型对象的静态关系说明图
图1:结构说明图,查看自然语言、JSON Schema、TicketIntent 与业务字段之间的静态关系;不是运行截图。

把 parsed、拒答与解析失败分开处理

结构化输出最容易被忽略的边界是拒答。拒答不是“字段为空”,也不是可以继续写入工单的正常对象。读取消息时先判断拒答,再判断解析结果;同时保留完成原因,便于区分模型主动拒绝和响应被截断。

message = response.choices[0].message
finish_reason = response.choices[0].finish_reason

if message.refusal:
    # 拒答只进入安全记录或人工队列,不伪装成 TicketIntent
    result = {"kind": "refusal", "detail": message.refusal}
elif message.parsed is not None and finish_reason == "stop":
    # 只有完整对象才进入后续业务判断
    result = {"kind": "ok", "ticket": message.parsed}
else:
    # 截断、服务异常或解析缺失都需要独立的降级策略
    result = {"kind": "unusable", "finish_reason": finish_reason}

“贴合 Schema”只解决格式一致性,不保证每次都能拿到可办理结果。拒答可能来自安全策略,unusable 可能来自截断、超时或上游异常,两者的日志、重试次数和人工处理路径都不应混在一起。

Structured Outputs 中 parsed 正常对象、refusal 明确拒答与 unusable 解析失败的边界关系说明图
图2:边界说明图,区分 parsed、refusal 与 unusable 三类结果的归属;不是运行截图。

让业务兜底接住解析异常

建议在 API 调用外再包一层有限的运行时处理:网络错误可以短暂重试,响应不完整可以进入待补充队列,明确拒答则记录原因并停止自动办理。不要对所有失败都无限重试,否则同一请求可能被重复消费。

现象应该确认处理方向
parsed 为空且有 refusal是否为明确安全拒答记录拒答,转人工或安全流程
finish_reason 不是 stop响应是否被截断或中断有限重试,失败后保留原文
对象完整但订单号无效业务系统能否查到订单交给业务校验,不回头改 Schema

用边界样例验证契约

上线前至少准备五类样例:正常退款、缺少订单号、同时提到退款和换货、需要拒答的请求、长文本或中途截断。每个样例都记录最终分支和是否产生业务副作用。这样出现问题时,能快速回答“是 Schema 没覆盖、模型拒答,还是业务数据校验失败”。

最后再检查两件事:Schema 字段是否真的是业务所需的最小集合;降级路径是否有幂等键和人工回收点。Structured Outputs 让模型结果更像可靠接口,但可靠的业务接口仍需要超时、日志、重试上限和数据校验共同完成。

常见问题

Structured Outputs 和 JSON mode 有什么区别?

JSON mode 主要保证返回是合法 JSON;Structured Outputs 还要求它遵守提供的 JSON Schema,适合固定字段和枚举的业务对象。

Schema 严格了,还需要业务校验吗?

需要。Schema 能约束类型和结构,不能确认订单存在、权限正确或业务状态允许退款。

拒答能不能当成空对象继续处理?

不能。拒答应通过 message.refusal 单独记录,避免把安全边界误写成正常工单。

什么时候应该重试?

网络抖动或可识别的截断可以有限重试;明确拒答和业务数据无效不应靠重复请求绕过。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>