Structured Outputs 最容易翻车的地方,不是“模型不听话”,而是你把 schema 当成了永远不变的圣旨

线上一旦进入版本演进期,最常见的事故就是:字段新增后老消费端崩、枚举值扩展后校验误杀、坏样本把整条链路拖死,最后只能半夜回滚,像在给自己写惊悚片。

先给结论:保守上线靠 4 道闸门

  1. Schema 要版本化,不要直接原地改。
  2. 解析要双轨兜底,严格失败后能降级到兼容路径。
  3. 灰度要按比例放量,别 100% 一把梭。
  4. 回滚要预先可执行,而不是出事后现编命令。

如果你现在已经在 Go 服务里接 OpenAI Responses Structured Outputs,最稳的做法不是追求“最严”,而是追求可演进、可观测、可回退

事故通常怎么来的

典型链路长这样:

  • 你把 required 字段从 4 个加到 6 个
  • 你新增了一个枚举值,老逻辑没认出来
  • 你把 steps 从字符串改成对象数组
  • 你假设模型输出永远满足最新版 schema
  • 你把严格校验失败直接当 500 返回

于是结果就很朴素:

  • 新 schema 刚上线,老流量立刻报错
  • 重试把坏样本放大成风暴
  • 日志里只有一句 invalid json schema output
  • 最后你发现问题不在模型,在你自己太自信

一、Schema 演进别做“原地爆改”

推荐做法:每次结构性变更都带版本号。

一个够实用的返回结构:

{
  "schema_version": "v2",
  "ticket_type": "billing",
  "priority": "high",
  "summary": "duplicate charge after retry",
  "actions": [
    "check idempotency key",
    "review payment callback logs"
  ]
}

Go 侧不要只定义一个“永恒结构体”,而是显式保留版本模型:

type TicketV1 struct {
    SchemaVersion string   `json:"schema_version"`
    Type          string   `json:"ticket_type"`
    Summary       string   `json:"summary"`
}

type TicketV2 struct {
    SchemaVersion string   `json:"schema_version"`
    Type          string   `json:"ticket_type"`
    Priority      string   `json:"priority"`
    Summary       string   `json:"summary"`
    Actions       []string `json:"actions"`
}

上线前先把 schema 做归一化 diff,别靠肉眼找区别:

jq -S . schema/ticket_v1.json > /tmp/ticket_v1.norm.json
jq -S . schema/ticket_v2.json > /tmp/ticket_v2.norm.json
diff -u /tmp/ticket_v1.norm.json /tmp/ticket_v2.norm.json

你真正要关心的,不是“schema 变了没有”,而是:

  • 是否新增了 required
  • 是否缩窄了 enum
  • 是否改变了字段类型
  • 是否删除了旧字段

前两类最容易把线上打碎。

二、坏样本兜底:严格失败后,走兼容解析

保守策略很简单:

  • 第一层:按当前版本严格解析
  • 第二层:失败后尝试兼容版本或宽松结构
  • 第三层:再失败就降级成人工可读摘要,而不是整个请求报废

示例代码:

func decodeTicket(raw []byte) (any, error) {
    var peek struct {
        SchemaVersion string `json:"schema_version"`
    }
    if err := json.Unmarshal(raw, &peek); err != nil {
        return nil, fmt.Errorf("peek version: %w", err)
    }

    switch peek.SchemaVersion {
    case "v2":
        var out TicketV2
        if err := json.Unmarshal(raw, &out); err == nil {
            return out, nil
        }
        // fallback to tolerant path
        return decodeTicketCompat(raw)
    case "v1":
        var out TicketV1
        if err := json.Unmarshal(raw, &out); err != nil {
            return nil, err
        }
        return out, nil
    default:
        return decodeTicketCompat(raw)
    }
}

兼容路径不要悄悄吞错,至少要记录:

  • schema_version_detected
  • decode_mode=strict|compat|summary
  • fallback_reason
  • model
  • prompt_version

快速做一个坏样本回放目录,值回票价:

mkdir -p /Users/wow/dev/book/mengboy/tmp/structured-output-samples/bad
cp bad_case.json /Users/wow/dev/book/mengboy/tmp/structured-output-samples/bad/
go test ./... -run TestDecodeTicketCompat -v

三、灰度发布:别全量冲,按比例放量

Structured Outputs 的问题,很多只有在真实长尾输入里才会冒出来。

所以灰度不要做成“开发环境没报错 = 生产全量”。那是事故邀请函。

推荐分 4 档:

  • 5%:只看解析成功率和坏样本分布
  • 20%:开始看业务字段正确率
  • 50%:观察成本、延迟、回退比例
  • 100%:连续稳定后再切主路径

最笨但非常有用的放量方式:直接走环境变量。

export STRUCTURED_OUTPUT_SCHEMA_VERSION=v2
export STRUCTURED_OUTPUT_CANARY_PERCENT=20
export STRUCTURED_OUTPUT_COMPAT_FALLBACK=1
./bin/api

验证当前服务配置:

curl -s http://127.0.0.1:8080/debug/config | jq '.structured_output'

当你发现 compat 路径占比持续升高时,不要骗自己说“还能跑”。那通常意味着:

  • prompt 和 schema 已经漂了
  • 某些样本触发了模型输出边界
  • 你以为的必填字段,模型并不总能稳定给出

四、灰度回滚:回滚命令要先写好

真正有用的回滚,不是 Git 里有历史版本,而是5 分钟内能恢复线上

我建议至少准备两种回滚:

  1. 配置回滚:把 v2 切回 v1
  2. 逻辑回滚:保留 v2 生成,但消费端退回 compat

最小回滚清单:

export STRUCTURED_OUTPUT_SCHEMA_VERSION=v1
export STRUCTURED_OUTPUT_CANARY_PERCENT=0
export STRUCTURED_OUTPUT_COMPAT_FALLBACK=1
./bin/api

如果你有 systemd 或容器编排,就把它做成明确动作,不要靠记忆:

kubectl set env deploy/agent-api \
  STRUCTURED_OUTPUT_SCHEMA_VERSION=v1 \
  STRUCTURED_OUTPUT_CANARY_PERCENT=0 \
  STRUCTURED_OUTPUT_COMPAT_FALLBACK=1

五、指标别只看“成功率”

Structured Outputs 线上最坑人之处,在于“HTTP 200 但业务结构已烂”。

至少打这些指标:

  • structured_decode_total{mode,version}
  • structured_decode_fail_total{reason}
  • structured_fallback_total{from,to}
  • structured_summary_degrade_total
  • structured_field_missing_total{field}
  • structured_enum_unknown_total{field,value}

排障时先查这三件事:

grep -R "decode_mode=compat" /var/log/agent-api | tail -n 50

grep -R "fallback_reason" /var/log/agent-api | sort | uniq -c

grep -R "schema_version=v2" /var/log/agent-api | tail -n 100

如果 summary 降级在涨,说明你不是“更严格了”,而是“更脆了”。这两者差得很远。

六、一个够保守的 Go 落地顺序

如果你要在一周内把 Structured Outputs 稳定落地,我的推荐顺序是:

  1. 先加 schema_version
  2. 再做严格 + 兼容双解析
  3. 然后补坏样本回放测试
  4. 最后才做全量切换

别反过来。先全量再补兜底,这种操作属于给未来的自己挖坑。

常见错误与排障

1)新增 required 字段后成功率骤降

优先检查:

  • prompt 是否明确要求该字段
  • 历史样本里是否本来就经常缺失
  • compat 路径是否还能接受旧结构

2)模型返回了未知枚举值

处理建议:

  • 先落到 unknown
  • 记录原始值到日志或审计字段
  • 不要因为一个新枚举把整个请求打死

3)灰度期间线上有 200,但下游报空指针

这通常不是接口成功,而是解码成功但语义没成功

要补:

  • 字段级业务校验
  • 默认值策略
  • 下游消费前的非空断言日志

总结

Structured Outputs 真正难的不是“生成 JSON”,而是在 schema 持续变化时,系统还能稳稳活着

保守目标下,我的建议很直接:

  • schema 带版本
  • 消费端双解析
  • 灰度按比例
  • 回滚命令提前写好

最小可行方案(MVP)

如果你今天只能补一轮:

  1. 给输出加 schema_version
  2. Go 里增加 strict -> compat -> summary 三段解码链
  3. v2 只放 5% 灰度
  4. 监控 structured_fallback_total

先把系统做成不轻易炸,再去追求结构多漂亮。线上系统不是艺术展,稳才值钱。